diff --git a/src/agent/agent.module.ts b/src/agent/agent.module.ts index 5ea63f0..3eb2d4c 100644 --- a/src/agent/agent.module.ts +++ b/src/agent/agent.module.ts @@ -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], diff --git a/src/agent/agent.service.spec.ts b/src/agent/agent.service.spec.ts index ece435b..f4fe4e3 100644 --- a/src/agent/agent.service.spec.ts +++ b/src/agent/agent.service.spec.ts @@ -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; + 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; + 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; 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( diff --git a/src/agent/agent.service.ts b/src/agent/agent.service.ts index e0ec7cc..791f917 100644 --- a/src/agent/agent.service.ts +++ b/src/agent/agent.service.ts @@ -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([ + 'failure-diagnosis', + 'incident-review', +]); +const contextRequiredProjectTypes = new Set([ + '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 { 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 { + 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; } } diff --git a/src/agent/agent.types.ts b/src/agent/agent.types.ts index 77247b1..33ec7ec 100644 --- a/src/agent/agent.types.ts +++ b/src/agent/agent.types.ts @@ -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; };