@zhushanwen/pi-goal 0.1.3 → 0.1.5
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/action-handlers.ts +341 -0
- package/src/agent-end-handler.ts +304 -0
- package/src/before-agent-start-handler.ts +182 -0
- package/src/budget.ts +7 -7
- package/src/command-handler.ts +280 -0
- package/src/commands.ts +1 -1
- package/src/index.ts +109 -635
- package/src/state.ts +1 -1
- package/src/templates.ts +5 -5
- package/src/tool-handler.ts +55 -288
- package/src/widget.ts +8 -7
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zhushanwen/pi-goal",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.5",
|
|
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",
|
|
@@ -0,0 +1,341 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* goal_manager tool 的 action 处理子函数
|
|
3
|
+
*
|
|
4
|
+
* 每个 action 一个 ≤60 行的子函数,由 executeGoalAction 调度。
|
|
5
|
+
* 解决了 P1-6: executeGoalAction ~260 行超过 80 行限制。
|
|
6
|
+
*
|
|
7
|
+
* 行为完全不变 —— 仅做代码组织重构。
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent";
|
|
11
|
+
import type { Static } from "typebox";
|
|
12
|
+
|
|
13
|
+
import { SECONDS_PER_MINUTE } from "./constants";
|
|
14
|
+
import {
|
|
15
|
+
getCompletedCount,
|
|
16
|
+
getElapsedTimeSeconds,
|
|
17
|
+
getIncompleteTasks,
|
|
18
|
+
type GoalRuntimeState,
|
|
19
|
+
type GoalTask,
|
|
20
|
+
isTerminalStatus,
|
|
21
|
+
isTerminalTaskStatus,
|
|
22
|
+
type Subtask,
|
|
23
|
+
transitionStatus,
|
|
24
|
+
} from "./state";
|
|
25
|
+
import { formatTaskList } from "./templates";
|
|
26
|
+
import {
|
|
27
|
+
clearGoalSession,
|
|
28
|
+
errorResult,
|
|
29
|
+
type GoalManagerDetails,
|
|
30
|
+
GoalManagerParams,
|
|
31
|
+
type GoalSession,
|
|
32
|
+
makeGoalResult,
|
|
33
|
+
persistGoalState,
|
|
34
|
+
writeGoalHistoryEntry,
|
|
35
|
+
} from "./tool-handler";
|
|
36
|
+
|
|
37
|
+
/** action 处理器签名:所有处理器返回 ToolResult(成功或 errorResult) */
|
|
38
|
+
type ActionResult = ReturnType<typeof makeGoalResult> | {
|
|
39
|
+
content: Array<{ type: "text"; text: string }>;
|
|
40
|
+
details: GoalManagerDetails;
|
|
41
|
+
};
|
|
42
|
+
type ActionContext = {
|
|
43
|
+
pi: ExtensionAPI;
|
|
44
|
+
session: GoalSession;
|
|
45
|
+
state: GoalRuntimeState;
|
|
46
|
+
params: Static<typeof GoalManagerParams>;
|
|
47
|
+
ctx: ExtensionContext;
|
|
48
|
+
};
|
|
49
|
+
type ActionHandler = (ctx: ActionContext) => ActionResult;
|
|
50
|
+
|
|
51
|
+
// ── create_tasks ──────────────────────────────────────
|
|
52
|
+
|
|
53
|
+
export const handleCreateTasks: ActionHandler = ({ state, params, pi, session, ctx }) => {
|
|
54
|
+
if (!params.tasks || params.tasks.length === 0) {
|
|
55
|
+
return errorResult("create_tasks requires a non-empty tasks array");
|
|
56
|
+
}
|
|
57
|
+
const existingIncomplete = getIncompleteTasks(state.tasks);
|
|
58
|
+
if (state.tasks.length > 0 && existingIncomplete.length > 0) {
|
|
59
|
+
return errorResult(
|
|
60
|
+
`Already has ${state.tasks.length} tasks (${existingIncomplete.length} incomplete). Use add_tasks to append, or /goal update to re-plan.`,
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
state.tasks = params.tasks.map((desc: string, i: number) => ({
|
|
64
|
+
id: i + 1,
|
|
65
|
+
description: normalizeDescription(desc),
|
|
66
|
+
status: "pending" as const,
|
|
67
|
+
lastUpdatedTurn: state.currentTurnIndex,
|
|
68
|
+
}));
|
|
69
|
+
persistGoalState(pi, session, ctx);
|
|
70
|
+
return makeGoalResult(session,
|
|
71
|
+
`Created ${state.tasks.length} tasks:\n${state.tasks.map((t) => ` #${t.id}: ${t.description}`).join("\n")}`,
|
|
72
|
+
);
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
// ── add_tasks ─────────────────────────────────────────
|
|
76
|
+
|
|
77
|
+
export const handleAddTasks: ActionHandler = ({ state, params, pi, session, ctx }) => {
|
|
78
|
+
if (!params.tasks || params.tasks.length === 0) {
|
|
79
|
+
return errorResult("add_tasks requires a non-empty tasks array");
|
|
80
|
+
}
|
|
81
|
+
const startId = state.tasks.length > 0
|
|
82
|
+
? Math.max(...state.tasks.map((t) => t.id)) + 1
|
|
83
|
+
: 1;
|
|
84
|
+
const newTasks: GoalTask[] = params.tasks.map((desc: string, i: number) => ({
|
|
85
|
+
id: startId + i,
|
|
86
|
+
description: normalizeDescription(desc),
|
|
87
|
+
status: "pending" as const,
|
|
88
|
+
lastUpdatedTurn: state.currentTurnIndex,
|
|
89
|
+
}));
|
|
90
|
+
state.tasks.push(...newTasks);
|
|
91
|
+
persistGoalState(pi, session, ctx);
|
|
92
|
+
return makeGoalResult(session,
|
|
93
|
+
`Appended ${newTasks.length} tasks:\n${newTasks.map((t) => ` #${t.id}: ${t.description}`).join("\n")}`,
|
|
94
|
+
);
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
// ── update_tasks ──────────────────────────────────────
|
|
98
|
+
|
|
99
|
+
export const handleUpdateTasks: ActionHandler = ({ state, params, pi, session, ctx }) => {
|
|
100
|
+
if (!params.updates || params.updates.length === 0) {
|
|
101
|
+
return errorResult("update_tasks requires a non-empty updates array");
|
|
102
|
+
}
|
|
103
|
+
const validationErr = validateUpdateTasks(state, params.updates);
|
|
104
|
+
if (validationErr) return validationErr;
|
|
105
|
+
const results: string[] = [];
|
|
106
|
+
for (const u of params.updates) {
|
|
107
|
+
const task = state.tasks.find((t) => t.id === u.taskId)!;
|
|
108
|
+
const prev = task.status;
|
|
109
|
+
task.lastUpdatedTurn = state.currentTurnIndex;
|
|
110
|
+
if (u.status === "completed") {
|
|
111
|
+
task.status = "completed";
|
|
112
|
+
task.evidence = u.evidence;
|
|
113
|
+
results.push(`#${task.id}: ${prev} → completed (${u.evidence})`);
|
|
114
|
+
} else {
|
|
115
|
+
task.status = u.status;
|
|
116
|
+
results.push(`#${task.id}: ${prev} → ${u.status}`);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
persistGoalState(pi, session, ctx);
|
|
120
|
+
return makeGoalResult(session, `Updated ${results.length} tasks:\n${results.join("\n")}`);
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
/** 验证 update_tasks 的所有更新项;返回首个错误或 null。 */
|
|
124
|
+
function validateUpdateTasks(state: GoalRuntimeState, updates: NonNullable<Static<typeof GoalManagerParams>["updates"]>) {
|
|
125
|
+
const taskIds = updates.map((u: { taskId: number }) => u.taskId);
|
|
126
|
+
const duplicateIds = taskIds.filter((id: number, i: number) => taskIds.indexOf(id) !== i);
|
|
127
|
+
if (duplicateIds.length > 0) {
|
|
128
|
+
return errorResult(`Duplicate taskIds: ${[...new Set(duplicateIds)].join(", ")}`);
|
|
129
|
+
}
|
|
130
|
+
for (const u of updates) {
|
|
131
|
+
const task = state.tasks.find((t) => t.id === u.taskId);
|
|
132
|
+
if (!task) return errorResult(`Task #${u.taskId} not found`);
|
|
133
|
+
if (isTerminalTaskStatus(task.status)) {
|
|
134
|
+
return errorResult(`Task #${task.id} already in terminal state (${task.status}), cannot be changed`);
|
|
135
|
+
}
|
|
136
|
+
if (u.status === "completed" && (!u.evidence || u.evidence.trim() === "")) {
|
|
137
|
+
return errorResult(`Task #${task.id}: completed requires evidence`);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
return null;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// ── list_tasks ────────────────────────────────────────
|
|
144
|
+
|
|
145
|
+
export const handleListTasks: ActionHandler = ({ state, session }) => {
|
|
146
|
+
return makeGoalResult(session, formatTaskList(state.tasks));
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
// ── complete_goal ─────────────────────────────────────
|
|
150
|
+
|
|
151
|
+
export const handleCompleteGoal: ActionHandler = ({ state, params, pi, session, ctx }) => {
|
|
152
|
+
if (!params.evidence || params.evidence.trim() === "") {
|
|
153
|
+
return errorResult("complete_goal requires evidence — provide concrete proof that the objective has been achieved");
|
|
154
|
+
}
|
|
155
|
+
if (state.tasks.length === 0) {
|
|
156
|
+
return errorResult("Create a task list with create_tasks before completing the goal.");
|
|
157
|
+
}
|
|
158
|
+
const incomplete = getIncompleteTasks(state.tasks);
|
|
159
|
+
if (incomplete.length > 0) {
|
|
160
|
+
return errorResult(
|
|
161
|
+
`${incomplete.length} tasks still incomplete: ${incomplete.map((t) => `#${t.id}`).join(", ")}. Complete them first or explain why they don't need completion.`,
|
|
162
|
+
);
|
|
163
|
+
}
|
|
164
|
+
if (getCompletedCount(state.tasks) === 0) {
|
|
165
|
+
return errorResult("At least one task must be completed. All-cancelled does not count.");
|
|
166
|
+
}
|
|
167
|
+
state.status = transitionStatus(state.status, "complete");
|
|
168
|
+
state.completedAtTurnIndex = state.currentTurnIndex;
|
|
169
|
+
writeGoalHistoryEntry(pi, session);
|
|
170
|
+
persistGoalState(pi, session, ctx);
|
|
171
|
+
const budgetReport = buildBudgetReport(state);
|
|
172
|
+
return makeGoalResult(session,
|
|
173
|
+
`Objective completed!\nEvidence: ${params.evidence}\n\n--- Budget Report ---\n${budgetReport.join("\n")}`,
|
|
174
|
+
);
|
|
175
|
+
};
|
|
176
|
+
|
|
177
|
+
function buildBudgetReport(state: GoalRuntimeState): string[] {
|
|
178
|
+
const lines: string[] = [];
|
|
179
|
+
lines.push(`Total turns: ${state.currentTurnIndex}`);
|
|
180
|
+
lines.push(`Tasks completed: ${getCompletedCount(state.tasks)}/${state.tasks.length}`);
|
|
181
|
+
if (state.budget.tokenBudget) {
|
|
182
|
+
lines.push(`Token usage: ${state.tokensUsed}/${state.budget.tokenBudget}`);
|
|
183
|
+
}
|
|
184
|
+
const elapsed = getElapsedTimeSeconds(state);
|
|
185
|
+
lines.push(`Duration: ${Math.floor(elapsed / SECONDS_PER_MINUTE)}m${Math.floor(elapsed % SECONDS_PER_MINUTE)}s`);
|
|
186
|
+
return lines;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// ── report_blocked ────────────────────────────────────
|
|
190
|
+
|
|
191
|
+
export const handleReportBlocked: ActionHandler = ({ state, params, pi, session, ctx }) => {
|
|
192
|
+
if (!params.reason || params.reason.trim() === "") {
|
|
193
|
+
return errorResult("report_blocked requires reason — describe what is blocking you");
|
|
194
|
+
}
|
|
195
|
+
state.lastBlockerReason = params.reason;
|
|
196
|
+
state.status = transitionStatus(state.status, "blocked");
|
|
197
|
+
persistGoalState(pi, session, ctx);
|
|
198
|
+
return makeGoalResult(session, `Blocked reported. Reason: ${params.reason}`);
|
|
199
|
+
};
|
|
200
|
+
|
|
201
|
+
// ── cancel_goal ───────────────────────────────────────
|
|
202
|
+
|
|
203
|
+
export const handleCancelGoal: ActionHandler = ({ state, params, pi, session, ctx }) => {
|
|
204
|
+
if (isTerminalStatus(state.status)) {
|
|
205
|
+
return errorResult(`Goal is already in terminal state (${state.status}).`);
|
|
206
|
+
}
|
|
207
|
+
const reason = params.cancelReason ?? "User requested cancellation";
|
|
208
|
+
const goalId = state.goalId;
|
|
209
|
+
state.status = "cancelled";
|
|
210
|
+
state.completedAtTurnIndex = state.currentTurnIndex;
|
|
211
|
+
writeGoalHistoryEntry(pi, session);
|
|
212
|
+
persistGoalState(pi, session, ctx);
|
|
213
|
+
clearGoalSession(session, ctx);
|
|
214
|
+
const cancelDetails: GoalManagerDetails = {
|
|
215
|
+
action: "cancel",
|
|
216
|
+
tasks: [] as GoalTask[],
|
|
217
|
+
goalId,
|
|
218
|
+
status: "cancelled",
|
|
219
|
+
_render: {
|
|
220
|
+
type: "task-list" as const,
|
|
221
|
+
summary: "Cancelled",
|
|
222
|
+
data: { items: [], meta: {} },
|
|
223
|
+
},
|
|
224
|
+
};
|
|
225
|
+
return {
|
|
226
|
+
content: [{ type: "text" as const, text: `Goal cancelled: ${reason}` }],
|
|
227
|
+
details: cancelDetails,
|
|
228
|
+
};
|
|
229
|
+
};
|
|
230
|
+
|
|
231
|
+
// ── add_subtasks ──────────────────────────────────────
|
|
232
|
+
|
|
233
|
+
export const handleAddSubtasks: ActionHandler = ({ state, params, pi, session, ctx }) => {
|
|
234
|
+
if (params.taskId === undefined) return errorResult("add_subtasks requires taskId");
|
|
235
|
+
if (!params.texts || params.texts.length === 0) {
|
|
236
|
+
return errorResult("add_subtasks requires a non-empty texts array");
|
|
237
|
+
}
|
|
238
|
+
const parentTask = state.tasks.find((t) => t.id === params.taskId);
|
|
239
|
+
if (!parentTask) return errorResult(`Task #${params.taskId} not found`);
|
|
240
|
+
if (isTerminalTaskStatus(parentTask.status)) {
|
|
241
|
+
return errorResult(`Task #${parentTask.id} already in terminal state (${parentTask.status}), cannot add subtask`);
|
|
242
|
+
}
|
|
243
|
+
const subtasks = parentTask.subtasks ?? [];
|
|
244
|
+
const startId = subtasks.length > 0 ? Math.max(...subtasks.map((s) => s.id)) + 1 : 1;
|
|
245
|
+
const trimmed = params.texts.map((t: string) => t.trim()).filter((t: string) => t.length > 0);
|
|
246
|
+
if (trimmed.length === 0) return errorResult("texts requires at least one non-empty string");
|
|
247
|
+
const newSubtasks: Subtask[] = trimmed.map((text: string, i: number) => ({
|
|
248
|
+
id: startId + i,
|
|
249
|
+
text,
|
|
250
|
+
status: "pending" as const,
|
|
251
|
+
lastUpdatedTurn: state.currentTurnIndex,
|
|
252
|
+
}));
|
|
253
|
+
parentTask.subtasks = [...subtasks, ...newSubtasks];
|
|
254
|
+
persistGoalState(pi, session, ctx);
|
|
255
|
+
return makeGoalResult(session,
|
|
256
|
+
`Added ${newSubtasks.length} subtasks to Task #${parentTask.id}:\n` +
|
|
257
|
+
newSubtasks.map((s) => ` - #${parentTask.id}.${s.id}: ${s.text}`).join("\n"),
|
|
258
|
+
);
|
|
259
|
+
};
|
|
260
|
+
|
|
261
|
+
// ── update_subtasks ───────────────────────────────────
|
|
262
|
+
|
|
263
|
+
export const handleUpdateSubtasks: ActionHandler = ({ state, params, pi, session, ctx }) => {
|
|
264
|
+
if (params.taskId === undefined) return errorResult("update_subtasks requires taskId");
|
|
265
|
+
if (!params.subUpdates || params.subUpdates.length === 0) {
|
|
266
|
+
return errorResult("update_subtasks requires a non-empty subUpdates array");
|
|
267
|
+
}
|
|
268
|
+
const targetTask = state.tasks.find((t) => t.id === params.taskId);
|
|
269
|
+
if (!targetTask) return errorResult(`Task #${params.taskId} not found`);
|
|
270
|
+
if (!targetTask.subtasks || targetTask.subtasks.length === 0) {
|
|
271
|
+
return errorResult(`Task #${params.taskId} has no subtasks`);
|
|
272
|
+
}
|
|
273
|
+
const results: string[] = [];
|
|
274
|
+
for (const u of params.subUpdates) {
|
|
275
|
+
const sub = targetTask.subtasks.find((s) => s.id === u.subId);
|
|
276
|
+
if (!sub) return errorResult(`Subtask #${params.taskId}.${u.subId} not found`);
|
|
277
|
+
if (sub.status === "completed") {
|
|
278
|
+
return errorResult(`Subtask #${params.taskId}.${sub.id} already completed, cannot be changed`);
|
|
279
|
+
}
|
|
280
|
+
const prev = sub.status;
|
|
281
|
+
sub.status = u.status;
|
|
282
|
+
sub.lastUpdatedTurn = state.currentTurnIndex;
|
|
283
|
+
results.push(`#${params.taskId}.${sub.id}: ${prev} → ${u.status}`);
|
|
284
|
+
}
|
|
285
|
+
persistGoalState(pi, session, ctx);
|
|
286
|
+
return makeGoalResult(session, `Updated ${results.length} subtasks:\n${results.join("\n")}`);
|
|
287
|
+
};
|
|
288
|
+
|
|
289
|
+
// ── delete_subtasks ───────────────────────────────────
|
|
290
|
+
|
|
291
|
+
export const handleDeleteSubtasks: ActionHandler = ({ state, params, pi, session, ctx }) => {
|
|
292
|
+
if (params.taskId === undefined) return errorResult("delete_subtasks requires taskId");
|
|
293
|
+
if (!params.subIds || params.subIds.length === 0) {
|
|
294
|
+
return errorResult("delete_subtasks requires a non-empty subIds array");
|
|
295
|
+
}
|
|
296
|
+
const delTask = state.tasks.find((t) => t.id === params.taskId);
|
|
297
|
+
if (!delTask) return errorResult(`Task #${params.taskId} not found`);
|
|
298
|
+
if (!delTask.subtasks || delTask.subtasks.length === 0) {
|
|
299
|
+
return errorResult(`Task #${params.taskId} has no subtasks`);
|
|
300
|
+
}
|
|
301
|
+
const uniqueIds = [...new Set(params.subIds)];
|
|
302
|
+
const missing = uniqueIds.filter((id) => !delTask.subtasks!.some((s) => s.id === id));
|
|
303
|
+
if (missing.length > 0) {
|
|
304
|
+
return errorResult(`Subtask ${missing.map((id) => `#${params.taskId}.${id}`).join(", ")} not found`);
|
|
305
|
+
}
|
|
306
|
+
delTask.subtasks = delTask.subtasks.filter((s) => !uniqueIds.includes(s.id));
|
|
307
|
+
if (delTask.subtasks.length === 0) delTask.subtasks = undefined;
|
|
308
|
+
persistGoalState(pi, session, ctx);
|
|
309
|
+
return makeGoalResult(session,
|
|
310
|
+
`Deleted ${uniqueIds.length} subtasks, Task #${params.taskId} has ${delTask.subtasks?.length ?? 0} remaining`,
|
|
311
|
+
);
|
|
312
|
+
};
|
|
313
|
+
|
|
314
|
+
// ── Helpers ───────────────────────────────────────────
|
|
315
|
+
|
|
316
|
+
/** 将 AI 传入的 task description 标准化:去换行、截断 */
|
|
317
|
+
function normalizeDescription(desc: string): string {
|
|
318
|
+
const singleLine = desc.replace(/\r?\n/g, " ").replace(/\s+/g, " ").trim();
|
|
319
|
+
const ELLIPSIS_LENGTH = 3;
|
|
320
|
+
const MAX_TASK_DESC_LENGTH = 80;
|
|
321
|
+
if (singleLine.length > MAX_TASK_DESC_LENGTH) {
|
|
322
|
+
return singleLine.slice(0, MAX_TASK_DESC_LENGTH - ELLIPSIS_LENGTH) + "...";
|
|
323
|
+
}
|
|
324
|
+
return singleLine;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
// ── Action Dispatch Map ──────────────────────────────
|
|
328
|
+
|
|
329
|
+
/** action 字符串到处理器的映射。executeGoalAction 使用此表进行分发。 */
|
|
330
|
+
export const ACTION_HANDLERS: Record<string, ActionHandler> = {
|
|
331
|
+
create_tasks: handleCreateTasks,
|
|
332
|
+
add_tasks: handleAddTasks,
|
|
333
|
+
update_tasks: handleUpdateTasks,
|
|
334
|
+
list_tasks: handleListTasks,
|
|
335
|
+
complete_goal: handleCompleteGoal,
|
|
336
|
+
report_blocked: handleReportBlocked,
|
|
337
|
+
cancel_goal: handleCancelGoal,
|
|
338
|
+
add_subtasks: handleAddSubtasks,
|
|
339
|
+
update_subtasks: handleUpdateSubtasks,
|
|
340
|
+
delete_subtasks: handleDeleteSubtasks,
|
|
341
|
+
};
|
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* agent_end 事件处理子函数
|
|
3
|
+
*
|
|
4
|
+
* handleAgentEnd (orchestrator) 按顺序委托到 5 个 ≤20 行子函数:
|
|
5
|
+
* 1. handleTerminalStateAgentEnd — 终态处理(complete / blocked)
|
|
6
|
+
* 2. handleBudgetChecks — 预算预警 + 耗尽 + steering
|
|
7
|
+
* 3. handleAllTasksDone — 全部任务完成 → 提示 complete_goal
|
|
8
|
+
* 4. handleNoTasksOrMaxTurns — 无任务创建 / 最大轮次
|
|
9
|
+
* 5. handleStallAndContinuation — Stall 检测 + Normal continuation
|
|
10
|
+
*
|
|
11
|
+
* P0-2 修复:将 197 行的大函数拆分为 ≤20 行子函数。
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent";
|
|
15
|
+
|
|
16
|
+
import type { BudgetCheckResult } from "./budget.js";
|
|
17
|
+
import {
|
|
18
|
+
checkBudgetOnTurnEnd,
|
|
19
|
+
checkProgress,
|
|
20
|
+
} from "./budget.js";
|
|
21
|
+
import { PERCENT_FACTOR } from "./constants";
|
|
22
|
+
import {
|
|
23
|
+
getCompletedCount,
|
|
24
|
+
getIncompleteTasks,
|
|
25
|
+
isActiveStatus,
|
|
26
|
+
transitionStatus,
|
|
27
|
+
} from "./state";
|
|
28
|
+
import {
|
|
29
|
+
budgetLimitPrompt,
|
|
30
|
+
continuationPrompt,
|
|
31
|
+
} from "./templates";
|
|
32
|
+
import {
|
|
33
|
+
type GoalSession,
|
|
34
|
+
persistGoalState,
|
|
35
|
+
updateWidget,
|
|
36
|
+
writeGoalHistoryEntry,
|
|
37
|
+
} from "./tool-handler";
|
|
38
|
+
|
|
39
|
+
// ── Orchestrator ──────────────────────────────────────
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* agent_end 事件处理主函数
|
|
43
|
+
*
|
|
44
|
+
* 关键约定:
|
|
45
|
+
* - 防重入:session.isProcessing 标志在入口加锁,finally 释放
|
|
46
|
+
* - 快照检查:snapshotGoalId 防止旧回调操作新 goal
|
|
47
|
+
* - 所有副作用(persist / widget / notify / sendUserMessage)都通过 checkStale 守卫
|
|
48
|
+
*/
|
|
49
|
+
export async function handleAgentEnd(pi: ExtensionAPI, session: GoalSession, ctx: ExtensionContext): Promise<void> {
|
|
50
|
+
if (!session.state || session.isProcessing) return;
|
|
51
|
+
session.isProcessing = true;
|
|
52
|
+
try {
|
|
53
|
+
const checkStale = makeStaleChecker(session);
|
|
54
|
+
if (checkStale()) return;
|
|
55
|
+
if (session.state.status === "complete" || session.state.status === "blocked") {
|
|
56
|
+
await handleTerminalStateAgentEnd(pi, session, ctx, checkStale); return;
|
|
57
|
+
}
|
|
58
|
+
if (!isActiveStatus(session.state.status)) return;
|
|
59
|
+
const budgetAction = await handleBudgetChecks(pi, session, ctx, checkBudgetOnTurnEnd(session.state), checkStale);
|
|
60
|
+
if (budgetAction !== "continue") return;
|
|
61
|
+
session.state.turnCount++;
|
|
62
|
+
const progress = checkProgress(session.state, session.tasksCompletedAtAgentStart);
|
|
63
|
+
const progressAction = handleProgressAndTasks(pi, session, ctx, progress, checkStale);
|
|
64
|
+
if (progressAction !== "continue") return;
|
|
65
|
+
await handleStallAndContinuation(pi, session, ctx, progress, checkStale);
|
|
66
|
+
} finally {
|
|
67
|
+
session.isProcessing = false;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** 构造 stale-check 闭包:在入口快照 goalId,后续可判断是否被新 goal 覆盖。 */
|
|
72
|
+
function makeStaleChecker(session: GoalSession): () => boolean {
|
|
73
|
+
const snapshotGoalId = session.state?.goalId;
|
|
74
|
+
return () => !session.state || session.state.goalId !== snapshotGoalId;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// ── Sub-handler 1: 终态 ─────────────────────────────
|
|
78
|
+
|
|
79
|
+
async function handleTerminalStateAgentEnd(
|
|
80
|
+
pi: ExtensionAPI, session: GoalSession, ctx: ExtensionContext,
|
|
81
|
+
checkStale: () => boolean,
|
|
82
|
+
): Promise<void> {
|
|
83
|
+
const state = session.state!;
|
|
84
|
+
persistGoalState(pi, session, ctx);
|
|
85
|
+
if (checkStale()) return;
|
|
86
|
+
updateWidget(session, ctx);
|
|
87
|
+
if (state.status === "complete") {
|
|
88
|
+
ctx.ui.notify(
|
|
89
|
+
`Objective completed ✓ (${getCompletedCount(state.tasks)}/${state.tasks.length} tasks, ${state.currentTurnIndex} turns)`,
|
|
90
|
+
"info",
|
|
91
|
+
);
|
|
92
|
+
} else {
|
|
93
|
+
ctx.ui.notify("Goal blocked. Use /goal resume to continue or /goal clear to reset.", "warning");
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// ── Sub-handler 2: 预算检查 ─────────────────────────
|
|
98
|
+
|
|
99
|
+
type BudgetAction = "continue" | "stop";
|
|
100
|
+
|
|
101
|
+
async function handleBudgetChecks(
|
|
102
|
+
pi: ExtensionAPI, session: GoalSession, ctx: ExtensionContext,
|
|
103
|
+
budgetResult: BudgetCheckResult, checkStale: () => boolean,
|
|
104
|
+
): Promise<BudgetAction> {
|
|
105
|
+
// 发送预警
|
|
106
|
+
for (const w of budgetResult.warnings) {
|
|
107
|
+
if (w.type === "warning90") {
|
|
108
|
+
session.state!.budgetWarning90Sent = true;
|
|
109
|
+
ctx.ui.notify(`${w.dimension === "token" ? "Token" : "Time"} budget 90% used — start wrapping up.`, "warning");
|
|
110
|
+
} else if (w.type === "warning70") {
|
|
111
|
+
session.state!.budgetWarning70Sent = true;
|
|
112
|
+
ctx.ui.notify(`${w.dimension === "token" ? "Token" : "Time"} budget 70% used — keep scope in check.`, "info");
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
// 预算耗尽 → 终止
|
|
116
|
+
if (budgetResult.terminal) {
|
|
117
|
+
const dim = budgetResult.terminal.dimension;
|
|
118
|
+
session.state!.status = transitionStatus(session.state!.status, dim === "token" ? "budget_limited" : "time_limited");
|
|
119
|
+
session.state!.completedAtTurnIndex = session.state!.currentTurnIndex;
|
|
120
|
+
writeGoalHistoryEntry(pi, session);
|
|
121
|
+
persistGoalState(pi, session, ctx);
|
|
122
|
+
if (checkStale()) return "stop";
|
|
123
|
+
updateWidget(session, ctx);
|
|
124
|
+
ctx.ui.notify(
|
|
125
|
+
dim === "token"
|
|
126
|
+
? "Token budget exhausted, Goal terminated."
|
|
127
|
+
: `Time budget exhausted (${session.state!.budget.timeBudgetMinutes} min), Goal terminated.`,
|
|
128
|
+
"warning",
|
|
129
|
+
);
|
|
130
|
+
return "stop";
|
|
131
|
+
}
|
|
132
|
+
// 90% steering → 收尾
|
|
133
|
+
if (budgetResult.shouldSendSteering) {
|
|
134
|
+
session.state!.budgetLimitSteeringSent = true;
|
|
135
|
+
persistGoalState(pi, session, ctx);
|
|
136
|
+
if (checkStale()) return "stop";
|
|
137
|
+
updateWidget(session, ctx);
|
|
138
|
+
pi.sendUserMessage(budgetLimitPrompt(session.state!, "token"), { deliverAs: "steer" });
|
|
139
|
+
return "stop";
|
|
140
|
+
}
|
|
141
|
+
if (checkStale()) return "stop";
|
|
142
|
+
return "continue";
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// ── Sub-handler 3: 进展 + 任务列表 ───────────────────
|
|
146
|
+
|
|
147
|
+
type ProgressAction = "continue" | "stop";
|
|
148
|
+
|
|
149
|
+
function handleProgressAndTasks(
|
|
150
|
+
pi: ExtensionAPI, session: GoalSession, ctx: ExtensionContext,
|
|
151
|
+
progress: ReturnType<typeof checkProgress>, checkStale: () => boolean,
|
|
152
|
+
): ProgressAction {
|
|
153
|
+
// 全部任务完成
|
|
154
|
+
if (progress.allTasksDone) {
|
|
155
|
+
return handleAllTasksDone(pi, session, ctx, progress, checkStale);
|
|
156
|
+
}
|
|
157
|
+
// 无任务创建
|
|
158
|
+
if (progress.noTasksCreated) {
|
|
159
|
+
return handleNoTasksOrMaxTurns(pi, session, ctx, progress, checkStale);
|
|
160
|
+
}
|
|
161
|
+
// 最大轮次
|
|
162
|
+
if (progress.maxTurnsReached) {
|
|
163
|
+
return handleMaxTurnsReached(pi, session, ctx, checkStale);
|
|
164
|
+
}
|
|
165
|
+
return "continue";
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function handleAllTasksDone(
|
|
169
|
+
pi: ExtensionAPI, session: GoalSession, ctx: ExtensionContext,
|
|
170
|
+
progress: ReturnType<typeof checkProgress>, checkStale: () => boolean,
|
|
171
|
+
): ProgressAction {
|
|
172
|
+
const state = session.state!;
|
|
173
|
+
if (progress.maxTurnsReached) {
|
|
174
|
+
state.status = transitionStatus(state.status, "complete");
|
|
175
|
+
state.completedAtTurnIndex = state.currentTurnIndex;
|
|
176
|
+
writeGoalHistoryEntry(pi, session);
|
|
177
|
+
persistGoalState(pi, session, ctx);
|
|
178
|
+
if (checkStale()) return "stop";
|
|
179
|
+
updateWidget(session, ctx);
|
|
180
|
+
ctx.ui.notify(
|
|
181
|
+
`All tasks completed, Goal auto-closed. (${progress.completedCount}/${progress.totalCount} tasks, ${state.currentTurnIndex} turns)`,
|
|
182
|
+
"info",
|
|
183
|
+
);
|
|
184
|
+
return "stop";
|
|
185
|
+
}
|
|
186
|
+
if (progress.budgetTight) {
|
|
187
|
+
pi.sendUserMessage(
|
|
188
|
+
`All tasks completed, token budget ${Math.round(state.tokensUsed / state.budget.tokenBudget! * PERCENT_FACTOR)}% used.` +
|
|
189
|
+
`Call goal_manager's complete_goal now with overall evidence.` +
|
|
190
|
+
`\n\nObjective: ${state.objective}`,
|
|
191
|
+
{ deliverAs: "steer" },
|
|
192
|
+
);
|
|
193
|
+
} else {
|
|
194
|
+
pi.sendUserMessage(
|
|
195
|
+
`All ${progress.totalCount} tasks completed. Call goal_manager's complete_goal with overall evidence.` +
|
|
196
|
+
`\n\nObjective: ${state.objective}`,
|
|
197
|
+
{ deliverAs: "followUp" },
|
|
198
|
+
);
|
|
199
|
+
}
|
|
200
|
+
persistGoalState(pi, session, ctx);
|
|
201
|
+
updateWidget(session, ctx);
|
|
202
|
+
return "stop";
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function handleNoTasksOrMaxTurns(
|
|
206
|
+
pi: ExtensionAPI, session: GoalSession, ctx: ExtensionContext,
|
|
207
|
+
progress: ReturnType<typeof checkProgress>, checkStale: () => boolean,
|
|
208
|
+
): ProgressAction {
|
|
209
|
+
const state = session.state!;
|
|
210
|
+
if (progress.maxTurnsReached) {
|
|
211
|
+
state.status = transitionStatus(state.status, "cancelled");
|
|
212
|
+
state.completedAtTurnIndex = state.currentTurnIndex;
|
|
213
|
+
writeGoalHistoryEntry(pi, session);
|
|
214
|
+
persistGoalState(pi, session, ctx);
|
|
215
|
+
if (checkStale()) return "stop";
|
|
216
|
+
updateWidget(session, ctx);
|
|
217
|
+
ctx.ui.notify(
|
|
218
|
+
`Max turns reached (${state.budget.maxTurns}), LLM did not create task list.`,
|
|
219
|
+
"warning",
|
|
220
|
+
);
|
|
221
|
+
return "stop";
|
|
222
|
+
}
|
|
223
|
+
pi.sendUserMessage(
|
|
224
|
+
`No task list created yet. Call goal_manager's create_tasks immediately to decompose the work into verifiable task steps.` +
|
|
225
|
+
`\n\nObjective: ${state.objective}`,
|
|
226
|
+
{ deliverAs: "followUp" },
|
|
227
|
+
);
|
|
228
|
+
persistGoalState(pi, session, ctx);
|
|
229
|
+
updateWidget(session, ctx);
|
|
230
|
+
return "stop";
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function handleMaxTurnsReached(
|
|
234
|
+
pi: ExtensionAPI, session: GoalSession, ctx: ExtensionContext,
|
|
235
|
+
checkStale: () => boolean,
|
|
236
|
+
): ProgressAction {
|
|
237
|
+
const state = session.state!;
|
|
238
|
+
const incomplete = getIncompleteTasks(state.tasks);
|
|
239
|
+
state.status = transitionStatus(state.status, "cancelled");
|
|
240
|
+
state.completedAtTurnIndex = state.currentTurnIndex;
|
|
241
|
+
writeGoalHistoryEntry(pi, session);
|
|
242
|
+
persistGoalState(pi, session, ctx);
|
|
243
|
+
if (checkStale()) return "stop";
|
|
244
|
+
updateWidget(session, ctx);
|
|
245
|
+
ctx.ui.notify(
|
|
246
|
+
`Max turns reached (${state.budget.maxTurns}), ${incomplete.length} tasks still incomplete.`,
|
|
247
|
+
"warning",
|
|
248
|
+
);
|
|
249
|
+
return "stop";
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// ── Sub-handler 4: Stall + Continuation ─────────────
|
|
253
|
+
|
|
254
|
+
async function handleStallAndContinuation(
|
|
255
|
+
pi: ExtensionAPI, session: GoalSession, ctx: ExtensionContext,
|
|
256
|
+
progress: ReturnType<typeof checkProgress>, checkStale: () => boolean,
|
|
257
|
+
): Promise<void> {
|
|
258
|
+
const state = session.state!;
|
|
259
|
+
if (checkStale()) return;
|
|
260
|
+
|
|
261
|
+
// Stall 检测
|
|
262
|
+
updateStallCounter(state, progress.isStalled);
|
|
263
|
+
if (state.stallCount >= state.budget.maxStallTurns) {
|
|
264
|
+
markGoalBlocked(pi, session, ctx, checkStale);
|
|
265
|
+
return;
|
|
266
|
+
}
|
|
267
|
+
if (checkStale()) return;
|
|
268
|
+
|
|
269
|
+
// 去抖 + Continuation
|
|
270
|
+
if (!consumeTokensForDebounce(state)) {
|
|
271
|
+
persistGoalState(pi, session, ctx);
|
|
272
|
+
updateWidget(session, ctx);
|
|
273
|
+
return;
|
|
274
|
+
}
|
|
275
|
+
persistGoalState(pi, session, ctx);
|
|
276
|
+
updateWidget(session, ctx);
|
|
277
|
+
pi.sendUserMessage(continuationPrompt(state), { deliverAs: "followUp" });
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/** 更新 stall 计数。stall 时递增,否则重置。 */
|
|
281
|
+
function updateStallCounter(state: { stallCount: number; lastProgressTurn: number; currentTurnIndex: number }, isStalled: boolean): void {
|
|
282
|
+
if (isStalled) state.stallCount++;
|
|
283
|
+
else { state.stallCount = 0; state.lastProgressTurn = state.currentTurnIndex; }
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/** Stall 超限 → 标记 blocked。 */
|
|
287
|
+
function markGoalBlocked(pi: ExtensionAPI, session: GoalSession, ctx: ExtensionContext, checkStale: () => boolean): void {
|
|
288
|
+
const state = session.state!;
|
|
289
|
+
state.status = transitionStatus(state.status, "blocked");
|
|
290
|
+
persistGoalState(pi, session, ctx);
|
|
291
|
+
if (checkStale()) return;
|
|
292
|
+
updateWidget(session, ctx);
|
|
293
|
+
ctx.ui.notify(
|
|
294
|
+
`${state.stallCount} consecutive turns without progress, Goal auto-blocked. Use /goal resume to continue or /goal clear to reset.`,
|
|
295
|
+
"warning",
|
|
296
|
+
);
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/** 去抖检查:返回 true 表示本 turn 有 token 消耗,可以发送 continuation。 */
|
|
300
|
+
function consumeTokensForDebounce(state: { tokensUsed: number; lastTurnTokensUsed: number }): boolean {
|
|
301
|
+
const tokenDelta = state.tokensUsed - state.lastTurnTokensUsed;
|
|
302
|
+
state.lastTurnTokensUsed = state.tokensUsed;
|
|
303
|
+
return tokenDelta > 0;
|
|
304
|
+
}
|