diff --git a/src/agent/agent-invocation.repository.ts b/src/agent/agent-invocation.repository.ts new file mode 100644 index 0000000..0c8179c --- /dev/null +++ b/src/agent/agent-invocation.repository.ts @@ -0,0 +1,212 @@ +/** + * Agent 调用记录仓储,统一封装 MySQL/Prisma 持久化和本地内存降级。 + * 服务层只提交脱敏后的摘要,避免把完整密钥、请求头或外部凭据写入历史。 + */ +import { Injectable } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { AgentInvocationPurpose, Prisma } from '@prisma/client'; +import { redactSensitive } from '../common/security/redact-sensitive'; +import { EnvConfig } from '../config/env.schema'; +import { PrismaService } from '../prisma/prisma.service'; +import { + AgentInvocation, + AgentInvocationStatus, + AgentPurpose, +} from './agent.types'; + +type StoredAgentInvocationInputDigest = { + projectKey?: string; + promptSummary?: string; + contextSummary?: string; +}; + +type StoredAgentInvocationOutputDigest = { + resultMarkdown?: string; +}; + +export type CreateAgentInvocationRecordInput = { + runId?: string; + projectKey?: string; + type: AgentPurpose; + status: AgentInvocationStatus; + promptVersion: string; + promptSummary: string; + contextSummary?: string; + resultMarkdown?: string; + actorId?: string; + actorName?: string; + model?: string; + tokenEstimate?: number; + errorSummary?: string; +}; + +type AgentInvocationRecord = Prisma.AgentInvocationGetPayload; + +const purposeToPrisma: Record = { + 'release-risk': AgentInvocationPurpose.RISK_SUMMARY, + 'failure-diagnosis': AgentInvocationPurpose.FAILURE_DIAGNOSIS, + 'runbook-qa': AgentInvocationPurpose.RUNBOOK_QA, + 'release-note': AgentInvocationPurpose.RELEASE_NOTES, + 'incident-review': AgentInvocationPurpose.INCIDENT_REVIEW, +}; + +const purposeFromPrisma: Record = { + [AgentInvocationPurpose.RISK_SUMMARY]: 'release-risk', + [AgentInvocationPurpose.FAILURE_DIAGNOSIS]: 'failure-diagnosis', + [AgentInvocationPurpose.RUNBOOK_QA]: 'runbook-qa', + [AgentInvocationPurpose.RELEASE_NOTES]: 'release-note', + [AgentInvocationPurpose.INCIDENT_REVIEW]: 'incident-review', +}; + +const completedStatuses = new Set(['success', 'failed']); + +@Injectable() +export class AgentInvocationRepository { + private readonly memoryInvocations: AgentInvocation[] = []; + + constructor( + private readonly config: ConfigService, + private readonly prisma: PrismaService, + ) {} + + /** + * 返回最近的 Agent 调用记录;权限过滤放在服务层,避免 JSON 条件绑定数据库方言。 + */ + async listRecent(limit = 100): Promise { + if (!this.useDatabase()) { + return [...this.memoryInvocations] + .sort((left, right) => right.createdAt.localeCompare(left.createdAt)) + .slice(0, limit); + } + + const records = await this.prisma.agentInvocation.findMany({ + orderBy: { + createdAt: 'desc', + }, + take: limit, + }); + + return records.map((record) => this.toSummary(record)); + } + + /** + * 持久化单次 Agent 调用的脱敏摘要;完整上下文不落库,只保留可回放的业务结果。 + */ + async create( + input: CreateAgentInvocationRecordInput, + ): Promise { + const sanitized = this.sanitizeInput(input); + + if (!this.useDatabase()) { + const invocation: AgentInvocation = { + id: `agent_${Date.now()}_${this.memoryInvocations.length + 1}`, + runId: sanitized.runId, + projectKey: sanitized.projectKey, + type: sanitized.type, + status: sanitized.status, + promptSummary: sanitized.promptSummary, + contextSummary: sanitized.contextSummary, + resultMarkdown: sanitized.resultMarkdown, + createdAt: new Date().toISOString(), + }; + + this.memoryInvocations.unshift(invocation); + return invocation; + } + + const record = await this.prisma.agentInvocation.create({ + data: { + deployRunId: sanitized.runId, + purpose: purposeToPrisma[sanitized.type], + actorId: sanitized.actorId, + actorName: sanitized.actorName, + model: sanitized.model, + promptVersion: sanitized.promptVersion, + inputDigest: this.inputDigest(sanitized), + outputDigest: this.outputDigest(sanitized), + tokenEstimate: sanitized.tokenEstimate, + status: sanitized.status, + errorSummary: sanitized.errorSummary, + completedAt: completedStatuses.has(sanitized.status) + ? new Date() + : undefined, + }, + }); + + return this.toSummary(record); + } + + private sanitizeInput( + input: CreateAgentInvocationRecordInput, + ): CreateAgentInvocationRecordInput { + return redactSensitive(input); + } + + private inputDigest( + input: CreateAgentInvocationRecordInput, + ): Prisma.InputJsonObject { + return this.compactJsonObject({ + projectKey: input.projectKey, + promptSummary: input.promptSummary, + contextSummary: input.contextSummary, + }); + } + + private outputDigest( + input: CreateAgentInvocationRecordInput, + ): Prisma.InputJsonObject | undefined { + if (!input.resultMarkdown) { + return undefined; + } + + return this.compactJsonObject({ + resultMarkdown: input.resultMarkdown, + }); + } + + private toSummary(record: AgentInvocationRecord): AgentInvocation { + const inputDigest = this.jsonObject( + record.inputDigest, + ); + const outputDigest = this.jsonObject( + record.outputDigest, + ); + + return { + id: record.id, + runId: record.deployRunId ?? undefined, + projectKey: inputDigest.projectKey, + type: purposeFromPrisma[record.purpose], + status: record.status as AgentInvocationStatus, + promptSummary: inputDigest.promptSummary ?? 'Agent 调用记录', + contextSummary: inputDigest.contextSummary, + resultMarkdown: outputDigest.resultMarkdown ?? record.errorSummary ?? undefined, + createdAt: record.createdAt.toISOString(), + }; + } + + private jsonObject>( + value: Prisma.JsonValue | null | undefined, + ): T { + if (value && typeof value === 'object' && !Array.isArray(value)) { + return { ...(value as Record) } as T; + } + + return {} as T; + } + + private compactJsonObject( + input: Record, + ): Prisma.InputJsonObject { + return Object.fromEntries( + Object.entries(input).filter(([, value]) => value !== undefined), + ); + } + + private useDatabase(): boolean { + return ( + this.config.get('USE_DATABASE_READS', { infer: true }) && + Boolean(this.config.get('DATABASE_URL', { infer: true })) + ); + } +} diff --git a/src/agent/agent.controller.ts b/src/agent/agent.controller.ts index f55dccc..5be85af 100644 --- a/src/agent/agent.controller.ts +++ b/src/agent/agent.controller.ts @@ -26,8 +26,10 @@ export class AgentController { @Get() @ApiOkResponse({ description: '查询 Agent 调用记录。' }) - listInvocations(): AgentInvocation[] { - return this.agentService.listInvocations(); + async listInvocations( + @CurrentUser() user: AuthenticatedUser, + ): Promise { + return this.agentService.listInvocations(user); } @Post() diff --git a/src/agent/agent.module.ts b/src/agent/agent.module.ts index 3eb2d4c..7802ecb 100644 --- a/src/agent/agent.module.ts +++ b/src/agent/agent.module.ts @@ -4,6 +4,7 @@ import { AuditModule } from '../audit/audit.module'; import { AuthModule } from '../auth/auth.module'; import { DeployRunsModule } from '../deploy-runs/deploy-runs.module'; import { ProjectsModule } from '../projects/projects.module'; +import { AgentInvocationRepository } from './agent-invocation.repository'; import { AgentController } from './agent.controller'; import { AgentService } from './agent.service'; import { LlmClient } from './llm.client'; @@ -17,7 +18,7 @@ import { LlmClient } from './llm.client'; ProjectsModule, ], controllers: [AgentController], - providers: [AgentService, LlmClient], - exports: [AgentService, LlmClient], + providers: [AgentInvocationRepository, AgentService, LlmClient], + exports: [AgentInvocationRepository, AgentService, LlmClient], }) export class AgentModule {} diff --git a/src/agent/agent.service.spec.ts b/src/agent/agent.service.spec.ts index f4fe4e3..a0173df 100644 --- a/src/agent/agent.service.spec.ts +++ b/src/agent/agent.service.spec.ts @@ -4,7 +4,9 @@ import { AuthenticatedUser } from '../auth/auth.types'; import { AppError } from '../common/errors/app-error'; import { DeployRunsService } from '../deploy-runs/deploy-runs.service'; import { ProjectsService } from '../projects/projects.service'; +import { AgentInvocationRepository } from './agent-invocation.repository'; import { AgentService } from './agent.service'; +import { AgentInvocation } from './agent.types'; import { CreateAgentInvocationInput } from './create-agent-invocation.dto'; import { LlmClient } from './llm.client'; @@ -100,11 +102,28 @@ describe('AgentService', () => { }), ), } satisfies Pick; + const agentInvocations = { + listRecent: jest.fn(() => Promise.resolve([])), + create: jest.fn((input) => + Promise.resolve({ + id: `agent_${Date.now()}`, + runId: input.runId, + projectKey: input.projectKey, + type: input.type, + status: input.status, + promptSummary: input.promptSummary, + contextSummary: input.contextSummary, + resultMarkdown: input.resultMarkdown, + createdAt: new Date().toISOString(), + } satisfies AgentInvocation), + ), + } satisfies Pick; return { audit, auth, deployRuns, + agentInvocations, llm, projects, service: new AgentService( @@ -113,6 +132,7 @@ describe('AgentService', () => { auth as unknown as AuthService, deployRuns as unknown as DeployRunsService, projects as unknown as ProjectsService, + agentInvocations as unknown as AgentInvocationRepository, ), }; } diff --git a/src/agent/agent.service.ts b/src/agent/agent.service.ts index 791f917..f3d7dab 100644 --- a/src/agent/agent.service.ts +++ b/src/agent/agent.service.ts @@ -11,6 +11,7 @@ import { DeployRunSummary } from '../deploy-runs/deploy-run.types'; import { DeployRunsService } from '../deploy-runs/deploy-runs.service'; import { ProjectSummary } from '../projects/project.types'; import { ProjectsService } from '../projects/projects.service'; +import { AgentInvocationRepository } from './agent-invocation.repository'; import { AgentInvocation, AgentPurpose } from './agent.types'; import { CreateAgentInvocationInput } from './create-agent-invocation.dto'; import { LlmClient } from './llm.client'; @@ -46,19 +47,31 @@ type AgentInvocationContext = { @Injectable() export class AgentService { - private readonly invocations: AgentInvocation[] = []; - constructor( private readonly auditService: AuditService, private readonly llmClient: LlmClient, private readonly authService: AuthService, private readonly deployRunsService: DeployRunsService, private readonly projectsService: ProjectsService, + private readonly agentInvocationRepository: AgentInvocationRepository, ) {} - listInvocations(): AgentInvocation[] { - return [...this.invocations].sort((left, right) => - right.createdAt.localeCompare(left.createdAt), + async listInvocations( + operator: AuthenticatedUser, + ): Promise { + const invocations = await this.agentInvocationRepository.listRecent(); + + if (operator.role === 'super_admin') { + return invocations; + } + + const allowedProjectKeys = new Set( + await this.authService.listAllowedProjectKeys(operator), + ); + + return invocations.filter( + (invocation) => + invocation.projectKey && allowedProjectKeys.has(invocation.projectKey), ); } @@ -85,14 +98,18 @@ export class AgentService { llmHealth.status === 'ok', ); const contextSummary = this.contextSummaryFor(context); + const promptVersion = 'devops-agent-v2'; + const systemPrompt = this.systemPromptFor(input.type); + const userPrompt = this.userPromptFor(input, context); const baseInvocation = { - id: `agent_${Date.now()}_${this.invocations.length + 1}`, runId: context.run?.id ?? input.runId, projectKey: context.project?.key ?? input.projectKey, type: input.type, + promptVersion, promptSummary: input.promptSummary, contextSummary, - createdAt: new Date().toISOString(), + actorId: operator.id, + actorName: operator.account, }; let invocation: AgentInvocation; @@ -100,16 +117,22 @@ export class AgentService { try { const completion = await this.llmClient.complete({ purpose: input.type, - promptVersion: 'devops-agent-v2', - systemPrompt: this.systemPromptFor(input.type), - userPrompt: this.userPromptFor(input, context), + promptVersion, + systemPrompt, + userPrompt, }); - invocation = { + invocation = await this.agentInvocationRepository.create({ ...baseInvocation, status: 'success', resultMarkdown: completion.text || 'Agent 返回了空结果,请检查模型配置。', - }; + model: completion.model, + tokenEstimate: this.estimateTokenCount( + systemPrompt, + userPrompt, + completion.text, + ), + }); await this.recordInvocationAudit( invocation, operator, @@ -118,14 +141,18 @@ export class AgentService { contextSummary, ); } catch (error) { - invocation = { + const errorSummary = + error instanceof Error + ? redactSensitive({ message: error.message }).message + : 'Agent 调用失败'; + + invocation = await this.agentInvocationRepository.create({ ...baseInvocation, status: 'failed', - resultMarkdown: - error instanceof Error - ? redactSensitive({ message: error.message }).message - : 'Agent 调用失败', - }; + resultMarkdown: errorSummary, + errorSummary, + tokenEstimate: this.estimateTokenCount(systemPrompt, userPrompt), + }); await this.recordInvocationAudit( invocation, operator, @@ -135,12 +162,16 @@ export class AgentService { ); } } else { - invocation = { + const errorSummary = + llmHealth.message ?? 'Agent 配置不完整,请先配置 key、baseURL 和 model。'; + + invocation = await this.agentInvocationRepository.create({ ...baseInvocation, status: 'failed', - resultMarkdown: - llmHealth.message ?? 'Agent 配置不完整,请先配置 key、baseURL 和 model。', - }; + resultMarkdown: errorSummary, + errorSummary, + tokenEstimate: this.estimateTokenCount(systemPrompt, userPrompt), + }); await this.recordInvocationAudit( invocation, operator, @@ -151,7 +182,6 @@ export class AgentService { ); } - this.invocations.unshift(invocation); return invocation; } @@ -424,6 +454,14 @@ export class AgentService { : text.slice(text.length - jenkinsLogContextLimit); } + private estimateTokenCount(...parts: Array): number { + const characters = parts + .filter((part): part is string => Boolean(part)) + .reduce((total, part) => total + part.length, 0); + + return Math.max(1, Math.ceil(characters / 4)); + } + private safeErrorMessage(error: unknown): string { const message = error instanceof Error ? error.message : '未知错误'; return redactSensitive({ message }).message;