@zhushanwen/pi-goal 0.6.2 → 0.6.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/__tests__/event-adapter.test.ts +157 -47
- package/src/__tests__/goal-control-prompt.test.ts +16 -0
- package/src/adapters/event-handlers/agent-end.ts +53 -0
- package/src/adapters/event-handlers/before-agent-start.ts +4 -47
- package/src/adapters/goal-control-adapter.ts +12 -0
- package/src/projection/__tests__/prompts.test.ts +2 -3
- package/src/projection/prompts.ts +1 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zhushanwen/pi-goal",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.4",
|
|
4
4
|
"description": "Codex-style /goal command for Pi — persistent goal-driven autonomous loop with evidence-based completion, token/time budgets, blocked detection, and steering templates.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.ts",
|
|
@@ -65,6 +65,7 @@ function makeFakeCtx(overrides?: {
|
|
|
65
65
|
aborted?: boolean;
|
|
66
66
|
hasUI?: boolean;
|
|
67
67
|
contextUsage?: { tokens?: number; contextWindow?: number };
|
|
68
|
+
entries?: unknown[];
|
|
68
69
|
}): { ctx: ExtensionContext; calls: RecordedCall[] } {
|
|
69
70
|
const calls: RecordedCall[] = [];
|
|
70
71
|
const signal = { aborted: overrides?.aborted ?? false } as AbortSignal;
|
|
@@ -87,7 +88,7 @@ function makeFakeCtx(overrides?: {
|
|
|
87
88
|
bold: (text: string) => text,
|
|
88
89
|
},
|
|
89
90
|
},
|
|
90
|
-
sessionManager: { getEntries: () => [], getBranch: () => undefined },
|
|
91
|
+
sessionManager: { getEntries: () => overrides?.entries ?? [], getBranch: () => undefined },
|
|
91
92
|
} as unknown as ExtensionContext;
|
|
92
93
|
return { ctx, calls };
|
|
93
94
|
}
|
|
@@ -179,6 +180,153 @@ describe("handleAgentEnd — continuation", () => {
|
|
|
179
180
|
expect(sendContext).toHaveLength(1);
|
|
180
181
|
expect(sendContext[0]!.content).toContain("[GOAL]");
|
|
181
182
|
});
|
|
183
|
+
|
|
184
|
+
// ── pending 守卫:background subagent/workflow 运行时不死循环 ──
|
|
185
|
+
|
|
186
|
+
it("有活跃 pending → 不发 continuation(pending 守卫生效)", async () => {
|
|
187
|
+
const { pi, calls: piCalls } = makeFakePi();
|
|
188
|
+
const { ctx, calls: ctxCalls } = makeFakeCtx({
|
|
189
|
+
entries: [
|
|
190
|
+
{ type: "custom", customType: "pending:register", data: { id: "bg-1", type: "subagent", name: "worker" } },
|
|
191
|
+
],
|
|
192
|
+
});
|
|
193
|
+
const session = createGoalSession();
|
|
194
|
+
session.state = makeRunningState({ tokensUsed: 200, lastTurnTokensUsed: 0 });
|
|
195
|
+
|
|
196
|
+
await handleAgentEnd(pi, session, ctx);
|
|
197
|
+
const all = allCalls(piCalls, ctxCalls);
|
|
198
|
+
|
|
199
|
+
const followUp = all.filter(
|
|
200
|
+
(c) => c.kind === "sendContext" && (c.payload as { deliverAs?: string } | undefined)?.deliverAs === "followUp",
|
|
201
|
+
);
|
|
202
|
+
expect(followUp).toHaveLength(0); // 有活跃 pending,不发 continuation
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
it("pending register 已 unregister → 差集归零,正常发 continuation", async () => {
|
|
206
|
+
const { pi, calls: piCalls } = makeFakePi();
|
|
207
|
+
const { ctx, calls: ctxCalls } = makeFakeCtx({
|
|
208
|
+
entries: [
|
|
209
|
+
{ type: "custom", customType: "pending:register", data: { id: "bg-1" } },
|
|
210
|
+
{ type: "custom", customType: "pending:unregister", data: { id: "bg-1" } },
|
|
211
|
+
],
|
|
212
|
+
});
|
|
213
|
+
const session = createGoalSession();
|
|
214
|
+
session.state = makeRunningState({ tokensUsed: 200, lastTurnTokensUsed: 0 });
|
|
215
|
+
|
|
216
|
+
await handleAgentEnd(pi, session, ctx);
|
|
217
|
+
const all = allCalls(piCalls, ctxCalls);
|
|
218
|
+
|
|
219
|
+
const followUp = all.filter(
|
|
220
|
+
(c) => c.kind === "sendContext" && (c.payload as { deliverAs?: string } | undefined)?.deliverAs === "followUp",
|
|
221
|
+
);
|
|
222
|
+
expect(followUp).toHaveLength(1);
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
it("pending expiresAt 已过期 → 仍判活跃不发 continuation(刻意不校验 TTL)", async () => {
|
|
226
|
+
const { pi, calls: piCalls } = makeFakePi();
|
|
227
|
+
const { ctx, calls: ctxCalls } = makeFakeCtx({
|
|
228
|
+
entries: [
|
|
229
|
+
{ type: "custom", customType: "pending:register", data: { id: "bg-1", expiresAt: Date.now() - 1000 } },
|
|
230
|
+
],
|
|
231
|
+
});
|
|
232
|
+
const session = createGoalSession();
|
|
233
|
+
session.state = makeRunningState({ tokensUsed: 200, lastTurnTokensUsed: 0 });
|
|
234
|
+
|
|
235
|
+
await handleAgentEnd(pi, session, ctx);
|
|
236
|
+
const all = allCalls(piCalls, ctxCalls);
|
|
237
|
+
|
|
238
|
+
const followUp = all.filter(
|
|
239
|
+
(c) => c.kind === "sendContext" && (c.payload as { deliverAs?: string } | undefined)?.deliverAs === "followUp",
|
|
240
|
+
);
|
|
241
|
+
expect(followUp).toHaveLength(0); // 过期仍判活跃,避免长任务死循环复现
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
it("有活跃 pending → appendEntry goal:log 诊断记录(多元素计数 + ids 透出)", async () => {
|
|
245
|
+
const { pi, states } = makeFakePi();
|
|
246
|
+
const { ctx } = makeFakeCtx({
|
|
247
|
+
entries: [
|
|
248
|
+
{ type: "custom", customType: "pending:register", data: { id: "bg-1" } },
|
|
249
|
+
{ type: "custom", customType: "pending:register", data: { id: "bg-2" } },
|
|
250
|
+
],
|
|
251
|
+
});
|
|
252
|
+
const session = createGoalSession();
|
|
253
|
+
session.state = makeRunningState({ tokensUsed: 200, lastTurnTokensUsed: 0 });
|
|
254
|
+
|
|
255
|
+
await handleAgentEnd(pi, session, ctx);
|
|
256
|
+
|
|
257
|
+
// goal:log 走 appendEntry(非 goal-history)→ 进 states
|
|
258
|
+
const deferredLog = states.find(
|
|
259
|
+
(s) =>
|
|
260
|
+
(s as { component?: string } | undefined)?.component === "goal:agent-end" &&
|
|
261
|
+
/continuation deferred/.test(String((s as { message?: string } | undefined)?.message ?? "")),
|
|
262
|
+
);
|
|
263
|
+
expect(deferredLog).toBeDefined();
|
|
264
|
+
const data = (deferredLog as { data?: { activePending?: number; ids?: string[] } } | undefined)?.data;
|
|
265
|
+
expect(data?.activePending).toBe(2); // 多元素计数(替代原 >=1 弱断言)
|
|
266
|
+
expect(data?.ids).toEqual(["bg-1", "bg-2"]); // ids 透出
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
it("重复 register 同 id → 去重计为 1(seen.has 守卫)", async () => {
|
|
270
|
+
const { pi, calls: piCalls, states } = makeFakePi();
|
|
271
|
+
const { ctx, calls: ctxCalls } = makeFakeCtx({
|
|
272
|
+
entries: [
|
|
273
|
+
{ type: "custom", customType: "pending:register", data: { id: "bg-1" } },
|
|
274
|
+
{ type: "custom", customType: "pending:register", data: { id: "bg-1" } },
|
|
275
|
+
],
|
|
276
|
+
});
|
|
277
|
+
const session = createGoalSession();
|
|
278
|
+
session.state = makeRunningState({ tokensUsed: 200, lastTurnTokensUsed: 0 });
|
|
279
|
+
|
|
280
|
+
await handleAgentEnd(pi, session, ctx);
|
|
281
|
+
const all = allCalls(piCalls, ctxCalls);
|
|
282
|
+
|
|
283
|
+
// 守卫触发:count>0 不发 continuation
|
|
284
|
+
const followUp = all.filter(
|
|
285
|
+
(c) => c.kind === "sendContext" && (c.payload as { deliverAs?: string } | undefined)?.deliverAs === "followUp",
|
|
286
|
+
);
|
|
287
|
+
expect(followUp).toHaveLength(0);
|
|
288
|
+
// seen 去重:2 条同 id register 计为 1(非 2)
|
|
289
|
+
const deferredLog = states.find(
|
|
290
|
+
(s) => (s as { component?: string } | undefined)?.component === "goal:agent-end",
|
|
291
|
+
);
|
|
292
|
+
expect((deferredLog as { data?: { activePending?: number } } | undefined)?.data?.activePending).toBe(1);
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
it("非字符串/缺失 id 的 register → 被忽略,正常发 continuation(typeof id 防御)", async () => {
|
|
296
|
+
const { pi, calls: piCalls } = makeFakePi();
|
|
297
|
+
const { ctx, calls: ctxCalls } = makeFakeCtx({
|
|
298
|
+
entries: [
|
|
299
|
+
{ type: "custom", customType: "pending:register", data: { id: 123 } }, // 数字 id(非字符串)
|
|
300
|
+
{ type: "custom", customType: "pending:register", data: {} }, // 无 id
|
|
301
|
+
],
|
|
302
|
+
});
|
|
303
|
+
const session = createGoalSession();
|
|
304
|
+
session.state = makeRunningState({ tokensUsed: 200, lastTurnTokensUsed: 0 });
|
|
305
|
+
|
|
306
|
+
await handleAgentEnd(pi, session, ctx);
|
|
307
|
+
const all = allCalls(piCalls, ctxCalls);
|
|
308
|
+
|
|
309
|
+
// count=0,守卫不触发,走正常 continuation
|
|
310
|
+
const followUp = all.filter(
|
|
311
|
+
(c) => c.kind === "sendContext" && (c.payload as { deliverAs?: string } | undefined)?.deliverAs === "followUp",
|
|
312
|
+
);
|
|
313
|
+
expect(followUp).toHaveLength(1);
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
it("无 pending entries → 正常发 continuation(回归保护)", async () => {
|
|
317
|
+
const { pi, calls: piCalls } = makeFakePi();
|
|
318
|
+
const { ctx, calls: ctxCalls } = makeFakeCtx(); // getEntries 默认空
|
|
319
|
+
const session = createGoalSession();
|
|
320
|
+
session.state = makeRunningState({ tokensUsed: 200, lastTurnTokensUsed: 0 });
|
|
321
|
+
|
|
322
|
+
await handleAgentEnd(pi, session, ctx);
|
|
323
|
+
const all = allCalls(piCalls, ctxCalls);
|
|
324
|
+
|
|
325
|
+
const followUp = all.filter(
|
|
326
|
+
(c) => c.kind === "sendContext" && (c.payload as { deliverAs?: string } | undefined)?.deliverAs === "followUp",
|
|
327
|
+
);
|
|
328
|
+
expect(followUp).toHaveLength(1);
|
|
329
|
+
});
|
|
182
330
|
});
|
|
183
331
|
|
|
184
332
|
// ── handleAgentEnd:并发保护(G-021)+ stale(G-020)───
|
|
@@ -339,53 +487,14 @@ describe("handleBeforeAgentStart", () => {
|
|
|
339
487
|
|
|
340
488
|
// ── pending-notifications(W2 跨扩展集成)───────────────────
|
|
341
489
|
|
|
342
|
-
it("有活跃 pending
|
|
343
|
-
const { pi } = makeFakePi();
|
|
344
|
-
// 创建带 pending:register entries 的 ctx
|
|
345
|
-
const entries = [
|
|
346
|
-
{ type: "custom", customType: "pending:register", data: { id: "wf-1", type: "workflow", name: "test-wf" } },
|
|
347
|
-
{ type: "custom", customType: "pending:register", data: { id: "bg-1", type: "subagent", name: "worker" } },
|
|
348
|
-
];
|
|
349
|
-
const ctx = {
|
|
350
|
-
hasUI: true,
|
|
351
|
-
signal: { aborted: false } as AbortSignal,
|
|
352
|
-
getContextUsage: () => undefined,
|
|
353
|
-
ui: {
|
|
354
|
-
notify: vi.fn(),
|
|
355
|
-
setStatus: vi.fn(),
|
|
356
|
-
setWidget: vi.fn(),
|
|
357
|
-
theme: { fg: (_c: string, t: string) => t, bold: (t: string) => t },
|
|
358
|
-
},
|
|
359
|
-
sessionManager: { getEntries: () => entries, getBranch: () => undefined },
|
|
360
|
-
} as unknown as ExtensionContext;
|
|
361
|
-
const session = createGoalSession();
|
|
362
|
-
session.state = makeRunningState();
|
|
363
|
-
|
|
364
|
-
const result = await handleBeforeAgentStart(pi, session, ctx);
|
|
365
|
-
|
|
366
|
-
expect(result).toBeDefined();
|
|
367
|
-
expect(result!.message.customType).toBe("goal-context");
|
|
368
|
-
expect(result!.message.content).toContain("2 pending async operation(s)");
|
|
369
|
-
});
|
|
370
|
-
|
|
371
|
-
it("有 pending register 但已 unregister → 不注入提示", async () => {
|
|
490
|
+
it("有活跃 pending entries → 注入 content 不含 pending 提示(pendingHint 已移除)", async () => {
|
|
372
491
|
const { pi } = makeFakePi();
|
|
373
|
-
const
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
signal: { aborted: false } as AbortSignal,
|
|
380
|
-
getContextUsage: () => undefined,
|
|
381
|
-
ui: {
|
|
382
|
-
notify: vi.fn(),
|
|
383
|
-
setStatus: vi.fn(),
|
|
384
|
-
setWidget: vi.fn(),
|
|
385
|
-
theme: { fg: (_c: string, t: string) => t, bold: (t: string) => t },
|
|
386
|
-
},
|
|
387
|
-
sessionManager: { getEntries: () => entries, getBranch: () => undefined },
|
|
388
|
-
} as unknown as ExtensionContext;
|
|
492
|
+
const { ctx } = makeFakeCtx({
|
|
493
|
+
entries: [
|
|
494
|
+
{ type: "custom", customType: "pending:register", data: { id: "wf-1", type: "workflow", name: "test-wf" } },
|
|
495
|
+
{ type: "custom", customType: "pending:register", data: { id: "bg-1", type: "subagent", name: "worker" } },
|
|
496
|
+
],
|
|
497
|
+
});
|
|
389
498
|
const session = createGoalSession();
|
|
390
499
|
session.state = makeRunningState();
|
|
391
500
|
|
|
@@ -393,6 +502,7 @@ describe("handleBeforeAgentStart", () => {
|
|
|
393
502
|
|
|
394
503
|
expect(result).toBeDefined();
|
|
395
504
|
expect(result!.message.customType).toBe("goal-context");
|
|
505
|
+
// pendingHint 已删除:pending 感知交给 LLM 调 pending_notifications tool(mandatory)
|
|
396
506
|
expect(result!.message.content).not.toContain("pending async operation");
|
|
397
507
|
});
|
|
398
508
|
|
|
@@ -61,6 +61,22 @@ describe("goal_control description — 含参数结构反例(Don't 段)", ()
|
|
|
61
61
|
});
|
|
62
62
|
});
|
|
63
63
|
|
|
64
|
+
describe("goal_control promptGuidelines — complete/report_blocked 主动触发引导(防不主动收尾)", () => {
|
|
65
|
+
// promptGuidelines 进 system prompt guidelines 段(强信号),是修复 complete/report_blocked
|
|
66
|
+
// 「该主动调但不调」的核心载体。用源码断言锁定两条正向触发引导,防止后续重构删掉。
|
|
67
|
+
it("源码含 promptGuidelines 字段", () => {
|
|
68
|
+
expect(ADAPTER_SRC).toMatch(/promptGuidelines:\s*\[/);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it("promptGuidelines 含 complete 主动触发信号(proactively + objective is actually achieved)", () => {
|
|
72
|
+
expect(ADAPTER_SRC).toMatch(/complete:.*proactively call.*objective is actually achieved/s);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it("promptGuidelines 含 report_blocked 主动触发信号(proactively + ≥3 approaches)", () => {
|
|
76
|
+
expect(ADAPTER_SRC).toMatch(/report_blocked:.*proactively call.*≥3 distinct alternative approaches/s);
|
|
77
|
+
});
|
|
78
|
+
});
|
|
79
|
+
|
|
64
80
|
describe("goal_control runtime 错误文案 — 含 'Correct:' 纠正正例(≥4 处)", () => {
|
|
65
81
|
it("源码含 ≥4 处 'Correct:' 纠错文案(4 条 required throw 各一带正例)", () => {
|
|
66
82
|
// objective/slug/evidence/reason 四条必填 throw 各应带完整 JSON 正例,
|
|
@@ -28,6 +28,7 @@ import {
|
|
|
28
28
|
} from "../../projection/prompts";
|
|
29
29
|
import { persistAndUpdate, tickState } from "../../service";
|
|
30
30
|
import type { GoalSession } from "../../session";
|
|
31
|
+
import type { SessionEntryLike } from "../../ports";
|
|
31
32
|
import { buildPorts } from "../ports";
|
|
32
33
|
import { makeStaleChecker } from "./shared";
|
|
33
34
|
|
|
@@ -169,9 +170,61 @@ async function handleContinuation(
|
|
|
169
170
|
// 终态守卫:persistAndUpdate 可能把 goal 转为 budget_limited/time_limited 终态。
|
|
170
171
|
// 此时不应发 continuation(deliverAs:"followUp" 会触发新 turn,让已耗尽预算的 agent 再跑一轮)。
|
|
171
172
|
if (isTerminalStatus(state.status)) return;
|
|
173
|
+
// pending 守卫:有活跃 background subagent/workflow 时不发 continuation。
|
|
174
|
+
// 靠 subagent/workflow 完成时 sendMessage({triggerTurn:true,deliverAs:"steer"}) 自然唤醒主 agent;
|
|
175
|
+
// goal 抢先催会与异步任务完成通知叠加,形成“停不下来”的死循环——continuation 排入
|
|
176
|
+
// _followUpMessages 队列 → _handlePostAgentRun 的 hasQueuedMessages() 返回 true → agent.continue()
|
|
177
|
+
// 新 turn → 又 agent_end → 又 continuation。
|
|
178
|
+
// 刻意不校验 expiresAt:长任务 subagent(>1h TTL)完成时仍 triggerTurn 唤醒主 agent,
|
|
179
|
+
// 按 TTL 判非活跃会让死循环在长任务场景复现。
|
|
180
|
+
const pendingOps = countActivePendingOps(ctx.sessionManager.getEntries());
|
|
181
|
+
if (pendingOps.count > 0) {
|
|
182
|
+
pi.appendEntry("goal:log", {
|
|
183
|
+
timestamp: Date.now(),
|
|
184
|
+
level: "debug",
|
|
185
|
+
component: "goal:agent-end",
|
|
186
|
+
message: `continuation deferred: ${pendingOps.count} active pending operation(s)`,
|
|
187
|
+
data: { activePending: pendingOps.count, ids: pendingOps.ids },
|
|
188
|
+
});
|
|
189
|
+
// 用户可见反馈:goal 在等待后台任务完成而非卡死(deferred 无反馈时用户只看到
|
|
190
|
+
// goal active 但无产出,无法区分等待 vs 死循环)。
|
|
191
|
+
ctx.ui.notify(
|
|
192
|
+
`Goal waiting for ${pendingOps.count} background task(s) to complete.`,
|
|
193
|
+
"info",
|
|
194
|
+
);
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
172
197
|
// 发 continuation(FR-8.7: 去 debounce 后才发)
|
|
173
198
|
buildPorts(pi, ctx).messaging.sendContextMessage(
|
|
174
199
|
continuationPrompt(state, state.timeUsedSeconds),
|
|
175
200
|
"followUp",
|
|
176
201
|
);
|
|
177
202
|
}
|
|
203
|
+
|
|
204
|
+
/** 类型守卫:unknown → Record<string, unknown>(替代裸 as 断言访问 entry.data.id)。 */
|
|
205
|
+
const isRecord = (x: unknown): x is Record<string, unknown> =>
|
|
206
|
+
typeof x === "object" && x !== null;
|
|
207
|
+
|
|
208
|
+
/** 计算活跃的 pending async operation(background subagent/workflow)数量。
|
|
209
|
+
* 读 session entries 里 customType="pending:register" 减 "pending:unregister" 的 id 差集。
|
|
210
|
+
* 刻意不校验 expiresAt(见 handleContinuation 守卫注释)。 */
|
|
211
|
+
function countActivePendingOps(
|
|
212
|
+
entries: readonly SessionEntryLike[],
|
|
213
|
+
): { count: number; ids: string[] } {
|
|
214
|
+
const unregistered = new Set<string>();
|
|
215
|
+
for (const e of entries) {
|
|
216
|
+
if (e.type !== "custom" || e.customType !== "pending:unregister") continue;
|
|
217
|
+
const id = isRecord(e.data) ? e.data.id : undefined;
|
|
218
|
+
if (typeof id === "string") unregistered.add(id);
|
|
219
|
+
}
|
|
220
|
+
const activeIds: string[] = [];
|
|
221
|
+
const seen = new Set<string>();
|
|
222
|
+
for (const e of entries) {
|
|
223
|
+
if (e.type !== "custom" || e.customType !== "pending:register") continue;
|
|
224
|
+
const id = isRecord(e.data) ? e.data.id : undefined;
|
|
225
|
+
if (typeof id !== "string" || unregistered.has(id) || seen.has(id)) continue;
|
|
226
|
+
seen.add(id);
|
|
227
|
+
activeIds.push(id);
|
|
228
|
+
}
|
|
229
|
+
return { count: activeIds.length, ids: activeIds };
|
|
230
|
+
}
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
* contextInjectionPrompt 恒定建议 plan mode(AI 自行决定是否用)。
|
|
16
16
|
*/
|
|
17
17
|
|
|
18
|
-
import type {
|
|
18
|
+
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
19
19
|
|
|
20
20
|
import { AUTO_CLEAR_TURNS, CONTEXT_USAGE_RATIO_LIMIT } from "../../constants";
|
|
21
21
|
import { isActiveStatus, isTerminalStatus } from "../../engine/goal";
|
|
@@ -33,14 +33,6 @@ interface BeforeAgentStartResult {
|
|
|
33
33
|
};
|
|
34
34
|
}
|
|
35
35
|
|
|
36
|
-
/** 类型守卫:把 SDK 的 SessionEntry 联合收窄到 CustomEntry。
|
|
37
|
-
* SessionEntry 现为联合类型(SessionMessageEntry | CompactionEntry | CustomEntry | ...),
|
|
38
|
-
* 只有 CustomEntry 带 customType/data(pending:register/unregister 由 pi.appendEntry 落地,
|
|
39
|
-
* type 恒为 "custom")。读取前必须先收窄。 */
|
|
40
|
-
function isCustomEntry(e: { type: string }): e is CustomEntry {
|
|
41
|
-
return e.type === "custom";
|
|
42
|
-
}
|
|
43
|
-
|
|
44
36
|
export async function handleBeforeAgentStart(
|
|
45
37
|
pi: ExtensionAPI,
|
|
46
38
|
session: GoalSession,
|
|
@@ -69,47 +61,12 @@ export async function handleBeforeAgentStart(
|
|
|
69
61
|
|
|
70
62
|
// 正常 context injection。
|
|
71
63
|
// 全解耦:planAvailable 恒 true(contextInjectionPrompt 恒定建议 plan mode,AI 自行决定)。
|
|
72
|
-
// pending
|
|
73
|
-
//
|
|
74
|
-
// pending-notifications 的 rebuildFromEntries 在其 session_start 已 flush 跨 session/过期残留,
|
|
75
|
-
// 但同一 session 内超时未 unregister 的 entry(如崩溃恢复遗漏)需此处完补以防误报 active。
|
|
76
|
-
const now = Date.now();
|
|
77
|
-
const entries = ctx.sessionManager.getEntries();
|
|
78
|
-
// pending:register/unregister 由 pi.appendEntry 落地为 CustomEntry(type:"custom")。
|
|
79
|
-
// 先用 isCustomEntry 收窄联合类型,再读 customType/data。
|
|
80
|
-
const customEntries = entries.filter(isCustomEntry);
|
|
81
|
-
const pendingUnregisters = new Set(
|
|
82
|
-
customEntries.filter((e) => e.customType === "pending:unregister").map((e) => (e.data as Record<string, unknown>)?.id),
|
|
83
|
-
);
|
|
84
|
-
const activePending = customEntries.filter((e) => {
|
|
85
|
-
if (e.customType !== "pending:register") return false;
|
|
86
|
-
const id = (e.data as Record<string, unknown>)?.id;
|
|
87
|
-
if (pendingUnregisters.has(id)) return false;
|
|
88
|
-
// TTL 过期视为非活跃(expiresAt 缺失或类型错误时宽容处理为活跃)
|
|
89
|
-
const expiresAt = (e.data as Record<string, unknown>)?.expiresAt;
|
|
90
|
-
if (typeof expiresAt === "number" && expiresAt <= now) return false;
|
|
91
|
-
return true;
|
|
92
|
-
});
|
|
93
|
-
const pendingHint = activePending.length > 0
|
|
94
|
-
? `\nNote: There are ${activePending.length} pending async operation(s) running. Consider waiting for them to complete before starting new work.`
|
|
95
|
-
: "";
|
|
96
|
-
|
|
97
|
-
pi.appendEntry("goal:log", {
|
|
98
|
-
timestamp: Date.now(),
|
|
99
|
-
level: "debug",
|
|
100
|
-
component: "goal:before-agent-start",
|
|
101
|
-
message: "pending entries computed",
|
|
102
|
-
data: {
|
|
103
|
-
activePending: activePending.length,
|
|
104
|
-
injectHint: activePending.length > 0,
|
|
105
|
-
pendingIds: activePending.map((e) => (e.data as Record<string, unknown>)?.id),
|
|
106
|
-
},
|
|
107
|
-
});
|
|
108
|
-
|
|
64
|
+
// pending 感知由 LLM 自行调 pending_notifications tool(mandatory)查询当前活跃异步操作,
|
|
65
|
+
// goal 不在此注入——避免与 tool 查询结果形成双信息源不一致(tool 是 EventBus+entries 维护的权威源)。
|
|
109
66
|
return {
|
|
110
67
|
message: {
|
|
111
68
|
customType: "goal-context",
|
|
112
|
-
content: contextInjectionPrompt(session.state, session.state.timeUsedSeconds, true)
|
|
69
|
+
content: contextInjectionPrompt(session.state, session.state.timeUsedSeconds, true),
|
|
113
70
|
display: false,
|
|
114
71
|
},
|
|
115
72
|
};
|
|
@@ -376,6 +376,18 @@ Don't:
|
|
|
376
376
|
- complete when no goal is active — create or resume one first (fails with 'Goal mode not active').`,
|
|
377
377
|
promptSnippet:
|
|
378
378
|
"Use goal_control to manage the thread goal: create (with slug + objective, only when user asks) or complete (with evidence) or report_blocked (with reason, after trying alternatives).",
|
|
379
|
+
// promptGuidelines:进 system prompt guidelines 段(强信号位)。
|
|
380
|
+
// 聚焦 complete/report_blocked 的正向触发引导——这两个 action 是「该主动调但实际不调」
|
|
381
|
+
// 的核心症结(contextInjectionPrompt 规则 3/4 虽引导,但属 goal 活跃时才注入的 steering,
|
|
382
|
+
// 措辞被动;此处给 system prompt 层的常驻强信号)。
|
|
383
|
+
// create 不列:其劝退已在 description + promptSnippet 双重覆盖,此处第三重冗余反而稀释
|
|
384
|
+
// complete/report_blocked 的注意力权重(P14 约束衰减)。
|
|
385
|
+
promptGuidelines: [
|
|
386
|
+
// 全解耦下 todo 非硬前置——objective 实际达成才算(与 handleComplete「todo 由 AI 自判」一致)
|
|
387
|
+
"complete: proactively call when the active goal's objective is actually achieved, not merely in progress. Evidence must be concrete artifacts (files changed, tests green, commands run). Finishing all todos (incl. verification todos) is the usual readiness signal, but the real bar is the objective being met — you decide.",
|
|
388
|
+
// P3 数字阈值 ≥3,与 params.reason description 的 "at least 3 approaches" 双重冗余
|
|
389
|
+
"report_blocked: proactively call when genuinely blocked after ≥3 distinct alternative approaches — not for hard/slow work or uncertainty. State the blocker and what you tried. Do NOT silently stop or leave the goal hanging.",
|
|
390
|
+
],
|
|
379
391
|
executionMode: "sequential",
|
|
380
392
|
parameters: GoalControlParams,
|
|
381
393
|
|
|
@@ -212,12 +212,11 @@ describe("contextInjectionPrompt", () => {
|
|
|
212
212
|
expect(out).toContain("test objective");
|
|
213
213
|
});
|
|
214
214
|
|
|
215
|
-
it("要求建 todo(含 verification todo
|
|
215
|
+
it("要求建 todo(含 verification todo)", () => {
|
|
216
216
|
const state = makeState();
|
|
217
217
|
const out = contextInjectionPrompt(state, 0);
|
|
218
218
|
expect(out).toContain("todo tool");
|
|
219
|
-
expect(out).toContain("verification
|
|
220
|
-
expect(out).toContain("isVerification intent");
|
|
219
|
+
expect(out).toContain("separate todo for verification checks");
|
|
221
220
|
});
|
|
222
221
|
|
|
223
222
|
it("无 goal_manager 引用(#1 清理后)", () => {
|
|
@@ -239,7 +239,7 @@ export function contextInjectionPrompt(
|
|
|
239
239
|
`Track work with todos:\n` +
|
|
240
240
|
`Before working, create todos for the task breakdown using the todo tool:\n` +
|
|
241
241
|
`- Each concrete step becomes a todo item\n` +
|
|
242
|
-
`-
|
|
242
|
+
`- Add a separate todo for verification checks (e.g., 'run tests', 'typecheck')\n` +
|
|
243
243
|
`- Track progress by updating todo status as you complete items\n` +
|
|
244
244
|
planSection +
|
|
245
245
|
`\n` +
|