@dumbmice/pi-todo-loop 0.1.0 → 0.2.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/AGENTS.md +67 -54
- package/CHANGELOG.md +75 -5
- package/README.md +25 -24
- package/docs/behaviour.md +68 -57
- package/docs/pi-api-alignment.md +17 -20
- package/package.json +2 -2
- package/scripts/check-pack.mjs +0 -1
- package/src/config.ts +0 -8
- package/src/index.ts +156 -83
- package/src/loop.ts +36 -26
- package/src/inject.ts +0 -77
package/src/index.ts
CHANGED
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* @dumbmice/pi-todo-loop — continuation driven by an rpiv-todo list.
|
|
3
3
|
*
|
|
4
|
-
* Pairs with `@juicesharp/rpiv-todo`; does not import or patch it.
|
|
4
|
+
* Pairs with `@juicesharp/rpiv-todo`; does not import or patch it. Hooks and tool:
|
|
5
5
|
*
|
|
6
|
-
* context
|
|
7
|
-
*
|
|
8
|
-
* turn_end request another turn while tasks are open, bounded by
|
|
9
|
-
*
|
|
6
|
+
* context drop a marker that would sit next to a human message, and
|
|
7
|
+
* nothing else: the prompt is never rewritten with task state
|
|
8
|
+
* turn_end request another turn while tasks are open, bounded by maxRounds
|
|
9
|
+
* (see loop.ts), and stop when the agent calls todo_loop_block
|
|
10
|
+
* todo_loop_block the agent's "I need a person" signal, scoped to one message
|
|
10
11
|
* /todoloop status | pause | resume | reset
|
|
11
12
|
*
|
|
12
13
|
* Architecture notes worth keeping in mind when changing this file:
|
|
@@ -14,9 +15,8 @@
|
|
|
14
15
|
* - A `turn_end` continuation must leave the final message role != assistant,
|
|
15
16
|
* so one entry is mandatory (`_buildBoundaryContext().canContinue` in pi
|
|
16
17
|
* 1.5.0). We append a `custom_message` with `display: false`.
|
|
17
|
-
* - Markers are never
|
|
18
|
-
*
|
|
19
|
-
* by the state block, so the request ends on exactly one user message.
|
|
18
|
+
* - Markers are never added to a request by this extension, and are only removed
|
|
19
|
+
* when one would sit next to a human message. A marker at the tail is the nudge.
|
|
20
20
|
* - pi's non-assistant requirement binds the *persisted projection* checked at
|
|
21
21
|
* `turn_end`, not the provider request (register item 3).
|
|
22
22
|
* - The marker customType is deliberately stable: sessions created by earlier
|
|
@@ -27,7 +27,6 @@ import { existsSync } from "node:fs";
|
|
|
27
27
|
import { homedir } from "node:os";
|
|
28
28
|
import { join } from "node:path";
|
|
29
29
|
import { loadConfig } from "./config.ts";
|
|
30
|
-
import { renderInjection } from "./inject.ts";
|
|
31
30
|
import { decide, initialState, type LoopState } from "./loop.ts";
|
|
32
31
|
import { readSnapshot } from "./state.ts";
|
|
33
32
|
|
|
@@ -51,13 +50,22 @@ interface SessionContext {
|
|
|
51
50
|
export const MARKER_TYPE = "todo-loop:continue";
|
|
52
51
|
|
|
53
52
|
/**
|
|
54
|
-
*
|
|
55
|
-
* `turn_end` a non-assistant tail
|
|
56
|
-
*
|
|
57
|
-
*
|
|
53
|
+
* The only text this extension ever gives the model. It keeps the persisted
|
|
54
|
+
* history alternating, gives `turn_end` a non-assistant tail, and points at the
|
|
55
|
+
* todo tool rather than restating state: the list is the agent's to read.
|
|
56
|
+
* It carries no counters, so a stale marker in history cannot claim the loop is
|
|
57
|
+
* on some other round.
|
|
58
58
|
*/
|
|
59
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.";
|
|
60
|
+
"Continue with the open todo tasks; use the todo tool to read and update the list. If the human's latest instruction says to stop or limits what to do, do that instead.";
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* The same nudge, offered once per stall: this is the first stop since real progress
|
|
64
|
+
* that did not move the list, which is what a blocked agent looks like. The concession
|
|
65
|
+
* stays last — the block still ends by telling the model the human outranks it.
|
|
66
|
+
*/
|
|
67
|
+
export const MARKER_BODY_HINT =
|
|
68
|
+
"Continue with the open todo tasks; use the todo tool to read and update the list. If you cannot proceed without a person, call todo_loop_block with the reason. If the human's latest instruction says to stop or limits what to do, do that instead.";
|
|
61
69
|
|
|
62
70
|
export const CONFIG_PATH = join(homedir(), ".pi/agent/todo-loop.json");
|
|
63
71
|
const KILL_SWITCH_GLOBAL = join(homedir(), ".pi/agent/todo-loop-off");
|
|
@@ -97,13 +105,82 @@ export default function todoLoop(pi: any, options: TodoLoopOptions = {}): void {
|
|
|
97
105
|
}
|
|
98
106
|
};
|
|
99
107
|
|
|
100
|
-
const state: LoopState & {
|
|
108
|
+
const state: LoopState & {
|
|
109
|
+
paused: boolean;
|
|
110
|
+
reason: string;
|
|
111
|
+
lastOpenCount: number;
|
|
112
|
+
/** Set by `todo_loop_block`: this message waits for a human. */
|
|
113
|
+
waiting: { reason: string; anchor: string } | null;
|
|
114
|
+
} = {
|
|
101
115
|
...initialState(),
|
|
102
116
|
paused: false,
|
|
103
117
|
reason: "idle",
|
|
104
118
|
lastOpenCount: 0,
|
|
119
|
+
waiting: null,
|
|
105
120
|
};
|
|
106
121
|
|
|
122
|
+
// The agent's own exit. A blocked agent looks exactly like a lazy one from the
|
|
123
|
+
// loop's side, so the loop cannot detect it — but the agent can say so. One
|
|
124
|
+
// sentence of state, scoped to this message: the human's next message resumes.
|
|
125
|
+
pi.registerTool({
|
|
126
|
+
name: "todo_loop_block",
|
|
127
|
+
label: "Block the todo loop",
|
|
128
|
+
description:
|
|
129
|
+
"Stop the automatic continuation loop for the rest of this message because you need a person: a decision, a review, a credential or information only the human has. The human is shown your reason and their next message resumes the loop. Use it only when you cannot proceed on the open tasks without them.",
|
|
130
|
+
parameters: {
|
|
131
|
+
type: "object",
|
|
132
|
+
properties: {
|
|
133
|
+
reason: { type: "string", description: "What you need from the human, in one sentence." },
|
|
134
|
+
},
|
|
135
|
+
required: ["reason"],
|
|
136
|
+
additionalProperties: false,
|
|
137
|
+
},
|
|
138
|
+
async execute(_id: any, params: any, _signal: any, _onUpdate: any, ctx: SessionContext) {
|
|
139
|
+
// The hooks respect the kill switch and pause; a tool must not be the one
|
|
140
|
+
// surface that keeps talking after the extension was switched off.
|
|
141
|
+
if (isDisabled(ctx) || state.paused) {
|
|
142
|
+
return {
|
|
143
|
+
content: [
|
|
144
|
+
{ type: "text", text: "The todo loop is not running in this session, so there is nothing to block." },
|
|
145
|
+
],
|
|
146
|
+
details: undefined,
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
// Model-authored and rendered in the TUI: one line, bounded, no control characters.
|
|
150
|
+
// `\p{Cc}` covers C0 and C1 (U+0085 NEL and U+009B CSI included); the slice is by
|
|
151
|
+
// code point, so a cut at the limit cannot split a surrogate pair.
|
|
152
|
+
const cleaned = String(params?.reason ?? "")
|
|
153
|
+
.replace(/\p{Cc}+/gu, " ")
|
|
154
|
+
.replace(/\s+/g, " ")
|
|
155
|
+
.trim();
|
|
156
|
+
const reason = [...cleaned].slice(0, 200).join("");
|
|
157
|
+
if (reason === "") {
|
|
158
|
+
return {
|
|
159
|
+
content: [
|
|
160
|
+
{
|
|
161
|
+
type: "text",
|
|
162
|
+
text: "todo_loop_block needs a reason: one sentence saying what you need from the human.",
|
|
163
|
+
},
|
|
164
|
+
],
|
|
165
|
+
details: undefined,
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
const snapshot = readSnapshot(branchEntries(ctx));
|
|
169
|
+
state.waiting = { reason, anchor: snapshot.anchor };
|
|
170
|
+
state.reason = `waiting for you: ${reason}`;
|
|
171
|
+
ctx.ui?.notify?.(`todo-loop: waiting for you — ${reason}`, "info");
|
|
172
|
+
return {
|
|
173
|
+
content: [
|
|
174
|
+
{
|
|
175
|
+
type: "text",
|
|
176
|
+
text: `The loop is stopped for this message; the human sees: ${reason}. Do not start other work — wait for their next message.`,
|
|
177
|
+
},
|
|
178
|
+
],
|
|
179
|
+
details: undefined,
|
|
180
|
+
};
|
|
181
|
+
},
|
|
182
|
+
});
|
|
183
|
+
|
|
107
184
|
pi.on("context", async (event: any, ctx: SessionContext) => {
|
|
108
185
|
if (isDisabled(ctx) || state.paused) return undefined;
|
|
109
186
|
// A hook that rewrites an unknown payload into a one-message request is
|
|
@@ -111,73 +188,43 @@ export default function todoLoop(pi: any, options: TodoLoopOptions = {}): void {
|
|
|
111
188
|
if (!Array.isArray(event?.messages)) return undefined;
|
|
112
189
|
|
|
113
190
|
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) };
|
|
129
191
|
const record = (message: unknown): Record<string, unknown> | null =>
|
|
130
192
|
typeof message === "object" && message !== null ? (message as Record<string, unknown>) : null;
|
|
131
193
|
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.
|
|
194
|
+
// pi projects every role except these to a user-role message in the request.
|
|
137
195
|
const NON_USER_ROLES = new Set(["assistant", "toolResult", "system", "tool"]);
|
|
138
196
|
const isUserRole = (message: unknown): boolean => {
|
|
139
197
|
const role = record(message)?.role;
|
|
140
198
|
return typeof role === "string" && !NON_USER_ROLES.has(role);
|
|
141
199
|
};
|
|
142
200
|
|
|
143
|
-
//
|
|
144
|
-
//
|
|
145
|
-
//
|
|
146
|
-
//
|
|
147
|
-
//
|
|
148
|
-
|
|
201
|
+
// Nothing is ever *added* to a request: the prompt the human sends, and every
|
|
202
|
+
// tool turn, go out exactly as built. The one thing this hook may do is remove
|
|
203
|
+
// the marker this extension itself appended, when it would sit next to a human
|
|
204
|
+
// message and project to `user → user` — an adjacency pi's providers reject, and
|
|
205
|
+
// one the extension created. Between two assistant turns the marker stays: it is
|
|
206
|
+
// the glue that keeps that pair alternating.
|
|
207
|
+
const neighbour = (list: unknown[], from: number, step: number): unknown => {
|
|
208
|
+
for (let index = from; index >= 0 && index < list.length; index += step) {
|
|
209
|
+
if (!isMarker(list[index])) return list[index];
|
|
210
|
+
}
|
|
211
|
+
return undefined;
|
|
212
|
+
};
|
|
213
|
+
const kept = original.filter((message, index, list) => {
|
|
149
214
|
if (!isMarker(message)) return true;
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
//
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
215
|
+
// Keep only the first marker of a run: that is what preserves
|
|
216
|
+
// `assistant → marker → assistant`, while testing the literal neighbours drops
|
|
217
|
+
// the whole run and leaves two assistant turns adjacent.
|
|
218
|
+
if (isMarker(list[index - 1])) return false;
|
|
219
|
+
const previous = neighbour(list, index - 1, -1);
|
|
220
|
+
const next = neighbour(list, index + 1, 1);
|
|
221
|
+
// Drop it only when it would project to `user → user`: next to a human message,
|
|
222
|
+
// on either side. A marker at the tail is the nudge itself — dropping it would
|
|
223
|
+
// leave the continuation request ending on an assistant turn.
|
|
224
|
+
return !isUserRole(previous) && !isUserRole(next);
|
|
156
225
|
});
|
|
157
226
|
|
|
158
|
-
|
|
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() }] };
|
|
227
|
+
return kept.length === original.length ? undefined : { messages: kept };
|
|
181
228
|
});
|
|
182
229
|
|
|
183
230
|
pi.on("turn_end", async (event: any, ctx: SessionContext) => {
|
|
@@ -192,10 +239,9 @@ export default function todoLoop(pi: any, options: TodoLoopOptions = {}): void {
|
|
|
192
239
|
// would be redundant — and counting it as a round spends the budget on work the
|
|
193
240
|
// loop did not cause. Only a settle (no tool results) can spend a round.
|
|
194
241
|
//
|
|
195
|
-
// A tool turn is
|
|
196
|
-
//
|
|
197
|
-
//
|
|
198
|
-
// while it was working the whole time.
|
|
242
|
+
// A tool turn is *work*, so it clears the stall counter and re-arms the block
|
|
243
|
+
// hint: an agent that keeps working is not stuck, and a later stall is a new
|
|
244
|
+
// question rather than a repeat of the old one.
|
|
199
245
|
// A list that goes open -> clear in a turn whose only tool calls were `todo` is
|
|
200
246
|
// the loop's success state and its failure state at once. Never police it;
|
|
201
247
|
// just do not launder the trust: say so once.
|
|
@@ -210,13 +256,30 @@ export default function todoLoop(pi: any, options: TodoLoopOptions = {}): void {
|
|
|
210
256
|
}
|
|
211
257
|
}
|
|
212
258
|
state.lastOpenCount = snapshot.open.length;
|
|
259
|
+
// The human answered: the block belonged to the previous message.
|
|
260
|
+
if (state.waiting !== null && state.waiting.anchor !== snapshot.anchor) {
|
|
261
|
+
state.waiting = null;
|
|
262
|
+
state.reason = "resumed by user";
|
|
263
|
+
}
|
|
213
264
|
|
|
214
265
|
if (toolResultCount > 0) {
|
|
215
266
|
if (String(event?.outcome) !== "completed") {
|
|
216
267
|
state.reason = `stopped: turn outcome was ${String(event?.outcome)}`;
|
|
217
268
|
return undefined;
|
|
218
269
|
}
|
|
219
|
-
state.
|
|
270
|
+
state.stalled = 0;
|
|
271
|
+
state.hintSent = false;
|
|
272
|
+
return undefined;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// The agent asked for a person. Nothing continues until the human answers; their
|
|
276
|
+
// next message is a new anchor, so the block clears itself.
|
|
277
|
+
if (state.waiting !== null && state.waiting.anchor === snapshot.anchor) {
|
|
278
|
+
const waiting = `waiting for you: ${state.waiting.reason}`;
|
|
279
|
+
if (state.reason !== waiting) {
|
|
280
|
+
state.reason = waiting;
|
|
281
|
+
ctx.ui?.notify?.(`todo-loop: ${waiting}`, "info");
|
|
282
|
+
}
|
|
220
283
|
return undefined;
|
|
221
284
|
}
|
|
222
285
|
|
|
@@ -251,7 +314,7 @@ export default function todoLoop(pi: any, options: TodoLoopOptions = {}): void {
|
|
|
251
314
|
{
|
|
252
315
|
type: "custom_message",
|
|
253
316
|
customType: MARKER_TYPE,
|
|
254
|
-
content: MARKER_BODY,
|
|
317
|
+
content: decision.hint ? MARKER_BODY_HINT : MARKER_BODY,
|
|
255
318
|
display: false,
|
|
256
319
|
},
|
|
257
320
|
],
|
|
@@ -281,7 +344,11 @@ export default function todoLoop(pi: any, options: TodoLoopOptions = {}): void {
|
|
|
281
344
|
return;
|
|
282
345
|
}
|
|
283
346
|
if (command === "reset") {
|
|
284
|
-
Object.assign(state, initialState(), {
|
|
347
|
+
Object.assign(state, initialState(), {
|
|
348
|
+
paused: state.paused,
|
|
349
|
+
reason: "counters reset",
|
|
350
|
+
waiting: null,
|
|
351
|
+
});
|
|
285
352
|
ctx.ui?.notify?.("todo-loop: counters reset", "info");
|
|
286
353
|
return;
|
|
287
354
|
}
|
|
@@ -299,20 +366,26 @@ export default function todoLoop(pi: any, options: TodoLoopOptions = {}): void {
|
|
|
299
366
|
}
|
|
300
367
|
|
|
301
368
|
const snapshot = readSnapshot(branchEntries(ctx));
|
|
369
|
+
const waitingNow = state.waiting !== null && state.waiting.anchor === snapshot.anchor;
|
|
302
370
|
const mode = isDisabled(ctx)
|
|
303
371
|
? "DISABLED (kill-switch present)"
|
|
304
372
|
: state.paused
|
|
305
373
|
? "PAUSED"
|
|
306
|
-
:
|
|
307
|
-
? "
|
|
308
|
-
: "
|
|
374
|
+
: waitingNow
|
|
375
|
+
? "waiting (the agent asked for you; your next message resumes)"
|
|
376
|
+
: state.reason.startsWith("stopped:")
|
|
377
|
+
? "stopped (next human message starts a new budget)"
|
|
378
|
+
: "active";
|
|
309
379
|
ctx.ui?.notify?.(
|
|
310
380
|
[
|
|
311
381
|
`todo-loop ${mode}`,
|
|
312
|
-
`rounds ${state.rounds}/${config.maxRounds} ·
|
|
313
|
-
|
|
382
|
+
`rounds ${state.rounds}/${config.maxRounds} · stalled ${state.stalled}`,
|
|
383
|
+
// Only while it still belongs to this message: the human's next one
|
|
384
|
+
// resumes the loop even though the reason is still in memory.
|
|
385
|
+
...(waitingNow ? [`waiting: ${(state.waiting as { reason: string }).reason}`] : []),
|
|
386
|
+
`open ${snapshot.open.length}/${snapshot.tasks.length} tracked`,
|
|
314
387
|
`anchor ${snapshot.anchor || "(none)"}`,
|
|
315
|
-
`last: ${state.reason}`,
|
|
388
|
+
`last: ${waitingNow ? `waiting for you: ${(state.waiting as { reason: string }).reason}` : state.reason}`,
|
|
316
389
|
`config ${configPath}`,
|
|
317
390
|
].join("\n"),
|
|
318
391
|
"info",
|
package/src/loop.ts
CHANGED
|
@@ -9,11 +9,16 @@
|
|
|
9
9
|
export interface LoopState {
|
|
10
10
|
/** Continuations started since the last human message. */
|
|
11
11
|
rounds: number;
|
|
12
|
-
/**
|
|
13
|
-
|
|
12
|
+
/**
|
|
13
|
+
* Consecutive settles that neither ran a tool nor changed the task list. No cap:
|
|
14
|
+
* it decides when to offer the block tool, not when to give up.
|
|
15
|
+
*/
|
|
16
|
+
stalled: number;
|
|
17
|
+
/** Whether the "call todo_loop_block" clause has already been offered this stall. */
|
|
18
|
+
hintSent: boolean;
|
|
14
19
|
/** Task signature at the previous decision, to detect list changes. */
|
|
15
20
|
lastSignature: string;
|
|
16
|
-
/** Settles since the list last changed: a stale list stops on the cap
|
|
21
|
+
/** Settles since the list last changed: a stale list stops on the cap. */
|
|
17
22
|
staleSettles: number;
|
|
18
23
|
/** Id of the user message the current budget belongs to. */
|
|
19
24
|
anchor: string;
|
|
@@ -22,19 +27,10 @@ export interface LoopState {
|
|
|
22
27
|
export interface LoopConfig {
|
|
23
28
|
/** Max consecutive auto-continuations per human message. */
|
|
24
29
|
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
30
|
}
|
|
32
31
|
|
|
33
32
|
export const DEFAULTS: LoopConfig = {
|
|
34
33
|
maxRounds: 12,
|
|
35
|
-
maxStrikes: 2,
|
|
36
|
-
injectState: true,
|
|
37
|
-
maxInjectedTasks: 6,
|
|
38
34
|
};
|
|
39
35
|
|
|
40
36
|
export type DecisionInput = {
|
|
@@ -60,17 +56,24 @@ export interface Decision {
|
|
|
60
56
|
/** True when the caller should persist `rounds` as they are (no reset). */
|
|
61
57
|
next: LoopState;
|
|
62
58
|
reason: string;
|
|
59
|
+
/**
|
|
60
|
+
* True when this continuation should carry the "if you cannot proceed without a
|
|
61
|
+
* person, call todo_loop_block" clause. Offered once per stall: this is the first
|
|
62
|
+
* stop since real progress that did not move the list — the shape a blocked agent
|
|
63
|
+
* has.
|
|
64
|
+
*/
|
|
65
|
+
hint: boolean;
|
|
63
66
|
}
|
|
64
67
|
|
|
65
68
|
/**
|
|
66
69
|
* A stop keeps the counters. Only a new human message starts a new budget: a cap
|
|
67
|
-
* or
|
|
70
|
+
* or cap stop that cleared them would re-arm the full budget on the next
|
|
68
71
|
* `turn_end` under the same anchor (another extension's continuation, a provider
|
|
69
72
|
* retry). A queued human message is a new anchor by definition, so it resets the
|
|
70
73
|
* budget the ordinary way.
|
|
71
74
|
*/
|
|
72
75
|
function stop(state: LoopState, reason: string): Decision {
|
|
73
|
-
return { continue: false, next: { ...state }, reason };
|
|
76
|
+
return { continue: false, next: { ...state }, hint: false, reason };
|
|
74
77
|
}
|
|
75
78
|
|
|
76
79
|
/**
|
|
@@ -85,7 +88,7 @@ export function decide(input: DecisionInput): Decision {
|
|
|
85
88
|
// A new human message owns a fresh budget.
|
|
86
89
|
let current: LoopState =
|
|
87
90
|
input.anchor !== state.anchor
|
|
88
|
-
? { ...state, rounds: 0,
|
|
91
|
+
? { ...state, rounds: 0, stalled: 0, hintSent: false, lastSignature: "", staleSettles: 0, anchor: input.anchor }
|
|
89
92
|
: { ...state };
|
|
90
93
|
|
|
91
94
|
if (input.outcome !== "completed") {
|
|
@@ -97,35 +100,42 @@ export function decide(input: DecisionInput): Decision {
|
|
|
97
100
|
|
|
98
101
|
const progressed = input.toolResultCount > 0 || input.signature !== current.lastSignature;
|
|
99
102
|
if (progressed) {
|
|
100
|
-
|
|
103
|
+
// Work re-arms the hint: a second stall, after real progress, is a new question.
|
|
104
|
+
current.stalled = 0;
|
|
105
|
+
current.hintSent = false;
|
|
101
106
|
current.staleSettles = 0;
|
|
102
107
|
} else {
|
|
103
108
|
current.staleSettles += 1;
|
|
104
|
-
|
|
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
|
-
}
|
|
109
|
+
current.stalled += 1;
|
|
112
110
|
}
|
|
113
111
|
|
|
112
|
+
// A repeat stop is the shape a blocked agent has: it already answered once, it did
|
|
113
|
+
// not move the list, and it stopped again. Offer the block tool there — once per
|
|
114
|
+
// stall — and never stop the loop for it. The only stops are a clear list, the
|
|
115
|
+
// round cap, an error, and the agent asking for a person.
|
|
116
|
+
const hint = current.stalled >= 1 && !current.hintSent;
|
|
117
|
+
|
|
114
118
|
if (current.rounds >= config.maxRounds) {
|
|
115
119
|
const stale =
|
|
116
120
|
current.staleSettles >= 2 ? `, list unchanged for ${current.staleSettles} settles` : "";
|
|
117
|
-
|
|
121
|
+
// A hint that comes due on the cap round has no continuation left to ride in.
|
|
122
|
+
// Do not drop it silently: the human reads the reason, and the agent may be
|
|
123
|
+
// blocked rather than done.
|
|
124
|
+
const stalled = hint ? ", the agent stalled and may need a person" : "";
|
|
125
|
+
return stop(current, `stopped: round cap ${config.maxRounds} reached, ${input.openCount} open${stale}${stalled}`);
|
|
118
126
|
}
|
|
119
127
|
|
|
120
128
|
current.rounds += 1;
|
|
121
129
|
current.lastSignature = input.signature;
|
|
130
|
+
if (hint) current.hintSent = true;
|
|
122
131
|
return {
|
|
123
132
|
continue: true,
|
|
124
133
|
next: current,
|
|
134
|
+
hint,
|
|
125
135
|
reason: `continuing: round ${current.rounds}/${config.maxRounds}, ${input.openCount} open`,
|
|
126
136
|
};
|
|
127
137
|
}
|
|
128
138
|
|
|
129
139
|
export function initialState(): LoopState {
|
|
130
|
-
return { rounds: 0,
|
|
140
|
+
return { rounds: 0, stalled: 0, hintSent: false, lastSignature: "", staleSettles: 0, anchor: "" };
|
|
131
141
|
}
|
package/src/inject.ts
DELETED
|
@@ -1,77 +0,0 @@
|
|
|
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("<", "<").replaceAll("<", "<");
|
|
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 function renderInjection(snapshot: Snapshot, config: LoopConfig): string {
|
|
56
|
-
const shown = snapshot.open.slice(0, config.maxInjectedTasks);
|
|
57
|
-
const rows = shown.map(
|
|
58
|
-
(task) =>
|
|
59
|
-
` ${task.id}. [${sanitizeLabel(task.status)}] ${sanitizeLabel(
|
|
60
|
-
label(task.status, task.subject, task.activeForm),
|
|
61
|
-
)}`,
|
|
62
|
-
);
|
|
63
|
-
const hidden = snapshot.open.length - shown.length;
|
|
64
|
-
|
|
65
|
-
return [
|
|
66
|
-
`<${INJECT_TAG}>`,
|
|
67
|
-
`${snapshot.open.length} tracked task(s) are still open on this branch:`,
|
|
68
|
-
...rows,
|
|
69
|
-
...(hidden > 0 ? [` ... and ${hidden} more`] : []),
|
|
70
|
-
"The human's latest message outranks everything above: if it says to stop, to wait,",
|
|
71
|
-
"or limits what to do, do that instead and say so in one line. Otherwise carry on",
|
|
72
|
-
"with the open tasks, and move a task to in_progress when you start it and to",
|
|
73
|
-
"completed only once it is actually verified — if a task cannot be verified now,",
|
|
74
|
-
"leave it open and say what is blocking it.",
|
|
75
|
-
`</${INJECT_TAG}>`,
|
|
76
|
-
].join("\n");
|
|
77
|
-
}
|