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()
@ApiOkResponse({ description: '查询 Agent 调用记录。' })
listInvocations(): AgentInvocation[] {
return this.agentService.listInvocations();
async listInvocations(
@CurrentUser() user: AuthenticatedUser,
): Promise<AgentInvocation[]> {
return this.agentService.listInvocations(user);
}
@Post()
+3 -2
View File
@@ -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 {}
+20
View File
@@ -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<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 {
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,
),
};
}
+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 { 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<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',
);
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<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 {
const message = error instanceof Error ? error.message : '未知错误';
return redactSensitive({ message }).message;