From ecea1ae9b67b957bf86c8e2180046e3085ebd0db Mon Sep 17 00:00:00 2001 From: wangjia <809946525@qq.com> Date: Wed, 24 Jun 2026 22:09:06 +0800 Subject: [PATCH] =?UTF-8?q?feat(agent):=20L1=20=E8=AE=B0=E5=BF=86=E6=B3=A8?= =?UTF-8?q?=E5=85=A5=E2=80=94=E2=80=94=E9=87=8D=E8=AF=95=E4=BB=BB=E5=8A=A1?= =?UTF-8?q?=E5=B8=A6=E4=B8=8A=E6=AC=A1=E5=A4=B1=E8=B4=A5=E5=8E=9F=E5=9B=A0?= =?UTF-8?q?=E5=96=82=E5=9B=9E=20prompt?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - store.lastRunErrorOf(taskId):取最近一次失败 run 的 error(任意 kind) - orchestrator.claimOne:写 job.json 时填充 task.lastRunError 下发 worker - runner 三处 prompt(executor/planner/conflict)注入「## 上次失败原因」段(截断 2000 字符) Co-Authored-By: Claude Opus 4.8 --- src/daemon/orchestrator.ts | 5 +++- src/executor/runner.ts | 24 +++++++++++++++- src/store/store.ts | 11 +++++++ test/memory-l1.test.ts | 59 ++++++++++++++++++++++++++++++++++++++ 4 files changed, 97 insertions(+), 2 deletions(-) create mode 100644 test/memory-l1.test.ts diff --git a/src/daemon/orchestrator.ts b/src/daemon/orchestrator.ts index a4c3b56..53a4b90 100644 --- a/src/daemon/orchestrator.ts +++ b/src/daemon/orchestrator.ts @@ -174,9 +174,12 @@ export function createOrchestrator(store: Store, log: OrchestratorLogger, deps: const run = store.startRun(task.id, dbKind, { worktree: isPlanner ? project.repoPath : dir, branch }); runId = run.id; + // L1 记忆注入:重试任务带上「上次失败原因」,worker 端拼进 prompt(见 runner.buildPrompt*) + const lastRunError = store.lastRunErrorOf(task.id); + d.writeJobSpec({ runId: run.id, - task: { ...task, status: isPlanner ? task.status : 'executing' }, + task: { ...task, status: isPlanner ? task.status : 'executing', lastRunError }, project, worktreeDir: dir, branch, runKind, }); diff --git a/src/executor/runner.ts b/src/executor/runner.ts index 5ff659f..4310e4d 100644 --- a/src/executor/runner.ts +++ b/src/executor/runner.ts @@ -25,7 +25,24 @@ const DEFAULT_MAX_TURNS = 100; /** 若项目未配置 timeoutMs 时的全局兜底超时(30min) */ const DEFAULT_TIMEOUT_MS = 30 * 60_000; -/** 组装任务提示词:标题 + operations/spec 全文 + 执行约束 */ +/** + * L1 记忆注入:若任务带「上次失败原因」(重试任务),生成一段提醒;否则空数组。 + * 由 daemon 在 claimOne 时把 store.lastRunErrorOf 填进 task.lastRunError 下发(见 orchestrator)。 + */ +function lastErrorLines(task: Task): string[] { + const err = task.lastRunError?.trim(); + if (!err) return []; + return [ + '## 上次失败原因(这是重试任务,请勿重蹈覆辙)', + '上一次执行本任务的 agent 失败,错误信息如下。请先理解失败根因,针对性规避:', + '```', + err.length > 2000 ? err.slice(0, 2000) + '\n…(已截断)' : err, + '```', + '', + ]; +} + +/** 组装任务提示词:标题 + operations/spec 全文 + 上次失败(L1) + 执行约束 */ export function buildPrompt(task: Task): string { const body = task.operations ?? task.spec ?? task.plan ?? ''; return [ @@ -35,6 +52,7 @@ export function buildPrompt(task: Task): string { '## 任务内容', body || '(无详细说明,按标题完成)', '', + ...lastErrorLines(task), '## 执行约束(必须遵守)', '- 只在当前工作目录(git worktree)内改动文件,不得读写或修改 worktree 之外的任何文件。', `- 完成后用 \`git add -A && git commit\` 提交全部改动,commit message 必须包含任务 ID「${task.id}」。`, @@ -117,10 +135,12 @@ export type PlannerFn = (task: Task, project: Project, kind: PlanKind, runId: st /** planner 提示词:只读代码库,spec=写改动方案;decompose=拆子任务并在末尾输出 fenced JSON。 */ export function buildPlannerPrompt(task: Task, kind: PlanKind): string { const head = [`# 任务:${task.title}`, `任务 ID:${task.id}`]; + const errLines = lastErrorLines(task); if (kind === 'spec') { if (task.spec) head.push('', '## 现有方案草稿(可改进/替换)', task.spec); return [ ...head, '', + ...errLines, '## 你的角色:方案作者(只读,不改任何文件)', '只读这个代码库,为上述任务写一份「具体改动方案」。不要编辑文件、不要执行有副作用的命令。', '## 输出(markdown 正文)', @@ -133,6 +153,7 @@ export function buildPlannerPrompt(task: Task, kind: PlanKind): string { if (task.plan) head.push('', '## 现有分析草稿(可改进/替换)', task.plan); return [ ...head, '', + ...errLines, '## 你的角色:任务拆解者(只读,不改任何文件)', `当前任务层级 depth=${task.depth ?? 1}(根=1),最多拆到 depth=4(剩余可拆层数 ${Math.max(0, 4 - (task.depth ?? 1))} 层)。`, '只读这个代码库,分析上述(复杂)任务,拆成 2–6 个更小、可独立交付的子任务。不要编辑文件、不要执行有副作用的命令。', @@ -189,6 +210,7 @@ export function buildConflictPrompt(task: Task, conflictFiles: string[]): string '## 冲突文件', conflictFiles.length ? conflictFiles.map((f) => `- ${f}`).join('\n') : '(以 git status 为准)', '', + ...lastErrorLines(task), '## 执行约束(必须遵守)', '- 逐个打开冲突文件,理解 <<<<<<< HEAD 与 >>>>>>> 两侧的意图,保留双方有价值的改动,不得丢弃任一方的实质内容。', '- 解决完所有冲突后,用 `git add -A` 暂存,然后 `git commit`(直接用默认合并 commit message,不要修改)。', diff --git a/src/store/store.ts b/src/store/store.ts index 042057f..4ffbc08 100644 --- a/src/store/store.ts +++ b/src/store/store.ts @@ -866,6 +866,17 @@ export class Store { return rows.map(rowToApproval); } + /** + * 取某任务最近一次失败 run 的错误信息(任意 kind:executor/planner)。供 L1 记忆注入: + * 重试任务时把"上次为何失败"喂回 agent prompt,避免重蹈覆辙。无失败 run 返回 null。 + */ + lastRunErrorOf(taskId: string): string | null { + const row = this.db.prepare( + `SELECT error FROM runs WHERE task_id = ? AND status = 'failed' AND error IS NOT NULL ORDER BY ended_at DESC LIMIT 1`, + ).get(taskId) as { error?: string | null } | undefined; + return row?.error ?? null; + } + /** 列出所有处于审批闸状态(plan_review/spec_review/exec_review)的任务。 */ pendingApprovals(projectId?: string): Task[] { // 包含 needs_attention:让审核区展示失败任务供人工确认/重排 diff --git a/test/memory-l1.test.ts b/test/memory-l1.test.ts new file mode 100644 index 0000000..c2d4f5d --- /dev/null +++ b/test/memory-l1.test.ts @@ -0,0 +1,59 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { buildPrompt, buildPlannerPrompt, buildConflictPrompt } from '../src/executor/runner.js'; +import { Store } from '../src/store/index.js'; +import type { Task } from '../src/model/types.js'; + +function fakeTask(over: Partial = {}): Task { + return { + id: 'tsk_x', title: '改个东西', complexity: 'medium', depth: 1, + operations: '把 A 改成 B', spec: null, plan: null, deps: [], + ...over, + } as Task; +} + +const SECTION = '上次失败原因'; + +test('L1: 无 lastRunError 时三处 prompt 都不含「上次失败原因」段', () => { + const t = fakeTask(); + assert.ok(!buildPrompt(t).includes(SECTION)); + assert.ok(!buildPlannerPrompt(fakeTask({ complexity: 'medium' }), 'spec').includes(SECTION)); + assert.ok(!buildPlannerPrompt(fakeTask({ complexity: 'hard' }), 'decompose').includes(SECTION)); + assert.ok(!buildConflictPrompt(t, ['a.ts']).includes(SECTION)); +}); + +test('L1: 有 lastRunError 时三处 prompt 注入失败原因', () => { + const err = 'verify 失败:npm test 退出码 1\nFAIL src/foo.test.ts'; + const t = fakeTask({ lastRunError: err }); + for (const p of [ + buildPrompt(t), + buildPlannerPrompt(fakeTask({ complexity: 'medium', lastRunError: err }), 'spec'), + buildPlannerPrompt(fakeTask({ complexity: 'hard', lastRunError: err }), 'decompose'), + buildConflictPrompt(t, ['a.ts']), + ]) { + assert.ok(p.includes(SECTION), '应含失败段标题'); + assert.ok(p.includes('npm test 退出码 1'), '应含具体错误'); + } +}); + +test('L1: 超长错误被截断到 2000 字符', () => { + const err = 'X'.repeat(5000); + const p = buildPrompt(fakeTask({ lastRunError: err })); + assert.ok(p.includes('已截断')); + assert.ok(!p.includes('X'.repeat(2100))); +}); + +test('lastRunErrorOf 取最近一次失败 run 的错误(任意 kind)', () => { + const s = new Store(':memory:'); + const p = s.createProject({ name: 'l1', repoPath: '/tmp/l1-' + Math.random() }); + const t = s.createTask({ projectId: p.id, title: 'feat', complexity: 'easy' }); + assert.equal(s.lastRunErrorOf(t.id), null, '无失败 run → null'); + + s.setOperations(t.id, 'do it'); + s.transition(t.id, 'queued'); + s.transition(t.id, 'executing'); + const run = s.startRun(t.id, 'executor', { worktree: '/tmp/w', branch: 'b' }); + s.failTaskAttempt(t.id, run.id, '第一次失败:编译错误'); + assert.equal(s.lastRunErrorOf(t.id), '第一次失败:编译错误'); + s.close(); +});