feat: 持久化 Agent 调用记录

- src/agent: 新增 AgentInvocationRepository,统一封装 Prisma 落库和本地降级存储
- src/agent/agent.service.ts: 创建 Agent 调用后写入数据库,并按项目权限过滤历史列表
- src/agent/agent.module.ts: 注册 Agent 调用记录仓储依赖
This commit is contained in:
湛兮
2026-06-12 05:55:12 +08:00
parent 9343e74b2c
commit 1d15c1c676
5 changed files with 300 additions and 27 deletions
+212
View File
@@ -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<object>;
const purposeToPrisma: Record<AgentPurpose, AgentInvocationPurpose> = {
'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, AgentPurpose> = {
[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<AgentInvocationStatus>(['success', 'failed']);
@Injectable()
export class AgentInvocationRepository {
private readonly memoryInvocations: AgentInvocation[] = [];
constructor(
private readonly config: ConfigService<EnvConfig, true>,
private readonly prisma: PrismaService,
) {}
/**
* 返回最近的 Agent 调用记录;权限过滤放在服务层,避免 JSON 条件绑定数据库方言。
*/
async listRecent(limit = 100): Promise<AgentInvocation[]> {
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<AgentInvocation> {
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<StoredAgentInvocationInputDigest>(
record.inputDigest,
);
const outputDigest = this.jsonObject<StoredAgentInvocationOutputDigest>(
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<T extends Record<string, unknown>>(
value: Prisma.JsonValue | null | undefined,
): T {
if (value && typeof value === 'object' && !Array.isArray(value)) {
return { ...(value as Record<string, unknown>) } as T;
}
return {} as T;
}
private compactJsonObject(
input: Record<string, string | number | boolean | undefined>,
): 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 }))
);
}
}
+4 -2
View File
@@ -26,8 +26,10 @@ export class AgentController {
@Get() @Get()
@ApiOkResponse({ description: '查询 Agent 调用记录。' }) @ApiOkResponse({ description: '查询 Agent 调用记录。' })
listInvocations(): AgentInvocation[] { async listInvocations(
return this.agentService.listInvocations(); @CurrentUser() user: AuthenticatedUser,
): Promise<AgentInvocation[]> {
return this.agentService.listInvocations(user);
} }
@Post() @Post()
+3 -2
View File
@@ -4,6 +4,7 @@ import { AuditModule } from '../audit/audit.module';
import { AuthModule } from '../auth/auth.module'; import { AuthModule } from '../auth/auth.module';
import { DeployRunsModule } from '../deploy-runs/deploy-runs.module'; import { DeployRunsModule } from '../deploy-runs/deploy-runs.module';
import { ProjectsModule } from '../projects/projects.module'; import { ProjectsModule } from '../projects/projects.module';
import { AgentInvocationRepository } from './agent-invocation.repository';
import { AgentController } from './agent.controller'; import { AgentController } from './agent.controller';
import { AgentService } from './agent.service'; import { AgentService } from './agent.service';
import { LlmClient } from './llm.client'; import { LlmClient } from './llm.client';
@@ -17,7 +18,7 @@ import { LlmClient } from './llm.client';
ProjectsModule, ProjectsModule,
], ],
controllers: [AgentController], controllers: [AgentController],
providers: [AgentService, LlmClient], providers: [AgentInvocationRepository, AgentService, LlmClient],
exports: [AgentService, LlmClient], exports: [AgentInvocationRepository, AgentService, LlmClient],
}) })
export class AgentModule {} export class AgentModule {}
+20
View File
@@ -4,7 +4,9 @@ import { AuthenticatedUser } from '../auth/auth.types';
import { AppError } from '../common/errors/app-error'; import { AppError } from '../common/errors/app-error';
import { DeployRunsService } from '../deploy-runs/deploy-runs.service'; import { DeployRunsService } from '../deploy-runs/deploy-runs.service';
import { ProjectsService } from '../projects/projects.service'; import { ProjectsService } from '../projects/projects.service';
import { AgentInvocationRepository } from './agent-invocation.repository';
import { AgentService } from './agent.service'; import { AgentService } from './agent.service';
import { AgentInvocation } from './agent.types';
import { CreateAgentInvocationInput } from './create-agent-invocation.dto'; import { CreateAgentInvocationInput } from './create-agent-invocation.dto';
import { LlmClient } from './llm.client'; import { LlmClient } from './llm.client';
@@ -100,11 +102,28 @@ describe('AgentService', () => {
}), }),
), ),
} satisfies Pick<ProjectsService, 'getProject'>; } satisfies Pick<ProjectsService, 'getProject'>;
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<AgentInvocationRepository, 'listRecent' | 'create'>;
return { return {
audit, audit,
auth, auth,
deployRuns, deployRuns,
agentInvocations,
llm, llm,
projects, projects,
service: new AgentService( service: new AgentService(
@@ -113,6 +132,7 @@ describe('AgentService', () => {
auth as unknown as AuthService, auth as unknown as AuthService,
deployRuns as unknown as DeployRunsService, deployRuns as unknown as DeployRunsService,
projects as unknown as ProjectsService, projects as unknown as ProjectsService,
agentInvocations as unknown as AgentInvocationRepository,
), ),
}; };
} }
+61 -23
View File
@@ -11,6 +11,7 @@ import { DeployRunSummary } from '../deploy-runs/deploy-run.types';
import { DeployRunsService } from '../deploy-runs/deploy-runs.service'; import { DeployRunsService } from '../deploy-runs/deploy-runs.service';
import { ProjectSummary } from '../projects/project.types'; import { ProjectSummary } from '../projects/project.types';
import { ProjectsService } from '../projects/projects.service'; import { ProjectsService } from '../projects/projects.service';
import { AgentInvocationRepository } from './agent-invocation.repository';
import { AgentInvocation, AgentPurpose } from './agent.types'; import { AgentInvocation, AgentPurpose } from './agent.types';
import { CreateAgentInvocationInput } from './create-agent-invocation.dto'; import { CreateAgentInvocationInput } from './create-agent-invocation.dto';
import { LlmClient } from './llm.client'; import { LlmClient } from './llm.client';
@@ -46,19 +47,31 @@ type AgentInvocationContext = {
@Injectable() @Injectable()
export class AgentService { export class AgentService {
private readonly invocations: AgentInvocation[] = [];
constructor( constructor(
private readonly auditService: AuditService, private readonly auditService: AuditService,
private readonly llmClient: LlmClient, private readonly llmClient: LlmClient,
private readonly authService: AuthService, private readonly authService: AuthService,
private readonly deployRunsService: DeployRunsService, private readonly deployRunsService: DeployRunsService,
private readonly projectsService: ProjectsService, private readonly projectsService: ProjectsService,
private readonly agentInvocationRepository: AgentInvocationRepository,
) {} ) {}
listInvocations(): AgentInvocation[] { async listInvocations(
return [...this.invocations].sort((left, right) => operator: AuthenticatedUser,
right.createdAt.localeCompare(left.createdAt), ): Promise<AgentInvocation[]> {
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', llmHealth.status === 'ok',
); );
const contextSummary = this.contextSummaryFor(context); const contextSummary = this.contextSummaryFor(context);
const promptVersion = 'devops-agent-v2';
const systemPrompt = this.systemPromptFor(input.type);
const userPrompt = this.userPromptFor(input, context);
const baseInvocation = { const baseInvocation = {
id: `agent_${Date.now()}_${this.invocations.length + 1}`,
runId: context.run?.id ?? input.runId, runId: context.run?.id ?? input.runId,
projectKey: context.project?.key ?? input.projectKey, projectKey: context.project?.key ?? input.projectKey,
type: input.type, type: input.type,
promptVersion,
promptSummary: input.promptSummary, promptSummary: input.promptSummary,
contextSummary, contextSummary,
createdAt: new Date().toISOString(), actorId: operator.id,
actorName: operator.account,
}; };
let invocation: AgentInvocation; let invocation: AgentInvocation;
@@ -100,16 +117,22 @@ export class AgentService {
try { try {
const completion = await this.llmClient.complete({ const completion = await this.llmClient.complete({
purpose: input.type, purpose: input.type,
promptVersion: 'devops-agent-v2', promptVersion,
systemPrompt: this.systemPromptFor(input.type), systemPrompt,
userPrompt: this.userPromptFor(input, context), userPrompt,
}); });
invocation = { invocation = await this.agentInvocationRepository.create({
...baseInvocation, ...baseInvocation,
status: 'success', status: 'success',
resultMarkdown: completion.text || 'Agent 返回了空结果,请检查模型配置。', resultMarkdown: completion.text || 'Agent 返回了空结果,请检查模型配置。',
}; model: completion.model,
tokenEstimate: this.estimateTokenCount(
systemPrompt,
userPrompt,
completion.text,
),
});
await this.recordInvocationAudit( await this.recordInvocationAudit(
invocation, invocation,
operator, operator,
@@ -118,14 +141,18 @@ export class AgentService {
contextSummary, contextSummary,
); );
} catch (error) { } catch (error) {
invocation = { const errorSummary =
...baseInvocation,
status: 'failed',
resultMarkdown:
error instanceof Error error instanceof Error
? redactSensitive({ message: error.message }).message ? redactSensitive({ message: error.message }).message
: 'Agent 调用失败', : 'Agent 调用失败';
};
invocation = await this.agentInvocationRepository.create({
...baseInvocation,
status: 'failed',
resultMarkdown: errorSummary,
errorSummary,
tokenEstimate: this.estimateTokenCount(systemPrompt, userPrompt),
});
await this.recordInvocationAudit( await this.recordInvocationAudit(
invocation, invocation,
operator, operator,
@@ -135,12 +162,16 @@ export class AgentService {
); );
} }
} else { } else {
invocation = { const errorSummary =
llmHealth.message ?? 'Agent 配置不完整,请先配置 key、baseURL 和 model。';
invocation = await this.agentInvocationRepository.create({
...baseInvocation, ...baseInvocation,
status: 'failed', status: 'failed',
resultMarkdown: resultMarkdown: errorSummary,
llmHealth.message ?? 'Agent 配置不完整,请先配置 key、baseURL 和 model。', errorSummary,
}; tokenEstimate: this.estimateTokenCount(systemPrompt, userPrompt),
});
await this.recordInvocationAudit( await this.recordInvocationAudit(
invocation, invocation,
operator, operator,
@@ -151,7 +182,6 @@ export class AgentService {
); );
} }
this.invocations.unshift(invocation);
return invocation; return invocation;
} }
@@ -424,6 +454,14 @@ export class AgentService {
: text.slice(text.length - jenkinsLogContextLimit); : text.slice(text.length - jenkinsLogContextLimit);
} }
private estimateTokenCount(...parts: Array<string | undefined>): 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 { private safeErrorMessage(error: unknown): string {
const message = error instanceof Error ? error.message : '未知错误'; const message = error instanceof Error ? error.message : '未知错误';
return redactSensitive({ message }).message; return redactSensitive({ message }).message;