feat: 补齐 Agent 真实运维上下文
This commit is contained in:
@@ -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],
|
||||
|
||||
@@ -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(
|
||||
|
||||
+258
-11
@@ -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,18 +171,21 @@ export class AgentService {
|
||||
);
|
||||
}
|
||||
|
||||
if (input.projectKey) {
|
||||
await this.authService.requireProjectPermission(
|
||||
operator,
|
||||
input.projectKey,
|
||||
'read',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private async recordInvocationAudit(
|
||||
invocation: AgentInvocation,
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user