@zhushanwen/pi-goal 0.12.2 → 0.14.0
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/README.md +3 -3
- package/package.json +3 -3
- package/src/__tests__/circuit-breaker.test.ts +449 -0
- package/src/__tests__/criteria-array-adapter.test.ts +4 -4
- package/src/__tests__/criteria-array-migration.test.ts +0 -2
- package/src/__tests__/deserialize-state.test.ts +54 -6
- package/src/__tests__/event-adapter.test.ts +2 -2
- package/src/__tests__/goal-control-adapter.test.ts +5 -5
- package/src/__tests__/goal-control-rpc.test.ts +1 -1
- package/src/__tests__/liveness.test.ts +162 -0
- package/src/__tests__/ports.test.ts +1 -1
- package/src/__tests__/schema.test.ts +15 -0
- package/src/__tests__/service.test.ts +5 -5
- package/src/__tests__/session.test.ts +0 -2
- package/src/__tests__/stale-checker.test.ts +2 -2
- package/src/adapters/command-adapter.ts +31 -8
- package/src/adapters/event-handlers/agent-end.ts +202 -29
- package/src/adapters/event-handlers/before-agent-start.ts +7 -3
- package/src/adapters/event-handlers/message-end.ts +2 -14
- package/src/adapters/event-handlers/session-shutdown.ts +23 -0
- package/src/adapters/event-handlers/session-start.ts +4 -1
- package/src/adapters/goal-control-adapter.ts +1 -1
- package/src/adapters/ports.ts +17 -23
- package/src/constants.ts +13 -0
- package/src/engine/__tests__/budget.test.ts +7 -7
- package/src/engine/__tests__/goal.test.ts +40 -1
- package/src/engine/budget.ts +7 -13
- package/src/engine/goal.ts +6 -2
- package/src/engine/liveness.ts +150 -0
- package/src/engine/types.ts +21 -2
- package/src/index.ts +23 -50
- package/src/persistence.ts +22 -5
- package/src/ports.ts +13 -3
- package/src/projection/__tests__/prompts.test.ts +19 -11
- package/src/projection/__tests__/widget.test.ts +9 -6
- package/src/projection/prompts.ts +8 -28
- package/src/projection/widget.ts +6 -38
- package/src/service.ts +6 -6
- package/src/session.ts +25 -0
- package/src/adapters/event-adapter.ts +0 -16
- package/src/adapters/event-handlers/shared.ts +0 -27
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* engine/liveness.ts 纯函数测试 — 轮次活性熔断判定单元(chat-domain-v1x D4 / W5)
|
|
3
|
+
*
|
|
4
|
+
* 覆盖:env 配置解析(合法/非法/缺省)、退避等级数学(第 N 轮起 ×2、指数天花板)、
|
|
5
|
+
* 进展信号边界、toolCall 块计数(duck-typed 守卫)、pending 集合变化判定。
|
|
6
|
+
* 行为级四断言(封顶/退避/恢复/defer 去重经 handleAgentEnd)见 circuit-breaker.test.ts。
|
|
7
|
+
*/
|
|
8
|
+
import { describe, expect, it } from "vitest";
|
|
9
|
+
|
|
10
|
+
import {
|
|
11
|
+
continuationBackoffDelayMs,
|
|
12
|
+
countToolCallBlocks,
|
|
13
|
+
isProgressSignal,
|
|
14
|
+
nextNoProgressTurns,
|
|
15
|
+
pendingSetChanged,
|
|
16
|
+
resolveLivenessConfig,
|
|
17
|
+
type LivenessConfig,
|
|
18
|
+
} from "../engine/liveness";
|
|
19
|
+
import { createGoalState } from "../engine/goal";
|
|
20
|
+
|
|
21
|
+
const CFG: LivenessConfig = resolveLivenessConfig({});
|
|
22
|
+
|
|
23
|
+
// ── resolveLivenessConfig(env 覆盖)──────────────────
|
|
24
|
+
|
|
25
|
+
describe("resolveLivenessConfig", () => {
|
|
26
|
+
it("缺省 env → 全默认值(50 / 5 / 1000 / 10s / 10min)", () => {
|
|
27
|
+
const cfg = resolveLivenessConfig({});
|
|
28
|
+
expect(cfg.continuationCap).toBe(50);
|
|
29
|
+
expect(cfg.noProgressTurnsThreshold).toBe(5);
|
|
30
|
+
expect(cfg.noProgressTokenThreshold).toBe(1000);
|
|
31
|
+
expect(cfg.backoffBaseMs).toBe(10_000);
|
|
32
|
+
expect(cfg.backoffMaxMs).toBe(600_000);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("PI_GOAL_* 合法正整数 → 覆盖默认值", () => {
|
|
36
|
+
const cfg = resolveLivenessConfig({
|
|
37
|
+
PI_GOAL_CONTINUATION_CAP: "10",
|
|
38
|
+
PI_GOAL_NO_PROGRESS_TURNS: "3",
|
|
39
|
+
PI_GOAL_NO_PROGRESS_TOKEN_THRESHOLD: "500",
|
|
40
|
+
PI_GOAL_BACKOFF_BASE_MS: "1000",
|
|
41
|
+
PI_GOAL_BACKOFF_MAX_MS: "30000",
|
|
42
|
+
});
|
|
43
|
+
expect(cfg.continuationCap).toBe(10);
|
|
44
|
+
expect(cfg.noProgressTurnsThreshold).toBe(3);
|
|
45
|
+
expect(cfg.noProgressTokenThreshold).toBe(500);
|
|
46
|
+
expect(cfg.backoffBaseMs).toBe(1000);
|
|
47
|
+
expect(cfg.backoffMaxMs).toBe(30000);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it.each([
|
|
51
|
+
["非数字", "abc"],
|
|
52
|
+
["零", "0"],
|
|
53
|
+
["负数", "-5"],
|
|
54
|
+
["小数", "2.7"],
|
|
55
|
+
])("非法值(%s)→ 回退默认(安全网不因坏配置挂掉)", (_label, raw) => {
|
|
56
|
+
const cfg = resolveLivenessConfig({ PI_GOAL_CONTINUATION_CAP: raw });
|
|
57
|
+
expect(cfg.continuationCap).toBe(50);
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
// ── 进展信号 + 无进展计数 ────────────────────────────
|
|
62
|
+
|
|
63
|
+
describe("isProgressSignal / nextNoProgressTurns", () => {
|
|
64
|
+
it("有工具调用(toolDelta>0)即进展,即使 tokenDelta 低", () => {
|
|
65
|
+
expect(isProgressSignal(1, 100, CFG)).toBe(true);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it("tokenDelta 越阈值即进展,即使无工具调用(纯文本迭代型任务豁免退避)", () => {
|
|
69
|
+
expect(isProgressSignal(0, 1000, CFG)).toBe(true);
|
|
70
|
+
expect(isProgressSignal(0, 999, CFG)).toBe(false);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("无进展 → 计数 +1;进展 → 清零", () => {
|
|
74
|
+
expect(nextNoProgressTurns(4, false)).toBe(5);
|
|
75
|
+
expect(nextNoProgressTurns(4, true)).toBe(0);
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
// ── 退避等级数学 ─────────────────────────────────────
|
|
80
|
+
|
|
81
|
+
describe("continuationBackoffDelayMs", () => {
|
|
82
|
+
it("未达阈值(1~4 轮)→ 0(立即发)", () => {
|
|
83
|
+
expect(continuationBackoffDelayMs(0, CFG)).toBe(0);
|
|
84
|
+
expect(continuationBackoffDelayMs(4, CFG)).toBe(0);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it("第 5 轮起 ×2 递增:5 轮 → 2×base,6 轮 → 4×base,7 轮 → 8×base", () => {
|
|
88
|
+
expect(continuationBackoffDelayMs(5, CFG)).toBe(20_000);
|
|
89
|
+
expect(continuationBackoffDelayMs(6, CFG)).toBe(40_000);
|
|
90
|
+
expect(continuationBackoffDelayMs(7, CFG)).toBe(80_000);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it("指数封顶:delay 永不超过 backoffMaxMs", () => {
|
|
94
|
+
expect(continuationBackoffDelayMs(50, CFG)).toBe(600_000);
|
|
95
|
+
expect(continuationBackoffDelayMs(1000, CFG)).toBe(600_000);
|
|
96
|
+
});
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
// ── toolCall 块计数 ──────────────────────────────────
|
|
100
|
+
|
|
101
|
+
describe("countToolCallBlocks", () => {
|
|
102
|
+
it("assistant content 内 toolCall 块计数(多个 entry 累计)", () => {
|
|
103
|
+
const entries = [
|
|
104
|
+
{ type: "message", message: { role: "user", content: "hi" } },
|
|
105
|
+
{
|
|
106
|
+
type: "message",
|
|
107
|
+
message: {
|
|
108
|
+
role: "assistant",
|
|
109
|
+
content: [
|
|
110
|
+
{ type: "text", text: "thinking..." },
|
|
111
|
+
{ type: "toolCall", id: "tc-1", name: "bash" },
|
|
112
|
+
{ type: "toolCall", id: "tc-2", name: "read" },
|
|
113
|
+
],
|
|
114
|
+
},
|
|
115
|
+
},
|
|
116
|
+
{
|
|
117
|
+
type: "message",
|
|
118
|
+
message: { role: "assistant", content: [{ type: "toolCall", id: "tc-3", name: "bash" }] },
|
|
119
|
+
},
|
|
120
|
+
{ type: "message", message: { role: "toolResult", content: [], toolName: "bash", toolCallId: "tc-1" } },
|
|
121
|
+
{ type: "custom", customType: "goal-state", data: {} },
|
|
122
|
+
];
|
|
123
|
+
expect(countToolCallBlocks(entries)).toBe(3);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it("畸形 entry(null/非对象/缺 message/content 非数组)→ 跳过不计数", () => {
|
|
127
|
+
expect(
|
|
128
|
+
countToolCallBlocks([null, 42, "x", { type: "message" }, { type: "message", message: null }, {
|
|
129
|
+
type: "message",
|
|
130
|
+
message: { role: "assistant", content: "plain-string" },
|
|
131
|
+
}]),
|
|
132
|
+
).toBe(0);
|
|
133
|
+
});
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
// ── pending 集合变化 ─────────────────────────────────
|
|
137
|
+
|
|
138
|
+
describe("pendingSetChanged", () => {
|
|
139
|
+
it("集合相同(含顺序不同)→ 无变化", () => {
|
|
140
|
+
expect(pendingSetChanged(["a", "b"], ["b", "a"])).toBe(false);
|
|
141
|
+
expect(pendingSetChanged([], [])).toBe(false);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it("首次进入 defer(last 为空)或有增删 → 变化", () => {
|
|
145
|
+
expect(pendingSetChanged(["a"], [])).toBe(true);
|
|
146
|
+
expect(pendingSetChanged(["a"], ["a", "b"])).toBe(true);
|
|
147
|
+
expect(pendingSetChanged(["a", "c"], ["a", "b"])).toBe(true);
|
|
148
|
+
});
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
// ── createGoalState 初始化 ───────────────────────────
|
|
152
|
+
|
|
153
|
+
describe("createGoalState — W5 计数初始化", () => {
|
|
154
|
+
it("新 goal 熔断计数全部从零开始", () => {
|
|
155
|
+
const state = createGoalState("obj");
|
|
156
|
+
expect(state.continuationsSent).toBe(0);
|
|
157
|
+
expect(state.noProgressTurns).toBe(0);
|
|
158
|
+
expect(state.continuationCapNotified).toBe(false);
|
|
159
|
+
expect(state.lastDeferredPendingIds).toEqual([]);
|
|
160
|
+
expect(state.toolCallsSeen).toBe(0);
|
|
161
|
+
});
|
|
162
|
+
});
|
|
@@ -46,7 +46,7 @@ describe("buildPorts UiPort.setGuiWidget(marker 编码集成)", () => {
|
|
|
46
46
|
const captured = getCaptured();
|
|
47
47
|
expect(captured).toBeDefined();
|
|
48
48
|
expect(captured).toHaveLength(1);
|
|
49
|
-
// marker 前缀(host 侧
|
|
49
|
+
// marker 前缀(host 侧 EventAdapter 据此解码成 extension:widgetGui)
|
|
50
50
|
expect(captured![0].startsWith(GUI_WIDGET_MARKER)).toBe(true);
|
|
51
51
|
// 解码还原 GuiComponent
|
|
52
52
|
const json = captured![0].slice(GUI_WIDGET_MARKER.length);
|
|
@@ -16,6 +16,8 @@ import { describe, expect, it } from "vitest";
|
|
|
16
16
|
import { Value } from "typebox/value";
|
|
17
17
|
|
|
18
18
|
import { GoalControlParams } from "../adapters/goal-control-adapter";
|
|
19
|
+
import { createGoalState } from "../engine/goal";
|
|
20
|
+
import { serializeState } from "../persistence";
|
|
19
21
|
|
|
20
22
|
describe("GoalControlParams — 扁平 Object(OpenAI 兼容)", () => {
|
|
21
23
|
// 顶层合规:序列化形态顶层必须有 type:"object",不得有 anyOf(否则被严格网关 400 拒绝)
|
|
@@ -52,3 +54,16 @@ describe("GoalControlParams — 扁平 Object(OpenAI 兼容)", () => {
|
|
|
52
54
|
expect(Value.Check(GoalControlParams, params)).toBe(false);
|
|
53
55
|
});
|
|
54
56
|
});
|
|
57
|
+
|
|
58
|
+
// ── 持久化 schema:serializeState 输出(P-m14-2,ext-simplify-03 D2)──
|
|
59
|
+
|
|
60
|
+
describe("persisted goal-state schema — serializeState 输出(P-m14-2)", () => {
|
|
61
|
+
// D2 一步删除 lastProgressTurn/objectiveUpdatedAt 后,新写入的 goal-state entry
|
|
62
|
+
// 不得再携带这两个 write-only 字段。serializeState 是浅拷贝透传({ ...state, budget: {...} }),
|
|
63
|
+
// 字段从 GoalRuntimeState 删除后输出自动少两键——此处断言删除完整(fixture 残留即红)。
|
|
64
|
+
it("新写入 entry(serializeState(createGoalState))不含已删两字段", () => {
|
|
65
|
+
const serialized = serializeState(createGoalState("obj"));
|
|
66
|
+
expect(serialized).not.toHaveProperty("lastProgressTurn");
|
|
67
|
+
expect(serialized).not.toHaveProperty("objectiveUpdatedAt");
|
|
68
|
+
});
|
|
69
|
+
});
|
|
@@ -34,9 +34,11 @@ function makeFakePorts(): ServicePorts & {
|
|
|
34
34
|
setStatus: () => {},
|
|
35
35
|
notify: () => {},
|
|
36
36
|
hasUI: true,
|
|
37
|
-
// ThemeLike
|
|
38
|
-
|
|
39
|
-
|
|
37
|
+
// ThemeLike 嵌套成员(UiPort.theme 显式声明,updateWidget 直读 uiPort.theme)
|
|
38
|
+
theme: {
|
|
39
|
+
fg: (_color: string, text: string) => text,
|
|
40
|
+
bold: (text: string) => text,
|
|
41
|
+
},
|
|
40
42
|
},
|
|
41
43
|
messaging: {
|
|
42
44
|
sendContextMessage: () => {},
|
|
@@ -44,8 +46,6 @@ function makeFakePorts(): ServicePorts & {
|
|
|
44
46
|
},
|
|
45
47
|
session: {
|
|
46
48
|
getEntries: () => [],
|
|
47
|
-
getContextUsage: () => null,
|
|
48
|
-
signal: undefined,
|
|
49
49
|
},
|
|
50
50
|
};
|
|
51
51
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* agent-end stale-check 辅助函数测试(TC-3)
|
|
3
3
|
*
|
|
4
4
|
* 直接测试 makeStaleChecker 的快照语义,
|
|
5
5
|
* 非间接覆盖。重点:makeStaleChecker 在 state=null 时 snapshot=undefined,
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
*/
|
|
8
8
|
import { describe, expect, it } from "vitest";
|
|
9
9
|
|
|
10
|
-
import { makeStaleChecker } from "../adapters/event-handlers/
|
|
10
|
+
import { makeStaleChecker } from "../adapters/event-handlers/agent-end";
|
|
11
11
|
import { createGoalState } from "../engine/goal";
|
|
12
12
|
import type { GoalRuntimeState } from "../engine/types";
|
|
13
13
|
import { createGoalSession } from "../session";
|
|
@@ -31,7 +31,7 @@ import { updateWidget } from "../projection/widget";
|
|
|
31
31
|
import { finalizeAndPersist, persistState, tickState } from "../service";
|
|
32
32
|
import type { GoalHistoryEntry } from "../ports";
|
|
33
33
|
import type { GoalSession } from "../session";
|
|
34
|
-
import { clearGoalSession } from "../session";
|
|
34
|
+
import { clearGoalSession, cancelContinuationTimer } from "../session";
|
|
35
35
|
import { buildPorts } from "./ports";
|
|
36
36
|
import { CRITERIA_HINTS_SLASH, validateSuccessCriteriaItems } from "./success-criteria";
|
|
37
37
|
|
|
@@ -122,6 +122,9 @@ function handlePause(pi: ExtensionAPI, session: GoalSession, ctx: ExtensionConte
|
|
|
122
122
|
// 先 tickState 累加当前运行段(此时 status 仍为 active)
|
|
123
123
|
tickState(state);
|
|
124
124
|
state.status = transitionStatus(state.status, "paused");
|
|
125
|
+
// W5:用户主动叫停 → 取消待发的退避 continuation(paused 期间不自动续跑;
|
|
126
|
+
// resume 后由其触发的 user message 重新驱动循环)
|
|
127
|
+
cancelContinuationTimer(session);
|
|
125
128
|
|
|
126
129
|
const ports = buildPorts(pi, ctx);
|
|
127
130
|
persistState(session, ports);
|
|
@@ -141,13 +144,27 @@ function handleResume(pi: ExtensionAPI, session: GoalSession, ctx: ExtensionCont
|
|
|
141
144
|
ctx.ui.notify(`Goal is in terminal state (${state.status}), cannot resume.`, "warning");
|
|
142
145
|
return;
|
|
143
146
|
}
|
|
147
|
+
// W5 恢复通道②:active 且 continuation 已封顶停发 → resume 重置熔断计数续跑
|
|
148
|
+
// (goal 封顶时刻意保持 active 不转终态,续跑决策权在用户)。
|
|
149
|
+
// 未封顶的普通 active goal 无需 resume(保持原语义)。
|
|
150
|
+
const cappedActive = state.status === "active" && state.continuationCapNotified;
|
|
144
151
|
// FR-3: resume 支持 paused→active 和 blocked→active(两者对称,都做 budget 重检 + 触发 AI)
|
|
145
|
-
if (state.status !== "paused" && state.status !== "blocked") {
|
|
152
|
+
if (state.status !== "paused" && state.status !== "blocked" && !cappedActive) {
|
|
146
153
|
ctx.ui.notify("Goal is not paused or blocked, no need to resume.", "info");
|
|
147
154
|
return;
|
|
148
155
|
}
|
|
149
|
-
state.status
|
|
150
|
-
|
|
156
|
+
if (state.status !== "active") {
|
|
157
|
+
state.status = transitionStatus(state.status, "active");
|
|
158
|
+
state.timeStartedAt = Date.now();
|
|
159
|
+
}
|
|
160
|
+
// W5:resume = 新激活周期——熔断计数清零。主判据总次数(continuationsSent)只经
|
|
161
|
+
// 用户显式 resume 重置,agent 侧任何行为(含每轮调工具)都不可清零(正交性)。
|
|
162
|
+
state.continuationsSent = 0;
|
|
163
|
+
state.noProgressTurns = 0;
|
|
164
|
+
state.continuationCapNotified = false;
|
|
165
|
+
state.lastDeferredPendingIds = [];
|
|
166
|
+
// toolCallsSeen 不清零:entries 差分口径跨周期连续(清零会让首轮差分把历史
|
|
167
|
+
// 调用全算成本轮进展,虚高一次无害但无意义)
|
|
151
168
|
|
|
152
169
|
const ports = buildPorts(pi, ctx);
|
|
153
170
|
|
|
@@ -171,8 +188,11 @@ function handleResume(pi: ExtensionAPI, session: GoalSession, ctx: ExtensionCont
|
|
|
171
188
|
const blockerNote = state.lastBlockerReason
|
|
172
189
|
? `\n\nPrevious blocker: ${state.lastBlockerReason}. Try a different approach.`
|
|
173
190
|
: "";
|
|
191
|
+
const capNote = cappedActive
|
|
192
|
+
? "\n\nContinuation circuit breaker was reached — pursue the objective with concrete tool-backed progress."
|
|
193
|
+
: "";
|
|
174
194
|
pi.sendUserMessage(
|
|
175
|
-
`Goal resumed. Continuing toward the objective.${blockerNote}\n\nObjective: ${state.objective}`,
|
|
195
|
+
`Goal resumed. Continuing toward the objective.${blockerNote}${capNote}\n\nObjective: ${state.objective}`,
|
|
176
196
|
{ deliverAs: "followUp" },
|
|
177
197
|
);
|
|
178
198
|
}
|
|
@@ -264,7 +284,7 @@ function handleClear(pi: ExtensionAPI, session: GoalSession, ctx: ExtensionConte
|
|
|
264
284
|
|
|
265
285
|
/**
|
|
266
286
|
* FR-8.4 G-002:重塑(reset)。重置 objective/budget flags/
|
|
267
|
-
* currentTurnIndex
|
|
287
|
+
* currentTurnIndex,保留 goalId。
|
|
268
288
|
* active 状态下向 AI 注入 objective-updated steering。
|
|
269
289
|
*/
|
|
270
290
|
function handleUpdate(
|
|
@@ -293,12 +313,15 @@ function handleUpdate(
|
|
|
293
313
|
const oldObjective = state.objective;
|
|
294
314
|
// FR-8.4 G-002: 重塑(重置,保留 goalId)
|
|
295
315
|
state.objective = newObjective;
|
|
296
|
-
state.objectiveUpdatedAt = Date.now();
|
|
297
316
|
state.currentTurnIndex = 0;
|
|
298
|
-
state.lastProgressTurn = 0;
|
|
299
317
|
state.budgetLimitSteeringSent = false;
|
|
300
318
|
state.tokenWarning70Sent = false;
|
|
301
319
|
state.tokenWarning90Sent = false;
|
|
320
|
+
// W5:重塑 = 新激活周期,熔断计数与 budget flag 同类(cycle-scoped 记账)一并重置
|
|
321
|
+
state.continuationsSent = 0;
|
|
322
|
+
state.noProgressTurns = 0;
|
|
323
|
+
state.continuationCapNotified = false;
|
|
324
|
+
state.lastDeferredPendingIds = [];
|
|
302
325
|
// GAP-6: update 是重塑,旧 slug 已不匹配新 objective → 置空(widget fallback objective 截断)
|
|
303
326
|
state.slug = undefined;
|
|
304
327
|
// update 重塑后旧 successCriteria 可能不再完全匹配新 objective,但语义内容仍可部分适用。
|
|
@@ -7,7 +7,8 @@
|
|
|
7
7
|
* - FR-6.7 ESC 守卫(最关键):ctx.signal?.aborted → 不发 continuation、不做 budget 检查、
|
|
8
8
|
* 不做任何状态变更,goal 保持 active,等用户下次输入恢复
|
|
9
9
|
*
|
|
10
|
-
* #8 后流程:budget 预警/steering → continuation
|
|
10
|
+
* #8 后流程:budget 预警/steering → continuation 去抖 + 轮次活性熔断(W5:
|
|
11
|
+
* 连发总次数封顶 + 无进展退避 + defer 通知去重,见 handleContinuation 头注)。
|
|
11
12
|
* agent_end 只做提醒(warning + steering),不做终态转换。
|
|
12
13
|
* 终态转换不在 agent_end——由 persistAndUpdate 兜底(#5 范围,单一检查点)。
|
|
13
14
|
*
|
|
@@ -23,15 +24,25 @@ import { countActiveFromEntries } from "@zhushanwen/pi-pending-notifications";
|
|
|
23
24
|
|
|
24
25
|
import { checkBudgetOnTurnEnd } from "../../engine/budget";
|
|
25
26
|
import { isActiveStatus, isTerminalStatus } from "../../engine/goal";
|
|
27
|
+
import {
|
|
28
|
+
type LivenessConfig,
|
|
29
|
+
continuationBackoffDelayMs,
|
|
30
|
+
countToolCallBlocks,
|
|
31
|
+
isContinuationCapped,
|
|
32
|
+
isProgressSignal,
|
|
33
|
+
nextNoProgressTurns,
|
|
34
|
+
pendingSetChanged,
|
|
35
|
+
resolveLivenessConfig,
|
|
36
|
+
} from "../../engine/liveness";
|
|
26
37
|
import {
|
|
27
38
|
budgetLimitPrompt,
|
|
28
39
|
continuationPrompt,
|
|
29
40
|
} from "../../projection/prompts";
|
|
30
41
|
import { persistAndUpdate, tickState } from "../../service";
|
|
31
|
-
import
|
|
42
|
+
import { serializeState } from "../../persistence";
|
|
43
|
+
import { cancelContinuationTimer, type GoalSession } from "../../session";
|
|
32
44
|
import type { ServicePorts } from "../../service";
|
|
33
45
|
import { buildPorts } from "../ports";
|
|
34
|
-
import { makeStaleChecker } from "./shared";
|
|
35
46
|
|
|
36
47
|
export async function handleAgentEnd(
|
|
37
48
|
pi: ExtensionAPI,
|
|
@@ -135,13 +146,19 @@ async function handleBudgetChecks(
|
|
|
135
146
|
}
|
|
136
147
|
|
|
137
148
|
/**
|
|
138
|
-
* continuation
|
|
149
|
+
* continuation 去抖 + 轮次活性熔断(budget 预警未拦截时的尾部逻辑)。
|
|
139
150
|
*
|
|
140
151
|
* - continuation 去抖:tokenDelta=0(空 turn)不发,只 persist
|
|
141
|
-
* -
|
|
152
|
+
* - W5 主判据:continuation 连发总次数封顶(默认 50,PI_GOAL_CONTINUATION_CAP)——
|
|
153
|
+
* 封顶必停发 + notify 用户 + goal 保持 active(不擅自终态)。与「是否调工具」
|
|
154
|
+
* 正交:计数只在 deliverContinuation 实发时 +1,绝不被工具调用清零
|
|
155
|
+
* (2026-09-08 事故:pending 守卫击穿后 64 分钟 ~320 turn 空转,无任何熔断)。
|
|
156
|
+
* - W5 辅判据:连续 N 轮(默认 5)无工具调用且 tokenDelta 低于阈值 → continuation
|
|
157
|
+
* 延迟 base×2^level 发出(间隔 ×2 递增);真实进展清零退避计数(不动总次数)。
|
|
158
|
+
* - W5 defer 通知去重:pending 集合不变的连续 defer 轮不重复 notify。
|
|
142
159
|
*
|
|
143
160
|
* 注:maxTurnsReached / stall 自动终态分支随 #6 删除;budget terminal 分支随 #8 删除。
|
|
144
|
-
* 终态转换由 persistAndUpdate
|
|
161
|
+
* 终态转换由 persistAndUpdate 兜底(#5 范围,单一检查点)。
|
|
145
162
|
*/
|
|
146
163
|
async function handleContinuation(
|
|
147
164
|
pi: ExtensionAPI,
|
|
@@ -153,20 +170,24 @@ async function handleContinuation(
|
|
|
153
170
|
const state = session.state!;
|
|
154
171
|
if (checkStale()) return;
|
|
155
172
|
|
|
173
|
+
const cfg = resolveLivenessConfig();
|
|
174
|
+
const entries = ctx.sessionManager.getEntries();
|
|
175
|
+
|
|
156
176
|
// FR-8.6: continuation 去抖(空 turn 不发)
|
|
157
177
|
const tokenDelta = state.tokensUsed - state.lastTurnTokensUsed;
|
|
158
178
|
state.lastTurnTokensUsed = state.tokensUsed;
|
|
159
|
-
|
|
160
|
-
//
|
|
161
|
-
//
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
179
|
+
|
|
180
|
+
// W5 辅判据记账:本 turn(自上次 agent_end 以来)真实工具活动 = toolCall 块差分。
|
|
181
|
+
// 无进展 → 计数 +1;有进展(工具调用或 tokenDelta 越阈值)→ 清零退避计数
|
|
182
|
+
// (主判据 continuationsSent 不在此处触碰——正交性)。
|
|
183
|
+
const toolCallsTotal = countToolCallBlocks(entries);
|
|
184
|
+
const toolDelta = toolCallsTotal - state.toolCallsSeen;
|
|
185
|
+
state.toolCallsSeen = toolCallsTotal;
|
|
186
|
+
state.noProgressTurns = nextNoProgressTurns(
|
|
187
|
+
state.noProgressTurns,
|
|
188
|
+
isProgressSignal(toolDelta, tokenDelta, cfg),
|
|
189
|
+
);
|
|
190
|
+
|
|
170
191
|
// pending 守卫:有活跃 background subagent/workflow 时不发 continuation。
|
|
171
192
|
// 靠 subagent/workflow 完成时 sendMessage({triggerTurn:true,deliverAs:"steer"}) 自然唤醒主 agent;
|
|
172
193
|
// goal 抢先催会与异步任务完成通知叠加,形成“停不下来”的死循环——continuation 排入
|
|
@@ -174,8 +195,15 @@ async function handleContinuation(
|
|
|
174
195
|
// 新 turn → 又 agent_end → 又 continuation。
|
|
175
196
|
// 刻意不校验 expiresAt:长任务 subagent(>1h TTL)完成时仍 triggerTurn 唤醒主 agent,
|
|
176
197
|
// 按 TTL 判非活跃会让死循环在长任务场景复现。
|
|
177
|
-
|
|
198
|
+
// [W4 读侧过滤①消费口] 传当前 session 基准:fork 继承的父级注册残留不进差集
|
|
199
|
+
// (守卫不幻 defer);设计 D4「读侧过滤一刀」在本消费口的落地(W6 探针实测
|
|
200
|
+
// registry-fork-filter.test.ts 证不传则虚增,A9② 验收口径)。
|
|
201
|
+
const pendingOps = countActiveFromEntries(entries, {
|
|
202
|
+
currentSessionId: ctx.sessionManager.getSessionId(),
|
|
203
|
+
});
|
|
204
|
+
let deferred = false;
|
|
178
205
|
if (pendingOps.count > 0) {
|
|
206
|
+
deferred = true;
|
|
179
207
|
pi.appendEntry("goal:log", {
|
|
180
208
|
timestamp: Date.now(),
|
|
181
209
|
level: "debug",
|
|
@@ -183,17 +211,162 @@ async function handleContinuation(
|
|
|
183
211
|
message: `continuation deferred: ${pendingOps.count} active pending operation(s)`,
|
|
184
212
|
data: { activePending: pendingOps.count, ids: pendingOps.ids },
|
|
185
213
|
});
|
|
186
|
-
//
|
|
187
|
-
//
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
214
|
+
// W5 defer 通知去重:状态变化(pending 集合变化或首次进入 defer)才 notify——
|
|
215
|
+
// 事故中每轮 agent_end 都发「Goal waiting for N…」,集合不变时是纯噪音。
|
|
216
|
+
// 用户可见反馈保留:deferred 无反馈时用户只看到 goal active 但无产出,
|
|
217
|
+
// 无法区分等待 vs 死循环。
|
|
218
|
+
if (pendingSetChanged(pendingOps.ids, state.lastDeferredPendingIds)) {
|
|
219
|
+
ctx.ui.notify(
|
|
220
|
+
`Goal waiting for ${pendingOps.count} background task(s) to complete.`,
|
|
221
|
+
"info",
|
|
222
|
+
);
|
|
223
|
+
state.lastDeferredPendingIds = [...pendingOps.ids].sort();
|
|
224
|
+
}
|
|
225
|
+
} else {
|
|
226
|
+
// 非 defer 轮清空去重锚:每个等待 episode 的首次 defer 都会通知一次
|
|
227
|
+
state.lastDeferredPendingIds = [];
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// 单次 persist(H2 合并):熔断计数(noProgressTurns / toolCallsSeen /
|
|
231
|
+
// lastDeferredPendingIds)随同一快照落盘。budget 终态检查点在 persistAndUpdate
|
|
232
|
+
// 内部(#5 单一检查点),与调用位置无关——合并不影响预算终态触发时机(explorer 论证)。
|
|
233
|
+
persistAndUpdate(session, ports);
|
|
234
|
+
if (deferred) {
|
|
235
|
+
// defer 轮:已 persist + 已去重通知,不发 continuation
|
|
192
236
|
return;
|
|
193
237
|
}
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
238
|
+
if (tokenDelta <= 0) {
|
|
239
|
+
// 空 turn:已 persist,不发 continuation
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
242
|
+
// 终态守卫:persistAndUpdate 可能把 goal 转为 budget_limited 终态。
|
|
243
|
+
// 此时不应发 continuation(deliverAs:"followUp" 会触发新 turn,让已耗尽预算的 agent 再跑一轮)。
|
|
244
|
+
if (isTerminalStatus(state.status)) return;
|
|
245
|
+
|
|
246
|
+
// W5 主判据:连发总次数封顶(与是否调工具正交——toolDelta 只影响辅判据退避)。
|
|
247
|
+
// 封顶必停发 + 通知一次(含恢复指引)+ goal 保持 active(不擅自终态)。
|
|
248
|
+
if (isContinuationCapped(state, cfg)) {
|
|
249
|
+
if (!state.continuationCapNotified) {
|
|
250
|
+
state.continuationCapNotified = true;
|
|
251
|
+
ports.ui.notify(
|
|
252
|
+
`Goal continuation stopped: ${cfg.continuationCap} continuations sent this cycle without completing (no-progress circuit breaker). The goal stays active. Use /goal resume to continue with a fresh allowance, or /goal clear to stop.`,
|
|
253
|
+
"warning",
|
|
254
|
+
);
|
|
255
|
+
pi.appendEntry("goal:log", {
|
|
256
|
+
timestamp: Date.now(),
|
|
257
|
+
level: "info",
|
|
258
|
+
component: "goal:agent-end",
|
|
259
|
+
message: `continuation capped: ${state.continuationsSent}/${cfg.continuationCap} sent, stopping (goal stays active; /goal resume resets)`,
|
|
260
|
+
data: { continuationsSent: state.continuationsSent, cap: cfg.continuationCap },
|
|
261
|
+
});
|
|
262
|
+
// 停发标志单独落盘(罕见路径——每激活周期至多一次):否则下次 persist 前
|
|
263
|
+
// 进程重启会让停发通知重发一次
|
|
264
|
+
ports.persistence.appendState(serializeState(state));
|
|
265
|
+
}
|
|
266
|
+
return;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// W5 辅判据:无进展退避。到决策点先取消旧的待发退避 continuation——等待期内若
|
|
270
|
+
// 有外部驱动的 turn(用户输入 / 后台任务通知唤醒)到达这里,按最新状态重算:
|
|
271
|
+
// 有进展 → 立即发;仍无进展 → 按当前等级重排。保证任意时刻至多一个待发。
|
|
272
|
+
cancelContinuationTimer(session);
|
|
273
|
+
|
|
274
|
+
const delayMs = continuationBackoffDelayMs(state.noProgressTurns, cfg);
|
|
275
|
+
if (delayMs <= 0) {
|
|
276
|
+
deliverContinuation(ports, session);
|
|
277
|
+
return;
|
|
278
|
+
}
|
|
279
|
+
pi.appendEntry("goal:log", {
|
|
280
|
+
timestamp: Date.now(),
|
|
281
|
+
level: "debug",
|
|
282
|
+
component: "goal:agent-end",
|
|
283
|
+
message: `continuation delayed by backoff: ${delayMs}ms (no-progress turns: ${state.noProgressTurns})`,
|
|
284
|
+
data: { delayMs, noProgressTurns: state.noProgressTurns },
|
|
285
|
+
});
|
|
286
|
+
const goalId = state.goalId;
|
|
287
|
+
session.continuationTimer = setTimeout(() => {
|
|
288
|
+
session.continuationTimer = null;
|
|
289
|
+
// MF-6②:timer 回调跨生命周期持有本次 agent_end 的 ctx/ports 闭包,且不在
|
|
290
|
+
// pi runner 的 handler 错误隔离范围内——回调内同步异常 = uncaughtException,
|
|
291
|
+
// 可能带崩 pi 进程。整体 try/catch:stale ctx(session 关闭/替换后 ctx 的
|
|
292
|
+
// isIdle / sessionManager getter 走 assertActive 抛错,development-guide
|
|
293
|
+
// §11.1)等异常降级为一条 goal:log 记录后放弃,goal 状态仍在盘上,后续任意
|
|
294
|
+
// agent_end 会重新决策。(正常运行时路径的兜底:session_shutdown 已在
|
|
295
|
+
// invalidate 前取消旧 timer,stale 到期属极端残留场景。)
|
|
296
|
+
try {
|
|
297
|
+
fireBackoffContinuation(session, ports, ctx, cfg, goalId);
|
|
298
|
+
} catch (err) {
|
|
299
|
+
// MF-R2-1 兜底:pi.appendEntry 与 ctx 同生命周期(loader.js appendEntry
|
|
300
|
+
// 首行 assertActive,0.84.4 实装核对)——stale 场景此调用同样必抛,
|
|
301
|
+
// 故降级日志自包 try/catch,保证 catch 块自身零抛出。放弃日志无害:
|
|
302
|
+
// goal 状态在盘上,后续任意 agent_end 会重新决策。
|
|
303
|
+
try {
|
|
304
|
+
pi.appendEntry("goal:log", {
|
|
305
|
+
timestamp: Date.now(),
|
|
306
|
+
level: "warn",
|
|
307
|
+
component: "goal:agent-end",
|
|
308
|
+
message: `backoff continuation dropped after error: ${String(err)}`,
|
|
309
|
+
});
|
|
310
|
+
} catch (logErr) {
|
|
311
|
+
// 刻意放弃:appendEntry(goal:log 落盘通道)自身失效的场景(stale /
|
|
312
|
+
// runtime 失效)下再调任何 runtime 方法都会二次抛出——本 catch 存在的
|
|
313
|
+
// 意义即阻断该 uncaughtException 路径。void 显式忽略,对齐
|
|
314
|
+
// extension-logger 内部 appendEntry 失败兜底的既定惯例。
|
|
315
|
+
void logErr;
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
}, delayMs);
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/**
|
|
322
|
+
* 退避 timer 到期后的发射前守卫 + 实发(延迟期间状态可能已变):
|
|
323
|
+
* - goal 未被覆盖/清除(goalId 比对)、仍 active
|
|
324
|
+
* - MF-6① idle 守卫:ctx.signal 为实时求值(pi-agent-core agent.js get signal 返回
|
|
325
|
+
* activeRun?.abortController.signal),agent idle 后恒 undefined,`signal?.aborted`
|
|
326
|
+
* 恒放行、不可作守卫;改用 ctx.isIdle()(SDK types.d.ts L232)——延迟窗内有活动
|
|
327
|
+
* turn 时不发,该轮 agent_end 会按最新状态重新决策
|
|
328
|
+
* - 未封顶、无活跃 pending(等待期新出现的后台任务由其完成通知唤醒,goal 不抢先催)
|
|
329
|
+
*/
|
|
330
|
+
function fireBackoffContinuation(
|
|
331
|
+
session: GoalSession,
|
|
332
|
+
ports: ServicePorts,
|
|
333
|
+
ctx: ExtensionContext,
|
|
334
|
+
cfg: LivenessConfig,
|
|
335
|
+
goalId: string,
|
|
336
|
+
): void {
|
|
337
|
+
if (!session.state || session.state.goalId !== goalId) return;
|
|
338
|
+
if (!isActiveStatus(session.state.status)) return;
|
|
339
|
+
if (!ctx.isIdle()) return;
|
|
340
|
+
if (isContinuationCapped(session.state, cfg)) return;
|
|
341
|
+
if (countActiveFromEntries(ctx.sessionManager.getEntries(), { currentSessionId: ctx.sessionManager.getSessionId() }).count > 0) return;
|
|
342
|
+
deliverContinuation(ports, session);
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/**
|
|
346
|
+
* 发出一次 continuation 并立即落盘计数(主判据只在实发时 +1,发出即持久化——
|
|
347
|
+
* 崩溃后封顶计数不从零开始)。
|
|
348
|
+
*/
|
|
349
|
+
function deliverContinuation(ports: ServicePorts, session: GoalSession): void {
|
|
350
|
+
const state = session.state!;
|
|
351
|
+
state.continuationsSent += 1;
|
|
352
|
+
ports.messaging.sendContextMessage(continuationPrompt(state), "followUp");
|
|
353
|
+
ports.persistence.appendState(serializeState(state));
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/**
|
|
357
|
+
* 构造 stale-check 闭包(FR-8.2 G-020):入口快照 goalId,后续判断是否被新 goal 覆盖。
|
|
358
|
+
*
|
|
359
|
+
* 用法(agent_end):
|
|
360
|
+
* ```ts
|
|
361
|
+
* const checkStale = makeStaleChecker(session);
|
|
362
|
+
* // ... 长流程 ...
|
|
363
|
+
* if (checkStale()) return; // goal 被覆盖,本次 agent_end 作废
|
|
364
|
+
* ```
|
|
365
|
+
*
|
|
366
|
+
* 语义:snapshot 时 session.state 可能为 null(首次启动),此时 snapshotGoalId
|
|
367
|
+
* 为 undefined;后续若有新 goal(goalId !== undefined)即视为 stale。
|
|
368
|
+
*/
|
|
369
|
+
export function makeStaleChecker(session: GoalSession): () => boolean {
|
|
370
|
+
const snapshotGoalId = session.state?.goalId;
|
|
371
|
+
return () => !session.state || session.state.goalId !== snapshotGoalId;
|
|
199
372
|
}
|
|
@@ -20,9 +20,9 @@ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-a
|
|
|
20
20
|
import { AUTO_CLEAR_TURNS, CONTEXT_USAGE_RATIO_LIMIT } from "../../constants";
|
|
21
21
|
import { isActiveStatus, isTerminalStatus } from "../../engine/goal";
|
|
22
22
|
import { contextInjectionPrompt } from "../../projection/prompts";
|
|
23
|
-
import {
|
|
23
|
+
import { renderTerminalStatusLine } from "../../projection/widget";
|
|
24
24
|
import type { GoalSession } from "../../session";
|
|
25
|
-
import { clearGoalSession } from "../../session";
|
|
25
|
+
import { cancelContinuationTimer, clearGoalSession } from "../../session";
|
|
26
26
|
import { buildPorts } from "../ports";
|
|
27
27
|
|
|
28
28
|
interface BeforeAgentStartResult {
|
|
@@ -38,6 +38,10 @@ export async function handleBeforeAgentStart(
|
|
|
38
38
|
session: GoalSession,
|
|
39
39
|
ctx: ExtensionContext,
|
|
40
40
|
): Promise<BeforeAgentStartResult | undefined> {
|
|
41
|
+
// MF-6③:新用户活动(新 turn 开始)使旧的退避 continuation 作废——该轮
|
|
42
|
+
// agent_end 会按最新状态重新决策。放在 state 检查之前:即使 goal 已清,
|
|
43
|
+
// 旧 timer 也不该在新 turn 开始后仍存活
|
|
44
|
+
cancelContinuationTimer(session);
|
|
41
45
|
if (!session.state) return;
|
|
42
46
|
|
|
43
47
|
pi.appendEntry("goal:log", {
|
|
@@ -88,7 +92,7 @@ function handleTerminalStateBeforeAgent(
|
|
|
88
92
|
return;
|
|
89
93
|
}
|
|
90
94
|
// 折叠 status bar(终态显示)
|
|
91
|
-
const statusText = renderTerminalStatusLine(state,
|
|
95
|
+
const statusText = renderTerminalStatusLine(state, buildPorts(pi, ctx).ui.theme);
|
|
92
96
|
if (statusText && ctx.hasUI) ctx.ui.setStatus("goal", statusText);
|
|
93
97
|
if (ctx.hasUI) ctx.ui.setWidget("goal", undefined);
|
|
94
98
|
}
|