@dumbmice/pi-todo-loop 0.0.0-stage → 0.1.1

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/src/config.ts ADDED
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Config loading. A missing, unreadable, or malformed file falls back to the
3
+ * defaults: this extension must never be the reason a turn fails.
4
+ */
5
+
6
+ import { readFileSync } from "node:fs";
7
+ import { DEFAULTS, type LoopConfig } from "./loop.ts";
8
+
9
+ export { DEFAULTS };
10
+
11
+ function coerce(raw: unknown): Partial<LoopConfig> {
12
+ if (typeof raw !== "object" || raw === null) return {};
13
+ const record = raw as Record<string, unknown>;
14
+ const out: Partial<LoopConfig> = {};
15
+
16
+ // Clamped, not just validated: a hostile or typo'd config must not be able to
17
+ // defeat the caps it configures (`maxRounds: 1e9` would otherwise be honoured).
18
+ const bounded = (value: unknown, ceiling: number): number | undefined =>
19
+ typeof value === "number" && Number.isFinite(value) && value >= 1
20
+ ? Math.min(Math.floor(value), ceiling)
21
+ : undefined;
22
+
23
+ const maxRounds = bounded(record.maxRounds, 1_000);
24
+ if (maxRounds !== undefined) out.maxRounds = maxRounds;
25
+
26
+ const maxStrikes = bounded(record.maxStrikes, 100);
27
+ if (maxStrikes !== undefined) out.maxStrikes = maxStrikes;
28
+
29
+ if (typeof record.injectState === "boolean") out.injectState = record.injectState;
30
+
31
+ const maxInjectedTasks = bounded(record.maxInjectedTasks, 50);
32
+ if (maxInjectedTasks !== undefined) out.maxInjectedTasks = maxInjectedTasks;
33
+
34
+ return out;
35
+ }
36
+
37
+ export function mergeConfig(raw: unknown): LoopConfig {
38
+ return { ...DEFAULTS, ...coerce(raw) };
39
+ }
40
+
41
+ export function loadConfig(path: string): LoopConfig {
42
+ try {
43
+ return mergeConfig(JSON.parse(readFileSync(path, "utf8")));
44
+ } catch {
45
+ // A fresh copy: the shared object must never be mutable by a caller.
46
+ return { ...DEFAULTS };
47
+ }
48
+ }
package/src/index.ts ADDED
@@ -0,0 +1,323 @@
1
+ /**
2
+ * @dumbmice/pi-todo-loop — continuation driven by an rpiv-todo list.
3
+ *
4
+ * Pairs with `@juicesharp/rpiv-todo`; does not import or patch it. Three hooks:
5
+ *
6
+ * context append the live open-task list at the tail (never persisted),
7
+ * replacing a trailing marker so the request never ends user → user
8
+ * turn_end request another turn while tasks are open, bounded by
9
+ * maxRounds / maxStrikes (see loop.ts)
10
+ * /todoloop status | pause | resume | reset
11
+ *
12
+ * Architecture notes worth keeping in mind when changing this file:
13
+ *
14
+ * - A `turn_end` continuation must leave the final message role != assistant,
15
+ * so one entry is mandatory (`_buildBoundaryContext().canContinue` in pi
16
+ * 1.5.0). We append a `custom_message` with `display: false`.
17
+ * - Markers are never removed from history: they sit between assistant turns and
18
+ * are what keep the conversation alternating. A marker at the tail is replaced
19
+ * by the state block, so the request ends on exactly one user message.
20
+ * - pi's non-assistant requirement binds the *persisted projection* checked at
21
+ * `turn_end`, not the provider request (register item 3).
22
+ * - The marker customType is deliberately stable: sessions created by earlier
23
+ * builds keep filtering correctly.
24
+ */
25
+
26
+ import { existsSync } from "node:fs";
27
+ import { homedir } from "node:os";
28
+ import { join } from "node:path";
29
+ import { loadConfig } from "./config.ts";
30
+ import { renderInjection } from "./inject.ts";
31
+ import { decide, initialState, type LoopState } from "./loop.ts";
32
+ import { readSnapshot } from "./state.ts";
33
+
34
+ /**
35
+ * Snapshot of the pi extension API surface this file depends on. Keep in step
36
+ * with the header of `docs/pi-api-alignment.md`.
37
+ *
38
+ * No pi type import on purpose: the extension is loaded by pi and the type
39
+ * package would otherwise need to be a dependency, for one signature.
40
+ */
41
+ export const PI_API_SNAPSHOT = "1.5.0";
42
+
43
+ /** The part of `ExtensionContext` this extension reads. */
44
+ interface SessionContext {
45
+ readonly cwd: string;
46
+ readonly sessionManager: { getBranch(): readonly unknown[] };
47
+ readonly ui: { notify(message: string, level: "info" | "error" | "warning"): void };
48
+ }
49
+
50
+ /** Stable across versions: the tail check must keep recognising markers from earlier builds. */
51
+ export const MARKER_TYPE = "todo-loop:continue";
52
+
53
+ /**
54
+ * Constant body. The marker keeps the persisted history alternating and gives
55
+ * `turn_end` a non-assistant tail; it carries no counters, so a stale marker in
56
+ * history cannot claim the loop is on some other round. It states the instruction
57
+ * because with `injectState: false` this line is the only text the model receives.
58
+ */
59
+ export const MARKER_BODY =
60
+ "Continue with the open todo tasks. If the human's latest instruction says to stop or limits what to do, do that instead.";
61
+
62
+ export const CONFIG_PATH = join(homedir(), ".pi/agent/todo-loop.json");
63
+ const KILL_SWITCH_GLOBAL = join(homedir(), ".pi/agent/todo-loop-off");
64
+
65
+ /**
66
+ * Overrides for tests and embedding. Production always uses the documented
67
+ * paths; nothing else in the extension varies.
68
+ */
69
+ export interface TodoLoopOptions {
70
+ /** Replaces `~/.pi/agent/todo-loop.json`. */
71
+ configPath?: string;
72
+ /** Replaces `~/.pi/agent/todo-loop-off`. */
73
+ killSwitchGlobal?: string;
74
+ /** Replaces `.pi/todo-loop-off`, resolved against `ctx.cwd`. */
75
+ killSwitchLocal?: string;
76
+ }
77
+
78
+ function branchEntries(ctx: SessionContext): readonly unknown[] {
79
+ try {
80
+ return (ctx.sessionManager.getBranch() as readonly unknown[]) ?? [];
81
+ } catch {
82
+ return [];
83
+ }
84
+ }
85
+
86
+ export default function todoLoop(pi: any, options: TodoLoopOptions = {}): void {
87
+ const configPath = options.configPath ?? CONFIG_PATH;
88
+ const killSwitchGlobal = options.killSwitchGlobal ?? KILL_SWITCH_GLOBAL;
89
+ const killSwitchLocal = options.killSwitchLocal ?? ".pi/todo-loop-off";
90
+
91
+ const isDisabled = (ctx: SessionContext): boolean => {
92
+ try {
93
+ return existsSync(killSwitchGlobal) || existsSync(join(ctx.cwd, killSwitchLocal));
94
+ } catch {
95
+ // existsSync swallows ENOENT; anything else must not fail a turn.
96
+ return false;
97
+ }
98
+ };
99
+
100
+ const state: LoopState & { paused: boolean; reason: string; lastOpenCount: number } = {
101
+ ...initialState(),
102
+ paused: false,
103
+ reason: "idle",
104
+ lastOpenCount: 0,
105
+ };
106
+
107
+ pi.on("context", async (event: any, ctx: SessionContext) => {
108
+ if (isDisabled(ctx) || state.paused) return undefined;
109
+ // A hook that rewrites an unknown payload into a one-message request is
110
+ // worse than doing nothing; leave the request alone.
111
+ if (!Array.isArray(event?.messages)) return undefined;
112
+
113
+ const original = event.messages as unknown[];
114
+ const config = loadConfig(configPath);
115
+ // No injection configured: nothing below needs the branch at all.
116
+ if (!config.injectState) return undefined;
117
+
118
+ const snapshot = readSnapshot(branchEntries(ctx));
119
+ const injecting = snapshot.open.length > 0;
120
+ if (!injecting) return undefined;
121
+
122
+ // The marker is persisted between assistant turns, so it is what keeps the
123
+ // history alternating: assistant → marker(user) → assistant. Stripping the
124
+ // historical ones would expose runs of consecutive assistant messages, which
125
+ // alternation-requiring providers reject. So markers stay in history; only a
126
+ // marker sitting at the tail (the continuation request about to be made) is
127
+ // replaced by the state block, so the request never ends on user → user.
128
+ const text = { type: "text", text: renderInjection(snapshot, config, { reconcile: state.rounds >= 1 }) };
129
+ const record = (message: unknown): Record<string, unknown> | null =>
130
+ typeof message === "object" && message !== null ? (message as Record<string, unknown>) : null;
131
+ const isMarker = (message: unknown): boolean => record(message)?.customType === MARKER_TYPE;
132
+ // pi projects every role except these to a user-role message in the request:
133
+ // `custom`, `bashExecution`, `branchSummary`, `compactionSummary`, and plain
134
+ // `user`. Anything else is user-role for the purpose of alternation, so the test
135
+ // is a whitelist of non-user roles — the direction that merges rather than
136
+ // appending a second user message.
137
+ const NON_USER_ROLES = new Set(["assistant", "toolResult", "system", "tool"]);
138
+ const isUserRole = (message: unknown): boolean => {
139
+ const role = record(message)?.role;
140
+ return typeof role === "string" && !NON_USER_ROLES.has(role);
141
+ };
142
+
143
+ // A marker only earns its place *between* two assistant turns, where it is the
144
+ // alternation glue. Anywhere else it is redundant bookkeeping that would sit
145
+ // next to a real user message and break alternation: drop it. That covers a
146
+ // marker at the tail, a marker followed by a human message, and two markers in
147
+ // a row.
148
+ const kept = (original as unknown[]).filter((message, index, list) => {
149
+ if (!isMarker(message)) return true;
150
+ const previous = list[index - 1];
151
+ const next = list[index + 1];
152
+ // Both neighbours must exist and neither may be user-role: `[A, M, H]` and
153
+ // `[A, H, M, A]` both leave a marker next to a human message, which is the
154
+ // adjacency this filter exists to prevent.
155
+ return previous !== undefined && next !== undefined && !isUserRole(previous) && !isUserRole(next);
156
+ });
157
+
158
+ // Nothing left to attach to: an empty request would be an instruction with no prompt.
159
+ if (kept.length === 0) return undefined;
160
+
161
+ const tail = kept[kept.length - 1];
162
+ const tailRole = record(tail)?.role;
163
+ // pi rebuilds the content of `bashExecution`, `branchSummary` and
164
+ // `compactionSummary` from their own fields and ignores `content`, so merging
165
+ // into one of those would silently drop the block. Only `user` and `custom`
166
+ // honour `content`; the others get a separate message.
167
+ const mergeable = tailRole === "user" || tailRole === "custom";
168
+ if (tail !== null && isUserRole(tail) && mergeable) {
169
+ // Merge into the human's own message rather than adding a second one after it.
170
+ const entry = tail as Record<string, unknown>;
171
+ const existing = Array.isArray(entry.content)
172
+ ? (entry.content as unknown[])
173
+ : typeof entry.content === "string" && entry.content !== ""
174
+ ? [{ type: "text", text: entry.content }]
175
+ : [];
176
+ const content = [...existing, text];
177
+ return { messages: [...kept.slice(0, -1), { ...entry, content }] };
178
+ }
179
+
180
+ return { messages: [...kept, { role: "user", content: [text], timestamp: Date.now() }] };
181
+ });
182
+
183
+ pi.on("turn_end", async (event: any, ctx: SessionContext) => {
184
+ if (isDisabled(ctx) || state.paused) return undefined;
185
+
186
+ const config = loadConfig(configPath);
187
+ const snapshot = readSnapshot(branchEntries(ctx));
188
+ const toolResults = Array.isArray(event?.toolResults) ? (event.toolResults as unknown[]) : [];
189
+ const toolResultCount = toolResults.length;
190
+ // `turn_end` fires for every assistant response, including one that ended in a
191
+ // tool call. Such a turn already causes pi's next request, so a continuation
192
+ // would be redundant — and counting it as a round spends the budget on work the
193
+ // loop did not cause. Only a settle (no tool results) can spend a round.
194
+ //
195
+ // A tool turn is still *work*, so it clears a pending strike: without this, a
196
+ // model that runs tools and never updates the list (the case this extension is
197
+ // for) collects two strikes across its settles and stops with "no progress"
198
+ // while it was working the whole time.
199
+ // A list that goes open -> clear in a turn whose only tool calls were `todo` is
200
+ // the loop's success state and its failure state at once. Never police it;
201
+ // just do not launder the trust: say so once.
202
+ if (snapshot.open.length === 0 && state.lastOpenCount > 0 && toolResultCount > 0) {
203
+ const names = toolResults.map((result) => String((result as Record<string, unknown>)?.toolName ?? ""));
204
+ const onlyTodo = names.every((name) => name === "todo");
205
+ if (onlyTodo) {
206
+ ctx.ui?.notify?.(
207
+ "todo-loop: the list was cleared by todo calls alone in that turn — no other tool work accompanied it",
208
+ "warning",
209
+ );
210
+ }
211
+ }
212
+ state.lastOpenCount = snapshot.open.length;
213
+
214
+ if (toolResultCount > 0) {
215
+ if (String(event?.outcome) !== "completed") {
216
+ state.reason = `stopped: turn outcome was ${String(event?.outcome)}`;
217
+ return undefined;
218
+ }
219
+ state.strikes = 0;
220
+ return undefined;
221
+ }
222
+
223
+ const decision = decide({
224
+ outcome: String(event?.outcome),
225
+ toolResultCount,
226
+ signature: snapshot.signature,
227
+ openCount: snapshot.open.length,
228
+ taskCount: snapshot.tasks.length,
229
+ anchor: snapshot.anchor,
230
+ state,
231
+ config,
232
+ });
233
+
234
+ if (!decision.continue) {
235
+ const previous = state.reason;
236
+ Object.assign(state, decision.next, { reason: decision.reason });
237
+ // A stop is otherwise silent until someone runs /todoloop.
238
+ if (previous !== decision.reason) ctx.ui?.notify?.(`todo-loop: ${decision.reason}`, "info");
239
+ return undefined;
240
+ }
241
+
242
+ // Build the proposal before committing the counters: a throw here (or a
243
+ // proposal pi rejects) must not leave `/todoloop` reporting a round that
244
+ // never happened.
245
+ const entries = Array.isArray(event?.entries) ? (event.entries as unknown[]) : [];
246
+ const next = decision.next;
247
+ const proposal = {
248
+ continue: true,
249
+ entries: [
250
+ ...entries,
251
+ {
252
+ type: "custom_message",
253
+ customType: MARKER_TYPE,
254
+ content: MARKER_BODY,
255
+ display: false,
256
+ },
257
+ ],
258
+ };
259
+
260
+ Object.assign(state, next, { reason: decision.reason });
261
+ return proposal;
262
+ });
263
+
264
+ pi.registerCommand("todoloop", {
265
+ description: "Show or control the todo-driven continuation loop",
266
+ handler: async (args: string, ctx: SessionContext) => {
267
+ const raw = (args ?? "").trim();
268
+ const [command, ...rest] = raw.toLowerCase().split(/\s+/);
269
+ const config = loadConfig(configPath);
270
+
271
+ if (command === "pause") {
272
+ state.paused = true;
273
+ state.reason = "paused by user";
274
+ ctx.ui?.notify?.("todo-loop: paused in this session", "info");
275
+ return;
276
+ }
277
+ if (command === "resume") {
278
+ state.paused = false;
279
+ state.reason = "resumed by user";
280
+ ctx.ui?.notify?.("todo-loop: resumed", "info");
281
+ return;
282
+ }
283
+ if (command === "reset") {
284
+ Object.assign(state, initialState(), { paused: state.paused, reason: "counters reset" });
285
+ ctx.ui?.notify?.("todo-loop: counters reset", "info");
286
+ return;
287
+ }
288
+
289
+ if (command !== "" && !["status", "pause", "resume", "reset"].includes(command)) {
290
+ ctx.ui?.notify?.(
291
+ `todo-loop: unknown argument "${command}"; use status, pause, resume or reset`,
292
+ "warning",
293
+ );
294
+ } else if (rest.length > 0) {
295
+ ctx.ui?.notify?.(
296
+ `todo-loop: ignoring extra arguments "${rest.join(" ")}"; use status, pause, resume or reset`,
297
+ "warning",
298
+ );
299
+ }
300
+
301
+ const snapshot = readSnapshot(branchEntries(ctx));
302
+ const mode = isDisabled(ctx)
303
+ ? "DISABLED (kill-switch present)"
304
+ : state.paused
305
+ ? "PAUSED"
306
+ : state.reason.startsWith("stopped:")
307
+ ? "stopped (next human message starts a new budget)"
308
+ : "active";
309
+ ctx.ui?.notify?.(
310
+ [
311
+ `todo-loop ${mode}`,
312
+ `rounds ${state.rounds}/${config.maxRounds} · strikes ${state.strikes}/${config.maxStrikes}`,
313
+ `open ${snapshot.open.length}/${snapshot.tasks.length} tracked · inject ${config.injectState ? "on" : "off"}`,
314
+ `anchor ${snapshot.anchor || "(none)"}`,
315
+ `last: ${state.reason}`,
316
+ `config ${configPath}`,
317
+ ].join("\n"),
318
+ "info",
319
+ );
320
+ },
321
+ });
322
+ }
323
+
package/src/inject.ts ADDED
@@ -0,0 +1,97 @@
1
+ /**
2
+ * The state injection.
3
+ *
4
+ * Appended at the tail of every request by the `context` hook and never
5
+ * persisted. Two properties matter:
6
+ *
7
+ * - It is the *whole* open list (bounded), not a hint to call `list`. After
8
+ * compaction the model has no other way to learn the list still exists.
9
+ * - It is a user-role block appended at the tail, replacing a trailing
10
+ * continuation marker so the request never ends on two user-role messages.
11
+ */
12
+
13
+ import type { LoopConfig } from "./loop.ts";
14
+ import type { Snapshot } from "./state.ts";
15
+
16
+ export const INJECT_TAG = "todo_loop_state";
17
+
18
+ function label(status: string, subject: string, activeForm?: string): string {
19
+ if (status === "in_progress" && activeForm) return activeForm;
20
+ return subject || "(untitled)";
21
+ }
22
+
23
+ /**
24
+ * One line of model-authored text, rendered inside a tag at the end of a request.
25
+ *
26
+ * A task subject is written by the model, possibly from a fetched page or a repo
27
+ * file, so it must not be able to: close the tag (`</`), start a new line that reads
28
+ * like one of our own directives, or push the block to an unbounded size.
29
+ */
30
+ export function sanitizeLabel(text: string): string {
31
+ // Every Unicode line terminator, not just \n: U+2028/9 and NEL (U+0085) split a
32
+ // line for some readers, which is the channel this function exists to close.
33
+ const flattened = text.replace(/[\r\n\t\u2028\u2029\u0085]+/g, " ");
34
+ // C0/C1 controls, bidi overrides and isolates, zero-width and BOM characters: a
35
+ // NUL can break JSON encoding and a bidi override can visually reverse a line.
36
+ const cleaned = flattened.replace(
37
+ /[\u0000-\u001f\u007f-\u009f\u200b-\u200f\u202a-\u202e\u2066-\u2069\ufeff]/g,
38
+ "",
39
+ );
40
+ // No tag can be opened or closed from task text: `<` is escaped, which also covers
41
+ // `</` and a bare `<todo_loop_state>` that would invert the block's nesting.
42
+ const escaped = cleaned.replaceAll("<", "&lt;").replaceAll("&lt;", "&lt;");
43
+ // Bound the working set before materialising code points (a 1 MB subject would
44
+ // otherwise allocate a 1M-element array per row); 480 UTF-16 units can hold far
45
+ // more than the 120 code points that survive.
46
+ const bounded = escaped.length > 480 ? escaped.slice(0, 480) : escaped;
47
+ // By code point, never UTF-16 units: `slice` can cut a surrogate pair in half and
48
+ // leave a lone surrogate in the request.
49
+ const points = Array.from(bounded).slice(0, 120).join("");
50
+ // An unpaired surrogate already in the payload survives `Array.from`; drop it so the
51
+ // request is always valid UTF-16.
52
+ return points.replace(/[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/g, "").trim();
53
+ }
54
+
55
+ export interface InjectionOptions {
56
+ /**
57
+ * Add the reconciliation clause. Off for the first nudge of a chain, on afterwards:
58
+ * handing a model an explicit "fix the list" instruction on its first sighting
59
+ * invites it to reshape the list instead of doing the work, while after one nudge a
60
+ * drifted list is the likelier explanation.
61
+ */
62
+ reconcile?: boolean;
63
+ }
64
+
65
+ const RECONCILE_CLAUSE = [
66
+ "If this list has drifted from the work, reconcile it now: update statuses,",
67
+ "add what is missing, and remove what no longer applies.",
68
+ ].join("\n");
69
+
70
+ export function renderInjection(
71
+ snapshot: Snapshot,
72
+ config: LoopConfig,
73
+ options: InjectionOptions = {},
74
+ ): string {
75
+ const shown = snapshot.open.slice(0, config.maxInjectedTasks);
76
+ const rows = shown.map(
77
+ (task) =>
78
+ ` ${task.id}. [${sanitizeLabel(task.status)}] ${sanitizeLabel(
79
+ label(task.status, task.subject, task.activeForm),
80
+ )}`,
81
+ );
82
+ const hidden = snapshot.open.length - shown.length;
83
+
84
+ return [
85
+ `<${INJECT_TAG}>`,
86
+ `${snapshot.open.length} tracked task(s) are still open on this branch:`,
87
+ ...rows,
88
+ ...(hidden > 0 ? [` ... and ${hidden} more`] : []),
89
+ "The human's latest message outranks everything above: if it says to stop, to wait,",
90
+ "or limits what to do, do that instead and say so in one line. Otherwise carry on",
91
+ "with the open tasks, and move a task to in_progress when you start it and to",
92
+ "completed only once it is actually verified — if a task cannot be verified now,",
93
+ "leave it open and say what is blocking it.",
94
+ ...(options.reconcile ? [RECONCILE_CLAUSE] : []),
95
+ `</${INJECT_TAG}>`,
96
+ ].join("\n");
97
+ }
package/src/loop.ts ADDED
@@ -0,0 +1,131 @@
1
+ /**
2
+ * Loop policy: the pure decision function.
3
+ *
4
+ * Everything that can loop forever lives here, so it can be unit-tested without
5
+ * running a model. `index.ts` only adapts pi events into `DecisionInput` and
6
+ * applies the result.
7
+ */
8
+
9
+ export interface LoopState {
10
+ /** Continuations started since the last human message. */
11
+ rounds: number;
12
+ /** Consecutive turns that neither ran a tool nor changed the task list. */
13
+ strikes: number;
14
+ /** Task signature at the previous decision, to detect list changes. */
15
+ lastSignature: string;
16
+ /** Settles since the list last changed: a stale list stops on the cap, not on strikes. */
17
+ staleSettles: number;
18
+ /** Id of the user message the current budget belongs to. */
19
+ anchor: string;
20
+ }
21
+
22
+ export interface LoopConfig {
23
+ /** Max consecutive auto-continuations per human message. */
24
+ maxRounds: number;
25
+ /** Consecutive no-progress turns tolerated before stopping. */
26
+ maxStrikes: number;
27
+ /** Inject the live task list at the tail of every request. */
28
+ injectState: boolean;
29
+ /** Max open tasks rendered in that injection. */
30
+ maxInjectedTasks: number;
31
+ }
32
+
33
+ export const DEFAULTS: LoopConfig = {
34
+ maxRounds: 12,
35
+ maxStrikes: 2,
36
+ injectState: true,
37
+ maxInjectedTasks: 6,
38
+ };
39
+
40
+ export type DecisionInput = {
41
+ /** `turn_end` outcome: "completed" is the only value that may continue. */
42
+ outcome: string;
43
+ /** Number of tool results in the turn that just ended. */
44
+ toolResultCount: number;
45
+ /** Current task signature (id:status joined), empty when no list. */
46
+ signature: string;
47
+ /** Open (pending or in_progress) task count. */
48
+ openCount: number;
49
+ /** Total tracked task count, used only for the stop reason. */
50
+ taskCount: number;
51
+ /** Id of the newest human message on the branch. */
52
+ anchor: string;
53
+ state: LoopState;
54
+ config: LoopConfig;
55
+ };
56
+
57
+ export interface Decision {
58
+ /** True when the caller should request another turn. */
59
+ continue: boolean;
60
+ /** True when the caller should persist `rounds` as they are (no reset). */
61
+ next: LoopState;
62
+ reason: string;
63
+ }
64
+
65
+ /**
66
+ * A stop keeps the counters. Only a new human message starts a new budget: a cap
67
+ * or strike stop that cleared them would re-arm the full budget on the next
68
+ * `turn_end` under the same anchor (another extension's continuation, a provider
69
+ * retry). A queued human message is a new anchor by definition, so it resets the
70
+ * budget the ordinary way.
71
+ */
72
+ function stop(state: LoopState, reason: string): Decision {
73
+ return { continue: false, next: { ...state }, reason };
74
+ }
75
+
76
+ /**
77
+ * Decide whether to continue.
78
+ *
79
+ * Order matters: a new human message resets the budget before anything else, so
80
+ * a long conversation cannot inherit the previous turn's spent rounds.
81
+ */
82
+ export function decide(input: DecisionInput): Decision {
83
+ const { state, config } = input;
84
+
85
+ // A new human message owns a fresh budget.
86
+ let current: LoopState =
87
+ input.anchor !== state.anchor
88
+ ? { ...state, rounds: 0, strikes: 0, lastSignature: "", staleSettles: 0, anchor: input.anchor }
89
+ : { ...state };
90
+
91
+ if (input.outcome !== "completed") {
92
+ return stop(current, `stopped: turn outcome was ${input.outcome}`);
93
+ }
94
+ if (input.openCount === 0) {
95
+ return stop(current, input.taskCount > 0 ? "stopped: list is clear" : "stopped: no list");
96
+ }
97
+
98
+ const progressed = input.toolResultCount > 0 || input.signature !== current.lastSignature;
99
+ if (progressed) {
100
+ current.strikes = 0;
101
+ current.staleSettles = 0;
102
+ } else {
103
+ current.staleSettles += 1;
104
+ // A budget must never read above the cap it is measured against (`strikes 3/2`).
105
+ current.strikes = Math.min(current.strikes + 1, config.maxStrikes);
106
+ if (current.strikes >= config.maxStrikes) {
107
+ return stop(
108
+ current,
109
+ `stopped: ${current.strikes} consecutive turns with no progress, ${input.openCount} open`,
110
+ );
111
+ }
112
+ }
113
+
114
+ if (current.rounds >= config.maxRounds) {
115
+ const stale =
116
+ current.staleSettles >= 2 ? `, list unchanged for ${current.staleSettles} settles` : "";
117
+ return stop(current, `stopped: round cap ${config.maxRounds} reached, ${input.openCount} open${stale}`);
118
+ }
119
+
120
+ current.rounds += 1;
121
+ current.lastSignature = input.signature;
122
+ return {
123
+ continue: true,
124
+ next: current,
125
+ reason: `continuing: round ${current.rounds}/${config.maxRounds}, ${input.openCount} open`,
126
+ };
127
+ }
128
+
129
+ export function initialState(): LoopState {
130
+ return { rounds: 0, strikes: 0, lastSignature: "", staleSettles: 0, anchor: "" };
131
+ }