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:
@@ -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
@@ -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,不要修改)。',
|
||||
|
||||
@@ -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:让审核区展示失败任务供人工确认/重排
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
Reference in New Issue
Block a user