feat(agent): L1 记忆注入——重试任务带上次失败原因喂回 prompt

- 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 <noreply@anthropic.com>
This commit is contained in:
wangjia
2026-06-24 22:09:06 +08:00
parent 7a6c826610
commit ecea1ae9b6
4 changed files with 97 additions and 2 deletions
+4 -1
View File
@@ -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,
});
+23 -1
View File
@@ -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,不要修改)。',
+11
View File
@@ -866,6 +866,17 @@ export class Store {
return rows.map(rowToApproval);
}
/**
* 取某任务最近一次失败 run 的错误信息(任意 kindexecutor/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:让审核区展示失败任务供人工确认/重排
+59
View File
@@ -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> = {}): 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();
});