@dumbmice/pi-todo-loop 0.2.1 → 0.3.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/AGENTS.md +26 -18
- package/CHANGELOG.md +51 -1
- package/README.md +38 -29
- package/docs/behaviour.md +49 -30
- package/docs/pi-api-alignment.md +5 -4
- package/package.json +1 -1
- package/src/config.ts +4 -0
- package/src/index.ts +54 -22
- package/src/loop.ts +37 -29
package/AGENTS.md
CHANGED
|
@@ -102,14 +102,17 @@ adding a `CHANGELOG.md` line — all in the same commit.
|
|
|
102
102
|
- **Only a settle spends a round.** `turn_end` fires for every assistant response, including one that
|
|
103
103
|
ended in a tool call; those already cause pi's next request, so the hook returns before deciding. A
|
|
104
104
|
round, a marker and a stall are therefore always about a turn that would have ended the run.
|
|
105
|
-
- **A tool turn
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
- **Nothing stops the loop for being quiet.** There is no strike limit: `
|
|
109
|
-
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
|
|
110
111
|
one are indistinguishable from here, so the agent is asked rather than judged; the only stops are a
|
|
111
|
-
clear list,
|
|
112
|
-
|
|
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
|
|
113
116
|
stop re-arms a full budget on the next `turn_end` under the same anchor (another extension's
|
|
114
117
|
continuation, a provider retry). The anchor comparison is the only *automatic* reset;
|
|
115
118
|
`/todoloop reset` and process start clear the counters explicitly. A queued human message is a
|
|
@@ -144,10 +147,11 @@ introduces an edge.
|
|
|
144
147
|
|---|---|
|
|
145
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` |
|
|
146
149
|
| error and aborted outcomes never continue | `never continues on an error or aborted turn` |
|
|
147
|
-
|
|
|
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` |
|
|
148
152
|
| the first sighting of a list is progress, not a stall | `the first sighting of a list counts as progress, not a stall` |
|
|
149
|
-
| a list change
|
|
150
|
-
|
|
|
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` |
|
|
151
155
|
| a new human message resets the budget | `a new human message resets the budget` |
|
|
152
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` |
|
|
153
157
|
| the newest custom entry wins over older ones | `reads the newest rpiv-todo custom entry` |
|
|
@@ -165,8 +169,8 @@ introduces an edge.
|
|
|
165
169
|
| a malformed context payload is not rewritten | `context leaves a malformed payload alone instead of rewriting it` |
|
|
166
170
|
| either kill switch disables both hooks | `the global kill switch disables both hooks`, `the project-local kill switch disables both hooks` |
|
|
167
171
|
| only a settle spends a round: tool turns cost nothing | `a turn that ran tools spends no round and appends no marker` |
|
|
168
|
-
| a tool turn
|
|
169
|
-
| 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` |
|
|
170
174
|
| `todo_loop_block` stops the loop and reports the reason | `todo_loop_block stops the loop for this message and reports the reason` |
|
|
171
175
|
| a block without a reason is refused | `a block without a reason is refused and does not stop the loop` |
|
|
172
176
|
| the human's next message clears the block | `the human's next message clears the block` |
|
|
@@ -177,7 +181,11 @@ introduces an edge.
|
|
|
177
181
|
| the kill switch silences the block tool too | `the kill switch also silences todo_loop_block` |
|
|
178
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` |
|
|
179
183
|
| a paused loop refuses the block tool and stays silent | `a paused loop refuses todo_loop_block and stays silent` |
|
|
180
|
-
|
|
|
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` |
|
|
181
189
|
| the next human message clears the block and the stale `last:` | `the human's next message clears the block` |
|
|
182
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` |
|
|
183
191
|
| a malformed event payload does not throw | `turn_end tolerates a malformed event payload instead of throwing` |
|
|
@@ -203,7 +211,7 @@ introduces an edge.
|
|
|
203
211
|
3. Add cases to `test/loop.test.ts`: the new rule, its boundary, and the ordering against the reset
|
|
204
212
|
and cap rules. Order matters — the anchor reset runs before every other check.
|
|
205
213
|
4. Add a user-visible line to the `/todoloop` status if an operator needs to see it.
|
|
206
|
-
5. Update the README *
|
|
214
|
+
5. Update the README *What it looks like* section and `CHANGELOG.md` under `[Unreleased]`.
|
|
207
215
|
|
|
208
216
|
## Fragile areas
|
|
209
217
|
|
|
@@ -212,7 +220,7 @@ Ranked by how easily a small change breaks them.
|
|
|
212
220
|
1. **Two different tails.** A continuation whose *persisted projection* ends on an assistant message is
|
|
213
221
|
rejected by pi (`canContinue`); the *request* has no such constraint but must keep role alternation.
|
|
214
222
|
Conflating the two produced the round-2 marker leak. `test/index.test.ts` pins both shapes.
|
|
215
|
-
2. **Counter lifecycle.** `rounds`, `
|
|
223
|
+
2. **Counter lifecycle.** `rounds`, `staleSettles`, `settleRun` and `anchor` are mutated in one place and reset in three
|
|
216
224
|
(anchor change, `/todoloop reset`, process start). A missing reset either loops forever or refuses to
|
|
217
225
|
continue after one bad turn; a reset in the wrong place (inside `stop()`) silently re-arms the cap.
|
|
218
226
|
3. **The anchor.** It must be human-authored and never empty. `#<index>` is the fallback when an entry
|
|
@@ -290,7 +298,7 @@ projects, or tightening `canContinue`; the second is how pi renders a custom too
|
|
|
290
298
|
prompt, since the harness stub never builds one — a tool without a `promptSnippet` was silently dropped
|
|
291
299
|
from `<tools>` in pi 1.5.0. Drafts are committed before the continuation is validated, so that change
|
|
292
300
|
would persist a marker, drop the continuation, and leave `/todoloop` reporting `continuing: round n/12`
|
|
293
|
-
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
|
|
294
302
|
harness drives a stub `pi`.
|
|
295
303
|
|
|
296
304
|
Before every version bump, on the *new* pi, run the documented live check once:
|
|
@@ -318,7 +326,7 @@ the request).
|
|
|
318
326
|
the final actionable boundary that can append entries and request one continuation, and at
|
|
319
327
|
`turn_end` the hook has to reconstruct "the run would otherwise end" from `toolResults.length === 0`.
|
|
320
328
|
The consequence is a bounded wasted round when a human message is queued mid-turn, and the stall
|
|
321
|
-
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
|
|
322
330
|
ship with a CHANGELOG line because it changes which turns spend a round.
|
|
323
331
|
- **`getBranch()` is not the model-visible context** (register item 16): after compaction the panel can
|
|
324
332
|
show work the model has no list for.
|
|
@@ -369,7 +377,7 @@ Cutting a release:
|
|
|
369
377
|
```bash
|
|
370
378
|
npm version patch --no-git-tag-version # or minor/major; the tree must be clean
|
|
371
379
|
VERSION="$(node -p "require('./package.json').version")"
|
|
372
|
-
# move the CHANGELOG entry from [Unreleased] to "## [$VERSION]
|
|
380
|
+
# move the CHANGELOG entry from [Unreleased] to "## [$VERSION] - YYYY-MM-DD"
|
|
373
381
|
git add -A
|
|
374
382
|
git commit -m "chore: release v$VERSION"
|
|
375
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.1] - 2026-11-10
|
|
8
|
+
|
|
9
|
+
### Changed
|
|
10
|
+
|
|
11
|
+
- Verified against **pi 1.5.1**: `PI_API_SNAPSHOT` and the tested-version table move with it. Nothing in
|
|
12
|
+
the extension needed changing — 1.5.1's breaking changes touch `GrepOperations.readFile` callbacks and
|
|
13
|
+
the `tools: []` field for OpenAI-compatible endpoints, neither of which this package uses. A live
|
|
14
|
+
session on 1.5.1 created a list, wrote two files, closed both tasks and stopped with no nudges, and
|
|
15
|
+
the tool still renders in the prompt's `<tools>` and `<rules>` sections.
|
|
16
|
+
|
|
17
|
+
## [0.3.0] - 2026-11-06
|
|
18
|
+
|
|
19
|
+
### Added
|
|
20
|
+
|
|
21
|
+
- A warning to the human every tenth reply that leaves the todo list unchanged, while the loop keeps
|
|
22
|
+
going: it names the count, the open tasks, and the two brakes (`/todoloop pause`, `maxRounds`). It is
|
|
23
|
+
keyed on `staleSettles`, which tool turns do not clear, so it also catches the model this package
|
|
24
|
+
exists for: tools running, list frozen. With a configured cap it says how many continuations remain.
|
|
25
|
+
|
|
26
|
+
### Changed
|
|
27
|
+
|
|
28
|
+
- **Breaking:** `maxRounds` defaults to `null`, so there is no continuation cap. The loop now ends when
|
|
29
|
+
the list clears, the agent calls `todo_loop_block`, an error or abort occurs, or you pause it. Set a
|
|
30
|
+
number to bound what one message of yours can cost; the value is still clamped to ≤ 1000, and a value
|
|
31
|
+
below 1 stays unlimited rather than falling back to a default.
|
|
32
|
+
- The "list was cleared by todo alone" warning now counts *calls* rather than snapshot rows: it fires
|
|
33
|
+
when the turn that emptied the list deleted tasks, and names how many. Finishing work and closing
|
|
34
|
+
every task in one turn is the honest ending and no longer warns, and a tombstone from an earlier turn
|
|
35
|
+
is not news. A result with no `action` at all keeps the old, vaguer wording.
|
|
36
|
+
- The user-facing text is plain English now: `/todoloop` says `asked to continue: 3 (no limit)`,
|
|
37
|
+
`replies since the todo list last changed: 0`, `tasks open: 2 of 5`,
|
|
38
|
+
`counting from: your message … — a new message restarts the count`; reasons read
|
|
39
|
+
`continuing: 2 tasks still open, continue 3`, and a cap reads
|
|
40
|
+
`stopped: 12 continuations used, 3 tasks still open`. The words "settles", "stalled", "round cap"
|
|
41
|
+
and "hard bound" are gone from `/todoloop`, the stop reasons and the warnings.
|
|
42
|
+
- The `todo_loop_block` clause now rides only on the bare stall: the model stopped, got one nudge, and
|
|
43
|
+
stopped again without running a tool and without the list changing (`settleRun === 2`). A stop that
|
|
44
|
+
follows real work gets the plain nudge; the stop after it is asked, so a tool-heavy run with a frozen
|
|
45
|
+
list is still asked. One request per stall, never repeated. `hintSent` is gone with it.
|
|
46
|
+
|
|
47
|
+
### Upgrade notes
|
|
48
|
+
|
|
49
|
+
- A session that relied on the old default of 12 now runs unbounded. An explicit `"maxRounds": 12` in
|
|
50
|
+
the config file is honoured exactly as before, so that is the one-line way to keep the old ceiling.
|
|
51
|
+
- `"maxRounds": 0` used to mean "use 12"; it now means unlimited, like any value below 1.
|
|
52
|
+
|
|
7
53
|
## [0.2.1] - 2026-11-05
|
|
8
54
|
|
|
9
55
|
### Changed
|
|
@@ -143,4 +189,8 @@ Initial release.
|
|
|
143
189
|
|
|
144
190
|
[0.1.0]: https://github.com/DumbMice/pi-todo-loop/releases/tag/v0.1.0
|
|
145
191
|
[0.1.1]: https://github.com/DumbMice/pi-todo-loop/releases/tag/v0.1.1
|
|
146
|
-
[
|
|
192
|
+
[0.2.0]: https://github.com/DumbMice/pi-todo-loop/releases/tag/v0.2.0
|
|
193
|
+
[0.2.1]: https://github.com/DumbMice/pi-todo-loop/releases/tag/v0.2.1
|
|
194
|
+
[0.3.0]: https://github.com/DumbMice/pi-todo-loop/releases/tag/v0.3.0
|
|
195
|
+
[0.3.1]: https://github.com/DumbMice/pi-todo-loop/releases/tag/v0.3.1
|
|
196
|
+
[Unreleased]: https://github.com/DumbMice/pi-todo-loop/compare/v0.3.1...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,25 +61,31 @@ 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
|
-
|
|
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
|
|
82
89
|
agent's to maintain and the conversation stays yours.
|
|
83
90
|
|
|
84
91
|
## Configuration
|
|
@@ -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
|
|
|
@@ -156,16 +165,16 @@ hidden continuation entries.
|
|
|
156
165
|
|
|
157
166
|
## Requirements
|
|
158
167
|
|
|
159
|
-
- **pi 1.5.
|
|
168
|
+
- **pi 1.5.1** (tested; needs the `turn_end` boundary).
|
|
160
169
|
- **A todo tool** named `todo` that stores its list in the session — tested with
|
|
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-10; 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,16 +42,24 @@ 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
|
-
-
|
|
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.
|
|
55
63
|
- **`todo_loop_block`** is the agent's own exit. It is advertised in the prompt from the first turn —
|
|
56
64
|
pi lists it in `<tools>` and carries its guideline in `<rules>`, because the tool declares a
|
|
57
65
|
`promptSnippet` — and the nudge is the timed offer, not the only way to learn it exists. It takes a
|
|
@@ -62,29 +70,35 @@ outside — and one of them must not be punished.
|
|
|
62
70
|
The tool also refuses while the loop is paused or a kill switch is present, and says so, so a
|
|
63
71
|
disabled extension never notifies or writes state.
|
|
64
72
|
- **`staleSettles`** counts settles since the list last changed; a tool turn that leaves the list
|
|
65
|
-
unchanged does not clear it. It
|
|
66
|
-
|
|
67
|
-
(`,
|
|
68
|
-
- **
|
|
69
|
-
turn whose only tool results were `todo`,
|
|
70
|
-
|
|
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.
|
|
71
84
|
|
|
72
85
|
## Counters
|
|
73
86
|
|
|
74
87
|
| Counter | Meaning | Reset by |
|
|
75
88
|
|---|---|---|
|
|
76
89
|
| `rounds` | continuations spent for the current human message | a new human message, `/todoloop reset`, a new process |
|
|
77
|
-
| `
|
|
78
|
-
| `
|
|
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` |
|
|
79
92
|
|
|
80
93
|
- **One budget per human message.** A user-role message carrying a `customType` (this extension's
|
|
81
94
|
marker, another extension's hidden message) is skipped when choosing the anchor, so it does not reset
|
|
82
95
|
the budget; `/todoloop` prints the anchor it is using.
|
|
83
96
|
- **A stop keeps the budget shut.** Hitting the cap does not clear the counters, so
|
|
84
|
-
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.
|
|
85
98
|
A queued human message is a new anchor and resets the budget the ordinary way.
|
|
86
|
-
- **A stale list
|
|
87
|
-
|
|
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`.
|
|
88
102
|
- **Errors do not close the budget.** A turn that ends `error` or `aborted` stops the loop for that turn
|
|
89
103
|
and spends no round; the counters are left as they are, so the next settle under the same message can
|
|
90
104
|
continue — which is what a provider retry hits.
|
|
@@ -98,11 +112,11 @@ outside — and one of them must not be punished.
|
|
|
98
112
|
|
|
99
113
|
| `last:` | Meaning |
|
|
100
114
|
|---|---|
|
|
101
|
-
| `continuing:
|
|
115
|
+
| `continuing: 2 tasks still open, continue 1` | working as intended (`continue 1/12` when `maxRounds` is set) |
|
|
102
116
|
| `stopped: list is clear` | every remaining task is `completed`; a list whose tasks are all `deleted` reports `no list` instead |
|
|
103
117
|
| `stopped: no list` | no recognised todo state on the branch — see the five causes below |
|
|
104
118
|
| `waiting for you: waiting on Dana's review` | the agent called `todo_loop_block`; your next message resumes |
|
|
105
|
-
| `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 |
|
|
106
120
|
| `stopped: turn outcome was error` / `aborted` | the provider failed, or you aborted the turn |
|
|
107
121
|
| `paused by user` | `/todoloop pause`; `/todoloop resume` or restart |
|
|
108
122
|
| `resumed by user` | `/todoloop resume`, or the human's next message clearing a pending block; the next settle may continue |
|
|
@@ -151,23 +165,28 @@ task `deleted`.
|
|
|
151
165
|
## Configuration limits
|
|
152
166
|
|
|
153
167
|
Invalid config never fails a turn: missing, unreadable or invalid values fall back to the defaults.
|
|
154
|
-
|
|
155
|
-
|
|
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".
|
|
156
172
|
|
|
157
173
|
## Cost
|
|
158
174
|
|
|
159
175
|
- A continuation is a normal model turn: the whole conversation is sent again, so cost scales with the
|
|
160
|
-
number of continuations × session size
|
|
161
|
-
|
|
162
|
-
|
|
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.
|
|
163
182
|
- The extension adds one short marker per continuation (~40 tokens), plus one constant tool declaration
|
|
164
183
|
and the prompt entries pi derives from it — `<tools>` line, `<rules>` guideline, description and
|
|
165
184
|
schema, roughly 700 characters together, in every request. None of it carries task state, and there
|
|
166
185
|
is no per-request block. Neither the snippet nor the guideline is gated by the kill switch or
|
|
167
186
|
`/todoloop pause`; only the hooks and the call itself are. Whatever the model needs it reads with the `todo` tool, and that result
|
|
168
187
|
is part of its own conversation rather than a parallel copy.
|
|
169
|
-
- `context`
|
|
170
|
-
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
|
|
171
190
|
walks the branch on every turn.
|
|
172
191
|
|
|
173
192
|
## More
|
package/docs/pi-api-alignment.md
CHANGED
|
@@ -5,11 +5,11 @@ versions that was established against.
|
|
|
5
5
|
|
|
6
6
|
| Component | Tested version | Recorded in |
|
|
7
7
|
|---|---|---|
|
|
8
|
-
| `@earendil-works/pi-coding-agent` | **1.5.
|
|
8
|
+
| `@earendil-works/pi-coding-agent` | **1.5.1** | `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-10**. 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
|
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
|
*
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
*
|
|
15
15
|
* - A `turn_end` continuation must leave the final message role != assistant,
|
|
16
16
|
* so one entry is mandatory (`_buildBoundaryContext().canContinue` in pi
|
|
17
|
-
* 1.5.
|
|
17
|
+
* 1.5.1). We append a `custom_message` with `display: false`.
|
|
18
18
|
* - Markers are never added to a request by this extension, and are only removed
|
|
19
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
|
|
@@ -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
|
/**
|
|
@@ -37,7 +37,7 @@ import { readSnapshot } from "./state.ts";
|
|
|
37
37
|
* No pi type import on purpose: the extension is loaded by pi and the type
|
|
38
38
|
* package would otherwise need to be a dependency, for one signature.
|
|
39
39
|
*/
|
|
40
|
-
export const PI_API_SNAPSHOT = "1.5.
|
|
40
|
+
export const PI_API_SNAPSHOT = "1.5.1";
|
|
41
41
|
|
|
42
42
|
/** The part of `ExtensionContext` this extension reads. */
|
|
43
43
|
interface SessionContext {
|
|
@@ -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.";
|
|
@@ -249,17 +250,28 @@ export default function todoLoop(pi: any, options: TodoLoopOptions = {}): void {
|
|
|
249
250
|
// A tool turn is *work*, so it clears the stall counter and re-arms the block
|
|
250
251
|
// hint: an agent that keeps working is not stuck, and a later stall is a new
|
|
251
252
|
// question rather than a repeat of the old one.
|
|
252
|
-
// A list that
|
|
253
|
-
//
|
|
254
|
-
//
|
|
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.
|
|
255
258
|
if (snapshot.open.length === 0 && state.lastOpenCount > 0 && toolResultCount > 0) {
|
|
256
259
|
const names = toolResults.map((result) => String((result as Record<string, unknown>)?.toolName ?? ""));
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
"todo-loop: the list was cleared by todo calls alone in that turn — no other tool work accompanied it",
|
|
261
|
-
"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 ?? ""),
|
|
262
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");
|
|
263
275
|
}
|
|
264
276
|
}
|
|
265
277
|
state.lastOpenCount = snapshot.open.length;
|
|
@@ -270,12 +282,13 @@ export default function todoLoop(pi: any, options: TodoLoopOptions = {}): void {
|
|
|
270
282
|
}
|
|
271
283
|
|
|
272
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;
|
|
273
288
|
if (String(event?.outcome) !== "completed") {
|
|
274
289
|
state.reason = `stopped: turn outcome was ${String(event?.outcome)}`;
|
|
275
290
|
return undefined;
|
|
276
291
|
}
|
|
277
|
-
state.stalled = 0;
|
|
278
|
-
state.hintSent = false;
|
|
279
292
|
return undefined;
|
|
280
293
|
}
|
|
281
294
|
|
|
@@ -328,6 +341,24 @@ export default function todoLoop(pi: any, options: TodoLoopOptions = {}): void {
|
|
|
328
341
|
};
|
|
329
342
|
|
|
330
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
|
+
}
|
|
331
362
|
return proposal;
|
|
332
363
|
});
|
|
333
364
|
|
|
@@ -381,17 +412,18 @@ export default function todoLoop(pi: any, options: TodoLoopOptions = {}): void {
|
|
|
381
412
|
: waitingNow
|
|
382
413
|
? "waiting (the agent asked for you; your next message resumes)"
|
|
383
414
|
: state.reason.startsWith("stopped:")
|
|
384
|
-
? "stopped (next
|
|
415
|
+
? "stopped (your next message restarts the count)"
|
|
385
416
|
: "active";
|
|
386
417
|
ctx.ui?.notify?.(
|
|
387
418
|
[
|
|
388
419
|
`todo-loop ${mode}`,
|
|
389
|
-
`
|
|
420
|
+
`asked to continue: ${state.rounds}${config.maxRounds === null ? " (no limit)" : ` of ${config.maxRounds}`}`,
|
|
421
|
+
`replies since the todo list last changed: ${state.staleSettles}`,
|
|
390
422
|
// Only while it still belongs to this message: the human's next one
|
|
391
423
|
// resumes the loop even though the reason is still in memory.
|
|
392
424
|
...(waitingNow ? [`waiting: ${(state.waiting as { reason: string }).reason}`] : []),
|
|
393
|
-
`open ${snapshot.open.length}
|
|
394
|
-
`
|
|
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`,
|
|
395
427
|
`last: ${waitingNow ? `waiting for you: ${(state.waiting as { reason: string }).reason}` : state.reason}`,
|
|
396
428
|
`config ${configPath}`,
|
|
397
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
|
}
|