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 { 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 { AgentController } from './agent.controller';
import { AgentService } from './agent.service';
import { LlmClient } from './llm.client';
@Module({
imports: [AgentConfigModule, AuditModule, AuthModule],
imports: [
AgentConfigModule,
AuditModule,
AuthModule,
DeployRunsModule,
ProjectsModule,
],
controllers: [AgentController],
providers: [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 { 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 { AgentService } from './agent.service';
import { CreateAgentInvocationInput } from './create-agent-invocation.dto';
import { LlmClient } from './llm.client';
@@ -48,15 +50,69 @@ describe('AgentService', () => {
const auth = {
requireProjectPermission: jest.fn(() => Promise.resolve()),
} 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 {
audit,
auth,
deployRuns,
llm,
projects,
service: new AgentService(
audit as unknown as AuditService,
llm as unknown as LlmClient,
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.objectContaining({
purpose: 'release-risk',
promptVersion: 'devops-agent-v1',
promptVersion: 'devops-agent-v2',
userPrompt: expect.stringContaining('## 项目上下文'),
}),
);
expect(audit.record).toHaveBeenCalledWith(
+263 -16
View File
@@ -1,9 +1,16 @@
/**
* DevOps Agent 后端编排层,负责把发布单、项目和 Jenkins 日志整理为受控上下文。
*/
import { Injectable } from '@nestjs/common';
import { AuthService } from '../auth/auth.service';
import { AuthenticatedUser } from '../auth/auth.types';
import { AppError } from '../common/errors/app-error';
import { AuditService } from '../audit/audit.service';
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 { CreateAgentInvocationInput } from './create-agent-invocation.dto';
import { LlmClient } from './llm.client';
@@ -15,6 +22,27 @@ const allowedTypes: AgentPurpose[] = [
'release-note',
'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()
export class AgentService {
@@ -24,6 +52,8 @@ export class AgentService {
private readonly auditService: AuditService,
private readonly llmClient: LlmClient,
private readonly authService: AuthService,
private readonly deployRunsService: DeployRunsService,
private readonly projectsService: ProjectsService,
) {}
listInvocations(): AgentInvocation[] {
@@ -49,12 +79,19 @@ export class AgentService {
await this.validateAgentScope(input, operator);
const llmHealth = await this.llmClient.healthSummary();
const context = await this.buildInvocationContext(
input,
operator,
llmHealth.status === 'ok',
);
const contextSummary = this.contextSummaryFor(context);
const baseInvocation = {
id: `agent_${Date.now()}_${this.invocations.length + 1}`,
runId: input.runId,
projectKey: input.projectKey,
runId: context.run?.id ?? input.runId,
projectKey: context.project?.key ?? input.projectKey,
type: input.type,
promptSummary: input.promptSummary,
contextSummary,
createdAt: new Date().toISOString(),
};
let invocation: AgentInvocation;
@@ -63,9 +100,9 @@ export class AgentService {
try {
const completion = await this.llmClient.complete({
purpose: input.type,
promptVersion: 'devops-agent-v1',
promptVersion: 'devops-agent-v2',
systemPrompt: this.systemPromptFor(input.type),
userPrompt: this.userPromptFor(input),
userPrompt: this.userPromptFor(input, context),
});
invocation = {
@@ -78,6 +115,7 @@ export class AgentService {
operator,
'llm',
completion.model,
contextSummary,
);
} catch (error) {
invocation = {
@@ -88,7 +126,13 @@ export class AgentService {
? redactSensitive({ message: error.message }).message
: 'Agent 调用失败',
};
await this.recordInvocationAudit(invocation, operator, 'llm_failed');
await this.recordInvocationAudit(
invocation,
operator,
'llm_failed',
undefined,
contextSummary,
);
}
} else {
invocation = {
@@ -102,6 +146,7 @@ export class AgentService {
operator,
'not_configured',
undefined,
contextSummary,
llmHealth.message,
);
}
@@ -118,7 +163,7 @@ export class AgentService {
return;
}
if (!input.projectKey) {
if (!input.projectKey && !input.runId) {
throw new AppError(
'FORBIDDEN',
'普通成员只能在已授权项目内使用 Agent',
@@ -126,11 +171,13 @@ export class AgentService {
);
}
await this.authService.requireProjectPermission(
operator,
input.projectKey,
'read',
);
if (input.projectKey) {
await this.authService.requireProjectPermission(
operator,
input.projectKey,
'read',
);
}
}
private async recordInvocationAudit(
@@ -138,6 +185,7 @@ export class AgentService {
operator: AuthenticatedUser,
mode: 'llm' | 'llm_failed' | 'not_configured',
model?: string,
contextSummary?: string,
reason?: string,
): Promise<void> {
const action =
@@ -157,10 +205,12 @@ export class AgentService {
projectKey: invocation.projectKey,
mode,
model,
contextSummary,
reason,
},
parameterDigest: {
promptSummary: invocation.promptSummary,
contextSummary,
},
});
}
@@ -174,11 +224,208 @@ export class AgentService {
].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 [
`项目:${input.projectKey ?? '未指定'}`,
`Run${input.runId ?? '未指定'}`,
`用户输入${input.promptSummary}`,
].join('\n');
`项目 key${project.key}`,
`项目名称${project.name}`,
`仓库${project.repositoryUrl}`,
`默认分支:${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 =
| 'release-risk'
| 'failure-diagnosis'
@@ -27,6 +30,7 @@ export type AgentInvocation = {
type: AgentPurpose;
status: AgentInvocationStatus;
promptSummary: string;
contextSummary?: string;
resultMarkdown?: string;
createdAt: string;
};