feat: 持久化 Agent 调用记录
- src/agent: 新增 AgentInvocationRepository,统一封装 Prisma 落库和本地降级存储 - src/agent/agent.service.ts: 创建 Agent 调用后写入数据库,并按项目权限过滤历史列表 - src/agent/agent.module.ts: 注册 Agent 调用记录仓储依赖
This commit is contained in:
@@ -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 }))
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user