@signalridge/pi-goal 0.49.7
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/CHANGELOG.md +13 -0
- package/LICENSE +21 -0
- package/README.md +347 -0
- package/package.json +67 -0
- package/src/accounting.ts +102 -0
- package/src/command-registration.ts +142 -0
- package/src/command.ts +170 -0
- package/src/commands.ts +693 -0
- package/src/errors.ts +170 -0
- package/src/goal.ts +52 -0
- package/src/index.ts +1 -0
- package/src/lifecycle.ts +626 -0
- package/src/markers.ts +25 -0
- package/src/menu.ts +854 -0
- package/src/persistence.ts +296 -0
- package/src/prompts.ts +93 -0
- package/src/queue.ts +102 -0
- package/src/run-protocol.ts +355 -0
- package/src/runtime.ts +1205 -0
- package/src/safety.ts +78 -0
- package/src/settings-ui.ts +605 -0
- package/src/settings.ts +200 -0
- package/src/tool-policy.ts +178 -0
- package/src/tools.ts +334 -0
package/src/lifecycle.ts
ADDED
|
@@ -0,0 +1,626 @@
|
|
|
1
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { currentTokenTotal } from "./accounting.js";
|
|
3
|
+
import type { GoalCommandController } from "./commands.js";
|
|
4
|
+
import { notifyTerminal } from "./errors.js";
|
|
5
|
+
import { type ActiveGoal, loadGoalStateFromSession } from "./persistence.js";
|
|
6
|
+
import { buildGoalPrompt, buildGoalSystemPrompt } from "./prompts.js";
|
|
7
|
+
import { activateQueuedGoal } from "./queue.js";
|
|
8
|
+
import type { GoalRunController } from "./run-protocol.js";
|
|
9
|
+
import {
|
|
10
|
+
type AssistantMessageLike,
|
|
11
|
+
abortCurrentTurn,
|
|
12
|
+
blocksStaleGoalToolCalls,
|
|
13
|
+
findFinalAssistantMessage,
|
|
14
|
+
formatError,
|
|
15
|
+
type GoalRuntime,
|
|
16
|
+
incrementGoal,
|
|
17
|
+
isGoalContextOverflow,
|
|
18
|
+
isRetryableGoalInterruption,
|
|
19
|
+
isUsageLimitedGoalInterruption,
|
|
20
|
+
resetGoalSafetyEpoch,
|
|
21
|
+
STATUS_KEY,
|
|
22
|
+
type StatusContext,
|
|
23
|
+
truncateNotification,
|
|
24
|
+
} from "./runtime.js";
|
|
25
|
+
import { hasAssistantToolCall } from "./safety.js";
|
|
26
|
+
import { DEFAULT_GOAL_SETTINGS, readGoalSettings } from "./settings.js";
|
|
27
|
+
|
|
28
|
+
const EXPERIMENTAL_GOALS_WARNING =
|
|
29
|
+
"Experimental ordered goals are enabled for pi-goal. Queue behavior and persisted state may change.";
|
|
30
|
+
|
|
31
|
+
interface GoalLifecycleOptions {
|
|
32
|
+
settingsPath?: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function registerGoalLifecycle(
|
|
36
|
+
pi: ExtensionAPI,
|
|
37
|
+
runtime: GoalRuntime,
|
|
38
|
+
commands: GoalCommandController,
|
|
39
|
+
runController: GoalRunController,
|
|
40
|
+
options: GoalLifecycleOptions = {},
|
|
41
|
+
) {
|
|
42
|
+
pi.on("session_start", async (_event, ctx) => {
|
|
43
|
+
runtime.replaceMenuSession();
|
|
44
|
+
runtime.clearCompletionStatusTimer();
|
|
45
|
+
runtime.clearContinuationTracking();
|
|
46
|
+
runtime.clearPendingGoalPrompts();
|
|
47
|
+
runtime.clearAgentRun();
|
|
48
|
+
runtime.guardAbortGoalId = undefined;
|
|
49
|
+
runtime.clearGoalRecovery();
|
|
50
|
+
runtime.clearBudgetWrapUp();
|
|
51
|
+
runtime.clearStaleGoalToolCallBlock();
|
|
52
|
+
runtime.queuedGoals = [];
|
|
53
|
+
runtime.pendingQueueAction = undefined;
|
|
54
|
+
runtime.queueFrozen = false;
|
|
55
|
+
runtime.queueFreezeAwaitingSettle = false;
|
|
56
|
+
runtime.clearTerminalDetails();
|
|
57
|
+
const previousToolVisibility = runtime.settings.toolVisibility;
|
|
58
|
+
const settingsResult = readGoalSettings(options.settingsPath);
|
|
59
|
+
runtime.settings = settingsResult.kind === "loaded" ? settingsResult.settings : DEFAULT_GOAL_SETTINGS;
|
|
60
|
+
runtime.settingsLoadIssue = settingsResult.kind === "invalid" ? settingsResult : undefined;
|
|
61
|
+
if (settingsResult.kind === "invalid") {
|
|
62
|
+
notifyTerminal(ctx.ui, `pi-goal settings ignored: ${settingsResult.reason}. Using default settings.`, "warning");
|
|
63
|
+
}
|
|
64
|
+
if (runtime.settings.experimental.goals) {
|
|
65
|
+
notifyTerminal(ctx.ui, EXPERIMENTAL_GOALS_WARNING, "warning");
|
|
66
|
+
}
|
|
67
|
+
try {
|
|
68
|
+
runtime.toolPolicy.prepareSessionStart(runtime.settings.toolVisibility, previousToolVisibility);
|
|
69
|
+
} catch (error) {
|
|
70
|
+
notifyTerminal(ctx.ui, `Could not restore always-visible goal tools: ${formatError(error)}`, "error");
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const loaded = loadGoalStateFromSession(ctx);
|
|
74
|
+
runtime.activeGoal = loaded.goal;
|
|
75
|
+
runtime.queuedGoals = loaded.queue;
|
|
76
|
+
runtime.pendingQueueAction = loaded.pendingAction;
|
|
77
|
+
runtime.queueFrozen = loaded.hasExperimentalQueueState && !runtime.settings.experimental.goals;
|
|
78
|
+
runController.bindSession(ctx);
|
|
79
|
+
if (runtime.queueFrozen) {
|
|
80
|
+
if (runtime.activeGoal) runtime.persistGoal(runtime.activeGoal);
|
|
81
|
+
ctx.ui.setStatus(STATUS_KEY, "queue off");
|
|
82
|
+
notifyTerminal(
|
|
83
|
+
ctx.ui,
|
|
84
|
+
"An experimental goal queue is frozen because experimental.goals is disabled. Re-enable it and run /reload to continue, or use /goal clear.",
|
|
85
|
+
"warning",
|
|
86
|
+
);
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
let startRestoredQueuedGoal = false;
|
|
91
|
+
if (runtime.activeGoal?.status === "queued" && !runtime.pendingQueueAction) {
|
|
92
|
+
runtime.activeGoal = activateQueuedGoal(runtime.activeGoal, currentTokenTotal(ctx));
|
|
93
|
+
startRestoredQueuedGoal = runtime.activeGoal.status === "active";
|
|
94
|
+
}
|
|
95
|
+
if (runtime.pendingQueueAction) await commands.dispatchPendingQueueActionIfSettled(ctx);
|
|
96
|
+
if (runtime.activeGoal) {
|
|
97
|
+
if (runtime.activeGoal.status === "active" && runtime.activeGoal.safetyResetPending) {
|
|
98
|
+
// Resume/edit activation is persisted before its queued prompt starts. A
|
|
99
|
+
// reload must commit that promised reset before enforcing the old limits.
|
|
100
|
+
runtime.activeGoal = resetGoalSafetyEpoch(runtime.activeGoal);
|
|
101
|
+
}
|
|
102
|
+
if (runtime.activeGoal.status === "active") {
|
|
103
|
+
runtime.recordGoalUsage(runtime.activeGoal, ctx);
|
|
104
|
+
if (runtime.limitActiveGoalForBudget(ctx, false)) return;
|
|
105
|
+
if (runtime.enforceAutomaticTurnLimit(ctx, false) || runtime.enforceNoProgressLimit(ctx)) return;
|
|
106
|
+
}
|
|
107
|
+
// On lazy restore, an earlier restrictive session-start policy still wins:
|
|
108
|
+
// reconciliation unlocks ownership without widening the active tool set.
|
|
109
|
+
runtime.toolPolicy.reconcileRestoredState(runtime.settings.toolVisibility, true);
|
|
110
|
+
if (runtime.activeGoal.status === "active" && !runtime.toolPolicy.toolsAvailable()) {
|
|
111
|
+
runtime.pauseGoalForUnavailableTools(ctx, false);
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
runtime.persistGoal(runtime.activeGoal);
|
|
115
|
+
runtime.updateStatus(ctx, runtime.activeGoal);
|
|
116
|
+
if (startRestoredQueuedGoal) {
|
|
117
|
+
const restoredGoal = runtime.activeGoal;
|
|
118
|
+
const sent = await runtime.sendOwnedGoalPrompt(
|
|
119
|
+
ctx,
|
|
120
|
+
restoredGoal.id,
|
|
121
|
+
buildGoalPrompt(restoredGoal),
|
|
122
|
+
false, // Reloaded queue activation preserves its persisted safety epoch.
|
|
123
|
+
);
|
|
124
|
+
if (!sent && runtime.activeGoal?.id === restoredGoal.id) {
|
|
125
|
+
runtime.stopActiveGoal(ctx, {
|
|
126
|
+
kind: "activation_rollback",
|
|
127
|
+
expectedGoalId: restoredGoal.id,
|
|
128
|
+
restoreGoal: restoredGoal,
|
|
129
|
+
abortTurn: false,
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
} else {
|
|
134
|
+
runtime.toolPolicy.reconcileRestoredState(runtime.settings.toolVisibility, false);
|
|
135
|
+
ctx.ui.setStatus(STATUS_KEY, undefined);
|
|
136
|
+
}
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
pi.on("session_shutdown", (_event, ctx) => {
|
|
140
|
+
runController.unbindSession();
|
|
141
|
+
runtime.closeMenuSession();
|
|
142
|
+
if (runtime.activeGoal) {
|
|
143
|
+
if (!runtime.queueFrozen && runtime.activeGoal.status === "active") {
|
|
144
|
+
runtime.recordGoalUsage(runtime.activeGoal, ctx, false);
|
|
145
|
+
}
|
|
146
|
+
runtime.persistGoal(runtime.activeGoal);
|
|
147
|
+
}
|
|
148
|
+
runtime.clearContinuationTracking();
|
|
149
|
+
runtime.clearPendingGoalPrompts();
|
|
150
|
+
runtime.clearAgentRun();
|
|
151
|
+
runtime.guardAbortGoalId = undefined;
|
|
152
|
+
runtime.clearGoalRecovery();
|
|
153
|
+
runtime.clearBudgetWrapUp();
|
|
154
|
+
runtime.clearStaleGoalToolCallBlock();
|
|
155
|
+
runtime.activeGoal = undefined;
|
|
156
|
+
runtime.queuedGoals = [];
|
|
157
|
+
runtime.pendingQueueAction = undefined;
|
|
158
|
+
runtime.queueFrozen = false;
|
|
159
|
+
runtime.queueFreezeAwaitingSettle = false;
|
|
160
|
+
ctx.ui.setStatus(STATUS_KEY, undefined);
|
|
161
|
+
runtime.clearCompletionStatusTimer();
|
|
162
|
+
runtime.clearTerminalDetails();
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
pi.on("session_before_compact", (event, ctx) => {
|
|
166
|
+
if (runtime.queueFrozen) return;
|
|
167
|
+
if (runtime.activeGoal?.status === "budget_limited") {
|
|
168
|
+
if ((event as { willRetry?: boolean }).willRetry === true) return { cancel: true as const };
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
if (runtime.activeGoal?.status !== "active") return;
|
|
172
|
+
if (!runtime.recordGoalUsage(runtime.activeGoal, ctx)) return;
|
|
173
|
+
runtime.cancelContinuationWork();
|
|
174
|
+
runtime.persistGoal(runtime.activeGoal);
|
|
175
|
+
runtime.updateStatus(ctx, runtime.activeGoal);
|
|
176
|
+
if (runtime.pendingQueueAction) return;
|
|
177
|
+
if (runtime.limitActiveGoalForBudget(ctx, false)) return { cancel: true as const };
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
pi.on("session_compact", async (event, ctx) => {
|
|
181
|
+
if (runtime.queueFrozen) return;
|
|
182
|
+
if (runtime.activeGoal?.status !== "active") {
|
|
183
|
+
runtime.clearGoalRecovery();
|
|
184
|
+
if (runtime.pendingQueueAction) await commands.dispatchPendingQueueActionIfSettled(ctx);
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const restoredState = loadGoalStateFromSession(ctx);
|
|
189
|
+
if (restoredState.goal?.id === runtime.activeGoal.id) {
|
|
190
|
+
runtime.activeGoal = restoredState.goal;
|
|
191
|
+
runtime.queuedGoals = restoredState.queue;
|
|
192
|
+
runtime.pendingQueueAction = restoredState.pendingAction;
|
|
193
|
+
}
|
|
194
|
+
const usageRecorded = runtime.recordGoalUsage(runtime.activeGoal, ctx);
|
|
195
|
+
if (usageRecorded) {
|
|
196
|
+
runtime.persistGoal(runtime.activeGoal);
|
|
197
|
+
runtime.updateStatus(ctx, runtime.activeGoal);
|
|
198
|
+
}
|
|
199
|
+
if (runtime.pendingQueueAction) {
|
|
200
|
+
await commands.dispatchPendingQueueActionIfSettled(ctx);
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
if (!usageRecorded) return;
|
|
204
|
+
if (runtime.limitActiveGoalForBudget(ctx, false)) return;
|
|
205
|
+
|
|
206
|
+
const wasPiRetry = runtime.isPiOwnedCompactionRetry(event, runtime.activeGoal.id);
|
|
207
|
+
if (wasPiRetry) return;
|
|
208
|
+
runtime.clearGoalRecoveryForGoal(runtime.activeGoal.id);
|
|
209
|
+
runtime.requestContinuation(runtime.activeGoal);
|
|
210
|
+
// Pi emits session_compact before it clears its manual-compaction controller,
|
|
211
|
+
// so sendUserMessage still rejects inside this hook even when ctx reports idle.
|
|
212
|
+
// Defer one task; threshold compaction retains the intent for agent_settled
|
|
213
|
+
// when Pi is still busy.
|
|
214
|
+
runtime.scheduleContinuationDispatch(ctx, runtime.activeGoal.id);
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
pi.on("input", (event, ctx) => {
|
|
218
|
+
if (event.source === "extension") {
|
|
219
|
+
if (runtime.consumeCancelledContinuationPrompt(event.text) || runtime.consumeStaleOwnedGoalPrompt(event.text)) {
|
|
220
|
+
return { action: "handled" as const };
|
|
221
|
+
}
|
|
222
|
+
if (runtime.queueFrozen) return;
|
|
223
|
+
// Streaming input is queued before its model work starts. Keep owned
|
|
224
|
+
// markers pending for message_start, and track non-goal delivery mode so a
|
|
225
|
+
// steer cannot consume a later follow-up's cleanup protection.
|
|
226
|
+
if (runtime.hasPendingOwnedGoalPrompt(event.text)) return;
|
|
227
|
+
if (event.streamingBehavior === "steer" || event.streamingBehavior === "followUp") {
|
|
228
|
+
runtime.noteQueuedNonGoalInput(event.text, event.streamingBehavior);
|
|
229
|
+
}
|
|
230
|
+
runtime.clearGoalRecovery();
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
233
|
+
if (runtime.queueFrozen) return;
|
|
234
|
+
if (/^\/goal(?:\s|$)/u.test(event.text.trimStart())) return;
|
|
235
|
+
if (event.streamingBehavior === "followUp") {
|
|
236
|
+
runtime.noteQueuedNonGoalInput(event.text, "followUp", true);
|
|
237
|
+
return;
|
|
238
|
+
}
|
|
239
|
+
if (event.streamingBehavior === "steer") {
|
|
240
|
+
runtime.noteQueuedNonGoalInput(event.text, "steer");
|
|
241
|
+
}
|
|
242
|
+
runtime.clearGoalRecovery();
|
|
243
|
+
runtime.clearBudgetWrapUp();
|
|
244
|
+
runtime.clearStaleGoalToolCallBlock();
|
|
245
|
+
runtime.resetActiveSafetyEpoch(ctx);
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
pi.on("message_start", (event, ctx) => {
|
|
249
|
+
const message = event.message as { role?: unknown; content?: unknown };
|
|
250
|
+
if (
|
|
251
|
+
message.role === "assistant" &&
|
|
252
|
+
runtime.activeGoal?.status === "paused" &&
|
|
253
|
+
runtime.guardAbortGoalId === runtime.activeGoal.id
|
|
254
|
+
) {
|
|
255
|
+
abortCurrentTurn(ctx);
|
|
256
|
+
return;
|
|
257
|
+
}
|
|
258
|
+
if (message.role === "custom") {
|
|
259
|
+
if (runtime.isActiveBudgetWrapUpMessage(message)) return;
|
|
260
|
+
if (runtime.guardAbortGoalId === runtime.activeGoal?.id) {
|
|
261
|
+
runtime.guardAbortGoalId = undefined;
|
|
262
|
+
}
|
|
263
|
+
beginNonGoalFollowUp(ctx, false);
|
|
264
|
+
return;
|
|
265
|
+
}
|
|
266
|
+
if (message.role !== "user") return;
|
|
267
|
+
const prompt = Array.isArray(message.content)
|
|
268
|
+
? message.content
|
|
269
|
+
.filter((part) => part && typeof part === "object" && Reflect.get(part, "type") === "text")
|
|
270
|
+
.map((part) => Reflect.get(part as object, "text"))
|
|
271
|
+
.filter((text): text is string => typeof text === "string")
|
|
272
|
+
.join("\n")
|
|
273
|
+
: typeof message.content === "string"
|
|
274
|
+
? message.content
|
|
275
|
+
: "";
|
|
276
|
+
const ownedPrompt = runtime.consumeOwnedGoalPrompt(prompt);
|
|
277
|
+
const ownedPromptBoundary = runtime.hasOwnedPromptBoundary(prompt);
|
|
278
|
+
const queuedNonGoalInput = runtime.consumeQueuedNonGoalInput(prompt, !ownedPromptBoundary);
|
|
279
|
+
if (!ownedPrompt) {
|
|
280
|
+
if (queuedNonGoalInput?.behavior === "followUp") {
|
|
281
|
+
beginNonGoalFollowUp(ctx, queuedNonGoalInput.resetSafetyEpoch);
|
|
282
|
+
}
|
|
283
|
+
return;
|
|
284
|
+
}
|
|
285
|
+
if (runtime.activeGoal?.id !== ownedPrompt.goalId || runtime.activeGoal.status !== "active") {
|
|
286
|
+
return;
|
|
287
|
+
}
|
|
288
|
+
if (runtime.agentRunGoalId !== undefined && runtime.agentRunGoalId !== ownedPrompt.goalId) {
|
|
289
|
+
runtime.activeGoal.baselineTokens = Math.max(0, currentTokenTotal(ctx) - runtime.activeGoal.tokensUsed);
|
|
290
|
+
}
|
|
291
|
+
runtime.beginAgentRun(ownedPrompt.goalId, "manual");
|
|
292
|
+
if (ownedPrompt.resetSafetyEpoch) {
|
|
293
|
+
runtime.activeGoal = resetGoalSafetyEpoch(runtime.activeGoal);
|
|
294
|
+
}
|
|
295
|
+
runtime.persistGoal(runtime.activeGoal);
|
|
296
|
+
runtime.updateStatus(ctx, runtime.activeGoal);
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
pi.on("context", (event, ctx) => {
|
|
300
|
+
const messages = event.messages.filter((message) => runtime.keepBudgetWrapUpMessage(message));
|
|
301
|
+
if (runtime.activeGoal?.status === "paused" && runtime.guardAbortGoalId === runtime.activeGoal.id) {
|
|
302
|
+
// A current custom follow-up clears the guard at message_start. Otherwise,
|
|
303
|
+
// context transformation aborts before the provider adapter receives the signal.
|
|
304
|
+
abortCurrentTurn(ctx);
|
|
305
|
+
}
|
|
306
|
+
if (messages.length !== event.messages.length) return { messages };
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
pi.on("tool_call", (event, ctx) => {
|
|
310
|
+
runtime.markAgentToolAttempted();
|
|
311
|
+
if (runtime.queueFrozen) {
|
|
312
|
+
if (!runtime.toolPolicy.isGoalToolName(event.toolName)) return;
|
|
313
|
+
// Blocking alone feeds an error tool result back to the model. Abort too so
|
|
314
|
+
// stale Goal calls cannot loop while the experimental queue remains frozen.
|
|
315
|
+
abortCurrentTurn(ctx);
|
|
316
|
+
return {
|
|
317
|
+
block: true,
|
|
318
|
+
reason:
|
|
319
|
+
"The experimental goal queue is frozen. Re-enable experimental.goals and run /reload, or use /goal clear.",
|
|
320
|
+
};
|
|
321
|
+
}
|
|
322
|
+
if (
|
|
323
|
+
runtime.activeGoal?.status === "budget_limited" &&
|
|
324
|
+
runtime.budgetWrapUp?.goalId === runtime.activeGoal.id &&
|
|
325
|
+
event.toolName !== "goal_complete"
|
|
326
|
+
) {
|
|
327
|
+
// A blocked tool result would normally trigger another model call. Abort the
|
|
328
|
+
// wrap-up instead so a tool-seeking model cannot create an unbounded loop.
|
|
329
|
+
abortCurrentTurn(ctx);
|
|
330
|
+
return {
|
|
331
|
+
block: true,
|
|
332
|
+
reason: "Goal token budget is exhausted; only goal_complete is allowed during wrap-up.",
|
|
333
|
+
};
|
|
334
|
+
}
|
|
335
|
+
if (!runtime.staleGoalToolCallsBlocked) return;
|
|
336
|
+
if (!runtime.activeGoal || !blocksStaleGoalToolCalls(runtime.activeGoal.status)) {
|
|
337
|
+
runtime.clearStaleGoalToolCallBlock();
|
|
338
|
+
return;
|
|
339
|
+
}
|
|
340
|
+
// A blocked tool result would normally trigger another model call. Abort the
|
|
341
|
+
// current turn so a tool-seeking model cannot create an unbounded loop that
|
|
342
|
+
// burns provider quota while the goal is stopped.
|
|
343
|
+
abortCurrentTurn(ctx);
|
|
344
|
+
return {
|
|
345
|
+
block: true,
|
|
346
|
+
reason: "Blocked stale /goal tool call after the goal stopped or was interrupted.",
|
|
347
|
+
};
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
pi.on("tool_execution_end", (_event, ctx) => {
|
|
351
|
+
if (runtime.queueFrozen) return;
|
|
352
|
+
if (
|
|
353
|
+
runtime.activeGoal?.status === "budget_limited" &&
|
|
354
|
+
runtime.budgetWrapUp?.goalId === runtime.activeGoal.id &&
|
|
355
|
+
!runtime.budgetWrapUp.delivered
|
|
356
|
+
) {
|
|
357
|
+
runtime.queueBudgetWrapUp(ctx, runtime.activeGoal);
|
|
358
|
+
return;
|
|
359
|
+
}
|
|
360
|
+
if (runtime.activeGoal?.status !== "active") return;
|
|
361
|
+
|
|
362
|
+
// AgentSession persists assistant message_end before tool execution events,
|
|
363
|
+
// so the completed assistant call's usage is authoritative at this boundary.
|
|
364
|
+
if (!runtime.recordGoalUsage(runtime.activeGoal, ctx)) return;
|
|
365
|
+
runtime.persistGoal(runtime.activeGoal);
|
|
366
|
+
runtime.updateStatus(ctx, runtime.activeGoal);
|
|
367
|
+
if (runtime.limitActiveGoalForBudget(ctx, true)) return;
|
|
368
|
+
if (!runtime.toolPolicy.toolsAvailable()) runtime.pauseGoalForUnavailableTools(ctx);
|
|
369
|
+
});
|
|
370
|
+
|
|
371
|
+
pi.on("before_agent_start", (event, ctx) => {
|
|
372
|
+
runtime.clearAgentRun();
|
|
373
|
+
if (runtime.queueFrozen) return;
|
|
374
|
+
// Pi-owned retries emit agent_start directly. Reaching a normal prompt
|
|
375
|
+
// boundary means cleanup no longer owns the next run, so the hard-cap guard
|
|
376
|
+
// must not abort it.
|
|
377
|
+
if (runtime.guardAbortGoalId) runtime.guardAbortGoalId = undefined;
|
|
378
|
+
const goalPrompt = runtime.consumeOwnedGoalPrompt(event.prompt);
|
|
379
|
+
const goalPromptGoalId = goalPrompt?.goalId;
|
|
380
|
+
const continuationGoalId = goalPromptGoalId ? undefined : runtime.markContinuationStarted(event.prompt);
|
|
381
|
+
const ownedPromptGoalId = goalPromptGoalId ?? continuationGoalId;
|
|
382
|
+
const ownedPromptBoundary = runtime.hasOwnedPromptBoundary(event.prompt);
|
|
383
|
+
const activeBudgetWrapUp = runtime.hasActiveBudgetWrapUp();
|
|
384
|
+
const activeGoalRecovery = runtime.hasActiveGoalRecovery();
|
|
385
|
+
const queuedNonGoalInput = activeBudgetWrapUp
|
|
386
|
+
? undefined
|
|
387
|
+
: runtime.consumeQueuedNonGoalInput(
|
|
388
|
+
event.prompt,
|
|
389
|
+
!activeGoalRecovery && ownedPromptGoalId === undefined && !ownedPromptBoundary,
|
|
390
|
+
);
|
|
391
|
+
if (queuedNonGoalInput?.behavior === "followUp") {
|
|
392
|
+
beginNonGoalFollowUp(ctx, queuedNonGoalInput.resetSafetyEpoch);
|
|
393
|
+
}
|
|
394
|
+
const runOrigin = continuationGoalId
|
|
395
|
+
? "automatic"
|
|
396
|
+
: activeGoalRecovery && runtime.goalRecovery?.automaticOwner
|
|
397
|
+
? "automatic"
|
|
398
|
+
: "manual";
|
|
399
|
+
if (runtime.pendingQueueAction?.kind === "prioritize" && !activeBudgetWrapUp && !activeGoalRecovery) {
|
|
400
|
+
// A turn that starts after priority intent is committed belongs to neither
|
|
401
|
+
// the displaced goal nor the not-yet-activated urgent goal. Persist the
|
|
402
|
+
// displaced goal's final accounting boundary so reload cannot absorb this run.
|
|
403
|
+
if (!runtime.pendingQueueAction.displacedUsageFinalized) {
|
|
404
|
+
if (runtime.activeGoal?.status === "active") {
|
|
405
|
+
runtime.recordGoalUsage(runtime.activeGoal, ctx, false);
|
|
406
|
+
}
|
|
407
|
+
runtime.pendingQueueAction.displacedUsageFinalized = true;
|
|
408
|
+
if (runtime.activeGoal) {
|
|
409
|
+
runtime.persistGoal(runtime.activeGoal);
|
|
410
|
+
runtime.updateStatus(ctx, runtime.activeGoal);
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
runtime.beginAgentRun(null, undefined);
|
|
414
|
+
if (ownedPromptGoalId) abortCurrentTurn(ctx);
|
|
415
|
+
return;
|
|
416
|
+
}
|
|
417
|
+
if (activeBudgetWrapUp && runtime.activeGoal) {
|
|
418
|
+
runtime.beginAgentRun(runtime.activeGoal.id, "manual");
|
|
419
|
+
return;
|
|
420
|
+
}
|
|
421
|
+
if (
|
|
422
|
+
runtime.pendingQueueAction?.kind === "advance" &&
|
|
423
|
+
runtime.pendingQueueAction.goalId === runtime.activeGoal?.id
|
|
424
|
+
) {
|
|
425
|
+
runtime.beginAgentRun(ownedPromptGoalId ?? runtime.activeGoal.id, runOrigin);
|
|
426
|
+
if (ownedPromptGoalId) abortCurrentTurn(ctx);
|
|
427
|
+
return;
|
|
428
|
+
}
|
|
429
|
+
if (ownedPromptGoalId && ownedPromptGoalId !== runtime.activeGoal?.id) {
|
|
430
|
+
runtime.beginAgentRun(ownedPromptGoalId, runOrigin);
|
|
431
|
+
if (runtime.activeGoal?.status === "active" && !runtime.toolPolicy.toolsAvailable()) {
|
|
432
|
+
runtime.pauseGoalForUnavailableTools(ctx, false);
|
|
433
|
+
}
|
|
434
|
+
abortCurrentTurn(ctx);
|
|
435
|
+
return;
|
|
436
|
+
}
|
|
437
|
+
if (runtime.activeGoal?.status !== "active") return;
|
|
438
|
+
runtime.beginAgentRun(runtime.activeGoal.id, runOrigin);
|
|
439
|
+
if (!runtime.toolPolicy.toolsAvailable()) {
|
|
440
|
+
runtime.pauseGoalForUnavailableTools(ctx, ownedPromptGoalId !== undefined);
|
|
441
|
+
return;
|
|
442
|
+
}
|
|
443
|
+
if (goalPrompt?.resetSafetyEpoch && goalPromptGoalId === runtime.activeGoal.id) {
|
|
444
|
+
runtime.activeGoal = resetGoalSafetyEpoch(runtime.activeGoal);
|
|
445
|
+
runtime.persistGoal(runtime.activeGoal);
|
|
446
|
+
runtime.updateStatus(ctx, runtime.activeGoal);
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
return {
|
|
450
|
+
systemPrompt: `${event.systemPrompt}\n\n${buildGoalSystemPrompt(runtime.activeGoal)}`,
|
|
451
|
+
};
|
|
452
|
+
});
|
|
453
|
+
|
|
454
|
+
pi.on("agent_start", (_event, _ctx) => {
|
|
455
|
+
if (runtime.queueFrozen) return;
|
|
456
|
+
const activeGoal = runtime.activeGoal;
|
|
457
|
+
if (activeGoal && runtime.guardAbortGoalId === activeGoal.id && activeGoal.status === "paused") {
|
|
458
|
+
if (runtime.consumeQueuedNonGoalFollowUpForAgentStart()) {
|
|
459
|
+
runtime.guardAbortGoalId = undefined;
|
|
460
|
+
runtime.clearStaleGoalToolCallBlock();
|
|
461
|
+
runtime.beginAgentRun(null, undefined);
|
|
462
|
+
}
|
|
463
|
+
// Unknown runs defer cleanup until their message/context boundary: custom
|
|
464
|
+
// follow-ups have no input event, while bare recovery is aborted pre-provider.
|
|
465
|
+
return;
|
|
466
|
+
}
|
|
467
|
+
runtime.beginRecoveryRunIfNeeded();
|
|
468
|
+
});
|
|
469
|
+
|
|
470
|
+
pi.on("turn_end", (event, ctx) => {
|
|
471
|
+
if (runtime.queueFrozen) return;
|
|
472
|
+
runtime.recordAutomaticTurn(ctx, event.message);
|
|
473
|
+
});
|
|
474
|
+
|
|
475
|
+
pi.on("agent_end", (event, ctx) => {
|
|
476
|
+
const run = runtime.finishAgentRun();
|
|
477
|
+
if (runtime.queueFrozen || run.goalId === null) return;
|
|
478
|
+
if (!runtime.canRecordGoalUsage() && !runtime.hasActiveBudgetWrapUp()) return;
|
|
479
|
+
if (run.goalId && run.goalId !== runtime.activeGoal?.id) return;
|
|
480
|
+
if (!runtime.activeGoal) return;
|
|
481
|
+
if (runtime.activeGoal.status === "budget_limited" && runtime.budgetWrapUp?.goalId === runtime.activeGoal.id) {
|
|
482
|
+
runtime.recordGoalUsage(runtime.activeGoal, ctx);
|
|
483
|
+
runtime.persistGoal(runtime.activeGoal);
|
|
484
|
+
runtime.updateStatus(ctx, runtime.activeGoal);
|
|
485
|
+
runtime.clearBudgetWrapUp();
|
|
486
|
+
return;
|
|
487
|
+
}
|
|
488
|
+
if (runtime.activeGoal.status !== "active") return;
|
|
489
|
+
if (runtime.pendingQueueAction?.kind === "advance" && runtime.pendingQueueAction.goalId === runtime.activeGoal.id) {
|
|
490
|
+
runtime.recordGoalUsage(runtime.activeGoal, ctx);
|
|
491
|
+
runtime.persistGoal(runtime.activeGoal);
|
|
492
|
+
runtime.updateStatus(ctx, runtime.activeGoal);
|
|
493
|
+
return;
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
const goalId = runtime.activeGoal.id;
|
|
497
|
+
const alreadyAwaitingContinuation = runtime.hasContinuationWorkForGoal(goalId);
|
|
498
|
+
const finalAssistant = findFinalAssistantMessage(event.messages);
|
|
499
|
+
|
|
500
|
+
if (!alreadyAwaitingContinuation) runtime.activeGoal = incrementGoal(runtime.activeGoal);
|
|
501
|
+
runtime.recordGoalUsage(runtime.activeGoal, ctx);
|
|
502
|
+
|
|
503
|
+
if (finalAssistant?.stopReason === "aborted") {
|
|
504
|
+
runtime.clearGoalRecoveryForGoal(goalId);
|
|
505
|
+
stopGoalAfterAgentEnd(ctx, runtime.activeGoal, finalAssistant, "paused");
|
|
506
|
+
return;
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
if (finalAssistant?.stopReason === "error") {
|
|
510
|
+
if (isRetryableGoalInterruption(finalAssistant)) {
|
|
511
|
+
if (run.origin === "automatic" && runtime.enforceAutomaticTurnLimit(ctx, true)) return;
|
|
512
|
+
if (runtime.limitActiveGoalForBudget(ctx, false)) return;
|
|
513
|
+
if (!runtime.toolPolicy.toolsAvailable()) {
|
|
514
|
+
runtime.pauseGoalForUnavailableTools(ctx);
|
|
515
|
+
return;
|
|
516
|
+
}
|
|
517
|
+
runtime.goalRecovery = {
|
|
518
|
+
goalId,
|
|
519
|
+
kind: isGoalContextOverflow(finalAssistant) ? "compaction_retry" : "provider_retry",
|
|
520
|
+
automaticOwner: run.origin === "automatic",
|
|
521
|
+
errorMessage: finalAssistant.errorMessage,
|
|
522
|
+
};
|
|
523
|
+
runtime.cancelContinuationWork();
|
|
524
|
+
runtime.persistGoal(runtime.activeGoal);
|
|
525
|
+
runtime.updateStatus(ctx, runtime.activeGoal);
|
|
526
|
+
return;
|
|
527
|
+
}
|
|
528
|
+
runtime.clearGoalRecoveryForGoal(goalId);
|
|
529
|
+
stopGoalAfterAgentEnd(
|
|
530
|
+
ctx,
|
|
531
|
+
runtime.activeGoal,
|
|
532
|
+
finalAssistant,
|
|
533
|
+
isUsageLimitedGoalInterruption(finalAssistant) ? "usage_limited" : "blocked",
|
|
534
|
+
);
|
|
535
|
+
return;
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
runtime.clearGoalRecoveryForGoal(goalId);
|
|
539
|
+
|
|
540
|
+
if (runtime.limitActiveGoalForBudget(ctx, false)) return;
|
|
541
|
+
if (!runtime.toolPolicy.toolsAvailable()) {
|
|
542
|
+
runtime.pauseGoalForUnavailableTools(ctx);
|
|
543
|
+
return;
|
|
544
|
+
}
|
|
545
|
+
if (
|
|
546
|
+
run.origin === "automatic" &&
|
|
547
|
+
runtime.recordAutomaticRunProgress(
|
|
548
|
+
ctx,
|
|
549
|
+
goalId,
|
|
550
|
+
event.messages,
|
|
551
|
+
run.toolAttempted || hasAssistantToolCall(event.messages),
|
|
552
|
+
)
|
|
553
|
+
) {
|
|
554
|
+
return;
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
runtime.persistGoal(runtime.activeGoal);
|
|
558
|
+
runtime.updateStatus(ctx, runtime.activeGoal);
|
|
559
|
+
|
|
560
|
+
const currentGoal = runtime.activeGoal;
|
|
561
|
+
if (!currentGoal || currentGoal.id !== goalId || currentGoal.status !== "active") return;
|
|
562
|
+
if (runtime.pendingQueueAction?.kind === "prioritize") return;
|
|
563
|
+
runtime.requestContinuation(currentGoal);
|
|
564
|
+
});
|
|
565
|
+
|
|
566
|
+
pi.on("agent_settled", async (_event, ctx) => {
|
|
567
|
+
if (runtime.queueFrozen) {
|
|
568
|
+
runtime.clearSettledSafetyTracking();
|
|
569
|
+
runtime.queueFreezeAwaitingSettle = false;
|
|
570
|
+
if (runtime.settings.experimental.goals) {
|
|
571
|
+
await commands.resumeQueueAfterUnfreeze(ctx);
|
|
572
|
+
}
|
|
573
|
+
return;
|
|
574
|
+
}
|
|
575
|
+
runtime.finalizeSettledRecovery(ctx);
|
|
576
|
+
let dispatchedQueueAction = false;
|
|
577
|
+
if (runtime.pendingQueueAction) {
|
|
578
|
+
dispatchedQueueAction = await commands.dispatchPendingQueueActionIfSettled(ctx);
|
|
579
|
+
}
|
|
580
|
+
if (!dispatchedQueueAction) runtime.dispatchContinuationIfSettled(ctx);
|
|
581
|
+
runtime.clearSettledSafetyTracking();
|
|
582
|
+
});
|
|
583
|
+
|
|
584
|
+
function beginNonGoalFollowUp(ctx: StatusContext, resetSafetyEpoch: boolean) {
|
|
585
|
+
runtime.clearGoalRecovery();
|
|
586
|
+
runtime.clearStaleGoalToolCallBlock();
|
|
587
|
+
if (resetSafetyEpoch) runtime.clearBudgetWrapUp();
|
|
588
|
+
const activeGoalId = runtime.activeGoal?.status === "active" ? runtime.activeGoal.id : undefined;
|
|
589
|
+
runtime.beginAgentRun(activeGoalId ?? null, activeGoalId ? "manual" : undefined);
|
|
590
|
+
if (resetSafetyEpoch && activeGoalId) runtime.resetActiveSafetyEpoch(ctx);
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
function stopGoalAfterAgentEnd(
|
|
594
|
+
ctx: StatusContext,
|
|
595
|
+
goal: ActiveGoal,
|
|
596
|
+
assistant: AssistantMessageLike,
|
|
597
|
+
status: "paused" | "blocked" | "usage_limited",
|
|
598
|
+
) {
|
|
599
|
+
const stoppedGoal = runtime.stopActiveGoal(ctx, {
|
|
600
|
+
kind: "agent_interruption",
|
|
601
|
+
expectedGoalId: goal.id,
|
|
602
|
+
status,
|
|
603
|
+
reason: assistant.errorMessage ?? `goal ${status} after agent interruption`,
|
|
604
|
+
});
|
|
605
|
+
if (!stoppedGoal) return;
|
|
606
|
+
|
|
607
|
+
const details = assistant.errorMessage ? ` (${truncateNotification(assistant.errorMessage)})` : "";
|
|
608
|
+
if (status === "paused") {
|
|
609
|
+
notifyTerminal(ctx.ui, `Goal paused after interruption${details}. Run /goal resume to continue.`, "warning");
|
|
610
|
+
return;
|
|
611
|
+
}
|
|
612
|
+
if (status === "usage_limited") {
|
|
613
|
+
notifyTerminal(
|
|
614
|
+
ctx.ui,
|
|
615
|
+
`Goal stopped after provider usage limit${details}. Run /goal resume when usage is available.`,
|
|
616
|
+
"warning",
|
|
617
|
+
);
|
|
618
|
+
return;
|
|
619
|
+
}
|
|
620
|
+
notifyTerminal(
|
|
621
|
+
ctx.ui,
|
|
622
|
+
`Goal blocked after agent error${details}. Resolve the blocker or run /goal resume to retry.`,
|
|
623
|
+
"warning",
|
|
624
|
+
);
|
|
625
|
+
}
|
|
626
|
+
}
|
package/src/markers.ts
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
const GOAL_PROMPT_MARKER_PREFIX = "pi-goal-prompt:";
|
|
2
|
+
const CONTINUATION_MARKER_PREFIX = "pi-goal-continuation:";
|
|
3
|
+
|
|
4
|
+
const GOAL_PROMPT_MARKER_PATTERN = new RegExp(
|
|
5
|
+
`<!--\\s*${escapeRegExpText(GOAL_PROMPT_MARKER_PREFIX)}([^\\s>]+)\\s*-->`,
|
|
6
|
+
);
|
|
7
|
+
const CONTINUATION_MARKER_PATTERN = new RegExp(
|
|
8
|
+
`<!--\\s*${escapeRegExpText(CONTINUATION_MARKER_PREFIX)}([^\\s>]+)\\s*-->`,
|
|
9
|
+
);
|
|
10
|
+
|
|
11
|
+
export function extractGoalPromptMarker(prompt: string) {
|
|
12
|
+
return GOAL_PROMPT_MARKER_PATTERN.exec(prompt)?.[1];
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function extractContinuationMarker(prompt: string) {
|
|
16
|
+
return CONTINUATION_MARKER_PATTERN.exec(prompt)?.[1];
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function appendGoalPromptMarker(prompt: string, marker: string) {
|
|
20
|
+
return `${prompt}\n\n<!-- ${GOAL_PROMPT_MARKER_PREFIX}${marker} -->`;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function escapeRegExpText(value: string) {
|
|
24
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
25
|
+
}
|