@dumbmice/pi-todo-loop 0.2.0 → 0.3.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 +51 -25
- package/CHANGELOG.md +50 -1
- package/README.md +38 -29
- package/docs/behaviour.md +58 -35
- package/docs/pi-api-alignment.md +8 -4
- package/package.json +1 -1
- package/src/config.ts +4 -0
- package/src/index.ts +59 -20
- package/src/loop.ts +37 -29
package/AGENTS.md
CHANGED
|
@@ -84,7 +84,11 @@ adding a `CHANGELOG.md` line — all in the same commit.
|
|
|
84
84
|
- **The only *variable* agent-facing text is the nudge.** One of two fixed sentences, appended once
|
|
85
85
|
per continuation. No state block, no reminder, no per-request rewrite: the list is the agent's to
|
|
86
86
|
read with the todo tool. The tool declaration (`todo_loop_block`) is constant and carries no task
|
|
87
|
-
state, but it is in every request — count it in cost claims
|
|
87
|
+
state, but it is in every request — count it in cost claims: the declaration plus the one-line
|
|
88
|
+
`<tools>` snippet and the `<rules>` guideline it earns (a few hundred characters together). It
|
|
89
|
+
declares a `promptSnippet` and a guideline that names it, because pi only shows a tool in the
|
|
90
|
+
prompt's `<tools>` list when a snippet exists; without one the exit is in the tool array but absent
|
|
91
|
+
from that summary. The non-assistant constraint is pi's `turn_end` projection check, not the request (register
|
|
88
92
|
item 3) — do not re-derive a coupling between the two.
|
|
89
93
|
- **One entry per continuation is a floor, not a choice.** pi has no `resume()` that appends nothing.
|
|
90
94
|
Do not try to reach zero entries; keep the single `custom_message` with `display: false`.
|
|
@@ -98,14 +102,17 @@ adding a `CHANGELOG.md` line — all in the same commit.
|
|
|
98
102
|
- **Only a settle spends a round.** `turn_end` fires for every assistant response, including one that
|
|
99
103
|
ended in a tool call; those already cause pi's next request, so the hook returns before deciding. A
|
|
100
104
|
round, a marker and a stall are therefore always about a turn that would have ended the run.
|
|
101
|
-
- **A tool turn
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
- **Nothing stops the loop for being quiet.** There is no strike limit: `
|
|
105
|
-
nudge
|
|
105
|
+
- **A tool turn spends no round and resets `settleRun`, whatever its outcome** — an errored or aborted
|
|
106
|
+
one also records the stop. Tool turns already cause pi's next request, so they must not consume a
|
|
107
|
+
round; because they are work, the stop right after one is not asked about, and the stop after that is.
|
|
108
|
+
- **Nothing stops the loop for being quiet.** There is no strike limit: `settleRun === 2` decides when
|
|
109
|
+
the nudge names `todo_loop_block` — the second consecutive stop with no work between them and no list
|
|
110
|
+
change, once per run of settles, the one shape the question answers. A blocked agent and a lazy
|
|
106
111
|
one are indistinguishable from here, so the agent is asked rather than judged; the only stops are a
|
|
107
|
-
clear list,
|
|
108
|
-
|
|
112
|
+
clear list, a configured round cap, an error, the agent's `todo_loop_block`, and your own pause or
|
|
113
|
+
kill switch. There is no cap by default, so a stall that never resolves warns every tenth settle
|
|
114
|
+
instead of ending the run.
|
|
115
|
+
- **A stop keeps the counters; only a new human message resets them.** Clearing `rounds` / `staleSettles` on
|
|
109
116
|
stop re-arms a full budget on the next `turn_end` under the same anchor (another extension's
|
|
110
117
|
continuation, a provider retry). The anchor comparison is the only *automatic* reset;
|
|
111
118
|
`/todoloop reset` and process start clear the counters explicitly. A queued human message is a
|
|
@@ -123,7 +130,9 @@ adding a `CHANGELOG.md` line — all in the same commit.
|
|
|
123
130
|
continues, but the loop would then stop silently — inert is better than loud.
|
|
124
131
|
- **Nothing is written.** No files, no network, no process execution. The only reads are the optional
|
|
125
132
|
config, the two kill-switch paths and the session branch.
|
|
126
|
-
- **The kill switch is checked in both hooks and in `todo_loop_block`'s `execute`.**
|
|
133
|
+
- **The kill switch is checked in both hooks and in `todo_loop_block`'s `execute`.** It cannot remove
|
|
134
|
+
the tool from the prompt: pi builds `<tools>`/`<rules>` from the registration, so a disabled session
|
|
135
|
+
still advertises an exit that will be refused. Disabling takes effect on the next request, without
|
|
127
136
|
`/reload`.
|
|
128
137
|
- **No pi type import; `pi: any` at the registration boundary.** Contexts the file reads are typed
|
|
129
138
|
structurally (`SessionContext`). Adding the pi types would make a dependency out of a package that
|
|
@@ -138,10 +147,11 @@ introduces an edge.
|
|
|
138
147
|
|---|---|
|
|
139
148
|
| continues only while items are open | `continues while work is open and the turn did work`, `stops when the list is clear`, `stops when there is no list at all` |
|
|
140
149
|
| error and aborted outcomes never continue | `never continues on an error or aborted turn` |
|
|
141
|
-
|
|
|
150
|
+
| the hint asks on the second consecutive stop, once per run of settles | `the hint asks exactly one stop after the last one`, `state machine: the block clause asks on the second consecutive stop, and once per run` |
|
|
151
|
+
| a tool turn restarts the hint run, so a tool-heavy stall is still asked | `state machine: a tool turn restarts the clause run, so a tool-heavy stall is still asked` |
|
|
142
152
|
| the first sighting of a list is progress, not a stall | `the first sighting of a list counts as progress, not a stall` |
|
|
143
|
-
| a list change
|
|
144
|
-
|
|
|
153
|
+
| a list change is progress, so it is never asked about | `a list change is progress, so it is not asked about` |
|
|
154
|
+
| no cap by default; a configured cap stops the loop | `no round cap by default`, `a configured round cap stops the loop`, `config caps are honoured` |
|
|
145
155
|
| a new human message resets the budget | `a new human message resets the budget` |
|
|
146
156
|
| a stop keeps the counters for the anchor, and a new message clears them | `a stop keeps the counters, so a cap stays shut for the same anchor`, `a new human message after a stop starts a fresh budget`, `the round cap is sticky for one human message` |
|
|
147
157
|
| the newest custom entry wins over older ones | `reads the newest rpiv-todo custom entry` |
|
|
@@ -159,8 +169,8 @@ introduces an edge.
|
|
|
159
169
|
| a malformed context payload is not rewritten | `context leaves a malformed payload alone instead of rewriting it` |
|
|
160
170
|
| either kill switch disables both hooks | `the global kill switch disables both hooks`, `the project-local kill switch disables both hooks` |
|
|
161
171
|
| only a settle spends a round: tool turns cost nothing | `a turn that ran tools spends no round and appends no marker` |
|
|
162
|
-
| a tool turn
|
|
163
|
-
| the
|
|
172
|
+
| a tool turn restarts the hint run, so a tool-heavy stall is still asked | `tool turns spend no round, clear the stall, and restart the hint's run` |
|
|
173
|
+
| only the first nudge that did not move the list names the block tool | `only the first nudge that did not move the list names todo_loop_block` |
|
|
164
174
|
| `todo_loop_block` stops the loop and reports the reason | `todo_loop_block stops the loop for this message and reports the reason` |
|
|
165
175
|
| a block without a reason is refused | `a block without a reason is refused and does not stop the loop` |
|
|
166
176
|
| the human's next message clears the block | `the human's next message clears the block` |
|
|
@@ -169,8 +179,13 @@ introduces an edge.
|
|
|
169
179
|
| the block's tool turn costs no round, and no marker is written after it | `state machine: the block call's tool turn costs no round, and the settle after it stops` |
|
|
170
180
|
| a model-authored reason is flattened and bounded | `a reason is flattened to one line and bounded` |
|
|
171
181
|
| the kill switch silences the block tool too | `the kill switch also silences todo_loop_block` |
|
|
182
|
+
| the block tool declares a prompt snippet and a self-naming guideline, so pi lists it in `<tools>`/`<rules>` | `the block tool declares a prompt snippet and a self-naming guideline` |
|
|
172
183
|
| a paused loop refuses the block tool and stays silent | `a paused loop refuses todo_loop_block and stays silent` |
|
|
173
|
-
|
|
|
184
|
+
| an uncapped runaway warns every tenth no-progress settle | `an uncapped runaway warns every tenth settle that changes nothing` |
|
|
185
|
+
| the warning counts replies since the list changed, not replies in a row | `the warning counts replies since the list changed, not replies in a row` |
|
|
186
|
+
| emptying the list by deleting tasks warns; by completing them does not | `a list emptied by deleting its tasks is announced`, `a list emptied by ticking finished tasks is silent`, `an older tombstone does not make a completion look like a deletion` |
|
|
187
|
+
| clearing the list with `clear` is announced, since nothing was completed | `a turn that clears the list with the todo clear action is announced` |
|
|
188
|
+
| a stall clause that lands on the cap round is reported, not dropped | `a cap that lands on the bare stall names it in the reason` |
|
|
174
189
|
| the next human message clears the block and the stale `last:` | `the human's next message clears the block` |
|
|
175
190
|
| a continuation appends one hidden `custom_message` with a constant body and preserves prior proposals | `turn_end requests one turn with a hidden marker when work is open` |
|
|
176
191
|
| a malformed event payload does not throw | `turn_end tolerates a malformed event payload instead of throwing` |
|
|
@@ -196,7 +211,7 @@ introduces an edge.
|
|
|
196
211
|
3. Add cases to `test/loop.test.ts`: the new rule, its boundary, and the ordering against the reset
|
|
197
212
|
and cap rules. Order matters — the anchor reset runs before every other check.
|
|
198
213
|
4. Add a user-visible line to the `/todoloop` status if an operator needs to see it.
|
|
199
|
-
5. Update the README *
|
|
214
|
+
5. Update the README *What it looks like* section and `CHANGELOG.md` under `[Unreleased]`.
|
|
200
215
|
|
|
201
216
|
## Fragile areas
|
|
202
217
|
|
|
@@ -205,7 +220,7 @@ Ranked by how easily a small change breaks them.
|
|
|
205
220
|
1. **Two different tails.** A continuation whose *persisted projection* ends on an assistant message is
|
|
206
221
|
rejected by pi (`canContinue`); the *request* has no such constraint but must keep role alternation.
|
|
207
222
|
Conflating the two produced the round-2 marker leak. `test/index.test.ts` pins both shapes.
|
|
208
|
-
2. **Counter lifecycle.** `rounds`, `
|
|
223
|
+
2. **Counter lifecycle.** `rounds`, `staleSettles`, `settleRun` and `anchor` are mutated in one place and reset in three
|
|
209
224
|
(anchor change, `/todoloop reset`, process start). A missing reset either loops forever or refuses to
|
|
210
225
|
continue after one bad turn; a reset in the wrong place (inside `stop()`) silently re-arms the cap.
|
|
211
226
|
3. **The anchor.** It must be human-authored and never empty. `#<index>` is the fallback when an entry
|
|
@@ -278,21 +293,32 @@ grep -c 'todo-loop:continue' ~/.pi/agent/sessions/<project>/*.jsonl # one per
|
|
|
278
293
|
|
|
279
294
|
## Release canary
|
|
280
295
|
|
|
281
|
-
|
|
282
|
-
or tightening `canContinue
|
|
296
|
+
Two failures this suite cannot see. The first is pi changing how a `display: false` `custom_message`
|
|
297
|
+
projects, or tightening `canContinue`; the second is how pi renders a custom tool into the system
|
|
298
|
+
prompt, since the harness stub never builds one — a tool without a `promptSnippet` was silently dropped
|
|
299
|
+
from `<tools>` in pi 1.5.0. Drafts are committed before the continuation is validated, so that change
|
|
283
300
|
would persist a marker, drop the continuation, and leave `/todoloop` reporting `continuing: round n/12`
|
|
284
|
-
until it reports
|
|
301
|
+
until it reports a cap stop (`stopped: N continuations used, …`) with zero continuations (with the default there is no cap, so the symptom is a rising `continuing: round n` that never stops) — while every test here stays green, because the
|
|
285
302
|
harness drives a stub `pi`.
|
|
286
303
|
|
|
287
304
|
Before every version bump, on the *new* pi, run the documented live check once:
|
|
288
305
|
|
|
289
306
|
```bash
|
|
290
|
-
|
|
307
|
+
# `-ne` because the released copy registers the same tool name: `pi -e .` next to the installed
|
|
308
|
+
# version fails with `Tool "todo_loop_block" conflicts with …`.
|
|
309
|
+
pi -ne -e . -p "create two todos 'a' and 'b', then reply ok" --tools todo
|
|
291
310
|
grep -c 'todo-loop:continue' ~/.pi/agent/sessions/--<project>--/*.jsonl # 0 is a failure
|
|
292
311
|
```
|
|
293
312
|
|
|
294
|
-
|
|
295
|
-
|
|
313
|
+
For the prompt half, run with `--mode json` and confirm the tool reached the system prompt section:
|
|
314
|
+
|
|
315
|
+
```bash
|
|
316
|
+
pi -ne -e . --mode json -p "reply ok" | grep -o 'todo_loop_block: Stop the todo loop'
|
|
317
|
+
```
|
|
318
|
+
|
|
319
|
+
and read the pi changelog for `custom_message`, `display`, `canContinue`, `promptSnippet`,
|
|
320
|
+
`promptGuidelines`, plus rpiv-todo's for `reminder` (upstream PR #190 would put a second task block in
|
|
321
|
+
the request).
|
|
296
322
|
|
|
297
323
|
## Known limitations to revisit
|
|
298
324
|
|
|
@@ -300,7 +326,7 @@ and read the pi changelog for `custom_message`, `display`, `canContinue`, plus r
|
|
|
300
326
|
the final actionable boundary that can append entries and request one continuation, and at
|
|
301
327
|
`turn_end` the hook has to reconstruct "the run would otherwise end" from `toolResults.length === 0`.
|
|
302
328
|
The consequence is a bounded wasted round when a human message is queued mid-turn, and the stall
|
|
303
|
-
reset lives in the adapter rather than in `decide()`. Moving the decision is the
|
|
329
|
+
reset lives in the adapter rather than in `decide()`. Moving the decision is the next major change; it must
|
|
304
330
|
ship with a CHANGELOG line because it changes which turns spend a round.
|
|
305
331
|
- **`getBranch()` is not the model-visible context** (register item 16): after compaction the panel can
|
|
306
332
|
show work the model has no list for.
|
|
@@ -351,7 +377,7 @@ Cutting a release:
|
|
|
351
377
|
```bash
|
|
352
378
|
npm version patch --no-git-tag-version # or minor/major; the tree must be clean
|
|
353
379
|
VERSION="$(node -p "require('./package.json').version")"
|
|
354
|
-
# move the CHANGELOG entry from [Unreleased] to "## [$VERSION]
|
|
380
|
+
# move the CHANGELOG entry from [Unreleased] to "## [$VERSION] - YYYY-MM-DD"
|
|
355
381
|
git add -A
|
|
356
382
|
git commit -m "chore: release v$VERSION"
|
|
357
383
|
git tag -a "v$VERSION" -m "v$VERSION"
|
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,52 @@ All notable changes to this project are documented here. The format follows
|
|
|
4
4
|
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and this project adheres to
|
|
5
5
|
[Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
6
6
|
|
|
7
|
+
## [0.3.0] - 2026-11-06
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
|
|
11
|
+
- A warning to the human every tenth reply that leaves the todo list unchanged, while the loop keeps
|
|
12
|
+
going: it names the count, the open tasks, and the two brakes (`/todoloop pause`, `maxRounds`). It is
|
|
13
|
+
keyed on `staleSettles`, which tool turns do not clear, so it also catches the model this package
|
|
14
|
+
exists for: tools running, list frozen. With a configured cap it says how many continuations remain.
|
|
15
|
+
|
|
16
|
+
### Changed
|
|
17
|
+
|
|
18
|
+
- **Breaking:** `maxRounds` defaults to `null`, so there is no continuation cap. The loop now ends when
|
|
19
|
+
the list clears, the agent calls `todo_loop_block`, an error or abort occurs, or you pause it. Set a
|
|
20
|
+
number to bound what one message of yours can cost; the value is still clamped to ≤ 1000, and a value
|
|
21
|
+
below 1 stays unlimited rather than falling back to a default.
|
|
22
|
+
- The "list was cleared by todo alone" warning now counts *calls* rather than snapshot rows: it fires
|
|
23
|
+
when the turn that emptied the list deleted tasks, and names how many. Finishing work and closing
|
|
24
|
+
every task in one turn is the honest ending and no longer warns, and a tombstone from an earlier turn
|
|
25
|
+
is not news. A result with no `action` at all keeps the old, vaguer wording.
|
|
26
|
+
- The user-facing text is plain English now: `/todoloop` says `asked to continue: 3 (no limit)`,
|
|
27
|
+
`replies since the todo list last changed: 0`, `tasks open: 2 of 5`,
|
|
28
|
+
`counting from: your message … — a new message restarts the count`; reasons read
|
|
29
|
+
`continuing: 2 tasks still open, continue 3`, and a cap reads
|
|
30
|
+
`stopped: 12 continuations used, 3 tasks still open`. The words "settles", "stalled", "round cap"
|
|
31
|
+
and "hard bound" are gone from `/todoloop`, the stop reasons and the warnings.
|
|
32
|
+
- The `todo_loop_block` clause now rides only on the bare stall: the model stopped, got one nudge, and
|
|
33
|
+
stopped again without running a tool and without the list changing (`settleRun === 2`). A stop that
|
|
34
|
+
follows real work gets the plain nudge; the stop after it is asked, so a tool-heavy run with a frozen
|
|
35
|
+
list is still asked. One request per stall, never repeated. `hintSent` is gone with it.
|
|
36
|
+
|
|
37
|
+
### Upgrade notes
|
|
38
|
+
|
|
39
|
+
- A session that relied on the old default of 12 now runs unbounded. An explicit `"maxRounds": 12` in
|
|
40
|
+
the config file is honoured exactly as before, so that is the one-line way to keep the old ceiling.
|
|
41
|
+
- `"maxRounds": 0` used to mean "use 12"; it now means unlimited, like any value below 1.
|
|
42
|
+
|
|
43
|
+
## [0.2.1] - 2026-11-05
|
|
44
|
+
|
|
45
|
+
### Changed
|
|
46
|
+
|
|
47
|
+
- `todo_loop_block` now declares a `promptSnippet` and a `promptGuidelines` bullet, so pi lists it in the
|
|
48
|
+
system prompt's `<tools>` section and its guideline in `<rules>`. Before this the tool was in the
|
|
49
|
+
request's tool array but absent from the prompt summary, so an agent could only find it through the
|
|
50
|
+
nudge. The guideline also tells the model to pick a reasonable option and state its assumption when a
|
|
51
|
+
choice is its own, rather than asking a person.
|
|
52
|
+
|
|
7
53
|
## [0.2.0] - 2026-11-05
|
|
8
54
|
|
|
9
55
|
### Added
|
|
@@ -133,4 +179,7 @@ Initial release.
|
|
|
133
179
|
|
|
134
180
|
[0.1.0]: https://github.com/DumbMice/pi-todo-loop/releases/tag/v0.1.0
|
|
135
181
|
[0.1.1]: https://github.com/DumbMice/pi-todo-loop/releases/tag/v0.1.1
|
|
136
|
-
[
|
|
182
|
+
[0.2.0]: https://github.com/DumbMice/pi-todo-loop/releases/tag/v0.2.0
|
|
183
|
+
[0.2.1]: https://github.com/DumbMice/pi-todo-loop/releases/tag/v0.2.1
|
|
184
|
+
[0.3.0]: https://github.com/DumbMice/pi-todo-loop/releases/tag/v0.3.0
|
|
185
|
+
[Unreleased]: https://github.com/DumbMice/pi-todo-loop/compare/v0.3.0...HEAD
|
package/README.md
CHANGED
|
@@ -52,7 +52,8 @@ It stops for one of these reasons, and `/todoloop` always says which:
|
|
|
52
52
|
|---|---|
|
|
53
53
|
| the list is clear | `stopped: list is clear` — the normal ending |
|
|
54
54
|
| the model said it needs a person | `waiting for you: waiting on Dana's review` — it called `todo_loop_block`; your next message resumes |
|
|
55
|
-
|
|
|
55
|
+
| a configured cap is reached | `stopped: 12 continuations used, 3 tasks still open` — only when `maxRounds` is set; if it ends with `the agent may need you`, ask what it is waiting on |
|
|
56
|
+
| it never had a list | `stopped: no list` — the model has not called the todo tool in this session |
|
|
56
57
|
| the turn failed | `stopped: turn outcome was error` — the provider failed, or you aborted it |
|
|
57
58
|
|
|
58
59
|
## When it helps
|
|
@@ -60,26 +61,32 @@ It stops for one of these reasons, and `/todoloop` always says which:
|
|
|
60
61
|
- Long, multi-step work where the agent summarises and hands back halfway.
|
|
61
62
|
- Sessions long enough to be compacted — the list survives on disk; the nudge is what gets the model to
|
|
62
63
|
read it again.
|
|
63
|
-
- A model that runs tools but rarely updates its list. It will still be nudged
|
|
64
|
-
the
|
|
64
|
+
- A model that runs tools but rarely updates its list. It will still be nudged, and with no cap by
|
|
65
|
+
default it keeps going until the list clears.
|
|
65
66
|
|
|
66
67
|
## When it does not
|
|
67
68
|
|
|
68
69
|
- **No todo tool installed.** The extension is inert: nothing continues.
|
|
69
|
-
- **You want an autonomous agent that invents its own goals.** This drives the list you have;
|
|
70
|
-
|
|
70
|
+
- **You want an autonomous agent that invents its own goals.** This drives the list you have; nothing
|
|
71
|
+
here adds work you did not ask for.
|
|
72
|
+
- **You want a hard ceiling on cost.** There is no continuation cap by default — set `maxRounds` if you
|
|
73
|
+
want one, otherwise a model that keeps stopping will keep costing turns. `/todoloop` says `(no limit)`,
|
|
74
|
+
and every tenth reply that leaves the list unchanged raises a warning.
|
|
71
75
|
- **You want the continuations visible.** They are silent by design.
|
|
72
|
-
- **You want
|
|
73
|
-
"stop" as final can say so with `todo_loop_block`, and that
|
|
76
|
+
- **You want your "stop" to be final.** Your instruction reaches the model, not the loop. A model that
|
|
77
|
+
reads "stop" as final can say so with `todo_loop_block`, and that ends it; otherwise the loop keeps
|
|
78
|
+
asking, so use `/todoloop pause` while you are away.
|
|
74
79
|
|
|
75
80
|
It buys *liveness*, not work. A model can satisfy the loop by closing tasks, including deleting
|
|
76
|
-
placeholders, and a list that is never updated looks the same as one that is finished.
|
|
77
|
-
|
|
81
|
+
placeholders, and a list that is never updated looks the same as one that is finished. What ends it is
|
|
82
|
+
the list emptying, the agent asking for a person, a turn that fails, the `maxRounds` limit you set, or
|
|
83
|
+
you pausing it — so the list has to mean something to you.
|
|
78
84
|
|
|
79
85
|
The extension never edits your prompt. The only *variable* text it gives the model is a one-line nudge —
|
|
80
|
-
*continue, and read or update the list with the todo tool* —
|
|
81
|
-
|
|
82
|
-
|
|
86
|
+
*continue, and read or update the list with the todo tool* — which adds one sentence offering
|
|
87
|
+
`todo_loop_block` when the agent has stopped twice without doing anything. Plus one constant tool
|
|
88
|
+
declaration and the prompt entry pi derives from it; neither carries task state. The list stays the
|
|
89
|
+
agent's to maintain and the conversation stays yours.
|
|
83
90
|
|
|
84
91
|
## Configuration
|
|
85
92
|
|
|
@@ -87,13 +94,13 @@ Optional, at `~/.pi/agent/todo-loop.json`:
|
|
|
87
94
|
|
|
88
95
|
```json
|
|
89
96
|
{
|
|
90
|
-
"maxRounds":
|
|
97
|
+
"maxRounds": null
|
|
91
98
|
}
|
|
92
99
|
```
|
|
93
100
|
|
|
94
101
|
| Key | Default | What it does |
|
|
95
102
|
|---|---|---|
|
|
96
|
-
| `maxRounds` | `
|
|
103
|
+
| `maxRounds` | `null` | continuations allowed per message; `null` means no limit, a number caps it (clamped to 1000) |
|
|
97
104
|
|
|
98
105
|
A missing or broken file falls back to these values; a bad config never fails a turn.
|
|
99
106
|
|
|
@@ -101,16 +108,16 @@ A missing or broken file falls back to these values; a bad config never fails a
|
|
|
101
108
|
|
|
102
109
|
| Command | What it does |
|
|
103
110
|
|---|---|
|
|
104
|
-
| `/todoloop` | status: mode,
|
|
111
|
+
| `/todoloop` | status: mode, how often it has been asked to continue, replies since the todo list last changed, open tasks, whether it waits for you, why it last stopped, config path |
|
|
105
112
|
| `/todoloop pause` | stop continuing until you resume or restart |
|
|
106
113
|
| `/todoloop resume` | allow the next turn to continue again |
|
|
107
|
-
| `/todoloop reset` |
|
|
114
|
+
| `/todoloop reset` | restart the count for this message and forget any pending `todo_loop_block` |
|
|
108
115
|
|
|
109
116
|
**Kill switch** — checked before every decision:
|
|
110
117
|
|
|
111
118
|
```bash
|
|
112
119
|
touch ~/.pi/agent/todo-loop-off # everywhere
|
|
113
|
-
touch .pi/todo-loop-off # this
|
|
120
|
+
touch .pi/todo-loop-off # this directory only
|
|
114
121
|
```
|
|
115
122
|
|
|
116
123
|
## Is it working?
|
|
@@ -119,11 +126,12 @@ After a response that made no tool calls while tasks are open, `/todoloop` shoul
|
|
|
119
126
|
|
|
120
127
|
```
|
|
121
128
|
todo-loop active
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
129
|
+
asked to continue: 1 (no limit)
|
|
130
|
+
replies since the todo list last changed: 0
|
|
131
|
+
tasks open: 2 of 5
|
|
132
|
+
counting from: your message 4f3c1a2e — a new message restarts the count
|
|
133
|
+
last: continuing: 2 tasks still open, continue 1
|
|
134
|
+
config /home/you/.pi/agent/todo-loop.json
|
|
127
135
|
```
|
|
128
136
|
|
|
129
137
|
To count continuations in a session file:
|
|
@@ -136,12 +144,13 @@ grep -c 'todo-loop:continue' ~/.pi/agent/sessions/<project>/*.jsonl
|
|
|
136
144
|
|
|
137
145
|
| Symptom | What to do |
|
|
138
146
|
|---|---|
|
|
139
|
-
| Nothing continues | Check `/todoloop`: `DISABLED` means a kill-switch file exists, `PAUSED` means you paused it. `last: stopped: no list` with `open 0
|
|
147
|
+
| Nothing continues | Check `/todoloop`: `DISABLED` means a kill-switch file exists, `PAUSED` means you paused it. `last: stopped: no list` with `tasks open: 0 of 0` means the model has not created a list in this session. `last:` only changes on a reply that ends a run, so it can lag a turn behind the counts |
|
|
140
148
|
| The model asked for a person | It called `todo_loop_block` with a reason, so the loop is stopped until your next message. The reason appears in `/todoloop` as `waiting for you: …` |
|
|
141
|
-
| It stopped at
|
|
142
|
-
|
|
|
149
|
+
| It stopped at your `maxRounds` limit | Only happens when `maxRounds` is set: the continuations for that message are used up; send another message. `no todo change for N replies` means it was working without updating the list; `the agent may need you` means it stopped twice in a row without doing anything — ask what it is waiting on |
|
|
150
|
+
| Continuations jump by two, or the same reply appears twice | Two continuation extensions are installed; `pi list` shows them, remove one, then `/reload` |
|
|
143
151
|
| The list is stale | The extension nudges, it does not write statuses. Nothing here fabricates progress |
|
|
144
|
-
|
|
|
152
|
+
| No status or warning appears | `/todoloop` and the warnings need a UI. In `pi -p` and `--mode json` the loop still runs, but the only controls are the kill-switch file and `maxRounds` |
|
|
153
|
+
| A warning says tasks were deleted | The list was emptied by a turn whose only tool was `todo`, and that turn deleted tasks rather than completing them. Check the work before trusting it |
|
|
145
154
|
|
|
146
155
|
## Uninstall
|
|
147
156
|
|
|
@@ -161,11 +170,11 @@ hidden continuation entries.
|
|
|
161
170
|
`@juicesharp/rpiv-todo` 2.15.0.
|
|
162
171
|
- **Node 22.19+**, the floor pi itself requires. The extension has no dependencies.
|
|
163
172
|
|
|
164
|
-
Last verified 2026-11-
|
|
173
|
+
Last verified 2026-11-06; the exact table and what was checked is in
|
|
165
174
|
[docs/pi-api-alignment.md](docs/pi-api-alignment.md).
|
|
166
175
|
|
|
167
|
-
Install only one continuation extension: a second one that also requests turns
|
|
168
|
-
the
|
|
176
|
+
Install only one continuation extension: a second one that also requests turns doubles every
|
|
177
|
+
continuation and makes the counts meaningless.
|
|
169
178
|
|
|
170
179
|
## Further reading
|
|
171
180
|
|
package/docs/behaviour.md
CHANGED
|
@@ -42,47 +42,63 @@ the object it already was — not re-built, not extended.
|
|
|
42
42
|
## Stalls, and the block tool
|
|
43
43
|
|
|
44
44
|
Nothing here stops the loop for being quiet. A model that keeps answering without moving the list is
|
|
45
|
-
kept alive until the
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
-
|
|
55
|
-
|
|
56
|
-
|
|
45
|
+
kept alive — until the list clears, the agent asks for a person, you stop it, or a cap you configured
|
|
46
|
+
is reached — because "stuck" and "waiting for a person" look identical from the outside, and one of them
|
|
47
|
+
must not be punished. Without a cap that means it keeps costing turns; every tenth settle since the list
|
|
48
|
+
last changed raises a warning, so a frozen list — the shape this package exists for — does not run
|
|
49
|
+
invisibly either.
|
|
50
|
+
|
|
51
|
+
- A **settle** whose task signature is unchanged increments `staleSettles`; a settle that changed the
|
|
52
|
+
list resets it. `settleRun` counts assistant turns since the last tool turn, and it is that counter —
|
|
53
|
+
not `staleSettles` — that decides *when to ask*.
|
|
54
|
+
- **The nudge has two variants.** The plain one says continue and use the todo tool. The second adds
|
|
55
|
+
*"If you cannot proceed without a person, call `todo_loop_block` with the reason."*, and is used for
|
|
56
|
+
exactly one shape: the model stopped, we sent one request, and it stopped again **without running a
|
|
57
|
+
tool and without the list changing** (`settleRun === 2`). `settleRun` counts assistant turns since the
|
|
58
|
+
last tool turn, so work in between restarts it — that stop gets the plain nudge, and the stop after it
|
|
59
|
+
is asked instead. Because the run keeps growing, one stall asks once rather than on every stop.
|
|
60
|
+
- A **tool turn** resets `settleRun`, so the next stop is not asked about even
|
|
61
|
+
when the list was not touched. A tool turn that ends `error`/`aborted` resets it too: it is still a
|
|
62
|
+
request between two stops.
|
|
63
|
+
- **`todo_loop_block`** is the agent's own exit. It is advertised in the prompt from the first turn —
|
|
64
|
+
pi lists it in `<tools>` and carries its guideline in `<rules>`, because the tool declares a
|
|
65
|
+
`promptSnippet` — and the nudge is the timed offer, not the only way to learn it exists. It takes a
|
|
66
|
+
reason, stores `{reason, anchor}` in memory and stops continuations for that message. The human is notified, `/todoloop` shows
|
|
57
67
|
`waiting for you: <reason>`, and the human's next message is a new anchor, so the block clears itself
|
|
58
68
|
— there is no unblock call.
|
|
59
69
|
- A block with an empty reason is refused, and the loop keeps running: the reason is the whole point.
|
|
60
70
|
The tool also refuses while the loop is paused or a kill switch is present, and says so, so a
|
|
61
71
|
disabled extension never notifies or writes state.
|
|
62
72
|
- **`staleSettles`** counts settles since the list last changed; a tool turn that leaves the list
|
|
63
|
-
unchanged does not clear it. It
|
|
64
|
-
|
|
65
|
-
(`,
|
|
66
|
-
- **
|
|
67
|
-
turn whose only tool results were `todo`,
|
|
68
|
-
|
|
73
|
+
unchanged does not clear it. It never injects anything and never stops anything: it only decorates
|
|
74
|
+
the reason string
|
|
75
|
+
(`, no todo change for N replies`) so a burn to a configured cap does not read like healthy work.
|
|
76
|
+
- **Deleting the list is announced; finishing it is not.** If every task goes from open to closed in a
|
|
77
|
+
turn whose only tool results were `todo`, the *calls* decide the message: a `delete` in that turn
|
|
78
|
+
produces a warning naming how many tasks were thrown away, because a deleted task is not finished
|
|
79
|
+
work. `completed` calls say nothing — finishing work and ticking the boxes in one turn is the normal
|
|
80
|
+
ending. Calls are counted rather than the snapshot's rows, because rpiv-todo keeps deleted tasks as
|
|
81
|
+
tombstones in every later snapshot; `clear` is announced the same way, since it empties the list
|
|
82
|
+
without completing anything; a result with no `action` at all falls back to the vague wording, since
|
|
83
|
+
there is nothing to inspect.
|
|
69
84
|
|
|
70
85
|
## Counters
|
|
71
86
|
|
|
72
87
|
| Counter | Meaning | Reset by |
|
|
73
88
|
|---|---|---|
|
|
74
89
|
| `rounds` | continuations spent for the current human message | a new human message, `/todoloop reset`, a new process |
|
|
75
|
-
| `
|
|
76
|
-
| `
|
|
90
|
+
| `staleSettles` | settles since the list last changed — decorates the cap reason and drives the every-tenth warning | the list changing, a new human message |
|
|
91
|
+
| `settleRun` | assistant turns since the last tool turn — `2` is the stop the hint asks on | any tool turn, a new human message, `/todoloop reset` |
|
|
77
92
|
|
|
78
93
|
- **One budget per human message.** A user-role message carrying a `customType` (this extension's
|
|
79
94
|
marker, another extension's hidden message) is skipped when choosing the anchor, so it does not reset
|
|
80
95
|
the budget; `/todoloop` prints the anchor it is using.
|
|
81
96
|
- **A stop keeps the budget shut.** Hitting the cap does not clear the counters, so
|
|
82
|
-
another extension's continuation or a provider retry under the same message cannot re-arm
|
|
97
|
+
another extension's continuation or a provider retry under the same message cannot re-arm the budget.
|
|
83
98
|
A queued human message is a new anchor and resets the budget the ordinary way.
|
|
84
|
-
- **A stale list
|
|
85
|
-
|
|
99
|
+
- **A stale list burns to the cap when one is set**, never earlier: only the list changing clears the
|
|
100
|
+
count — a tool turn does not reach the decision at all. That is why the cap reason appends
|
|
101
|
+
`, no todo change for N replies`.
|
|
86
102
|
- **Errors do not close the budget.** A turn that ends `error` or `aborted` stops the loop for that turn
|
|
87
103
|
and spends no round; the counters are left as they are, so the next settle under the same message can
|
|
88
104
|
continue — which is what a provider retry hits.
|
|
@@ -96,11 +112,11 @@ outside — and one of them must not be punished.
|
|
|
96
112
|
|
|
97
113
|
| `last:` | Meaning |
|
|
98
114
|
|---|---|
|
|
99
|
-
| `continuing:
|
|
115
|
+
| `continuing: 2 tasks still open, continue 1` | working as intended (`continue 1/12` when `maxRounds` is set) |
|
|
100
116
|
| `stopped: list is clear` | every remaining task is `completed`; a list whose tasks are all `deleted` reports `no list` instead |
|
|
101
117
|
| `stopped: no list` | no recognised todo state on the branch — see the five causes below |
|
|
102
118
|
| `waiting for you: waiting on Dana's review` | the agent called `todo_loop_block`; your next message resumes |
|
|
103
|
-
| `stopped:
|
|
119
|
+
| `stopped: 12 continuations used, 2 tasks still open` | the budget is spent; `, no todo change for N replies` means it worked without updating the list, `, the agent may need you` means it stopped on a bare stall |
|
|
104
120
|
| `stopped: turn outcome was error` / `aborted` | the provider failed, or you aborted the turn |
|
|
105
121
|
| `paused by user` | `/todoloop pause`; `/todoloop resume` or restart |
|
|
106
122
|
| `resumed by user` | `/todoloop resume`, or the human's next message clearing a pending block; the next settle may continue |
|
|
@@ -149,21 +165,28 @@ task `deleted`.
|
|
|
149
165
|
## Configuration limits
|
|
150
166
|
|
|
151
167
|
Invalid config never fails a turn: missing, unreadable or invalid values fall back to the defaults.
|
|
152
|
-
|
|
153
|
-
|
|
168
|
+
`maxRounds` is `null` by default, which means no limit: the loop ends when the list clears, the agent
|
|
169
|
+
blocks, or you stop it. Set a number to bound what one message of yours can cost; it is clamped so a
|
|
170
|
+
typo cannot defeat it (`maxRounds` ≤ 1000), and a value below 1 stays unlimited rather than becoming
|
|
171
|
+
"never continue".
|
|
154
172
|
|
|
155
173
|
## Cost
|
|
156
174
|
|
|
157
175
|
- A continuation is a normal model turn: the whole conversation is sent again, so cost scales with the
|
|
158
|
-
number of continuations × session size
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
176
|
+
number of continuations × session size — and each continuation lengthens the conversation the next
|
|
177
|
+
one re-sends, so a runaway grows faster than that product suggests. With the default there is no
|
|
178
|
+
continuation cap, so a model
|
|
179
|
+
that keeps stopping without moving the list keeps costing turns until the list clears or you stop
|
|
180
|
+
it; set `maxRounds` if you want a limit. Tool turns are pi's own and unbounded either way, so a
|
|
181
|
+
long tool-heavy stretch can be many turns for one round.
|
|
182
|
+
- The extension adds one short marker per continuation (~40 tokens), plus one constant tool declaration
|
|
183
|
+
and the prompt entries pi derives from it — `<tools>` line, `<rules>` guideline, description and
|
|
184
|
+
schema, roughly 700 characters together, in every request. None of it carries task state, and there
|
|
185
|
+
is no per-request block. Neither the snippet nor the guideline is gated by the kill switch or
|
|
186
|
+
`/todoloop pause`; only the hooks and the call itself are. Whatever the model needs it reads with the `todo` tool, and that result
|
|
164
187
|
is part of its own conversation rather than a parallel copy.
|
|
165
|
-
- `context`
|
|
166
|
-
a normal turn
|
|
188
|
+
- `context` never reads the branch: it returns `undefined` unless one of its own markers has to be
|
|
189
|
+
removed, so a normal turn costs one pass over the message list. `turn_end` reads the config, probes the kill switches and
|
|
167
190
|
walks the branch on every turn.
|
|
168
191
|
|
|
169
192
|
## More
|
package/docs/pi-api-alignment.md
CHANGED
|
@@ -7,9 +7,9 @@ versions that was established against.
|
|
|
7
7
|
|---|---|---|
|
|
8
8
|
| `@earendil-works/pi-coding-agent` | **1.5.0** | `PI_API_SNAPSHOT` in `src/index.ts` |
|
|
9
9
|
| `@juicesharp/rpiv-todo` | **2.15.0** | this file (item 12) |
|
|
10
|
-
| Node | 22.23.2 | README *Requirements* |
|
|
10
|
+
| Node | 22.23.2 | README *Requirements* (which states the floor, 22.19+) |
|
|
11
11
|
|
|
12
|
-
Last verified **2026-11-
|
|
12
|
+
Last verified **2026-11-06**. Bump a row here, its constant where one exists, and the README table in
|
|
13
13
|
the same commit as the change that depends on it.
|
|
14
14
|
|
|
15
15
|
The contract is followed where it is documented: `turn_end` boundary proposals, draft entry types,
|
|
@@ -35,7 +35,8 @@ full. Items that produce no signal in the transcript are marked _(silent)_.
|
|
|
35
35
|
what keeps the conversation alternating: `assistant → marker(user) → assistant`. Removing them
|
|
36
36
|
exposes runs of consecutive assistant messages, which alternation-requiring providers reject — a
|
|
37
37
|
stricter risk than the one marker-per-round growth they cost (about 200 bytes each, bounded by
|
|
38
|
-
`maxRounds` per human message, and they accumulate across messages). Their body is
|
|
38
|
+
`maxRounds` per human message — and only when `maxRounds` is set, since the default is no cap, and they accumulate across messages). Their body is one of two fixed sentences (the plain nudge, or the plain nudge plus the
|
|
39
|
+
`todo_loop_block` clause), so a marker left in
|
|
39
40
|
history cannot claim the loop is on some other round. With the kill switch set the hook rewrites
|
|
40
41
|
nothing, because a disabled extension must not touch the request.
|
|
41
42
|
3. **Two different tails are in play, and only one of them is constrained.** pi's boundary checks the
|
|
@@ -71,7 +72,10 @@ full. Items that produce no signal in the transcript are marked _(silent)_.
|
|
|
71
72
|
9. **`sendUserMessage` and `Agent.prompt` are deliberately not used.** `sendUserMessage` would add a
|
|
72
73
|
visible user-role message per continuation and pollute the transcript; calling the agent directly
|
|
73
74
|
bypasses `AgentSession` lifecycle handling (busy state, queuing, retry, abort).
|
|
74
|
-
10. **The kill switch is checked in both hooks and in the block tool, not once at load.**
|
|
75
|
+
10. **The kill switch is checked in both hooks and in the block tool, not once at load.** It cannot
|
|
76
|
+
withdraw the tool's prompt entry: pi renders `<tools>`/`<rules>` from the registration, so a
|
|
77
|
+
disabled session still shows an exit whose call is then refused with "not running". Removing it
|
|
78
|
+
would need `pi.setActiveTools` from a hook — a new coupling we did not take for this. Removing it or creating the file
|
|
75
79
|
takes effect on the next request, without `/reload`.
|
|
76
80
|
11. **No dependency on `@earendil-works/pi-coding-agent`, and no peer declaration for it.** Nothing
|
|
77
81
|
here imports the package: the boundary is typed `pi: any` and contexts are typed structurally
|
package/package.json
CHANGED
package/src/config.ts
CHANGED
|
@@ -20,6 +20,10 @@ function coerce(raw: unknown): Partial<LoopConfig> {
|
|
|
20
20
|
? Math.min(Math.floor(value), ceiling)
|
|
21
21
|
: undefined;
|
|
22
22
|
|
|
23
|
+
// `null` is the documented "no limit"; a number is clamped so a typo cannot defeat the
|
|
24
|
+
// budget it configures (`maxRounds: 1e9` would otherwise be honoured). A value below 1
|
|
25
|
+
// is ignored and stays unlimited, which is also the default.
|
|
26
|
+
if (record.maxRounds === null) out.maxRounds = null;
|
|
23
27
|
const maxRounds = bounded(record.maxRounds, 1_000);
|
|
24
28
|
if (maxRounds !== undefined) out.maxRounds = maxRounds;
|
|
25
29
|
|
package/src/index.ts
CHANGED
|
@@ -5,8 +5,8 @@
|
|
|
5
5
|
*
|
|
6
6
|
* context drop a marker that would sit next to a human message, and
|
|
7
7
|
* nothing else: the prompt is never rewritten with task state
|
|
8
|
-
* turn_end request another turn while tasks are open
|
|
9
|
-
*
|
|
8
|
+
* turn_end request another turn while tasks are open (uncapped unless maxRounds is
|
|
9
|
+
* set), and stop when the agent calls todo_loop_block
|
|
10
10
|
* todo_loop_block the agent's "I need a person" signal, scoped to one message
|
|
11
11
|
* /todoloop status | pause | resume | reset
|
|
12
12
|
*
|
|
@@ -27,7 +27,7 @@ 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 { decide, initialState, type LoopState } from "./loop.ts";
|
|
30
|
+
import { decide, initialState, plural, type LoopState } from "./loop.ts";
|
|
31
31
|
import { readSnapshot } from "./state.ts";
|
|
32
32
|
|
|
33
33
|
/**
|
|
@@ -60,9 +60,10 @@ export const MARKER_BODY =
|
|
|
60
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
61
|
|
|
62
62
|
/**
|
|
63
|
-
* The same nudge
|
|
64
|
-
*
|
|
65
|
-
* stays last
|
|
63
|
+
* The same nudge for the first settle since progress that did not move the list — the shape
|
|
64
|
+
* a blocked agent has. It is offered on exactly one request: repeating it would be pressure
|
|
65
|
+
* rather than information. The concession stays last, so the block still ends by telling the
|
|
66
|
+
* model the human outranks it.
|
|
66
67
|
*/
|
|
67
68
|
export const MARKER_BODY_HINT =
|
|
68
69
|
"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.";
|
|
@@ -127,6 +128,13 @@ export default function todoLoop(pi: any, options: TodoLoopOptions = {}): void {
|
|
|
127
128
|
label: "Block the todo loop",
|
|
128
129
|
description:
|
|
129
130
|
"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.",
|
|
131
|
+
// Without a snippet pi leaves the tool out of the prompt's `<tools>` summary; it is
|
|
132
|
+
// still in the request's tool array, but a model reading the prompt summary does not
|
|
133
|
+
// see the exit until the nudge names it. The exit must not be hidden that way.
|
|
134
|
+
promptSnippet: "Stop the todo loop for this message and ask the human for what you need",
|
|
135
|
+
promptGuidelines: [
|
|
136
|
+
"Use todo_loop_block when you cannot proceed on the open todo tasks without a person — a decision, a review, a credential or information only they have. If the choice is yours to make, pick the reasonable option and say what you assumed instead. Do not use it to end work you could finish yourself.",
|
|
137
|
+
],
|
|
130
138
|
parameters: {
|
|
131
139
|
type: "object",
|
|
132
140
|
properties: {
|
|
@@ -242,17 +250,28 @@ export default function todoLoop(pi: any, options: TodoLoopOptions = {}): void {
|
|
|
242
250
|
// A tool turn is *work*, so it clears the stall counter and re-arms the block
|
|
243
251
|
// hint: an agent that keeps working is not stuck, and a later stall is a new
|
|
244
252
|
// question rather than a repeat of the old one.
|
|
245
|
-
// A list that
|
|
246
|
-
//
|
|
247
|
-
//
|
|
253
|
+
// A list that empties in a turn whose only tool calls were `todo` is the loop's success
|
|
254
|
+
// state and its failure state at once. Never police it — but do not launder the trust.
|
|
255
|
+
// Count *calls*, not snapshot rows: rpiv-todo keeps deleted tasks as tombstones in every
|
|
256
|
+
// later snapshot, so counting rows would fire on a completion two turns after a deletion
|
|
257
|
+
// and would count the same tombstone once per call.
|
|
248
258
|
if (snapshot.open.length === 0 && state.lastOpenCount > 0 && toolResultCount > 0) {
|
|
249
259
|
const names = toolResults.map((result) => String((result as Record<string, unknown>)?.toolName ?? ""));
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
"todo-loop: the list was cleared by todo calls alone in that turn — no other tool work accompanied it",
|
|
254
|
-
"warning",
|
|
260
|
+
if (names.every((name) => name === "todo")) {
|
|
261
|
+
const actions = toolResults.map((result) =>
|
|
262
|
+
String(((result as Record<string, unknown>)?.details as Record<string, unknown> | undefined)?.action ?? ""),
|
|
255
263
|
);
|
|
264
|
+
const known = actions.some((action) => action !== "");
|
|
265
|
+
const deleted = actions.filter((action) => action === "delete").length;
|
|
266
|
+
const line =
|
|
267
|
+
deleted > 0
|
|
268
|
+
? `${plural(deleted, "task")} ${deleted === 1 ? "was" : "were"} deleted rather than completed in the turn that emptied the list. A deleted task is not finished work, so check the work before trusting the result.`
|
|
269
|
+
: actions.includes("clear")
|
|
270
|
+
? "the todo list was cleared in the turn that emptied it, so nothing there was completed. Check the work before trusting the result."
|
|
271
|
+
: known
|
|
272
|
+
? ""
|
|
273
|
+
: "the todo list emptied in a turn that only ran the todo tool, and its result carries no task list, so there is no way to tell whether tasks were finished or deleted. Check the work before trusting the result.";
|
|
274
|
+
if (line !== "") ctx.ui?.notify?.(`todo-loop: ${line}`, "warning");
|
|
256
275
|
}
|
|
257
276
|
}
|
|
258
277
|
state.lastOpenCount = snapshot.open.length;
|
|
@@ -263,12 +282,13 @@ export default function todoLoop(pi: any, options: TodoLoopOptions = {}): void {
|
|
|
263
282
|
}
|
|
264
283
|
|
|
265
284
|
if (toolResultCount > 0) {
|
|
285
|
+
// Any tool turn — even one that failed — is work between two stops, so it ends the
|
|
286
|
+
// run of settles the hint counts.
|
|
287
|
+
state.settleRun = 0;
|
|
266
288
|
if (String(event?.outcome) !== "completed") {
|
|
267
289
|
state.reason = `stopped: turn outcome was ${String(event?.outcome)}`;
|
|
268
290
|
return undefined;
|
|
269
291
|
}
|
|
270
|
-
state.stalled = 0;
|
|
271
|
-
state.hintSent = false;
|
|
272
292
|
return undefined;
|
|
273
293
|
}
|
|
274
294
|
|
|
@@ -321,6 +341,24 @@ export default function todoLoop(pi: any, options: TodoLoopOptions = {}): void {
|
|
|
321
341
|
};
|
|
322
342
|
|
|
323
343
|
Object.assign(state, next, { reason: decision.reason });
|
|
344
|
+
// With no cap by default, a runaway must still become visible. `staleSettles` counts
|
|
345
|
+
// settles since the list last changed and tool turns do not clear it, so this also
|
|
346
|
+
// catches the model this package exists for: tools running, list frozen.
|
|
347
|
+
if (next.staleSettles > 0 && next.staleSettles % 10 === 0) {
|
|
348
|
+
const open = snapshot.open.length;
|
|
349
|
+
const tasks = `${open} ${open === 1 ? "task is" : "tasks are"}`;
|
|
350
|
+
const left = config.maxRounds === null ? 0 : config.maxRounds - next.rounds;
|
|
351
|
+
const brake =
|
|
352
|
+
config.maxRounds === null
|
|
353
|
+
? "Run /todoloop pause to stop it, or set maxRounds in ~/.pi/agent/todo-loop.json to limit how often it continues"
|
|
354
|
+
: left <= 0
|
|
355
|
+
? `Your limit is ${config.maxRounds} continuations per message and this message has used ${next.rounds}, so it stops on the next reply`
|
|
356
|
+
: `Your limit is ${config.maxRounds} continuations per message and this message has used ${next.rounds}, so it can continue ${left === 1 ? "once more" : `${left} more times`}`;
|
|
357
|
+
ctx.ui?.notify?.(
|
|
358
|
+
`todo-loop: the agent has replied ${next.staleSettles} times without updating the todo list, and ${tasks} still open. It keeps being asked to continue. ${brake}`,
|
|
359
|
+
"warning",
|
|
360
|
+
);
|
|
361
|
+
}
|
|
324
362
|
return proposal;
|
|
325
363
|
});
|
|
326
364
|
|
|
@@ -374,17 +412,18 @@ export default function todoLoop(pi: any, options: TodoLoopOptions = {}): void {
|
|
|
374
412
|
: waitingNow
|
|
375
413
|
? "waiting (the agent asked for you; your next message resumes)"
|
|
376
414
|
: state.reason.startsWith("stopped:")
|
|
377
|
-
? "stopped (next
|
|
415
|
+
? "stopped (your next message restarts the count)"
|
|
378
416
|
: "active";
|
|
379
417
|
ctx.ui?.notify?.(
|
|
380
418
|
[
|
|
381
419
|
`todo-loop ${mode}`,
|
|
382
|
-
`
|
|
420
|
+
`asked to continue: ${state.rounds}${config.maxRounds === null ? " (no limit)" : ` of ${config.maxRounds}`}`,
|
|
421
|
+
`replies since the todo list last changed: ${state.staleSettles}`,
|
|
383
422
|
// Only while it still belongs to this message: the human's next one
|
|
384
423
|
// resumes the loop even though the reason is still in memory.
|
|
385
424
|
...(waitingNow ? [`waiting: ${(state.waiting as { reason: string }).reason}`] : []),
|
|
386
|
-
`open ${snapshot.open.length}
|
|
387
|
-
`
|
|
425
|
+
`tasks open: ${snapshot.open.length} of ${snapshot.tasks.length}`,
|
|
426
|
+
`counting from: your message ${snapshot.anchor || "(none yet)"} — a new message restarts the count`,
|
|
388
427
|
`last: ${waitingNow ? `waiting for you: ${(state.waiting as { reason: string }).reason}` : state.reason}`,
|
|
389
428
|
`config ${configPath}`,
|
|
390
429
|
].join("\n"),
|
package/src/loop.ts
CHANGED
|
@@ -10,27 +10,27 @@ export interface LoopState {
|
|
|
10
10
|
/** Continuations started since the last human message. */
|
|
11
11
|
rounds: number;
|
|
12
12
|
/**
|
|
13
|
-
*
|
|
14
|
-
*
|
|
13
|
+
* Assistant turns since the last tool turn. A stop that follows exactly one other stop —
|
|
14
|
+
* `settleRun === 2` — is the one-request-one-stop shape the hint asks about.
|
|
15
15
|
*/
|
|
16
|
-
|
|
17
|
-
/** Whether the "call todo_loop_block" clause has already been offered this stall. */
|
|
18
|
-
hintSent: boolean;
|
|
16
|
+
settleRun: number;
|
|
19
17
|
/** Task signature at the previous decision, to detect list changes. */
|
|
20
18
|
lastSignature: string;
|
|
21
|
-
/** Settles since the list last changed
|
|
19
|
+
/** Settles since the list last changed. Drives the every-tenth warning to the human. */
|
|
22
20
|
staleSettles: number;
|
|
23
21
|
/** Id of the user message the current budget belongs to. */
|
|
24
22
|
anchor: string;
|
|
25
23
|
}
|
|
26
24
|
|
|
27
25
|
export interface LoopConfig {
|
|
28
|
-
/**
|
|
29
|
-
maxRounds: number;
|
|
26
|
+
/** Continuations allowed per human message; `null` means no limit. */
|
|
27
|
+
maxRounds: number | null;
|
|
30
28
|
}
|
|
31
29
|
|
|
32
30
|
export const DEFAULTS: LoopConfig = {
|
|
33
|
-
|
|
31
|
+
// No cap by default: the loop ends when the list clears, the agent asks for a person,
|
|
32
|
+
// or you stop it. Set a number to bound what one message of yours can cost.
|
|
33
|
+
maxRounds: null,
|
|
34
34
|
};
|
|
35
35
|
|
|
36
36
|
export type DecisionInput = {
|
|
@@ -58,9 +58,8 @@ export interface Decision {
|
|
|
58
58
|
reason: string;
|
|
59
59
|
/**
|
|
60
60
|
* True when this continuation should carry the "if you cannot proceed without a
|
|
61
|
-
* person, call todo_loop_block" clause
|
|
62
|
-
*
|
|
63
|
-
* has.
|
|
61
|
+
* person, call todo_loop_block" clause: the first settle since real progress that did
|
|
62
|
+
* not move the list. One request, not a series.
|
|
64
63
|
*/
|
|
65
64
|
hint: boolean;
|
|
66
65
|
}
|
|
@@ -82,13 +81,22 @@ function stop(state: LoopState, reason: string): Decision {
|
|
|
82
81
|
* Order matters: a new human message resets the budget before anything else, so
|
|
83
82
|
* a long conversation cannot inherit the previous turn's spent rounds.
|
|
84
83
|
*/
|
|
84
|
+
export const plural = (count: number, word: string): string => `${count} ${word}${count === 1 ? "" : "s"}`;
|
|
85
|
+
|
|
85
86
|
export function decide(input: DecisionInput): Decision {
|
|
86
87
|
const { state, config } = input;
|
|
87
88
|
|
|
88
89
|
// A new human message owns a fresh budget.
|
|
89
90
|
let current: LoopState =
|
|
90
91
|
input.anchor !== state.anchor
|
|
91
|
-
? {
|
|
92
|
+
? {
|
|
93
|
+
...state,
|
|
94
|
+
rounds: 0,
|
|
95
|
+
settleRun: 0,
|
|
96
|
+
lastSignature: "",
|
|
97
|
+
staleSettles: 0,
|
|
98
|
+
anchor: input.anchor,
|
|
99
|
+
}
|
|
92
100
|
: { ...state };
|
|
93
101
|
|
|
94
102
|
if (input.outcome !== "completed") {
|
|
@@ -100,42 +108,42 @@ export function decide(input: DecisionInput): Decision {
|
|
|
100
108
|
|
|
101
109
|
const progressed = input.toolResultCount > 0 || input.signature !== current.lastSignature;
|
|
102
110
|
if (progressed) {
|
|
103
|
-
// Work re-arms the hint: a second stall, after real progress, is a new question.
|
|
104
|
-
current.stalled = 0;
|
|
105
|
-
current.hintSent = false;
|
|
106
111
|
current.staleSettles = 0;
|
|
107
112
|
} else {
|
|
108
113
|
current.staleSettles += 1;
|
|
109
|
-
current.stalled += 1;
|
|
110
114
|
}
|
|
111
115
|
|
|
112
|
-
//
|
|
113
|
-
//
|
|
114
|
-
//
|
|
115
|
-
//
|
|
116
|
-
|
|
116
|
+
// Every settle counts; a tool turn resets the run. `settleRun === 2` is exactly "it stopped,
|
|
117
|
+
// we sent one request, and it stopped again" — the shape the hint asks about. A list change
|
|
118
|
+
// is progress, so it is not asked there either, and the run keeps growing, so one stall asks
|
|
119
|
+
// once rather than on every stop.
|
|
120
|
+
current.settleRun += 1;
|
|
121
|
+
const hint = current.settleRun === 2 && !progressed;
|
|
117
122
|
|
|
118
|
-
if (current.rounds >= config.maxRounds) {
|
|
123
|
+
if (config.maxRounds !== null && current.rounds >= config.maxRounds) {
|
|
119
124
|
const stale =
|
|
120
|
-
current.staleSettles >= 2 ? `,
|
|
125
|
+
current.staleSettles >= 2 ? `, no todo change for ${current.staleSettles} replies` : "";
|
|
121
126
|
// A hint that comes due on the cap round has no continuation left to ride in.
|
|
122
127
|
// Do not drop it silently: the human reads the reason, and the agent may be
|
|
123
128
|
// blocked rather than done.
|
|
124
|
-
const stalled = hint ? ", the agent
|
|
125
|
-
return stop(
|
|
129
|
+
const stalled = hint ? ", the agent may need you" : "";
|
|
130
|
+
return stop(
|
|
131
|
+
current,
|
|
132
|
+
`stopped: ${plural(config.maxRounds, "continuation")} used, ${plural(input.openCount, "task")} still open${stale}${stalled}`,
|
|
133
|
+
);
|
|
126
134
|
}
|
|
127
135
|
|
|
128
136
|
current.rounds += 1;
|
|
129
137
|
current.lastSignature = input.signature;
|
|
130
|
-
|
|
138
|
+
const of = config.maxRounds === null ? "" : `/${config.maxRounds}`;
|
|
131
139
|
return {
|
|
132
140
|
continue: true,
|
|
133
141
|
next: current,
|
|
134
142
|
hint,
|
|
135
|
-
reason: `continuing:
|
|
143
|
+
reason: `continuing: ${plural(input.openCount, "task")} still open, continue ${current.rounds}${of}`,
|
|
136
144
|
};
|
|
137
145
|
}
|
|
138
146
|
|
|
139
147
|
export function initialState(): LoopState {
|
|
140
|
-
return { rounds: 0,
|
|
148
|
+
return { rounds: 0, settleRun: 0, lastSignature: "", staleSettles: 0, anchor: "" };
|
|
141
149
|
}
|