feat: 补齐 Agent 真实运维上下文

This commit is contained in:
湛兮
2026-06-12 02:57:33 +08:00
parent c9ce35c758
commit 2845e7cfd5
4 changed files with 334 additions and 18 deletions
+9 -1
View File
@@ -2,12 +2,20 @@ import { Module } from '@nestjs/common';
import { AgentConfigModule } from '../agent-config/agent-config.module'; import { AgentConfigModule } from '../agent-config/agent-config.module';
import { AuditModule } from '../audit/audit.module'; 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 { ProjectsModule } from '../projects/projects.module';
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';
@Module({ @Module({
imports: [AgentConfigModule, AuditModule, AuthModule], imports: [
AgentConfigModule,
AuditModule,
AuthModule,
DeployRunsModule,
ProjectsModule,
],
controllers: [AgentController], controllers: [AgentController],
providers: [AgentService, LlmClient], providers: [AgentService, LlmClient],
exports: [AgentService, LlmClient], exports: [AgentService, LlmClient],
+58 -1
View File
@@ -2,6 +2,8 @@ import { AuditService } from '../audit/audit.service';
import { AuthService } from '../auth/auth.service'; import { AuthService } from '../auth/auth.service';
import { AuthenticatedUser } from '../auth/auth.types'; 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 { ProjectsService } from '../projects/projects.service';
import { AgentService } from './agent.service'; import { AgentService } from './agent.service';
import { CreateAgentInvocationInput } from './create-agent-invocation.dto'; import { CreateAgentInvocationInput } from './create-agent-invocation.dto';
import { LlmClient } from './llm.client'; import { LlmClient } from './llm.client';
@@ -48,15 +50,69 @@ describe('AgentService', () => {
const auth = { const auth = {
requireProjectPermission: jest.fn(() => Promise.resolve()), requireProjectPermission: jest.fn(() => Promise.resolve()),
} satisfies Pick<AuthService, 'requireProjectPermission'>; } satisfies Pick<AuthService, 'requireProjectPermission'>;
const deployRuns = {
getRun: jest.fn((id: string) =>
Promise.resolve({
id,
projectKey: 'role-user',
environment: 'production' as const,
ref: 'master',
status: 'failed' as const,
trigger: 'manual' as const,
operator: 'codex',
jenkinsBuildNumber: 18,
failureSummary: 'Jenkins 构建失败',
createdAt: '2026-06-12T00:00:00.000Z',
steps: [],
}),
),
getJenkinsLog: jest.fn((id: string) =>
Promise.resolve({
runId: id,
jobPath: 'devops-platform/prod-role-user-master',
buildNumber: 18,
start: 0,
nextStart: 1024,
hasMore: false,
text: 'Started by user codex\nFinished: FAILURE',
fetchedAt: '2026-06-12T00:00:01.000Z',
}),
),
} satisfies Pick<DeployRunsService, 'getRun' | 'getJenkinsLog'>;
const projects = {
getProject: jest.fn((key: string) =>
Promise.resolve({
id: `project_${key}`,
key,
name: key,
repositoryUrl: `https://gitea.ops.mrzhan.top/my-project/${key}.git`,
defaultBranch: key === 'my-resume' ? 'master' : 'develop',
status: 'active' as const,
environments: [
{
name: 'production' as const,
displayName: '生产环境',
jenkinsJobPath: `devops-platform/prod-${key}-master`,
releasePolicy: 'master' as const,
refPattern: '^master$',
},
],
}),
),
} satisfies Pick<ProjectsService, 'getProject'>;
return { return {
audit, audit,
auth, auth,
deployRuns,
llm, llm,
projects,
service: new AgentService( service: new AgentService(
audit as unknown as AuditService, audit as unknown as AuditService,
llm as unknown as LlmClient, llm as unknown as LlmClient,
auth as unknown as AuthService, auth as unknown as AuthService,
deployRuns as unknown as DeployRunsService,
projects as unknown as ProjectsService,
), ),
}; };
} }
@@ -108,7 +164,8 @@ describe('AgentService', () => {
expect(llm.complete).toHaveBeenCalledWith( expect(llm.complete).toHaveBeenCalledWith(
expect.objectContaining({ expect.objectContaining({
purpose: 'release-risk', purpose: 'release-risk',
promptVersion: 'devops-agent-v1', promptVersion: 'devops-agent-v2',
userPrompt: expect.stringContaining('## 项目上下文'),
}), }),
); );
expect(audit.record).toHaveBeenCalledWith( expect(audit.record).toHaveBeenCalledWith(
+263 -16
View File
@@ -1,9 +1,16 @@
/**
* DevOps Agent 后端编排层,负责把发布单、项目和 Jenkins 日志整理为受控上下文。
*/
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { AuthService } from '../auth/auth.service'; import { AuthService } from '../auth/auth.service';
import { AuthenticatedUser } from '../auth/auth.types'; import { AuthenticatedUser } from '../auth/auth.types';
import { AppError } from '../common/errors/app-error'; import { AppError } from '../common/errors/app-error';
import { AuditService } from '../audit/audit.service'; import { AuditService } from '../audit/audit.service';
import { redactSensitive } from '../common/security/redact-sensitive'; import { redactSensitive } from '../common/security/redact-sensitive';
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 { 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';
@@ -15,6 +22,27 @@ const allowedTypes: AgentPurpose[] = [
'release-note', 'release-note',
'incident-review', 'incident-review',
]; ];
const contextRequiredRunTypes = new Set<AgentPurpose>([
'failure-diagnosis',
'incident-review',
]);
const contextRequiredProjectTypes = new Set<AgentPurpose>([
'release-risk',
'release-note',
]);
const jenkinsLogContextLimit = 12000;
type AgentInvocationContext = {
project?: ProjectSummary;
run?: DeployRunSummary;
jenkinsLog?: {
buildNumber: number;
hasMore: boolean;
nextStart: number;
text: string;
};
warnings: string[];
};
@Injectable() @Injectable()
export class AgentService { export class AgentService {
@@ -24,6 +52,8 @@ export class AgentService {
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 projectsService: ProjectsService,
) {} ) {}
listInvocations(): AgentInvocation[] { listInvocations(): AgentInvocation[] {
@@ -49,12 +79,19 @@ export class AgentService {
await this.validateAgentScope(input, operator); await this.validateAgentScope(input, operator);
const llmHealth = await this.llmClient.healthSummary(); const llmHealth = await this.llmClient.healthSummary();
const context = await this.buildInvocationContext(
input,
operator,
llmHealth.status === 'ok',
);
const contextSummary = this.contextSummaryFor(context);
const baseInvocation = { const baseInvocation = {
id: `agent_${Date.now()}_${this.invocations.length + 1}`, id: `agent_${Date.now()}_${this.invocations.length + 1}`,
runId: input.runId, runId: context.run?.id ?? input.runId,
projectKey: input.projectKey, projectKey: context.project?.key ?? input.projectKey,
type: input.type, type: input.type,
promptSummary: input.promptSummary, promptSummary: input.promptSummary,
contextSummary,
createdAt: new Date().toISOString(), createdAt: new Date().toISOString(),
}; };
let invocation: AgentInvocation; let invocation: AgentInvocation;
@@ -63,9 +100,9 @@ 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-v1', promptVersion: 'devops-agent-v2',
systemPrompt: this.systemPromptFor(input.type), systemPrompt: this.systemPromptFor(input.type),
userPrompt: this.userPromptFor(input), userPrompt: this.userPromptFor(input, context),
}); });
invocation = { invocation = {
@@ -78,6 +115,7 @@ export class AgentService {
operator, operator,
'llm', 'llm',
completion.model, completion.model,
contextSummary,
); );
} catch (error) { } catch (error) {
invocation = { invocation = {
@@ -88,7 +126,13 @@ export class AgentService {
? redactSensitive({ message: error.message }).message ? redactSensitive({ message: error.message }).message
: 'Agent 调用失败', : 'Agent 调用失败',
}; };
await this.recordInvocationAudit(invocation, operator, 'llm_failed'); await this.recordInvocationAudit(
invocation,
operator,
'llm_failed',
undefined,
contextSummary,
);
} }
} else { } else {
invocation = { invocation = {
@@ -102,6 +146,7 @@ export class AgentService {
operator, operator,
'not_configured', 'not_configured',
undefined, undefined,
contextSummary,
llmHealth.message, llmHealth.message,
); );
} }
@@ -118,7 +163,7 @@ export class AgentService {
return; return;
} }
if (!input.projectKey) { if (!input.projectKey && !input.runId) {
throw new AppError( throw new AppError(
'FORBIDDEN', 'FORBIDDEN',
'普通成员只能在已授权项目内使用 Agent', '普通成员只能在已授权项目内使用 Agent',
@@ -126,11 +171,13 @@ export class AgentService {
); );
} }
await this.authService.requireProjectPermission( if (input.projectKey) {
operator, await this.authService.requireProjectPermission(
input.projectKey, operator,
'read', input.projectKey,
); 'read',
);
}
} }
private async recordInvocationAudit( private async recordInvocationAudit(
@@ -138,6 +185,7 @@ export class AgentService {
operator: AuthenticatedUser, operator: AuthenticatedUser,
mode: 'llm' | 'llm_failed' | 'not_configured', mode: 'llm' | 'llm_failed' | 'not_configured',
model?: string, model?: string,
contextSummary?: string,
reason?: string, reason?: string,
): Promise<void> { ): Promise<void> {
const action = const action =
@@ -157,10 +205,12 @@ export class AgentService {
projectKey: invocation.projectKey, projectKey: invocation.projectKey,
mode, mode,
model, model,
contextSummary,
reason, reason,
}, },
parameterDigest: { parameterDigest: {
promptSummary: invocation.promptSummary, promptSummary: invocation.promptSummary,
contextSummary,
}, },
}); });
} }
@@ -174,11 +224,208 @@ export class AgentService {
].join('\n'); ].join('\n');
} }
private userPromptFor(input: CreateAgentInvocationInput): string { private async buildInvocationContext(
input: CreateAgentInvocationInput,
operator: AuthenticatedUser,
includeJenkinsLog: boolean,
): Promise<AgentInvocationContext> {
if (contextRequiredRunTypes.has(input.type) && !input.runId) {
throw new AppError(
'VALIDATION_FAILED',
'该 Agent 能力必须选择发布记录 Run',
400,
{ type: input.type },
);
}
if (
contextRequiredProjectTypes.has(input.type) &&
!input.projectKey &&
!input.runId
) {
throw new AppError(
'VALIDATION_FAILED',
'该 Agent 能力必须选择项目或发布记录 Run',
400,
{ type: input.type },
);
}
const warnings: string[] = [];
const run = input.runId
? await this.deployRunsService.getRun(input.runId, operator)
: undefined;
const projectKey = input.projectKey ?? run?.projectKey;
if (input.projectKey && run && input.projectKey !== run.projectKey) {
throw new AppError(
'VALIDATION_FAILED',
'Agent 请求中的项目与发布记录不匹配',
400,
{
projectKey: input.projectKey,
runProjectKey: run.projectKey,
runId: run.id,
},
);
}
const project = projectKey
? await this.projectsService.getProject(projectKey, operator)
: undefined;
const context: AgentInvocationContext = {
project,
run,
warnings,
};
if (!includeJenkinsLog || !run?.jenkinsBuildNumber) {
if (run && !run.jenkinsBuildNumber) {
warnings.push('当前 Run 尚未关联 Jenkins build,无法提供完整构建日志。');
}
return context;
}
try {
const log = await this.deployRunsService.getJenkinsLog(run.id, '0', operator);
context.jenkinsLog = {
buildNumber: log.buildNumber,
hasMore: log.hasMore,
nextStart: log.nextStart,
text: this.takeLogTail(log.text),
};
} catch (error) {
warnings.push(`Jenkins 日志读取失败:${this.safeErrorMessage(error)}`);
}
return context;
}
private contextSummaryFor(context: AgentInvocationContext): string {
const parts = [
context.project ? `project=${context.project.key}` : undefined,
context.run
? `run=${context.run.id} status=${context.run.status} ref=${context.run.ref}`
: undefined,
context.jenkinsLog
? `jenkinsBuild=#${context.jenkinsLog.buildNumber} logChars=${context.jenkinsLog.text.length}`
: undefined,
context.warnings.length ? `warnings=${context.warnings.length}` : undefined,
].filter(Boolean);
return parts.length ? parts.join('') : '未绑定项目上下文';
}
private userPromptFor(
input: CreateAgentInvocationInput,
context: AgentInvocationContext,
): string {
return redactSensitive(
[
'## 用户任务',
input.promptSummary,
'',
'## 项目上下文',
this.projectContextLines(context.project).join('\n') || '未指定项目。',
'',
'## 发布记录上下文',
this.runContextLines(context.run).join('\n') || '未指定发布记录。',
'',
'## BPMN 步骤',
this.stepContextLines(context.run).join('\n') || '未提供步骤。',
'',
'## Jenkins 构建日志',
this.jenkinsLogContextLines(context).join('\n') || '未提供 Jenkins 日志。',
'',
'## 上下文警告',
context.warnings.join('\n') || '无。',
].join('\n'),
);
}
private projectContextLines(project?: ProjectSummary): string[] {
if (!project) {
return [];
}
return [ return [
`项目:${input.projectKey ?? '未指定'}`, `项目 key${project.key}`,
`Run${input.runId ?? '未指定'}`, `项目名称${project.name}`,
`用户输入${input.promptSummary}`, `仓库${project.repositoryUrl}`,
].join('\n'); `默认分支:${project.defaultBranch}`,
`状态:${project.status}`,
`环境:${project.environments
.map(
(environment) =>
`${environment.name}/${environment.releasePolicy}/${environment.jenkinsJobPath}`,
)
.join('')}`,
];
}
private runContextLines(run?: DeployRunSummary): string[] {
if (!run) {
return [];
}
return [
`Run ID${run.id}`,
`项目:${run.projectKey}`,
`环境:${run.environment}`,
`ref${run.ref}`,
`状态:${run.status}`,
`触发方式:${run.trigger}`,
`操作者:${run.operator}`,
`Jenkins queue${run.jenkinsQueueId ?? '无'}`,
`Jenkins build${run.jenkinsBuildNumber ?? '无'}`,
`失败摘要:${run.failureSummary ?? '无'}`,
`开始时间:${run.startedAt ?? '未开始'}`,
`结束时间:${run.finishedAt ?? '未结束'}`,
];
}
private stepContextLines(run?: DeployRunSummary): string[] {
if (!run) {
return [];
}
return run.steps.map((step) =>
[
`- ${step.bpmnNodeId}`,
`name=${step.name}`,
`status=${step.status}`,
step.message ? `message=${step.message}` : undefined,
step.errorSummary ? `error=${step.errorSummary}` : undefined,
step.logExcerpt ? `log=${step.logExcerpt}` : undefined,
]
.filter(Boolean)
.join(''),
);
}
private jenkinsLogContextLines(context: AgentInvocationContext): string[] {
if (!context.jenkinsLog) {
return [];
}
return [
`Build#${context.jenkinsLog.buildNumber}`,
`hasMore${context.jenkinsLog.hasMore}`,
`nextStart${context.jenkinsLog.nextStart}`,
'日志尾部:',
context.jenkinsLog.text,
];
}
private takeLogTail(text: string): string {
return text.length <= jenkinsLogContextLimit
? text
: text.slice(text.length - jenkinsLogContextLimit);
}
private safeErrorMessage(error: unknown): string {
const message = error instanceof Error ? error.message : '未知错误';
return redactSensitive({ message }).message;
} }
} }
+4
View File
@@ -1,3 +1,6 @@
/**
* DevOps Agent 的输入输出模型,调用范围必须保持在发布、构建和运维诊断内。
*/
export type AgentPurpose = export type AgentPurpose =
| 'release-risk' | 'release-risk'
| 'failure-diagnosis' | 'failure-diagnosis'
@@ -27,6 +30,7 @@ export type AgentInvocation = {
type: AgentPurpose; type: AgentPurpose;
status: AgentInvocationStatus; status: AgentInvocationStatus;
promptSummary: string; promptSummary: string;
contextSummary?: string;
resultMarkdown?: string; resultMarkdown?: string;
createdAt: string; createdAt: string;
}; };