pi-goals 0.1.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/.pi/extensions/goal/budget.ts +74 -0
- package/.pi/extensions/goal/command.ts +201 -0
- package/.pi/extensions/goal/constants.ts +39 -0
- package/.pi/extensions/goal/continuation.ts +122 -0
- package/.pi/extensions/goal/format.ts +153 -0
- package/.pi/extensions/goal/index.ts +19 -0
- package/.pi/extensions/goal/lifecycle.ts +366 -0
- package/.pi/extensions/goal/model-output.ts +76 -0
- package/.pi/extensions/goal/monitor-prompts.ts +161 -0
- package/.pi/extensions/goal/monitor-report.ts +93 -0
- package/.pi/extensions/goal/monitor-state.ts +77 -0
- package/.pi/extensions/goal/monitor.ts +191 -0
- package/.pi/extensions/goal/prompts.ts +98 -0
- package/.pi/extensions/goal/state.ts +141 -0
- package/.pi/extensions/goal/telemetry.ts +153 -0
- package/.pi/extensions/goal/templates.ts +228 -0
- package/.pi/extensions/goal/tools.ts +279 -0
- package/.pi/extensions/goal/types.ts +168 -0
- package/.pi/extensions/goal/ui.ts +41 -0
- package/.pi/extensions/goal/widget.ts +159 -0
- package/LICENSE +21 -0
- package/README.md +43 -0
- package/package.json +44 -0
|
@@ -0,0 +1,366 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
ContextEvent,
|
|
3
|
+
ContextEventResult,
|
|
4
|
+
ExtensionAPI,
|
|
5
|
+
ExtensionContext,
|
|
6
|
+
MessageUpdateEvent,
|
|
7
|
+
SessionStartEvent,
|
|
8
|
+
ToolCallEvent,
|
|
9
|
+
ToolResultEvent,
|
|
10
|
+
TurnEndEvent,
|
|
11
|
+
TurnStartEvent,
|
|
12
|
+
} from "@earendil-works/pi-coding-agent/dist/core/extensions/types.js";
|
|
13
|
+
import {
|
|
14
|
+
BUDGET_LIMIT_MESSAGE_TYPE,
|
|
15
|
+
BUDGET_WARNING_PROMPT_ID,
|
|
16
|
+
CONTINUATION_MESSAGE_TYPE,
|
|
17
|
+
MAX_CONSECUTIVE_AUTO_TURNS,
|
|
18
|
+
MAX_NO_PROGRESS_AUTO_TURNS,
|
|
19
|
+
PAUSE_MESSAGE_TYPE,
|
|
20
|
+
GOAL_MONITOR_MESSAGE_TYPE,
|
|
21
|
+
} from "./constants";
|
|
22
|
+
import { evaluateBudgetPressure, isBudgetHardStop, isBudgetReached, isBudgetWarning } from "./budget";
|
|
23
|
+
import { cancelGoalContinuation, interruptActiveGoalTurn, scheduleBudgetLimitWrapUp, scheduleMaybeContinueGoal } from "./continuation";
|
|
24
|
+
import { buildBudgetLimitPrompt } from "./prompts";
|
|
25
|
+
import { getGoal, getTelemetry, persistAccountGoal, persistTelemetry, persistUpdateGoal, replayGoalState } from "./state";
|
|
26
|
+
import { cancelGoalMonitor, replayGoalMonitorState, scheduleGoalMonitor } from "./monitor";
|
|
27
|
+
import { applyTurnTelemetry, consumeNextTurnOrigin, makeTurnSnapshot, noteBudgetHardStop, noteBudgetLimit, noteBudgetWarning, noteSafetyPause } from "./telemetry";
|
|
28
|
+
import { notifyWarning, promptResumePausedGoal, syncGoalUi } from "./ui";
|
|
29
|
+
import type { BudgetHardStopReason, BudgetLimitReason, BudgetPressure, GoalState, GoalTelemetrySnapshot, StreamBudgetSignal, TurnAccountingSnapshot } from "./types";
|
|
30
|
+
|
|
31
|
+
let activeTurn: TurnAccountingSnapshot | null = null;
|
|
32
|
+
let streamBudgetSignalsSent: Set<StreamBudgetSignal> = new Set();
|
|
33
|
+
|
|
34
|
+
export function registerGoalLifecycle(pi: ExtensionAPI): void {
|
|
35
|
+
pi.on("session_start", async (event, ctx) => handleSessionStart(pi, event, ctx));
|
|
36
|
+
pi.on("session_tree", async (_event, ctx) => {
|
|
37
|
+
const state = replayGoalState(ctx);
|
|
38
|
+
replayGoalMonitorState(ctx);
|
|
39
|
+
syncGoalUi(ctx, state.goal);
|
|
40
|
+
if (state.goal?.status === "active") scheduleGoalMonitor(pi, ctx);
|
|
41
|
+
else cancelGoalMonitor(state.goal?.goalId, "session-tree");
|
|
42
|
+
});
|
|
43
|
+
pi.on("turn_start", (event) => { handleTurnStart(event); streamBudgetSignalsSent.clear(); });
|
|
44
|
+
pi.on("tool_call", (event) => handleToolCall(event));
|
|
45
|
+
pi.on("tool_result", (event) => handleToolResult(event));
|
|
46
|
+
pi.on("turn_end", async (event, ctx) => handleTurnEnd(pi, event, ctx));
|
|
47
|
+
pi.on("agent_end", async (_event, ctx) => scheduleMaybeContinueGoal(pi, ctx, "agentEnd"));
|
|
48
|
+
pi.on("message_update", (event, ctx) => handleMessageUpdate(pi, event, ctx));
|
|
49
|
+
pi.on("context", (event) => filterGoalContext(event));
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async function handleSessionStart(pi: ExtensionAPI, event: SessionStartEvent, ctx: ExtensionContext): Promise<void> {
|
|
53
|
+
const state = replayGoalState(ctx);
|
|
54
|
+
replayGoalMonitorState(ctx);
|
|
55
|
+
syncGoalUi(ctx, state.goal);
|
|
56
|
+
if (state.goal?.status === "active") scheduleGoalMonitor(pi, ctx);
|
|
57
|
+
else cancelGoalMonitor(state.goal?.goalId, "session-start");
|
|
58
|
+
if (event.reason !== "reload" && state.goal?.status === "paused" && ctx.hasUI) {
|
|
59
|
+
const resume = await promptResumePausedGoal(ctx, state.goal);
|
|
60
|
+
if (resume) {
|
|
61
|
+
const active: GoalState = { ...state.goal, status: "active", updatedAt: Date.now() };
|
|
62
|
+
persistUpdateGoal(pi, active, state.telemetry, "resume");
|
|
63
|
+
syncGoalUi(ctx, active);
|
|
64
|
+
scheduleGoalMonitor(pi, ctx);
|
|
65
|
+
scheduleMaybeContinueGoal(pi, ctx, "resumed");
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function handleTurnStart(event: TurnStartEvent): void {
|
|
71
|
+
const goal = getGoal();
|
|
72
|
+
if (!goal) {
|
|
73
|
+
activeTurn = null;
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
// Track turns for active and budget-limited goals so budget hard-stop detection
|
|
77
|
+
// still works during the budget wrap-up turn. Paused and completed goals are
|
|
78
|
+
// excluded because no agent work should be in progress for them.
|
|
79
|
+
if (goal.status !== "active" && goal.status !== "budgetLimited") {
|
|
80
|
+
activeTurn = null;
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
activeTurn = makeTurnSnapshot(goal.goalId, consumeNextTurnOrigin(), event.timestamp || Date.now());
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function handleMessageUpdate(pi: ExtensionAPI, event: MessageUpdateEvent, ctx: ExtensionContext): void {
|
|
87
|
+
if (event.message.role !== "assistant") return;
|
|
88
|
+
|
|
89
|
+
const goal = getGoal();
|
|
90
|
+
if (!goal || goal.status !== "active") return;
|
|
91
|
+
|
|
92
|
+
// Build an estimated goal with streaming usage added to persisted usage.
|
|
93
|
+
const streamTokens = event.message.usage?.totalTokens ?? 0;
|
|
94
|
+
const elapsedThisTurn = activeTurn ? Math.max(0, Math.floor((Date.now() - activeTurn.startedAt) / 1000)) : 0;
|
|
95
|
+
const estimated: GoalState = {
|
|
96
|
+
...goal,
|
|
97
|
+
tokensUsed: goal.tokensUsed + (streamTokens > 0 ? streamTokens : 0),
|
|
98
|
+
timeUsedSeconds: goal.timeUsedSeconds + elapsedThisTurn,
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
const pressure = evaluateBudgetPressure(estimated);
|
|
102
|
+
|
|
103
|
+
if (isBudgetHardStop(pressure.kind)) {
|
|
104
|
+
// Skip if already handled by a prior stream event or turn_end.
|
|
105
|
+
if (streamBudgetSignalsSent.has("hardStop") || getTelemetry()?.lastBudgetHardStopReason) return;
|
|
106
|
+
streamBudgetSignalsSent.add("hardStop");
|
|
107
|
+
const stopped: GoalState = { ...goal, status: "budgetLimited", updatedAt: Date.now() };
|
|
108
|
+
const nextTelemetry = noteBudgetHardStop(noteBudgetLimit(getTelemetry(), pressureBudgetLimitReason(pressure)), budgetHardStopReason(pressure));
|
|
109
|
+
persistUpdateGoal(pi, stopped, nextTelemetry, "budget");
|
|
110
|
+
cancelGoalMonitor(goal.goalId, "budget-hard-stop");
|
|
111
|
+
syncGoalUi(ctx, stopped);
|
|
112
|
+
notifyWarning(ctx, `${budgetResourceText(pressure)} budget hard stop enforced. Goal work stopped.`);
|
|
113
|
+
const prompt = buildBudgetLimitPrompt(estimated);
|
|
114
|
+
pi.sendMessage(
|
|
115
|
+
{ customType: BUDGET_LIMIT_MESSAGE_TYPE, content: prompt.content, display: false, details: prompt.details },
|
|
116
|
+
{ deliverAs: "steer" },
|
|
117
|
+
);
|
|
118
|
+
cancelGoalContinuation(goal.goalId, "budget-hard-stop");
|
|
119
|
+
ctx.abort();
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Reached (100%): Send a steering message so the agent sees budget context
|
|
124
|
+
// mid-stream. State transition to budgetLimited happens at turn_end, not here,
|
|
125
|
+
// because the wrap-up turn needs to complete so the agent can summarize progress.
|
|
126
|
+
if (isBudgetReached(pressure.kind)) {
|
|
127
|
+
if (streamBudgetSignalsSent.has("reached")) return;
|
|
128
|
+
streamBudgetSignalsSent.add("reached");
|
|
129
|
+
const prompt = buildBudgetLimitPrompt(estimated);
|
|
130
|
+
pi.sendMessage(
|
|
131
|
+
{ customType: BUDGET_LIMIT_MESSAGE_TYPE, content: prompt.content, display: false, details: prompt.details },
|
|
132
|
+
{ deliverAs: "steer" },
|
|
133
|
+
);
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// Warning: Two dedup layers — (1) per-stream signal tracker prevents repeat
|
|
138
|
+
// mid-turn, (2) telemetry flags prevent re-sending if turn_end already warned.
|
|
139
|
+
if (isBudgetWarning(pressure.kind)) {
|
|
140
|
+
if (streamBudgetSignalsSent.has("warning")) return;
|
|
141
|
+
const telemetry = getTelemetry();
|
|
142
|
+
if (pressure.kind === "tokenWarning" && telemetry?.tokenBudgetWarningSent) return;
|
|
143
|
+
if (pressure.kind === "timeWarning" && telemetry?.timeBudgetWarningSent) return;
|
|
144
|
+
|
|
145
|
+
streamBudgetSignalsSent.add("warning");
|
|
146
|
+
const resource = pressure.kind.startsWith("time") ? "Time" : "Token";
|
|
147
|
+
const remaining = Math.max(0, Math.floor(pressure.remaining ?? 0));
|
|
148
|
+
const unit = pressure.kind.startsWith("time") ? "seconds" : "tokens";
|
|
149
|
+
pi.sendMessage(
|
|
150
|
+
{ customType: BUDGET_LIMIT_MESSAGE_TYPE, content: `${resource} budget warning: ${remaining} ${unit} remaining before target. Start wrapping up.`, display: false, details: { goalId: goal.goalId, kind: "budgetLimit", promptId: BUDGET_WARNING_PROMPT_ID, createdAt: Date.now() } },
|
|
151
|
+
{ deliverAs: "steer" },
|
|
152
|
+
);
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function handleToolCall(_event: ToolCallEvent): void {
|
|
158
|
+
if (!activeTurn) return;
|
|
159
|
+
activeTurn.toolCallCount++;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function handleToolResult(event: ToolResultEvent): void {
|
|
163
|
+
if (!activeTurn) return;
|
|
164
|
+
activeTurn.toolResultCount++;
|
|
165
|
+
if (event.isError) return;
|
|
166
|
+
if (event.toolName === "update_goal") return noteGoalUpdateResult(event.details);
|
|
167
|
+
activeTurn.progressCount++;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function noteGoalUpdateResult(details: unknown): void {
|
|
171
|
+
if (!activeTurn || hasToolError(details)) return;
|
|
172
|
+
if (typeof details !== "object" || details === null) return;
|
|
173
|
+
const result = details as Record<string, unknown>;
|
|
174
|
+
const goal = result.goal;
|
|
175
|
+
if (typeof goal === "object" && goal !== null) {
|
|
176
|
+
activeTurn.progressCount++;
|
|
177
|
+
if ((goal as Record<string, unknown>).status === "complete") activeTurn.completedGoal = true;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function hasToolError(details: unknown): boolean {
|
|
182
|
+
return typeof details === "object" && details !== null && "error" in details;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
async function handleTurnEnd(pi: ExtensionAPI, event: TurnEndEvent, ctx: ExtensionContext): Promise<void> {
|
|
186
|
+
const turn = activeTurn;
|
|
187
|
+
activeTurn = null;
|
|
188
|
+
if (!turn) return;
|
|
189
|
+
|
|
190
|
+
const goal = getGoal();
|
|
191
|
+
if (!goal || goal.goalId !== turn.goalId) return;
|
|
192
|
+
const elapsed = Math.max(0, Math.floor((Date.now() - turn.startedAt) / 1000));
|
|
193
|
+
const tokens = assistantTokens(event.message);
|
|
194
|
+
const madeProgress = turn.completedGoal || turn.progressCount > 0;
|
|
195
|
+
let telemetry = applyTurnTelemetry(getTelemetry(), turn, madeProgress);
|
|
196
|
+
let result = persistAccountGoal(pi, turn.goalId, { timeUsedSeconds: elapsed, tokensUsed: tokens }, telemetry, "turn");
|
|
197
|
+
let updated = result.goal;
|
|
198
|
+
|
|
199
|
+
if (updated?.status === "active") {
|
|
200
|
+
result = handleBudgetPressure(pi, ctx, updated, result.telemetry);
|
|
201
|
+
updated = result.goal;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// Check for budget hard stop on budget-limited goals — the wrap-up turn or an
|
|
205
|
+
// untracked turn may push usage past 110%, requiring an immediate abort.
|
|
206
|
+
if (updated?.status === "budgetLimited") {
|
|
207
|
+
const pressure = evaluateBudgetPressure(updated);
|
|
208
|
+
if (isBudgetHardStop(pressure.kind)) {
|
|
209
|
+
enforceBudgetHardStop(pi, ctx, updated, pressure, result.telemetry);
|
|
210
|
+
return;
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
if (updated?.status === "active" && event.message.role === "assistant" && event.message.stopReason === "aborted") {
|
|
215
|
+
await pauseForSafety(pi, ctx, updated, "abort", "Goal paused because the assistant response was aborted. Run /goal resume to continue.");
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
telemetry = result.telemetry;
|
|
220
|
+
updated = result.goal;
|
|
221
|
+
if (updated?.status === "active" && shouldPauseForSafety(telemetry) && telemetry) {
|
|
222
|
+
const reason = telemetry.consecutiveAutoTurns >= MAX_CONSECUTIVE_AUTO_TURNS ? "maxAutoTurns" : "noProgress";
|
|
223
|
+
await pauseForSafety(pi, ctx, updated, reason, "Goal paused by pi-goal safety limits. Run /goal resume to continue.");
|
|
224
|
+
return;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
if (updated?.status === "active") scheduleGoalMonitor(pi, ctx);
|
|
228
|
+
else cancelGoalMonitor(updated?.goalId, "turn-end");
|
|
229
|
+
syncGoalUi(ctx, updated);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
async function pauseForSafety(
|
|
233
|
+
pi: ExtensionAPI,
|
|
234
|
+
ctx: ExtensionContext,
|
|
235
|
+
goal: GoalState,
|
|
236
|
+
reason: "abort" | "maxAutoTurns" | "noProgress",
|
|
237
|
+
message: string,
|
|
238
|
+
): Promise<void> {
|
|
239
|
+
cancelGoalContinuation(goal.goalId, reason);
|
|
240
|
+
cancelGoalMonitor(goal.goalId, reason);
|
|
241
|
+
const paused: GoalState = { ...goal, status: "paused", updatedAt: Date.now() };
|
|
242
|
+
const telemetry = noteSafetyPause(getTelemetry(), reason);
|
|
243
|
+
persistUpdateGoal(pi, paused, telemetry, reason === "abort" ? "abort" : "safety");
|
|
244
|
+
if (telemetry) persistTelemetry(pi, telemetry, reason === "abort" ? "abort" : "safety");
|
|
245
|
+
syncGoalUi(ctx, paused);
|
|
246
|
+
notifyWarning(ctx, message);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function shouldPauseForSafety(telemetry: GoalTelemetrySnapshot | null): boolean {
|
|
250
|
+
if (!telemetry) return false;
|
|
251
|
+
return telemetry.consecutiveAutoTurns >= MAX_CONSECUTIVE_AUTO_TURNS || telemetry.consecutiveNoProgressTurns >= MAX_NO_PROGRESS_AUTO_TURNS;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function handleBudgetPressure(pi: ExtensionAPI, ctx: ExtensionContext, goal: GoalState, telemetry: GoalTelemetrySnapshot | null) {
|
|
255
|
+
const pressure = evaluateBudgetPressure(goal);
|
|
256
|
+
if (isBudgetHardStop(pressure.kind)) return enforceBudgetHardStop(pi, ctx, goal, pressure, telemetry);
|
|
257
|
+
if (isBudgetReached(pressure.kind)) return markBudgetReached(pi, ctx, goal, pressure, telemetry);
|
|
258
|
+
if (isBudgetWarning(pressure.kind)) warnBudgetPressure(pi, ctx, pressure, telemetry);
|
|
259
|
+
return { ok: true, goal, telemetry };
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function enforceBudgetHardStop(pi: ExtensionAPI, ctx: ExtensionContext, goal: GoalState, pressure: BudgetPressure, telemetry: GoalTelemetrySnapshot | null) {
|
|
263
|
+
// If hard stop was already enforced by a mid-stream message_update handler,
|
|
264
|
+
// skip re-persisting and re-notifying. The stream handler already transitioned
|
|
265
|
+
// the goal to budgetLimited, persisted telemetry, and aborted.
|
|
266
|
+
if (telemetry?.lastBudgetHardStopReason) {
|
|
267
|
+
interruptActiveGoalTurn(pi, ctx, goal);
|
|
268
|
+
return { ok: true, goal, telemetry };
|
|
269
|
+
}
|
|
270
|
+
cancelGoalContinuation(goal.goalId, "budget-hard-stop");
|
|
271
|
+
cancelGoalMonitor(goal.goalId, "budget-hard-stop");
|
|
272
|
+
const stopped: GoalState = { ...goal, status: "budgetLimited", updatedAt: Date.now() };
|
|
273
|
+
const nextTelemetry = noteBudgetHardStop(noteBudgetLimit(telemetry, pressureBudgetLimitReason(pressure)), budgetHardStopReason(pressure));
|
|
274
|
+
const result = persistUpdateGoal(pi, stopped, nextTelemetry, "budget");
|
|
275
|
+
syncGoalUi(ctx, result.goal);
|
|
276
|
+
notifyWarning(ctx, `${budgetResourceText(pressure)} budget hard stop enforced. Goal work stopped.`);
|
|
277
|
+
if (result.goal) interruptActiveGoalTurn(pi, ctx, result.goal);
|
|
278
|
+
return result;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function markBudgetReached(pi: ExtensionAPI, ctx: ExtensionContext, goal: GoalState, pressure: BudgetPressure, telemetry: GoalTelemetrySnapshot | null) {
|
|
282
|
+
cancelGoalContinuation(goal.goalId, "budget-reached");
|
|
283
|
+
cancelGoalMonitor(goal.goalId, "budget-reached");
|
|
284
|
+
const limited: GoalState = { ...goal, status: "budgetLimited", updatedAt: Date.now() };
|
|
285
|
+
const result = persistUpdateGoal(pi, limited, noteBudgetLimit(telemetry, pressureBudgetLimitReason(pressure)), "budget");
|
|
286
|
+
if (result.goal) scheduleBudgetLimitWrapUp(pi, ctx, result.goal);
|
|
287
|
+
return result;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
function warnBudgetPressure(pi: ExtensionAPI, ctx: ExtensionContext, pressure: BudgetPressure, telemetry: GoalTelemetrySnapshot | null): void {
|
|
291
|
+
if (warningAlreadySent(pressure, telemetry)) return;
|
|
292
|
+
const nextTelemetry = noteBudgetWarning(telemetry, pressure.kind === "tokenWarning" ? "tokenWarning" : "timeWarning");
|
|
293
|
+
if (nextTelemetry) persistTelemetry(pi, nextTelemetry, "budget");
|
|
294
|
+
notifyWarning(ctx, `${budgetResourceText(pressure)} budget warning: ${budgetRemainingText(pressure)} remaining before target. Start wrapping up.`);
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
function warningAlreadySent(pressure: BudgetPressure, telemetry: GoalTelemetrySnapshot | null): boolean {
|
|
298
|
+
if (pressure.kind === "tokenWarning") return Boolean(telemetry?.tokenBudgetWarningSent);
|
|
299
|
+
if (pressure.kind === "timeWarning") return Boolean(telemetry?.timeBudgetWarningSent);
|
|
300
|
+
return true;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
function pressureBudgetLimitReason(pressure: BudgetPressure): BudgetLimitReason {
|
|
304
|
+
return pressure.kind.startsWith("time") ? "timeBudget" : "tokenBudget";
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
function budgetHardStopReason(pressure: BudgetPressure): BudgetHardStopReason {
|
|
308
|
+
return pressure.kind === "timeHardStop" ? "timeHardStop" : "tokenHardStop";
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
function budgetResourceText(pressure: BudgetPressure): "Token" | "Time" {
|
|
312
|
+
return pressure.kind.startsWith("time") ? "Time" : "Token";
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
function budgetRemainingText(pressure: BudgetPressure): string {
|
|
316
|
+
const remaining = Math.max(0, Math.floor(pressure.remaining ?? 0));
|
|
317
|
+
return pressure.kind.startsWith("time") ? `${remaining} seconds` : `${remaining} tokens`;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
function assistantTokens(message: TurnEndEvent["message"]): number {
|
|
321
|
+
if (message.role !== "assistant") return 0;
|
|
322
|
+
return Math.max(0, Math.floor(message.usage?.totalTokens ?? 0));
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
function filterGoalContext(event: ContextEvent): ContextEventResult | undefined {
|
|
326
|
+
// Keep only the latest status-compatible pi-goal steering message to prevent stale continuations.
|
|
327
|
+
let latestValidIndex = -1;
|
|
328
|
+
let sawGoalSteering = false;
|
|
329
|
+
for (let i = 0; i < event.messages.length; i++) {
|
|
330
|
+
const classification = classifyGoalSteeringMessage(event.messages[i]);
|
|
331
|
+
if (classification === "invalid") sawGoalSteering = true;
|
|
332
|
+
if (classification === "valid") {
|
|
333
|
+
sawGoalSteering = true;
|
|
334
|
+
latestValidIndex = i;
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
if (!sawGoalSteering) return undefined;
|
|
338
|
+
return {
|
|
339
|
+
messages: event.messages.filter((message, index) => {
|
|
340
|
+
const classification = classifyGoalSteeringMessage(message);
|
|
341
|
+
if (classification === "none") return true;
|
|
342
|
+
return classification === "valid" && index === latestValidIndex;
|
|
343
|
+
}),
|
|
344
|
+
};
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
function classifyGoalSteeringMessage(message: unknown): "none" | "valid" | "invalid" {
|
|
348
|
+
if (typeof message !== "object" || message === null) return "none";
|
|
349
|
+
const candidate = message as Record<string, unknown>;
|
|
350
|
+
if (!isGoalSteeringCustomType(candidate.customType)) return "none";
|
|
351
|
+
const details = typeof candidate.details === "object" && candidate.details !== null ? (candidate.details as Record<string, unknown>) : null;
|
|
352
|
+
const current = getGoal();
|
|
353
|
+
if (!current || details?.goalId !== current.goalId) return "invalid";
|
|
354
|
+
return goalSteeringMatchesStatus(candidate.customType, details?.kind, current.status) ? "valid" : "invalid";
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
function isGoalSteeringCustomType(customType: unknown): customType is string {
|
|
358
|
+
return [CONTINUATION_MESSAGE_TYPE, BUDGET_LIMIT_MESSAGE_TYPE, PAUSE_MESSAGE_TYPE, GOAL_MONITOR_MESSAGE_TYPE].includes(String(customType));
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
function goalSteeringMatchesStatus(customType: string, kind: unknown, status: GoalState["status"]): boolean {
|
|
362
|
+
if (customType === CONTINUATION_MESSAGE_TYPE) return status === "active" && kind === "continuation";
|
|
363
|
+
if (customType === BUDGET_LIMIT_MESSAGE_TYPE) return status === "budgetLimited" && kind === "budgetLimit";
|
|
364
|
+
if (customType === GOAL_MONITOR_MESSAGE_TYPE) return status === "active" && kind === "monitorSteer";
|
|
365
|
+
return status === "paused" && kind === "pause";
|
|
366
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
export type XmlExtraction = { ok: true; xml: string; warnings: string[] } | { ok: false; error: string; warnings: string[] };
|
|
2
|
+
|
|
3
|
+
export function extractXmlPayload(output: string, rootTag: string): XmlExtraction {
|
|
4
|
+
const warnings: string[] = [];
|
|
5
|
+
const trimmed = output.trim();
|
|
6
|
+
if (!trimmed) return { ok: false, error: "Monitor returned empty output.", warnings };
|
|
7
|
+
|
|
8
|
+
const fenced = firstMatchingFence(trimmed, rootTag);
|
|
9
|
+
if (fenced) {
|
|
10
|
+
warnings.push("Extracted XML from Markdown code fence.");
|
|
11
|
+
return { ok: true, xml: fenced, warnings };
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const direct = sliceRootElement(trimmed, rootTag);
|
|
15
|
+
if (direct) {
|
|
16
|
+
if (direct !== trimmed) warnings.push("Extracted XML from surrounding prose.");
|
|
17
|
+
return { ok: true, xml: direct, warnings };
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
return { ok: false, error: `Could not find <${rootTag}> XML payload.`, warnings };
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function readXmlTag(xml: string, tag: string): string | undefined {
|
|
24
|
+
return readXmlTags(xml, tag)[0];
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function readXmlTags(xml: string, tag: string): string[] {
|
|
28
|
+
const values: string[] = [];
|
|
29
|
+
const pattern = new RegExp(`<${escapeRegExp(tag)}(?:\\s[^>]*)?>([\\s\\S]*?)(?:<\\/${escapeRegExp(tag)}>|$)`, "gi");
|
|
30
|
+
let match: RegExpExecArray | null;
|
|
31
|
+
while ((match = pattern.exec(xml)) !== null) {
|
|
32
|
+
values.push(decodeXmlEntities(match[1].trim()));
|
|
33
|
+
}
|
|
34
|
+
return values;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function requireXmlTag(xml: string, tag: string): { ok: true; value: string } | { ok: false; error: string } {
|
|
38
|
+
const value = readXmlTag(xml, tag);
|
|
39
|
+
if (value === undefined || value.trim() === "") return { ok: false, error: `Missing required <${tag}> tag.` };
|
|
40
|
+
return { ok: true, value };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function firstMatchingFence(text: string, rootTag: string): string | undefined {
|
|
44
|
+
const fencePattern = /```(?:xml|[A-Za-z0-9_-]+)?\s*\n([\s\S]*?)```/g;
|
|
45
|
+
let match: RegExpExecArray | null;
|
|
46
|
+
while ((match = fencePattern.exec(text)) !== null) {
|
|
47
|
+
const payload = sliceRootElement(match[1].trim(), rootTag);
|
|
48
|
+
if (payload) return payload;
|
|
49
|
+
}
|
|
50
|
+
return undefined;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function sliceRootElement(text: string, rootTag: string): string | undefined {
|
|
54
|
+
const openPattern = new RegExp(`<${escapeRegExp(rootTag)}(?:\\s[^>]*)?>`, "i");
|
|
55
|
+
const open = openPattern.exec(text);
|
|
56
|
+
if (!open || open.index === undefined) return undefined;
|
|
57
|
+
const start = open.index;
|
|
58
|
+
const closePattern = new RegExp(`<\\/${escapeRegExp(rootTag)}>`, "i");
|
|
59
|
+
const rest = text.slice(start + open[0].length);
|
|
60
|
+
const close = closePattern.exec(rest);
|
|
61
|
+
if (!close || close.index === undefined) return text.slice(start).trim();
|
|
62
|
+
return text.slice(start, start + open[0].length + close.index + close[0].length).trim();
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function decodeXmlEntities(value: string): string {
|
|
66
|
+
return value
|
|
67
|
+
.replace(/</g, "<")
|
|
68
|
+
.replace(/>/g, ">")
|
|
69
|
+
.replace(/"/g, '"')
|
|
70
|
+
.replace(/'/g, "'")
|
|
71
|
+
.replace(/&/g, "&");
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function escapeRegExp(value: string): string {
|
|
75
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
76
|
+
}
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
import { GOAL_MONITOR_PROMPT_ID } from "./constants";
|
|
2
|
+
import { escapeXml } from "./format";
|
|
3
|
+
import type { GoalMonitorLogEntry, GoalMonitorRecentEntry, GoalMonitorReport } from "./types";
|
|
4
|
+
|
|
5
|
+
export function buildGoalMonitorPrompt(report: GoalMonitorReport): string {
|
|
6
|
+
return `You are the persistent third-party churn monitor for one active pi-goal.
|
|
7
|
+
|
|
8
|
+
You are not the worker. Do not solve the task. Do not mark the goal complete.
|
|
9
|
+
Your job is to judge whether the worker is visibly stuck in unproductive churn and, only when justified, provide one narrow steering instruction.
|
|
10
|
+
|
|
11
|
+
You are project-, goal-, and process-agnostic. Apply general reasoning.
|
|
12
|
+
|
|
13
|
+
Do not be impatient. A hard task, long runtime, one failed attempt, repeated validation, or sparse report cadence is not automatically churn.
|
|
14
|
+
Use timestamps, elapsed time, prior churn-log entries, and recent worker behavior to decide whether there is real identifiable churn.
|
|
15
|
+
You receive only bounded recent churn-log entries. Reason from that bounded history and do not assume omitted entries are evidence.
|
|
16
|
+
|
|
17
|
+
Look for patterns such as:
|
|
18
|
+
- strategy_fixation
|
|
19
|
+
- irrelevant_artifact_fixation
|
|
20
|
+
- unsupported_assumption_loop
|
|
21
|
+
- complexity_escalation
|
|
22
|
+
- evidence_blind_retry
|
|
23
|
+
- validation_tunnel_vision
|
|
24
|
+
- scope_drift
|
|
25
|
+
- thrash
|
|
26
|
+
- user_escalation_needed
|
|
27
|
+
|
|
28
|
+
Task-specific details belong in evidence, not in pattern names.
|
|
29
|
+
|
|
30
|
+
If steering is warranted:
|
|
31
|
+
- identify the bad pattern
|
|
32
|
+
- state what to stop doing
|
|
33
|
+
- tell the worker to step back to first principles
|
|
34
|
+
- suggest the single simplest concrete next action likely to unblock progress
|
|
35
|
+
- keep it concise
|
|
36
|
+
- do not take over the task
|
|
37
|
+
- do not ask for broad rewrites
|
|
38
|
+
|
|
39
|
+
If steering is not warranted, return action watch and explain briefly in <log_note> why you are waiting.
|
|
40
|
+
If user input, a user decision, missing credentials, or another external dependency is required, return action escalate.
|
|
41
|
+
|
|
42
|
+
Return XML only, using this schema:
|
|
43
|
+
|
|
44
|
+
<churn_monitor_decision>
|
|
45
|
+
<action>watch|steer|escalate</action>
|
|
46
|
+
<confidence>low|medium|high</confidence>
|
|
47
|
+
<pattern>optional_stable_pattern_name</pattern>
|
|
48
|
+
<evidence>Optional evidence item.</evidence>
|
|
49
|
+
<steer>Required only when action is steer.</steer>
|
|
50
|
+
<log_note>Timestamp-aware reason for waiting, steering, or escalating.</log_note>
|
|
51
|
+
</churn_monitor_decision>
|
|
52
|
+
|
|
53
|
+
<prompt_id>${GOAL_MONITOR_PROMPT_ID}</prompt_id>
|
|
54
|
+
|
|
55
|
+
${renderReport(report)}`;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function buildGoalMonitorSteerPrompt(report: GoalMonitorReport, steer: string): string {
|
|
59
|
+
return `Third-party pi-goal churn monitor steering for the active goal.
|
|
60
|
+
|
|
61
|
+
Before following this steering, verify with get_goal if needed. If get_goal reports this goal is paused, absent, complete, budget-limited, or has a different goal id, treat this monitor steering as stale and ignore it.
|
|
62
|
+
|
|
63
|
+
<goal_id>${escapeXml(report.goalId)}</goal_id>
|
|
64
|
+
<report_id>${escapeXml(report.reportId)}</report_id>
|
|
65
|
+
<monitor_steer>
|
|
66
|
+
${escapeXml(steer)}
|
|
67
|
+
</monitor_steer>`;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function renderReport(report: GoalMonitorReport): string {
|
|
71
|
+
return `<latest_sparse_report>
|
|
72
|
+
<schema_version>${report.version}</schema_version>
|
|
73
|
+
<report_id>${escapeXml(report.reportId)}</report_id>
|
|
74
|
+
<goal_id>${escapeXml(report.goalId)}</goal_id>
|
|
75
|
+
<sent_at>${report.sentAt}</sent_at>
|
|
76
|
+
<elapsed_since_goal_start_seconds>${report.elapsedSinceGoalStartSeconds}</elapsed_since_goal_start_seconds>
|
|
77
|
+
${optionalNumberTag("elapsed_since_previous_report_seconds", report.elapsedSincePreviousReportSeconds)}
|
|
78
|
+
<session>
|
|
79
|
+
<cwd>${escapeXml(report.session.cwd)}</cwd>
|
|
80
|
+
<session_id>${escapeXml(report.session.sessionId ?? "unknown")}</session_id>
|
|
81
|
+
<branch_entry_count>${report.session.branchEntryCount}</branch_entry_count>
|
|
82
|
+
</session>
|
|
83
|
+
${renderGoal(report)}
|
|
84
|
+
${renderTelemetry(report)}
|
|
85
|
+
<recent_worker_context>
|
|
86
|
+
${report.recentEntries.map(renderRecentEntry).join("\n")}
|
|
87
|
+
</recent_worker_context>
|
|
88
|
+
<recent_churn_log limit="${report.recentLogEntries.length}">
|
|
89
|
+
${report.recentLogEntries.map(renderLogEntry).join("\n")}
|
|
90
|
+
</recent_churn_log>
|
|
91
|
+
</latest_sparse_report>`;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function renderGoal(report: GoalMonitorReport): string {
|
|
95
|
+
const goal = report.goal;
|
|
96
|
+
return `<goal>
|
|
97
|
+
<objective>${escapeXml(goal.objective)}</objective>
|
|
98
|
+
<status>${goal.status}</status>
|
|
99
|
+
<token_budget>${goal.tokenBudget ?? "none"}</token_budget>
|
|
100
|
+
<time_budget_seconds>${goal.timeBudgetSeconds ?? "none"}</time_budget_seconds>
|
|
101
|
+
<tokens_used>${goal.tokensUsed}</tokens_used>
|
|
102
|
+
<time_used_seconds>${goal.timeUsedSeconds}</time_used_seconds>
|
|
103
|
+
<created_at>${goal.createdAt}</created_at>
|
|
104
|
+
<updated_at>${goal.updatedAt}</updated_at>
|
|
105
|
+
</goal>`;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function renderTelemetry(report: GoalMonitorReport): string {
|
|
109
|
+
const telemetry = report.telemetry;
|
|
110
|
+
if (!telemetry) return "<telemetry>none</telemetry>";
|
|
111
|
+
return `<telemetry>
|
|
112
|
+
<total_turns>${telemetry.totalTurns}</total_turns>
|
|
113
|
+
<user_turns>${telemetry.userTurns}</user_turns>
|
|
114
|
+
<auto_turns>${telemetry.autoTurns}</auto_turns>
|
|
115
|
+
<consecutive_auto_turns>${telemetry.consecutiveAutoTurns}</consecutive_auto_turns>
|
|
116
|
+
<consecutive_no_progress_turns>${telemetry.consecutiveNoProgressTurns}</consecutive_no_progress_turns>
|
|
117
|
+
<last_turn_origin>${escapeXml(telemetry.lastTurnOrigin ?? "unknown")}</last_turn_origin>
|
|
118
|
+
<last_continuation_reason>${escapeXml(telemetry.lastContinuationReason ?? "unknown")}</last_continuation_reason>
|
|
119
|
+
<last_skip_reason>${escapeXml(telemetry.lastSkipReason ?? "none")}</last_skip_reason>
|
|
120
|
+
<last_turn_tool_call_count>${telemetry.lastTurnToolCallCount ?? 0}</last_turn_tool_call_count>
|
|
121
|
+
<last_turn_tool_result_count>${telemetry.lastTurnToolResultCount ?? 0}</last_turn_tool_result_count>
|
|
122
|
+
<last_turn_completed_goal>${telemetry.lastTurnCompletedGoal ?? false}</last_turn_completed_goal>
|
|
123
|
+
<budget_wrap_up_sent>${telemetry.budgetWrapUpSent ?? false}</budget_wrap_up_sent>
|
|
124
|
+
<last_progress_at>${telemetry.lastProgressAt ?? "unknown"}</last_progress_at>
|
|
125
|
+
<last_safety_pause_reason>${escapeXml(telemetry.lastSafetyPauseReason ?? "none")}</last_safety_pause_reason>
|
|
126
|
+
<last_budget_limit_reason>${escapeXml(telemetry.lastBudgetLimitReason ?? "none")}</last_budget_limit_reason>
|
|
127
|
+
<last_budget_warning_reason>${escapeXml(telemetry.lastBudgetWarningReason ?? "none")}</last_budget_warning_reason>
|
|
128
|
+
<last_budget_hard_stop_reason>${escapeXml(telemetry.lastBudgetHardStopReason ?? "none")}</last_budget_hard_stop_reason>
|
|
129
|
+
<token_budget_warning_sent>${telemetry.tokenBudgetWarningSent ?? false}</token_budget_warning_sent>
|
|
130
|
+
<time_budget_warning_sent>${telemetry.timeBudgetWarningSent ?? false}</time_budget_warning_sent>
|
|
131
|
+
<updated_at>${telemetry.updatedAt}</updated_at>
|
|
132
|
+
</telemetry>`;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function renderRecentEntry(entry: GoalMonitorRecentEntry): string {
|
|
136
|
+
return ` <entry index="${entry.index}">
|
|
137
|
+
<type>${escapeXml(entry.type)}</type>
|
|
138
|
+
<role>${escapeXml(entry.role ?? "unknown")}</role>
|
|
139
|
+
<timestamp>${escapeXml(String(entry.timestamp ?? "unknown"))}</timestamp>
|
|
140
|
+
<tool_name>${escapeXml(entry.toolName ?? "none")}</tool_name>
|
|
141
|
+
<is_error>${entry.isError ?? false}</is_error>
|
|
142
|
+
<summary>${escapeXml(entry.summary)}</summary>
|
|
143
|
+
</entry>`;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function renderLogEntry(entry: GoalMonitorLogEntry): string {
|
|
147
|
+
return ` <log_entry>
|
|
148
|
+
<at>${entry.at}</at>
|
|
149
|
+
<report_id>${escapeXml(entry.reportId)}</report_id>
|
|
150
|
+
<action>${entry.action}</action>
|
|
151
|
+
<confidence>${entry.confidence}</confidence>
|
|
152
|
+
<pattern>${escapeXml(entry.pattern ?? "none")}</pattern>
|
|
153
|
+
<evidence_summary>${escapeXml(entry.evidenceSummary)}</evidence_summary>
|
|
154
|
+
<steer_injected>${entry.steerInjected}</steer_injected>
|
|
155
|
+
<log_note>${escapeXml(entry.logNote)}</log_note>
|
|
156
|
+
</log_entry>`;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function optionalNumberTag(tag: string, value?: number): string {
|
|
160
|
+
return value === undefined ? "" : `<${tag}>${value}</${tag}>`;
|
|
161
|
+
}
|