feat(ai): persist agent sessions and request context

This commit is contained in:
2026-10-14 17:42:07 +09:00
parent 767a6b40c7
commit 5290c61178
11 changed files with 905 additions and 4 deletions
@@ -0,0 +1,68 @@
CREATE TABLE "agent_sessions" (
"id" UUID NOT NULL,
"course_id" UUID NOT NULL,
"week_id" UUID,
"student_id" UUID NOT NULL,
"status" TEXT NOT NULL DEFAULT 'ACTIVE',
"title" TEXT,
"created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMPTZ(3) NOT NULL,
"last_activity_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "agent_sessions_pkey" PRIMARY KEY ("id")
);
CREATE TABLE "agent_messages" (
"id" UUID NOT NULL,
"session_id" UUID NOT NULL,
"client_message_id" TEXT NOT NULL,
"role" TEXT NOT NULL,
"content" TEXT NOT NULL,
"provider" TEXT,
"model" TEXT,
"summary" TEXT,
"explanation" TEXT,
"action_plan" JSONB,
"create_module" JSONB,
"apply_state" TEXT,
"apply_error" TEXT,
"usage" JSONB,
"created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMPTZ(3) NOT NULL,
CONSTRAINT "agent_messages_pkey" PRIMARY KEY ("id")
);
CREATE TABLE "agent_actions" (
"id" UUID NOT NULL,
"message_id" UUID NOT NULL,
"client_key" TEXT NOT NULL,
"type" TEXT NOT NULL,
"status" TEXT NOT NULL,
"module_name" TEXT,
"path" TEXT,
"command" TEXT,
"purpose" TEXT,
"reason" TEXT,
"content" TEXT,
"output" TEXT,
"exit_code" INTEGER,
"error" TEXT,
"payload" JSONB,
"sort_order" INTEGER NOT NULL DEFAULT 0,
"created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMPTZ(3) NOT NULL,
CONSTRAINT "agent_actions_pkey" PRIMARY KEY ("id")
);
CREATE INDEX "agent_sessions_student_id_course_id_week_id_status_last_activity_at_idx" ON "agent_sessions"("student_id", "course_id", "week_id", "status", "last_activity_at");
CREATE INDEX "agent_sessions_course_id_last_activity_at_idx" ON "agent_sessions"("course_id", "last_activity_at");
CREATE UNIQUE INDEX "agent_messages_session_id_client_message_id_key" ON "agent_messages"("session_id", "client_message_id");
CREATE INDEX "agent_messages_session_id_created_at_idx" ON "agent_messages"("session_id", "created_at");
CREATE UNIQUE INDEX "agent_actions_message_id_client_key_key" ON "agent_actions"("message_id", "client_key");
CREATE INDEX "agent_actions_message_id_sort_order_idx" ON "agent_actions"("message_id", "sort_order");
CREATE INDEX "agent_actions_type_status_created_at_idx" ON "agent_actions"("type", "status", "created_at");
ALTER TABLE "agent_sessions" ADD CONSTRAINT "agent_sessions_course_id_fkey" FOREIGN KEY ("course_id") REFERENCES "courses"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "agent_sessions" ADD CONSTRAINT "agent_sessions_week_id_fkey" FOREIGN KEY ("week_id") REFERENCES "course_weeks"("id") ON DELETE SET NULL ON UPDATE CASCADE;
ALTER TABLE "agent_sessions" ADD CONSTRAINT "agent_sessions_student_id_fkey" FOREIGN KEY ("student_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "agent_messages" ADD CONSTRAINT "agent_messages_session_id_fkey" FOREIGN KEY ("session_id") REFERENCES "agent_sessions"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "agent_actions" ADD CONSTRAINT "agent_actions_message_id_fkey" FOREIGN KEY ("message_id") REFERENCES "agent_messages"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+75
View File
@@ -28,6 +28,7 @@ model User {
studentChatThreads ChatThread[] @relation("ChatThreadStudent") studentChatThreads ChatThread[] @relation("ChatThreadStudent")
assignedChatThreads ChatThread[] @relation("ChatThreadInstructor") assignedChatThreads ChatThread[] @relation("ChatThreadInstructor")
chatMessages ChatMessage[] chatMessages ChatMessage[]
agentSessions AgentSession[]
platformControlSessions PlatformControlSession[] platformControlSessions PlatformControlSession[]
platformControlQueueEntries PlatformControlQueueEntry[] platformControlQueueEntries PlatformControlQueueEntry[]
studioRepositories StudioRepository[] studioRepositories StudioRepository[]
@@ -177,6 +178,7 @@ model Course {
studentProgress CourseStudentProgress[] studentProgress CourseStudentProgress[]
studentWeekProgress CourseStudentWeekProgress[] studentWeekProgress CourseStudentWeekProgress[]
chatThreads ChatThread[] chatThreads ChatThread[]
agentSessions AgentSession[]
enrollments StudentEnrollment[] enrollments StudentEnrollment[]
instructorCurriculumSettings InstructorCurriculumSetting[] instructorCurriculumSettings InstructorCurriculumSetting[]
instructorCurriculumWeekOverrides InstructorCurriculumWeekOverride[] instructorCurriculumWeekOverrides InstructorCurriculumWeekOverride[]
@@ -579,6 +581,7 @@ model CourseWeek {
references CourseWeekReference[] references CourseWeekReference[]
modules CourseWeekModule[] modules CourseWeekModule[]
chatThreads ChatThread[] chatThreads ChatThread[]
agentSessions AgentSession[]
submissionScores SubmissionScore[] submissionScores SubmissionScore[]
submissionTotalScores SubmissionTotalScore[] submissionTotalScores SubmissionTotalScore[]
@@ -835,3 +838,75 @@ model AiChatRun {
@@index([status, createdAt]) @@index([status, createdAt])
@@map("ai_chat_runs") @@map("ai_chat_runs")
} }
model AgentSession {
id String @id @default(uuid()) @db.Uuid
courseId String @map("course_id") @db.Uuid
weekId String? @map("week_id") @db.Uuid
studentId String @map("student_id") @db.Uuid
status String @default("ACTIVE")
title String?
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3)
lastActivityAt DateTime @default(now()) @map("last_activity_at") @db.Timestamptz(3)
course Course @relation(fields: [courseId], references: [id], onDelete: Cascade)
week CourseWeek? @relation(fields: [weekId], references: [id], onDelete: SetNull)
student User @relation(fields: [studentId], references: [id], onDelete: Cascade)
messages AgentMessage[]
@@index([studentId, courseId, weekId, status, lastActivityAt])
@@index([courseId, lastActivityAt])
@@map("agent_sessions")
}
model AgentMessage {
id String @id @default(uuid()) @db.Uuid
sessionId String @map("session_id") @db.Uuid
clientMessageId String @map("client_message_id")
role String
content String
provider String?
model String?
summary String?
explanation String?
actionPlan Json? @map("action_plan")
createModule Json? @map("create_module")
applyState String? @map("apply_state")
applyError String? @map("apply_error")
usage Json?
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3)
session AgentSession @relation(fields: [sessionId], references: [id], onDelete: Cascade)
actions AgentAction[]
@@unique([sessionId, clientMessageId])
@@index([sessionId, createdAt])
@@map("agent_messages")
}
model AgentAction {
id String @id @default(uuid()) @db.Uuid
messageId String @map("message_id") @db.Uuid
clientKey String @map("client_key")
type String
status String
moduleName String? @map("module_name")
path String?
command String?
purpose String?
reason String?
content String?
output String?
exitCode Int? @map("exit_code")
error String?
payload Json?
sortOrder Int @default(0) @map("sort_order")
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3)
message AgentMessage @relation(fields: [messageId], references: [id], onDelete: Cascade)
@@unique([messageId, clientKey])
@@index([messageId, sortOrder])
@@index([type, status, createdAt])
@@map("agent_actions")
}
+19
View File
@@ -0,0 +1,19 @@
import 'reflect-metadata';
import { RequestMethod } from '@nestjs/common';
import { METHOD_METADATA, PATH_METADATA } from '@nestjs/common/constants';
import { AgentController } from './agent.controller';
describe('AgentController', () => {
it.each([
['getSession', 'session', RequestMethod.GET],
['syncSession', 'session/sync', RequestMethod.POST],
['clearSession', 'session', RequestMethod.DELETE],
] as const)('exposes %s through the expected route', (methodName, path, method) => {
const handler = AgentController.prototype[methodName];
expect(Reflect.getMetadata(PATH_METADATA, handler)).toBe(path);
expect(Reflect.getMetadata(METHOD_METADATA, handler)).toBe(method);
});
});
+17 -1
View File
@@ -1,7 +1,8 @@
import { Body, Controller, Get, Post, Query, Req } from '@nestjs/common'; import { Body, Controller, Delete, Get, Post, Query, Req } from '@nestjs/common';
import { Request } from 'express'; import { Request } from 'express';
import { AgentService } from './agent.service'; import { AgentService } from './agent.service';
import { AgentSessionQueryDto, SyncAgentSessionDto } from './dto/agent-session.dto';
import { CreateAgentPlanDto } from './dto/create-agent-plan.dto'; import { CreateAgentPlanDto } from './dto/create-agent-plan.dto';
@Controller('chat/agent') @Controller('chat/agent')
@@ -18,6 +19,21 @@ export class AgentController {
return this.agentService.createPlan(this.getBearerToken(request), body); return this.agentService.createPlan(this.getBearerToken(request), body);
} }
@Get('session')
getSession(@Req() request: Request, @Query() query: AgentSessionQueryDto) {
return this.agentService.getSession(this.getBearerToken(request), query);
}
@Post('session/sync')
syncSession(@Req() request: Request, @Body() body: SyncAgentSessionDto) {
return this.agentService.syncSession(this.getBearerToken(request), body);
}
@Delete('session')
clearSession(@Req() request: Request, @Query() query: AgentSessionQueryDto) {
return this.agentService.clearSession(this.getBearerToken(request), query);
}
private getBearerToken(request: Request): string | undefined { private getBearerToken(request: Request): string | undefined {
const [type, token] = request.headers.authorization?.split(' ') ?? []; const [type, token] = request.headers.authorization?.split(' ') ?? [];
return type === 'Bearer' ? token : undefined; return type === 'Bearer' ? token : undefined;
+100
View File
@@ -118,4 +118,104 @@ describe('AgentService', () => {
createModule: null, createModule: null,
})).toThrow('위험한 시스템 명령'); })).toThrow('위험한 시스템 명령');
}); });
it('parses source and log context requests without proposing actions', () => {
const plan = parsePlan({
summary: '추가 컨텍스트가 필요합니다.',
explanation: '제어 모듈 소스와 실행 로그를 확인합니다.',
requestedModules: ['NAV2-Control'],
requestedLogs: ['NAV2-Control'],
actionPlan: ['소스와 로그 확인'],
commands: [],
changes: [],
createModule: null,
}) as unknown as { requestedModules: string[]; requestedLogs: string[] };
expect(plan.requestedModules).toEqual(['NAV2-Control']);
expect(plan.requestedLogs).toEqual(['NAV2-Control']);
});
it('rejects actions returned together with a context request', () => {
expect(() => parsePlan({
summary: '소스 확인과 빌드를 동시에 요청합니다.',
explanation: '컨텍스트 요청 단계에서는 허용되지 않아야 합니다.',
requestedModules: ['NAV2-Control'],
requestedLogs: [],
actionPlan: ['소스 확인', '빌드'],
commands: [{
id: 'command-1',
moduleName: 'NAV2-Control',
command: './build.sh',
purpose: '제어 모듈 빌드',
}],
changes: [],
createModule: null,
})).toThrow('소스나 로그를 요청하는 단계');
});
it('serializes command results and file changes for persistence', () => {
const service = Object.create(AgentService.prototype) as AgentService;
const privateService = service as unknown as {
parseSyncedMessage: (
value: Record<string, unknown>,
index: number,
) => unknown;
toStoredActions: (message: unknown) => Array<Record<string, unknown>>;
};
const message = privateService.parseSyncedMessage(
{
id: 'agent-plan-1',
role: 'assistant',
content: '빌드와 파일 변경을 제안합니다.',
applyState: 'applied',
plan: {
summary: '제어 모듈을 수정합니다.',
explanation: '로그를 추가한 뒤 빌드합니다.',
actionPlan: ['로그 추가', '빌드'],
commands: [
{
id: 'command-1',
moduleName: 'NAV2-Control',
command: './build.sh',
purpose: '제어 모듈 빌드',
},
],
changes: [
{
moduleName: 'NAV2-Control',
path: 'src/control_pkg/control_pkg/control_manager.py',
content: 'print("started")\n',
reason: '시작 로그 추가',
},
],
createModule: null,
},
commandExecutions: {
'command-1': {
status: 'completed',
output: 'build complete',
exitCode: 0,
},
},
},
0,
);
expect(privateService.toStoredActions(message)).toEqual([
expect.objectContaining({
clientKey: 'command:command-1',
type: 'COMMAND',
status: 'completed',
output: 'build complete',
exitCode: 0,
}),
expect.objectContaining({
clientKey:
'change:NAV2-Control:src/control_pkg/control_pkg/control_manager.py',
type: 'FILE_CHANGE',
status: 'applied',
content: 'print("started")\n',
}),
]);
});
}); });
+533 -2
View File
@@ -1,10 +1,12 @@
import { BadGatewayException, BadRequestException, Injectable, NotFoundException, UnauthorizedException } from '@nestjs/common'; import { BadGatewayException, BadRequestException, Injectable, NotFoundException, UnauthorizedException } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { AuthService, type AuthResponseUser } from '../auth/auth.service'; import { AuthService, type AuthResponseUser } from '../auth/auth.service';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
import { AiCredentialService } from './ai-credential.service'; import { AiCredentialService } from './ai-credential.service';
import { AiProviderService } from './ai-provider.service'; import { AiProviderService } from './ai-provider.service';
import type { AgentCommand, AgentFileChange, AgentModulePlan, AgentPlan, AiProvider } from './ai.types'; import type { AgentCommand, AgentFileChange, AgentModulePlan, AgentPlan, AiProvider } from './ai.types';
import { AgentSessionQueryDto, SyncAgentSessionDto } from './dto/agent-session.dto';
import { CreateAgentPlanDto } from './dto/create-agent-plan.dto'; import { CreateAgentPlanDto } from './dto/create-agent-plan.dto';
const PUBLIC_GUIDE_COURSE_SLUG = 'autonomy-studio-lms-guide'; const PUBLIC_GUIDE_COURSE_SLUG = 'autonomy-studio-lms-guide';
@@ -13,6 +15,13 @@ const MAX_RUNTIME_LOG_CHARACTERS = 60_000;
const MAX_CHANGE_COUNT = 24; const MAX_CHANGE_COUNT = 24;
const MAX_CHANGE_CONTENT_CHARACTERS = 200_000; const MAX_CHANGE_CONTENT_CHARACTERS = 200_000;
const MAX_AGENT_COMMAND_COUNT = 6; const MAX_AGENT_COMMAND_COUNT = 6;
const MAX_SYNC_PAYLOAD_CHARACTERS = 5_000_000;
const AGENT_EXECUTION_STATUSES = new Set(['pending', 'running', 'completed', 'failed', 'rejected']);
const AGENT_APPLY_STATES = new Set(['ready', 'applying', 'applied', 'error']);
type AgentSessionRecord = Prisma.AgentSessionGetPayload<{
include: { messages: { include: { actions: true } } };
}>;
@Injectable() @Injectable()
export class AgentService { export class AgentService {
@@ -34,6 +43,188 @@ export class AgentService {
}; };
} }
async getSession(
accessToken: string | undefined,
query: AgentSessionQueryDto,
) {
const scope = await this.getAgentScope(
accessToken,
query.courseId,
query.week,
);
const session = await this.prisma.agentSession.findFirst({
where: {
studentId: scope.student.id,
courseId: scope.course.id,
weekId: scope.week?.id ?? null,
status: 'ACTIVE',
},
orderBy: { lastActivityAt: 'desc' },
include: {
messages: {
orderBy: { createdAt: 'asc' },
include: { actions: { orderBy: { sortOrder: 'asc' } } },
},
},
});
return this.toSessionResponse(session);
}
async syncSession(accessToken: string | undefined, dto: SyncAgentSessionDto) {
if (JSON.stringify(dto.messages).length > MAX_SYNC_PAYLOAD_CHARACTERS) {
throw new BadRequestException(
'에이전트 대화 동기화 데이터가 너무 큽니다.',
);
}
const scope = await this.getAgentScope(accessToken, dto.courseId, dto.week);
const messages = dto.messages.map((message, index) =>
this.parseSyncedMessage(message, index),
);
const sessionId = await this.prisma.$transaction(async (tx) => {
let session = await tx.agentSession.findFirst({
where: {
studentId: scope.student.id,
courseId: scope.course.id,
weekId: scope.week?.id ?? null,
status: 'ACTIVE',
},
orderBy: { lastActivityAt: 'desc' },
});
const firstUserMessage = messages.find(
(message) => message.role === 'user',
);
if (!session) {
session = await tx.agentSession.create({
data: {
studentId: scope.student.id,
courseId: scope.course.id,
weekId: scope.week?.id ?? null,
title: firstUserMessage?.content.slice(0, 120) || null,
},
});
}
for (const message of messages) {
const plan = message.plan;
const storedMessage = await tx.agentMessage.upsert({
where: {
sessionId_clientMessageId: {
sessionId: session.id,
clientMessageId: message.id,
},
},
create: {
sessionId: session.id,
clientMessageId: message.id,
role: message.role,
content: message.content,
provider: message.provider,
model: message.model,
summary: plan?.summary ?? null,
explanation: plan?.explanation ?? null,
actionPlan: plan
? (plan.actionPlan as Prisma.InputJsonValue)
: Prisma.DbNull,
createModule: plan?.createModule
? (plan.createModule as unknown as Prisma.InputJsonValue)
: Prisma.DbNull,
applyState: message.applyState,
applyError: message.applyError,
usage: message.usage
? (message.usage as Prisma.InputJsonValue)
: Prisma.DbNull,
},
update: {
content: message.content,
provider: message.provider,
model: message.model,
summary: plan?.summary ?? null,
explanation: plan?.explanation ?? null,
actionPlan: plan
? (plan.actionPlan as Prisma.InputJsonValue)
: Prisma.DbNull,
createModule: plan?.createModule
? (plan.createModule as unknown as Prisma.InputJsonValue)
: Prisma.DbNull,
applyState: message.applyState,
applyError: message.applyError,
usage: message.usage
? (message.usage as Prisma.InputJsonValue)
: Prisma.DbNull,
},
});
const actions = this.toStoredActions(message);
await tx.agentAction.deleteMany({
where: {
messageId: storedMessage.id,
...(actions.length
? {
clientKey: {
notIn: actions.map((action) => action.clientKey),
},
}
: {}),
},
});
for (const action of actions) {
await tx.agentAction.upsert({
where: {
messageId_clientKey: {
messageId: storedMessage.id,
clientKey: action.clientKey,
},
},
create: { messageId: storedMessage.id, ...action },
update: action,
});
}
}
await tx.agentSession.update({
where: { id: session.id },
data: {
title:
session.title ?? firstUserMessage?.content.slice(0, 120) ?? null,
lastActivityAt: new Date(),
},
});
return session.id;
});
const session = await this.prisma.agentSession.findUnique({
where: { id: sessionId },
include: {
messages: {
orderBy: { createdAt: 'asc' },
include: { actions: { orderBy: { sortOrder: 'asc' } } },
},
},
});
return this.toSessionResponse(session);
}
async clearSession(
accessToken: string | undefined,
query: AgentSessionQueryDto,
) {
const scope = await this.getAgentScope(
accessToken,
query.courseId,
query.week,
);
const result = await this.prisma.agentSession.updateMany({
where: {
studentId: scope.student.id,
courseId: scope.course.id,
weekId: scope.week?.id ?? null,
status: 'ACTIVE',
},
data: { status: 'ARCHIVED', lastActivityAt: new Date() },
});
return { archivedCount: result.count };
}
async createPlan(accessToken: string | undefined, dto: CreateAgentPlanDto) { async createPlan(accessToken: string | undefined, dto: CreateAgentPlanDto) {
const student = await this.getLmsUser(accessToken); const student = await this.getLmsUser(accessToken);
const course = await this.getStudentCourse(student.id, dto.courseId); const course = await this.getStudentCourse(student.id, dto.courseId);
@@ -48,6 +239,8 @@ export class AgentService {
const modules = dto.modules.map((module) => ({ const modules = dto.modules.map((module) => ({
name: module.name.trim(), name: module.name.trim(),
folderName: module.folderName?.trim() || null, folderName: module.folderName?.trim() || null,
category: module.category?.trim() || null,
state: module.state?.trim() || null,
files: module.files.map((file) => ({ files: module.files.map((file) => ({
path: this.safeRelativePath(file.path), path: this.safeRelativePath(file.path),
content: file.content, content: file.content,
@@ -94,6 +287,323 @@ export class AgentService {
return { provider: setting.provider, model: setting.model, usage: response.usage, plan }; return { provider: setting.provider, model: setting.model, usage: response.usage, plan };
} }
private parseSyncedMessage(value: Record<string, unknown>, index: number) {
const id = this.syncText(value.id, `messages[${index}].id`, 200);
if (!/^[A-Za-z0-9:._-]+$/.test(id))
throw new BadRequestException(
`messages[${index}].id 형식이 올바르지 않습니다.`,
);
const role = this.syncText(value.role, `messages[${index}].role`, 20);
if (role !== 'user' && role !== 'assistant')
throw new BadRequestException(
`messages[${index}].role 값이 올바르지 않습니다.`,
);
const content = this.syncText(
value.content,
`messages[${index}].content`,
10_000,
true,
);
const rawPlan =
value.plan && typeof value.plan === 'object' && !Array.isArray(value.plan)
? (value.plan as Record<string, unknown>)
: null;
let plan: AgentPlan | null = null;
if (rawPlan) {
const moduleNames = new Set<string>();
for (const command of Array.isArray(rawPlan.commands)
? rawPlan.commands
: []) {
if (
command &&
typeof command === 'object' &&
!Array.isArray(command) &&
typeof (command as Record<string, unknown>).moduleName === 'string'
) {
moduleNames.add(
(command as Record<string, unknown>).moduleName as string,
);
}
}
for (const change of Array.isArray(rawPlan.changes)
? rawPlan.changes
: []) {
if (
change &&
typeof change === 'object' &&
!Array.isArray(change) &&
typeof (change as Record<string, unknown>).moduleName === 'string'
) {
moduleNames.add(
(change as Record<string, unknown>).moduleName as string,
);
}
}
plan = this.parsePlan(JSON.stringify(rawPlan), moduleNames);
}
const rawExecutions =
value.commandExecutions &&
typeof value.commandExecutions === 'object' &&
!Array.isArray(value.commandExecutions)
? (value.commandExecutions as Record<string, unknown>)
: {};
const commandExecutions: Record<
string,
{
status: string;
output?: string;
exitCode?: number | null;
error?: string;
}
> = {};
for (const command of plan?.commands ?? []) {
const rawExecution = rawExecutions[command.id];
if (
!rawExecution ||
typeof rawExecution !== 'object' ||
Array.isArray(rawExecution)
)
continue;
const execution = rawExecution as Record<string, unknown>;
const status =
typeof execution.status === 'string' &&
AGENT_EXECUTION_STATUSES.has(execution.status)
? execution.status
: 'pending';
commandExecutions[command.id] = {
status,
output:
typeof execution.output === 'string'
? execution.output.slice(-80_000)
: undefined,
exitCode:
typeof execution.exitCode === 'number' &&
Number.isInteger(execution.exitCode)
? execution.exitCode
: null,
error:
typeof execution.error === 'string'
? execution.error.slice(0, 2_000)
: undefined,
};
}
const applyState =
typeof value.applyState === 'string' &&
AGENT_APPLY_STATES.has(value.applyState)
? value.applyState
: null;
const usage =
value.usage &&
typeof value.usage === 'object' &&
!Array.isArray(value.usage)
? Object.fromEntries(
Object.entries(value.usage as Record<string, unknown>).filter(
([, item]) => typeof item === 'number',
),
)
: null;
return {
id,
role,
content,
provider:
typeof value.provider === 'string' ? value.provider.slice(0, 80) : null,
model: typeof value.model === 'string' ? value.model.slice(0, 200) : null,
plan,
applyState,
applyError:
typeof value.applyError === 'string'
? value.applyError.slice(0, 2_000)
: null,
commandExecutions,
usage: usage && Object.keys(usage).length ? usage : null,
};
}
private toStoredActions(
message: ReturnType<AgentService['parseSyncedMessage']>,
) {
if (!message.plan) return [];
const plan = message.plan;
const actions: Array<{
clientKey: string;
type: string;
status: string;
moduleName: string | null;
path: string | null;
command: string | null;
purpose: string | null;
reason: string | null;
content: string | null;
output: string | null;
exitCode: number | null;
error: string | null;
payload: Prisma.InputJsonValue | typeof Prisma.DbNull;
sortOrder: number;
}> = [];
plan.commands.forEach((command, index) => {
const execution = message.commandExecutions[command.id];
actions.push({
clientKey: `command:${command.id}`,
type: 'COMMAND',
status: execution?.status ?? 'pending',
moduleName: command.moduleName,
path: null,
command: command.command,
purpose: command.purpose,
reason: null,
content: null,
output: execution?.output ?? null,
exitCode: execution?.exitCode ?? null,
error: execution?.error ?? null,
payload: Prisma.DbNull,
sortOrder: index,
});
});
plan.changes.forEach((change, index) => {
actions.push({
clientKey: `change:${change.moduleName}:${change.path}`,
type: 'FILE_CHANGE',
status: message.applyState ?? 'ready',
moduleName: change.moduleName,
path: change.path,
command: null,
purpose: null,
reason: change.reason,
content: change.content,
output: null,
exitCode: null,
error: message.applyError,
payload: Prisma.DbNull,
sortOrder: plan.commands.length + index,
});
});
if (plan.createModule) {
actions.push({
clientKey: `module:${plan.createModule.name}`,
type: 'MODULE_CREATE',
status: message.applyState ?? 'ready',
moduleName: plan.createModule.name,
path: null,
command: null,
purpose: null,
reason: null,
content: null,
output: null,
exitCode: null,
error: message.applyError,
payload: plan.createModule as unknown as Prisma.InputJsonValue,
sortOrder: plan.commands.length + plan.changes.length,
});
}
return actions;
}
private toSessionResponse(session: AgentSessionRecord | null) {
if (!session) return { sessionId: null, messages: [] };
return {
sessionId: session.id,
messages: session.messages.map((message) => {
const commandActions = message.actions.filter(
(action) => action.type === 'COMMAND',
);
const changeActions = message.actions.filter(
(action) => action.type === 'FILE_CHANGE',
);
const moduleAction = message.actions.find(
(action) => action.type === 'MODULE_CREATE',
);
const hasPlan =
message.summary !== null && message.explanation !== null;
return {
id: message.clientMessageId,
role: message.role,
content: message.content,
provider: message.provider ?? undefined,
model: message.model ?? undefined,
usage:
message.usage && typeof message.usage === 'object'
? message.usage
: undefined,
plan: hasPlan
? {
summary: message.summary,
explanation: message.explanation,
requestedModules: [],
requestedLogs: [],
actionPlan: Array.isArray(message.actionPlan)
? message.actionPlan
: [],
commands: commandActions.map((action) => ({
id: action.clientKey.replace(/^command:/, ''),
moduleName: action.moduleName ?? '',
command: action.command ?? '',
purpose: action.purpose ?? '',
})),
changes: changeActions.map((action) => ({
moduleName: action.moduleName ?? '',
path: action.path ?? '',
content: action.content ?? '',
reason: action.reason ?? '',
})),
createModule:
moduleAction?.payload &&
typeof moduleAction.payload === 'object'
? moduleAction.payload
: null,
}
: undefined,
applyState: message.applyState ?? undefined,
applyError: message.applyError ?? undefined,
commandExecutions: Object.fromEntries(
commandActions.map((action) => [
action.clientKey.replace(/^command:/, ''),
{
status: action.status,
output: action.output ?? undefined,
exitCode: action.exitCode,
error: action.error ?? undefined,
},
]),
),
};
}),
};
}
private syncText(
value: unknown,
field: string,
maxLength: number,
allowEmpty = false,
): string {
if (
typeof value !== 'string' ||
(!allowEmpty && !value.trim()) ||
value.length > maxLength
) {
throw new BadRequestException(`${field} 값이 올바르지 않습니다.`);
}
return allowEmpty ? value : value.trim();
}
private async getAgentScope(
accessToken: string | undefined,
courseId: string,
weekNumber?: number,
) {
const student = await this.getLmsUser(accessToken);
const course = await this.getStudentCourse(student.id, courseId);
const week = weekNumber
? await this.prisma.courseWeek.findUnique({
where: { courseId_week: { courseId: course.id, week: weekNumber } },
})
: null;
if (weekNumber && !week)
throw new NotFoundException('강좌 주차를 찾을 수 없습니다.');
return { student, course, week };
}
private parsePlan(content: string, currentModules: Set<string>): AgentPlan { private parsePlan(content: string, currentModules: Set<string>): AgentPlan {
const normalized = content.trim().replace(/^```(?:json)?\s*/i, '').replace(/\s*```$/, ''); const normalized = content.trim().replace(/^```(?:json)?\s*/i, '').replace(/\s*```$/, '');
let value: unknown; let value: unknown;
@@ -106,6 +616,20 @@ export class AgentService {
throw new BadGatewayException('AI 변경 계획 형식이 올바르지 않습니다.'); throw new BadGatewayException('AI 변경 계획 형식이 올바르지 않습니다.');
} }
const input = value as Record<string, unknown>; const input = value as Record<string, unknown>;
const rawRequestedModules = Array.isArray(input.requestedModules) ? input.requestedModules : [];
if (rawRequestedModules.length > 4) throw new BadGatewayException('한 번에 4개 이하의 모듈 소스만 요청할 수 있습니다.');
const requestedModules = rawRequestedModules.map((moduleName, index) => {
const name = this.requiredText(moduleName, `requestedModules[${index}]`, 120);
if (!currentModules.has(name)) throw new BadGatewayException(`존재하지 않는 모듈 소스 요청입니다: ${name}`);
return name;
});
const rawRequestedLogs = Array.isArray(input.requestedLogs) ? input.requestedLogs : [];
if (rawRequestedLogs.length > 3) throw new BadGatewayException('한 번에 3개 이하의 모듈 로그만 요청할 수 있습니다.');
const requestedLogs = rawRequestedLogs.map((moduleName, index) => {
const name = this.requiredText(moduleName, `requestedLogs[${index}]`, 120);
if (!currentModules.has(name)) throw new BadGatewayException(`존재하지 않는 모듈 로그 요청입니다: ${name}`);
return name;
});
const createModule = this.parseModule(input.createModule); const createModule = this.parseModule(input.createModule);
const allowedModules = new Set(currentModules); const allowedModules = new Set(currentModules);
if (createModule) allowedModules.add(createModule.name); if (createModule) allowedModules.add(createModule.name);
@@ -118,9 +642,14 @@ export class AgentService {
const rawCommands = Array.isArray(input.commands) ? input.commands : []; const rawCommands = Array.isArray(input.commands) ? input.commands : [];
if (rawCommands.length > MAX_AGENT_COMMAND_COUNT) throw new BadGatewayException(`한 번에 ${MAX_AGENT_COMMAND_COUNT}개 이하의 명령만 제안할 수 있습니다.`); if (rawCommands.length > MAX_AGENT_COMMAND_COUNT) throw new BadGatewayException(`한 번에 ${MAX_AGENT_COMMAND_COUNT}개 이하의 명령만 제안할 수 있습니다.`);
const commands = rawCommands.map((command) => this.parseCommand(command, allowedModules)); const commands = rawCommands.map((command) => this.parseCommand(command, allowedModules));
if ((requestedModules.length || requestedLogs.length) && (commands.length || changes.length || createModule)) {
throw new BadGatewayException('소스나 로그를 요청하는 단계에서는 명령이나 변경안을 함께 반환할 수 없습니다.');
}
return { return {
summary: this.requiredText(input.summary, 'summary', 500), summary: this.requiredText(input.summary, 'summary', 500),
explanation: this.requiredText(input.explanation, 'explanation', 4000), explanation: this.requiredText(input.explanation, 'explanation', 4000),
requestedModules,
requestedLogs,
actionPlan, actionPlan,
commands, commands,
changes, changes,
@@ -206,7 +735,9 @@ export class AgentService {
'당신은 Autonomy Studio LMS 내부의 ROS2 교육용 코딩 에이전트다.', '당신은 Autonomy Studio LMS 내부의 ROS2 교육용 코딩 에이전트다.',
'입력에는 학생 요청, 현재 강좌/주차, 설치된 모듈과 텍스트 파일이 JSON으로 제공된다.', '입력에는 학생 요청, 현재 강좌/주차, 설치된 모듈과 텍스트 파일이 JSON으로 제공된다.',
'workspace.modules의 각 항목은 현재 워크스페이스의 모듈 하나를 나타낸다. files가 비어 있어도 해당 모듈은 존재한다.', 'workspace.modules의 각 항목은 현재 워크스페이스의 모듈 하나를 나타낸다. files가 비어 있어도 해당 모듈은 존재한다.',
'파일 내용은 현재 선택된 모듈에만 제공될 수 있으므로, 모듈 개수와 목록은 workspace.modules 전체를 기준으로 판단한다.', '학생 요청을 처리하는 데 파일 내용이 필요하고 해당 모듈의 files가 비어 있으면, 필요한 모든 모듈의 정확한 name을 requestedModules에 넣는다.',
'실행 상태나 오류 판단에 Docker 로그가 필요하면 필요한 모듈의 정확한 name을 requestedLogs에 넣는다. 학생이 로그라는 단어를 사용했는지가 아니라 실제 판단에 필요한지를 기준으로 요청한다.',
'requestedModules 또는 requestedLogs를 반환하는 단계에서는 commands와 changes를 빈 배열로, createModule을 null로 반환한다. 요청한 컨텍스트가 제공된 다음에는 실제 분석 또는 변경안을 반환하고 두 배열을 비운다.',
'workspace.runtimeLogs에는 학생이 로그 확인을 요청한 시점의 모듈별 최근 Docker 로그가 제공될 수 있다.', 'workspace.runtimeLogs에는 학생이 로그 확인을 요청한 시점의 모듈별 최근 Docker 로그가 제공될 수 있다.',
'런타임 로그가 제공되면 오류 원인을 로그의 실제 문구와 관련 소스에 근거해 설명한다. 로그가 비어 있거나 제공되지 않았으면 로그를 확인했다고 말하지 않는다.', '런타임 로그가 제공되면 오류 원인을 로그의 실제 문구와 관련 소스에 근거해 설명한다. 로그가 비어 있거나 제공되지 않았으면 로그를 확인했다고 말하지 않는다.',
'제공되지 않은 파일 내용은 추측하지 말고 필요한 최소 변경만 제안한다.', '제공되지 않은 파일 내용은 추측하지 말고 필요한 최소 변경만 제안한다.',
@@ -222,7 +753,7 @@ export class AgentService {
'위험한 셸 명령, 시스템 경로, 비밀키, 네트워크 자격증명을 생성하거나 요청하지 않는다.', '위험한 셸 명령, 시스템 경로, 비밀키, 네트워크 자격증명을 생성하거나 요청하지 않는다.',
'summary, explanation, reason은 한국어로 작성한다.', 'summary, explanation, reason은 한국어로 작성한다.',
'반드시 다음 JSON 객체만 반환한다:', '반드시 다음 JSON 객체만 반환한다:',
'{"summary":"요약","explanation":"변경 설명","actionPlan":["단계 1","단계 2"],"commands":[{"id":"command-1","moduleName":"모듈명","command":"실행 명령","purpose":"명령 목적"}],"changes":[{"moduleName":"모듈명","path":"상대/경로","content":"전체 내용","reason":"변경 이유"}],"createModule":null}', '{"summary":"요약","explanation":"변경 설명","requestedModules":[],"requestedLogs":[],"actionPlan":["단계 1","단계 2"],"commands":[{"id":"command-1","moduleName":"모듈명","command":"실행 명령","purpose":"명령 목적"}],"changes":[{"moduleName":"모듈명","path":"상대/경로","content":"전체 내용","reason":"변경 이유"}],"createModule":null}',
'새 모듈이면 createModule은 {"name","category","folderName","dockerImage","packageName","launchFile"} 객체다.', '새 모듈이면 createModule은 {"name","category","folderName","dockerImage","packageName","launchFile"} 객체다.',
].join('\n'); ].join('\n');
} }
+2
View File
@@ -38,6 +38,8 @@ export type AgentCommand = {
export type AgentPlan = { export type AgentPlan = {
summary: string; summary: string;
explanation: string; explanation: string;
requestedModules: string[];
requestedLogs: string[];
actionPlan: string[]; actionPlan: string[];
commands: AgentCommand[]; commands: AgentCommand[];
changes: AgentFileChange[]; changes: AgentFileChange[];
+44
View File
@@ -0,0 +1,44 @@
import 'reflect-metadata';
import { plainToInstance } from 'class-transformer';
import { validate } from 'class-validator';
import { AgentSessionQueryDto, SyncAgentSessionDto } from './agent-session.dto';
describe('AgentSessionDto', () => {
const courseId = '019f3689-4fe2-7b93-a7c4-72e51fe3ec17';
it('transforms and validates the requested course week', async () => {
const dto = plainToInstance(AgentSessionQueryDto, { courseId, week: '3' });
expect(await validate(dto)).toHaveLength(0);
expect(dto.week).toBe(3);
});
it('accepts bounded agent messages for synchronization', async () => {
const dto = plainToInstance(SyncAgentSessionDto, {
courseId,
week: 1,
messages: [
{ id: 'agent-user-1', role: 'user', content: '빌드 로그를 확인해줘' },
],
});
expect(await validate(dto)).toHaveLength(0);
});
it('rejects more than 100 synchronized messages', async () => {
const dto = plainToInstance(SyncAgentSessionDto, {
courseId,
messages: Array.from({ length: 101 }, (_, index) => ({
id: `agent-user-${index}`,
role: 'user',
content: 'test',
})),
});
const errors = await validate(dto);
expect(errors.some((error) => error.property === 'messages')).toBe(true);
});
});
+32
View File
@@ -0,0 +1,32 @@
import { Type } from 'class-transformer';
import {
ArrayMaxSize,
ArrayMinSize,
IsArray,
IsInt,
IsObject,
IsOptional,
IsUUID,
Max,
Min,
} from 'class-validator';
export class AgentSessionQueryDto {
@IsUUID()
courseId: string;
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
@Max(100)
week?: number;
}
export class SyncAgentSessionDto extends AgentSessionQueryDto {
@IsArray()
@ArrayMinSize(1)
@ArrayMaxSize(100)
@IsObject({ each: true })
messages: Array<Record<string, unknown>>;
}
+10
View File
@@ -21,6 +21,16 @@ class AgentWorkspaceModuleDto {
@MaxLength(120) @MaxLength(120)
folderName?: string; folderName?: string;
@IsOptional()
@IsString()
@MaxLength(80)
category?: string;
@IsOptional()
@IsString()
@MaxLength(20)
state?: string;
@IsArray() @IsArray()
@ArrayMaxSize(24) @ArrayMaxSize(24)
@ValidateNested({ each: true }) @ValidateNested({ each: true })
+5 -1
View File
@@ -1,11 +1,15 @@
import { RequestMethod, ValidationPipe } from '@nestjs/common'; import { RequestMethod, ValidationPipe } from '@nestjs/common';
import { NestFactory } from '@nestjs/core'; import { NestFactory } from '@nestjs/core';
import { ConfigService } from '@nestjs/config'; import { ConfigService } from '@nestjs/config';
import { json, urlencoded } from 'express';
import { AppModule } from './app.module'; import { AppModule } from './app.module';
async function bootstrap() { async function bootstrap() {
const app = await NestFactory.create(AppModule); const app = await NestFactory.create(AppModule, { bodyParser: false });
app.use('/api/chat/agent/session/sync', json({ limit: '16mb' }));
app.use(json({ limit: '1mb' }));
app.use(urlencoded({ extended: true, limit: '1mb' }));
const configService = app.get(ConfigService); const configService = app.get(ConfigService);
const port = configService.get<number>('PORT', 4400); const port = configService.get<number>('PORT', 4400);
const corsOrigin = configService.get<string>( const corsOrigin = configService.get<string>(