teapot-coding-agent 0.37.59 → 0.37.60
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/dist/acp/adapter.js +4 -72
- package/dist/agent/agent.js +52 -2246
- package/dist/agent/compact-rules.js +0 -53
- package/dist/agent/editorconfig.js +4 -56
- package/dist/agent/goal-audit.js +0 -34
- package/dist/agent/key-pool.js +0 -52
- package/dist/agent/kill.js +1 -13
- package/dist/agent/llm.js +8 -309
- package/dist/agent/mcp-oauth-manager.js +2 -255
- package/dist/agent/mcp-registry.js +1 -188
- package/dist/agent/mcp.js +1 -85
- package/dist/agent/meta-tools.js +0 -26
- package/dist/agent/plugins.js +1 -81
- package/dist/agent/provider-headers.js +1 -28
- package/dist/agent/skills.js +8 -94
- package/dist/agent/tools.js +16 -809
- package/dist/bus.js +1 -2
- package/dist/config-schema.js +0 -40
- package/dist/home.js +1 -19
- package/dist/index.js +0 -136
- package/dist/log/events.js +3 -195
- package/dist/master.js +31 -1176
- package/dist/migrations/index.js +0 -55
- package/dist/migrations/providers.js +0 -13
- package/dist/migrations/registry.js +0 -9
- package/dist/migrations/session-titles.js +3 -43
- package/dist/migrations/sessions.js +2 -20
- package/dist/model-meta.js +2 -74
- package/dist/scheduler/cron.js +2 -17
- package/dist/server/api.js +12 -778
- package/dist/server/image-resize.js +1 -64
- package/dist/server/term-size.js +1 -120
- package/dist/server/terminals.js +0 -159
- package/dist/session-title.js +2 -35
- package/package.json +1 -1
- package/public/assets/{index-KzHw4Sqo.js → index-DhJx7VDq.js} +4 -4
- package/public/assets/index-DhJx7VDq.js.map +1 -0
- package/public/assets/{shiki-BwDjoZdu.js → shiki-LBMkaWYb.js} +2 -2
- package/public/assets/shiki-LBMkaWYb.js.map +1 -0
- package/public/index.html +1 -1
- package/public/assets/index-KzHw4Sqo.js.map +0 -1
- package/public/assets/shiki-BwDjoZdu.js.map +0 -1
package/dist/agent/agent.js
CHANGED
|
@@ -1,11 +1,3 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Agent: an event-driven loop over one workspace.
|
|
3
|
-
*
|
|
4
|
-
* CPU discipline: the agent only consumes CPU while waiting on LLM/tool I/O
|
|
5
|
-
* promises; when idle it holds zero timers and zero polling loops. All
|
|
6
|
-
* periodic behaviour (progress reports, scheduled tasks) is driven by the
|
|
7
|
-
* master's single low-frequency scheduler tick or by turn boundaries.
|
|
8
|
-
*/
|
|
9
1
|
import { promises as fs, existsSync, readFileSync, readdirSync, statSync } from "node:fs";
|
|
10
2
|
import path from "node:path";
|
|
11
3
|
import { EventLog, readEvents } from "../log/events.js";
|
|
@@ -20,158 +12,31 @@ import { metaToolSpecs } from "./meta-tools.js";
|
|
|
20
12
|
import { skillRootsFingerprint, foreignSkillRoots } from "./skills.js";
|
|
21
13
|
import { extractRules, enforceRules, RULES_HEADING } from "./compact-rules.js";
|
|
22
14
|
import { bus } from "../bus.js";
|
|
23
|
-
|
|
24
|
-
* Harness nudges, named and exported so they can be asserted on directly.
|
|
25
|
-
*
|
|
26
|
-
* Both were fixed for a reason invisible in the prose:
|
|
27
|
-
*
|
|
28
|
-
* - AUTO_CONTINUE_NUDGE used to say only "continue working". The model
|
|
29
|
-
* was never told HOW TO STOP, so it worked forever: it replied in prose, the
|
|
30
|
-
* loop saw an active goal and no finish(), and nudged again. The sub-agent
|
|
31
|
-
* variant already named finish() — the ROOT one did not.
|
|
32
|
-
* - PROGRESS_REQUEST used to DESCRIBE the report instead of asking for
|
|
33
|
-
* the tool, so the model answered in prose, then — not knowing that had
|
|
34
|
-
* satisfied the request — sent a voluntary report_progress right after, and
|
|
35
|
-
* every requested report appeared TWICE on the timeline.
|
|
36
|
-
*/
|
|
37
|
-
export const AUTO_CONTINUE_NUDGE =
|
|
38
|
-
// the prefix marks this as harness text, not something the operator typed.
|
|
39
|
-
// Unprefixed, the model read it as a request to reply — and prose ends the round
|
|
40
|
-
// without finishing, which starts the next one.
|
|
41
|
-
"[harness] " +
|
|
15
|
+
export const AUTO_CONTINUE_NUDGE = "[harness] " +
|
|
42
16
|
"Continue working toward the current goal. If you are blocked, explain why briefly. " +
|
|
43
|
-
// name the tool explicitly, the way the sub-agent nudge already does
|
|
44
17
|
"When the goal is genuinely met — or truly blocked — call finish() " +
|
|
45
18
|
"(finish(goalComplete=true) when met, finish(goalComplete=false) when blocked) " +
|
|
46
19
|
"instead of replying in prose; prose here does not stop the loop.";
|
|
47
|
-
/**
|
|
48
|
-
* The auto-continue nudge with the CURRENT GOAL restated.
|
|
49
|
-
*
|
|
50
|
-
* the nudge said "the current goal" and never said what it was. The sub-agent
|
|
51
|
-
* variant has always included its task text for exactly this reason — a forked
|
|
52
|
-
* child otherwise drifts back into the inherited conversation and keeps going
|
|
53
|
-
* forever — but the ROOT nudge had no such reminder, so a root agent that
|
|
54
|
-
* had lost the thread had nothing to re-anchor on and simply continued until the
|
|
55
|
-
* `maxConsecutiveIdleRounds` valve tripped.
|
|
56
|
-
*
|
|
57
|
-
* The goal is clipped like the sub-agent's: a 20k-character goal would otherwise
|
|
58
|
-
* re-enter the context on EVERY round.
|
|
59
|
-
*/
|
|
60
20
|
export function autoContinueNudge(goalText) {
|
|
61
21
|
const goal = goalText.trim();
|
|
62
22
|
if (!goal)
|
|
63
23
|
return AUTO_CONTINUE_NUDGE;
|
|
64
24
|
return `${AUTO_CONTINUE_NUDGE}\nCurrent goal:\n=====\n${goal.slice(0, 1500)}\n=====`;
|
|
65
25
|
}
|
|
66
|
-
export const PROGRESS_REQUEST =
|
|
67
|
-
// The leading sentence is kept VERBATIM: it is what existing harness prompts,
|
|
68
|
-
// tests and any operator muscle-memory key off. #45 changed the INSTRUCTION
|
|
69
|
-
// (call the tool, don't answer in prose), not the identification of the request.
|
|
70
|
-
"[harness] Please give a brief progress report now: what you are doing, goal progress, " +
|
|
26
|
+
export const PROGRESS_REQUEST = "[harness] Please give a brief progress report now: what you are doing, goal progress, " +
|
|
71
27
|
"what you recently tried, any problems, and your next step. Keep it under 10 lines. " +
|
|
72
|
-
// ask for the TOOL, not prose — a prose answer left the model unsure whether it
|
|
73
|
-
// had satisfied the request, so it sent a voluntary report_progress right after
|
|
74
|
-
// and every requested report appeared TWICE on the timeline
|
|
75
28
|
"Record it with the report_progress TOOL (doing, goalStatus, recent, problems, next) — " +
|
|
76
29
|
"call that tool; do NOT answer in prose, which would just duplicate this request.";
|
|
77
|
-
/**
|
|
78
|
-
* Recent context kept VERBATIM through a compaction, in tokens.
|
|
79
|
-
*
|
|
80
|
-
* Pi defaults `keepRecentTokens` to 20000 and opencode to `keep: 15000` for the
|
|
81
|
-
* same reason: the newest exchanges are what the model needs to continue
|
|
82
|
-
* accurately, and a verbatim copy beats a summary of them.
|
|
83
|
-
*/
|
|
84
30
|
const KEEP_RECENT_TOKENS = 15_000;
|
|
85
|
-
/**
|
|
86
|
-
* When pruning old tool output becomes worthwhile, as a share of the budget.
|
|
87
|
-
*
|
|
88
|
-
* Just under 1: pruning is DESTRUCTIVE (a clipped tool result leaves the model's
|
|
89
|
-
* context entirely — the log keeps it, but the model cannot see it without re-issuing
|
|
90
|
-
* the call), so it must only spend that information when the next request would
|
|
91
|
-
* otherwise not fit. The old value was 0.6 of a budget that is itself 75% of the
|
|
92
|
-
* window, i.e. history was being clipped at 45% of the real window with a quarter of
|
|
93
|
-
* the window still free — which is what the operator noticed ("25%余ってる").
|
|
94
|
-
*
|
|
95
|
-
* A share rather than an absolute count, so it tracks whatever window the session is
|
|
96
|
-
* configured for.
|
|
97
|
-
*/
|
|
98
31
|
const PRUNE_AT_SHARE = 0.95;
|
|
99
|
-
/**
|
|
100
|
-
* Short-context mode: the same knobs, retuned for a window where the default
|
|
101
|
-
* tail is a large share of everything.
|
|
102
|
-
*
|
|
103
|
-
* 262k window (fine) but 47% of a 32k one — so on a small model the verbatim tail
|
|
104
|
-
* alone nearly fills the budget and the first prune does almost nothing useful.
|
|
105
|
-
* The mode therefore (a) prunes at 0.6 of the budget instead of 0.95, (b) keeps a
|
|
106
|
-
* QUARTER of the budget verbatim instead of a fixed 15k, and (c) clips tool results
|
|
107
|
-
* down to 200 chars instead of 400, because on a small window the head of an output
|
|
108
|
-
* is most of what fits.
|
|
109
|
-
*
|
|
110
|
-
* Opt-in by design: on a large window this throws away real information early.
|
|
111
|
-
*/
|
|
112
32
|
const SHORT_PRUNE_AT_SHARE = 0.6;
|
|
113
33
|
const SHORT_PRUNE_KEEP_CHARS = 200;
|
|
114
|
-
/**
|
|
115
|
-
* The summarize call is skipped when the summarizable prefix is a small share of
|
|
116
|
-
* the budget, because the call cannot reuse any prefix cache (its system prompt is
|
|
117
|
-
* its instructions) and so re-bills the whole history uncached. Below this share,
|
|
118
|
-
* dropping the prefix outright reclaims the budget for free and the model can
|
|
119
|
-
* recover the details by re-reading the goal or the files.
|
|
120
|
-
*
|
|
121
|
-
* A SHARE rather than an absolute token count: what counts as "a lot to lose"
|
|
122
|
-
* scales with the window a session is configured for. An absolute constant
|
|
123
|
-
* calibrated for the 96k default would disable summarization entirely for a
|
|
124
|
-
* session with a small budget (measured: a 200-token budget with a 654-token
|
|
125
|
-
* history is over every bar, yet its prefix is a few hundred tokens).
|
|
126
|
-
*
|
|
127
|
-
* Manual compaction and overflow recovery bypass this — those are not cost
|
|
128
|
-
* decisions.
|
|
129
|
-
*/
|
|
130
34
|
const SUMMARIZE_MIN_SHARE = 0.02;
|
|
131
|
-
/** the absolute floor for the bar above (see the note where it is applied) */
|
|
132
35
|
const SUMMARIZE_MIN_TOKENS = 2_000;
|
|
133
|
-
/**
|
|
134
|
-
* Bounded retries when the summarizer's own request is over the window, and how much
|
|
135
|
-
* of the shadowed region to shed each time. ZCode handles the same case with a bounded
|
|
136
|
-
* `prompt-too-long` retry ("compact 请求自身超窗时丢旧 API round 重试"): unbounded would
|
|
137
|
-
* burn tokens against a provider that refuses every size.
|
|
138
|
-
*/
|
|
139
36
|
const SUMMARIZE_OVERFLOW_RETRIES = 3;
|
|
140
37
|
const SUMMARIZE_SHRINK = 0.6;
|
|
141
|
-
/**
|
|
142
|
-
* Consecutive AUTO compaction failures before auto-compaction stops trying (ZCode:
|
|
143
|
-
* `max auto failures: 3`). The failure is usually environmental — an unavailable
|
|
144
|
-
* model, a provider outage — so retrying it every turn re-burns the provider for a
|
|
145
|
-
* pass that cannot succeed. A success resets the count, so one bad stretch does not
|
|
146
|
-
* disable compaction for the session.
|
|
147
|
-
*/
|
|
148
38
|
const MAX_AUTO_COMPACT_FAILURES = 3;
|
|
149
|
-
/**
|
|
150
|
-
* How much of the budget to KEEP on each successive overflow recovery.
|
|
151
|
-
*
|
|
152
|
-
* An overflow means the estimate was too low, so a single fixed cut can still
|
|
153
|
-
* leave the request too large — and the old code turned that second failure into a
|
|
154
|
-
* hard error. opencode uses the same shape (`SHRINK_STEPS [0.7, 0.5, 0.35]`): each
|
|
155
|
-
* retry keeps less, so the request shrinks even when the first attempt did not
|
|
156
|
-
* shrink it enough. The last entry repeats for any further attempt.
|
|
157
|
-
*/
|
|
158
39
|
const OVERFLOW_SHRINK_STEPS = [0.5, 0.35, 0.2];
|
|
159
|
-
/**
|
|
160
|
-
* The compaction directive, delivered as the FINAL user message after the replayed
|
|
161
|
-
* conversation rather than as a distinct summarizer `system` prompt.
|
|
162
|
-
*
|
|
163
|
-
* This position is the whole point: the request is then a genuine prefix-EXTENSION
|
|
164
|
-
* of the last routed request, so the provider's KV cache is reused instead of being
|
|
165
|
-
* discarded at the first token. Measured before the change — a bespoke summarizer
|
|
166
|
-
* system prompt shares nothing with the conversation, and the entire history is
|
|
167
|
-
* re-prefilled at full price exactly when the conversation is largest. DeepSeek
|
|
168
|
-
* Harness reaches the same conclusion in its `compaction-summary-prefix-cache-reuse`
|
|
169
|
-
* note.
|
|
170
|
-
*
|
|
171
|
-
* It opens with the sentence tests use to RECOGNISE a summarizer call; keeping that
|
|
172
|
-
* stable matters (an earlier version reworded it and silently made every update pass
|
|
173
|
-
* look like an ordinary turn).
|
|
174
|
-
*/
|
|
175
40
|
const COMPACT_INSTRUCTION = [
|
|
176
41
|
"You compress a coding agent's conversation into dense notes for it to continue working.",
|
|
177
42
|
"Condense the conversation ABOVE into a structured checkpoint so another model can",
|
|
@@ -185,23 +50,11 @@ const COMPACT_INSTRUCTION = [
|
|
|
185
50
|
"## Files and code sections",
|
|
186
51
|
"## Errors and fixes",
|
|
187
52
|
"## Problem solving",
|
|
188
|
-
"## All user messages",
|
|
53
|
+
"## All user messages",
|
|
189
54
|
"## Pending tasks",
|
|
190
55
|
"## Current work",
|
|
191
56
|
"## Next step",
|
|
192
57
|
"## Durable lessons",
|
|
193
|
-
/**
|
|
194
|
-
* Hard constraints, as their own section with a fixed shape.
|
|
195
|
-
*
|
|
196
|
-
* "must/must-not" rules are the one thing a summary cannot recover from losing: everything else
|
|
197
|
-
* can be re-derived by reading the code again, but a constraint the operator stated once and
|
|
198
|
-
* never repeated is simply gone. Reported as "compact時にルール(must/must notが中心だけど、should
|
|
199
|
-
* レベルのものも)が確実に引き継げる機構があると良いねって思った".
|
|
200
|
-
*
|
|
201
|
-
* So they get a dedicated heading with a MACHINE-CHECKABLE shape (one `MUST`/`MUST NOT`/`SHOULD`
|
|
202
|
-
* keyword per line, see `RULES_SECTION` + `extractRules`): the summarizer cannot merge them into
|
|
203
|
-
* prose, and `enforceRules` re-inserts any that went missing instead of trusting it did not.
|
|
204
|
-
*/
|
|
205
58
|
"## Hard constraints",
|
|
206
59
|
"",
|
|
207
60
|
"Rules: be terse bullet points, no prose flourishes. Quote file paths and key numbers exactly.",
|
|
@@ -211,7 +64,6 @@ const COMPACT_INSTRUCTION = [
|
|
|
211
64
|
"Output only the checkpoint text: do not call any tool, and do not mention this request or that the context was compacted.",
|
|
212
65
|
"The <analysis> block is discarded before your notes are stored, so think freely there.",
|
|
213
66
|
].join("\n");
|
|
214
|
-
/** Appended to COMPACT_INSTRUCTION when notes from an earlier pass are present. */
|
|
215
67
|
const UPDATE_INSTRUCTION = [
|
|
216
68
|
"",
|
|
217
69
|
"The notes you produced last time are above, framed as a checkpoint. This is an",
|
|
@@ -220,21 +72,9 @@ const UPDATE_INSTRUCTION = [
|
|
|
220
72
|
"request just because the new messages do not repeat it: an unanswered request",
|
|
221
73
|
"stays, an answered one goes. Return the FULL updated notes under the same headings.",
|
|
222
74
|
].join("\n");
|
|
223
|
-
/** framing around the prior notes when an update pass carries them forward */
|
|
224
75
|
const SUMMARY_OPEN = "<conversation_checkpoint>\n";
|
|
225
76
|
const SUMMARY_CLOSE = "\n</conversation_checkpoint>";
|
|
226
|
-
/**
|
|
227
|
-
* What the completion auditor may use. Read-only by construction — it inspects artifacts to check a
|
|
228
|
-
* claim, and must not be able to change what it is judging.
|
|
229
|
-
*/
|
|
230
77
|
const AUDIT_READ_ONLY_TOOLS = new Set(["read_file", "list_dir", "grep", "bash"]);
|
|
231
|
-
/**
|
|
232
|
-
* SYSTEM_TEMPLATE must stay byte-identical across every request of a session:
|
|
233
|
-
* provider prefix caches key on it, so changing it (or injecting per-turn
|
|
234
|
-
* state) re-prices the whole context. That is why session state lives behind
|
|
235
|
-
* meta tools instead. The cache rationale itself stays HERE in a comment —
|
|
236
|
-
* the model does not need our cost-engineering notes every turn.
|
|
237
|
-
*/
|
|
238
78
|
const SYSTEM_TEMPLATE = `You are a coding agent working autonomously inside a workspace.
|
|
239
79
|
|
|
240
80
|
Most session state is not injected into prompts — fetch it with tools instead:
|
|
@@ -323,34 +163,7 @@ Most session state is not injected into prompts — fetch it with tools instead:
|
|
|
323
163
|
read_memory(query), open at most one or two of the notes that match, and stop.
|
|
324
164
|
If nothing is relevant, carry on — do not keep searching, and do not read the
|
|
325
165
|
whole file "just in case". An unread note is an unwritten note.`;
|
|
326
|
-
|
|
327
|
-
* A message whose ENTIRE text is parenthesised is an acknowledgement, not work.
|
|
328
|
-
*
|
|
329
|
-
* the first version of this listed three literals — `(tool call)`,
|
|
330
|
-
* `(no content)`, `(no output)` — and caught 2 of the 10 forms actually reaching
|
|
331
|
-
* a parent. Measured across every session log, the rest are harness
|
|
332
|
-
* acknowledgements written by `answerMeta` and the meta-tool handlers:
|
|
333
|
-
*
|
|
334
|
-
* (goal complete) (round ended) (tool try) (no sub-agents)
|
|
335
|
-
* (tool_call) (nothing running to stop) (no result recorded)
|
|
336
|
-
* (todo.md is empty …) (decisions.md is empty …)
|
|
337
|
-
*
|
|
338
|
-
* So it matches the SHAPE — a whole line wrapped in parentheses — rather than an
|
|
339
|
-
* enumeration that is stale the moment another ack is added.
|
|
340
|
-
*
|
|
341
|
-
* The trade: a genuine message that happens to be entirely parenthesised would
|
|
342
|
-
* also be dropped. That is vanishingly rare in this feed, and the alternative —
|
|
343
|
-
* an enumeration — was measured to be wrong 80% of the time. If it ever matters,
|
|
344
|
-
* the ack should be marked structurally rather than by its punctuation.
|
|
345
|
-
*/ export const PLACEHOLDER_TEXT = /^\(.*\)$/;
|
|
346
|
-
/**
|
|
347
|
-
* Harness acknowledgements — meta-lines about the run itself rather than the
|
|
348
|
-
* work ("decision recorded to decisions.md", "progress recorded", …).
|
|
349
|
-
*
|
|
350
|
-
* these are addressed to the OPERATOR, not to whoever reads the next
|
|
351
|
-
* agent's report, and they consumed slots in a six-deep window that the parent
|
|
352
|
-
* reads as its only account of the work.
|
|
353
|
-
*/
|
|
166
|
+
export const PLACEHOLDER_TEXT = /^\(.*\)$/;
|
|
354
167
|
export const HARNESS_ACK = /^(decision recorded|progress recorded|goal saved|feedback saved|memory saved|todo saved|no skills yet|skills created|read_memory|memory|\(tool call\)|\(no content\)|\(no output\)|true|false|null|none)\b/i;
|
|
355
168
|
export class Agent {
|
|
356
169
|
log;
|
|
@@ -362,9 +175,7 @@ export class Agent {
|
|
|
362
175
|
currentBranch = "br0";
|
|
363
176
|
goal = { text: "", status: "active", updatedAt: new Date().toISOString() };
|
|
364
177
|
latestProgress = null;
|
|
365
|
-
/** operator-maintained task list (todo.md) — humans edit, agent reads */
|
|
366
178
|
todo = "";
|
|
367
|
-
/** set once the conversation has been restored (lazy: on first interaction) */
|
|
368
179
|
readyPromise = null;
|
|
369
180
|
stats = {
|
|
370
181
|
turns: 0,
|
|
@@ -374,172 +185,50 @@ export class Agent {
|
|
|
374
185
|
outputTokens: 0,
|
|
375
186
|
compactions: 0,
|
|
376
187
|
startedAt: null,
|
|
377
|
-
/** running cost estimate in USD (pricing known only) */
|
|
378
188
|
costUsd: 0,
|
|
379
189
|
};
|
|
380
|
-
/**
|
|
381
|
-
* Everything has a default EXCEPT `supportedParameters`, which must stay optional:
|
|
382
|
-
* `undefined` means "the endpoint published no capability list", and that is a
|
|
383
|
-
* different answer from an empty list ("published, and excludes it") — #150 turns
|
|
384
|
-
* on that distinction.
|
|
385
|
-
*/
|
|
386
190
|
opts;
|
|
387
191
|
messages = [];
|
|
388
|
-
/**
|
|
389
|
-
* The conversation this agent holds, for INSPECTION.
|
|
390
|
-
*
|
|
391
|
-
* Tests need to assert what the agent will actually send (compaction, tool pairing,
|
|
392
|
-
* restoration), and reaching into a private field meant every assertion had to launder
|
|
393
|
-
* the type through `as unknown as { messages: … }` — thirty of them, each of which would
|
|
394
|
-
* keep compiling after the shape changed. A read-only view makes the intent explicit and
|
|
395
|
-
* lets the type checker do its job. There is no setter: mutation belongs to the class.
|
|
396
|
-
*/
|
|
397
192
|
get conversation() {
|
|
398
193
|
return this.messages;
|
|
399
194
|
}
|
|
400
|
-
/**
|
|
401
|
-
* The live array, for SEEDING a scenario in a test.
|
|
402
|
-
*
|
|
403
|
-
* Separate from `conversation` on purpose: reading and appending are different
|
|
404
|
-
* intents, and a test that plants 4000-character turns to reach a compaction threshold
|
|
405
|
-
* should look like what it is. Production code never calls this — history is appended
|
|
406
|
-
* by the turn loop.
|
|
407
|
-
*/
|
|
408
195
|
get conversationForTest() {
|
|
409
196
|
return this.messages;
|
|
410
197
|
}
|
|
411
|
-
/**
|
|
412
|
-
* The endpoint this agent resolved, for INSPECTION.
|
|
413
|
-
*
|
|
414
|
-
* Tests assert that a per-agent override, a provider preset and the default all land on
|
|
415
|
-
* the same baseUrl/key/model — reaching into `opts` to do it meant the assertions had to
|
|
416
|
-
* defeat `private` and would have kept compiling after the shape changed.
|
|
417
|
-
*/
|
|
418
198
|
get llmConfig() {
|
|
419
199
|
return this.opts.llm;
|
|
420
200
|
}
|
|
421
|
-
/** the provider this agent resolved, for inspection (which preset won, and why) */
|
|
422
201
|
get providerName() {
|
|
423
202
|
return this.opts.provider;
|
|
424
203
|
}
|
|
425
|
-
/**
|
|
426
|
-
* User prompts waiting for the next turn boundary. Deliberately NOT queued
|
|
427
|
-
* on runChain: that chain holds the long-running loop, so queueing behind
|
|
428
|
-
* it would delay both the log entry (UI) and delivery until the round —
|
|
429
|
-
* sometimes the whole goal — finished.
|
|
430
|
-
*/
|
|
431
204
|
pendingPrompts = [];
|
|
432
205
|
stopRequested = false;
|
|
433
|
-
/**
|
|
434
|
-
* "Finish the round you are in, then stop" — a LIVE UPDATE stop.
|
|
435
|
-
*
|
|
436
|
-
* `stop()` aborts the in-flight LLM call and the running tool, which is right for an operator
|
|
437
|
-
* pressing ■ (they want it to stop NOW). It is wrong for a live update: aborting mid-tool leaves
|
|
438
|
-
* a half-written file and a call that never got its result, and the session then resumes from a
|
|
439
|
-
* state that never existed.
|
|
440
|
-
*
|
|
441
|
-
* Asked for as "動作中のエージェントループを適切なタイミング(ツール実行完了や生成終了などで1ターンが
|
|
442
|
-
* 終わる)で停止する". The loop already checks `stopRequested` at the TOP of each round, so this flag
|
|
443
|
-
* only has to make the loop notice at that boundary instead of being yanked out of it.
|
|
444
|
-
*/
|
|
445
206
|
windDownRequested = false;
|
|
446
207
|
wake = null;
|
|
447
|
-
/** set while a long-parking tool (wait_children) holds the run chain */
|
|
448
208
|
parkedByTool = false;
|
|
449
|
-
/** status before parking, restored on unpark */
|
|
450
209
|
preParkedStatus = null;
|
|
451
210
|
abort = null;
|
|
452
|
-
/** aborted only by dispose(): kills in-flight subprocess groups instantly */
|
|
453
211
|
toolAbort = new AbortController();
|
|
454
212
|
runChain = Promise.resolve();
|
|
455
|
-
/** whether contextTokenBudget came from config (manual) or was derived */
|
|
456
213
|
manualCompactBudget = false;
|
|
457
|
-
/** workspace dir absent at boot — session runs "as ghost" until created */
|
|
458
214
|
workspaceMissing = false;
|
|
459
215
|
lastProgressAt = Date.now();
|
|
460
|
-
/** the provider's own prompt_tokens from the last completed turn */
|
|
461
216
|
lastUsage;
|
|
462
|
-
/** USD per token for the CURRENT model (from provider /models metadata);
|
|
463
|
-
undefined = pricing unknown → cost estimate stays 0 and hidden */
|
|
464
217
|
modelPricing;
|
|
465
|
-
/** messages.length right after the last successful compaction */
|
|
466
218
|
compactedAtLen = 0;
|
|
467
|
-
/**
|
|
468
|
-
* A sub-agent's latest bare text turn, held back until the round ends.
|
|
469
|
-
* Models usually end a sub-agent task with a plain message instead of calling
|
|
470
|
-
* finish(); only finish() forwards a report to the parent, so without this the
|
|
471
|
-
* parent got nothing. Held (not sent immediately) so a mid-task
|
|
472
|
-
* narration never pre-empts a real finish() summary.
|
|
473
|
-
*/
|
|
474
219
|
pendingSubReport = "";
|
|
475
|
-
/** live compaction phase for UI progress ("summarizing"/"harvesting") */
|
|
476
220
|
compactPhase = "";
|
|
477
|
-
/** set by ask_user: the loop is parked until the operator replies */
|
|
478
221
|
awaitingUser = false;
|
|
479
|
-
/** real assistant output since the last progress report (chars / turns) */
|
|
480
222
|
activityChars = 0;
|
|
481
223
|
turnsSinceProgress = 0;
|
|
482
224
|
consecutiveToolErrors = 0;
|
|
483
|
-
/** consecutive round-fatal API errors (drives the retry backoff) */
|
|
484
225
|
consecutiveApiErrors = 0;
|
|
485
|
-
/**
|
|
486
|
-
* True while the round-level retry backoff is sleeping.
|
|
487
|
-
*
|
|
488
|
-
* Separate from `status`, which must keep reading "error" so the operator sees WHAT
|
|
489
|
-
* failed while the retry is pending — but an agent waiting to retry is stoppable
|
|
490
|
-
* work in flight, so `isLive()` has to say so or the UI offers "start" and no stop.
|
|
491
|
-
*/
|
|
492
226
|
retrySleeping = false;
|
|
493
|
-
/**
|
|
494
|
-
* Consecutive auto-continued rounds that did no work.
|
|
495
|
-
*
|
|
496
|
-
* Reset by any tool call, any finish, a stop, or fresh operator input — only a
|
|
497
|
-
* round that produced neither a tool call nor a finish increments it.
|
|
498
|
-
*/
|
|
499
227
|
consecutiveIdleRounds = 0;
|
|
500
|
-
/**
|
|
501
|
-
* Consecutive completion audits that REJECTED, #128.
|
|
502
|
-
*
|
|
503
|
-
* again, is rejected again… ran 381 audit calls in one turn. Nothing bounded it:
|
|
504
|
-
*
|
|
505
|
-
* - `maxConsecutiveIdleRounds` counts IDLE rounds, and a rejected audit is not
|
|
506
|
-
* idle — the agent is actively working
|
|
507
|
-
* - `maxTurnsPerRound` only LOGS `round-turn-cap`; it does not break, and it is
|
|
508
|
-
* evaluated per round, while a reject never ends the round
|
|
509
|
-
*
|
|
510
|
-
* So the agent can spend unbounded provider spend re-finishing. When the cap is
|
|
511
|
-
* hit the goal is marked as needing attention rather than left looping: silently
|
|
512
|
-
* marking it done would be #128's fail-open problem all over again, and silently
|
|
513
|
-
* stopping would strand the operator with no signal.
|
|
514
|
-
*/
|
|
515
228
|
consecutiveAuditRejects = 0;
|
|
516
|
-
/**
|
|
517
|
-
* consecutive completion audits that produced NO verdict (empty /
|
|
518
|
-
* prose / provider throw). Fail-CLOSED — the goal stays active and the
|
|
519
|
-
* worker self-verifies again — but bounded: after 3 the retrying stops with
|
|
520
|
-
* a loud note (the same philosophy as the reject bound above), so a
|
|
521
|
-
* permanently broken auditor cannot trap the worker either. Reset by any
|
|
522
|
-
* real verdict and by setGoal.
|
|
523
|
-
*/
|
|
524
229
|
auditUnavailableStreak = 0;
|
|
525
|
-
/**
|
|
526
|
-
* #91/#94: audit lineage for the FINAL event. `auditAttempts` counts every
|
|
527
|
-
* audit run (incremented at audit-started); `lastRejectedAudit` remembers
|
|
528
|
-
* the most recent CHANGES-REQUIRED verdict so the approving final can carry
|
|
529
|
-
* what the first attempt was told; `lastAuditOutcome` is the metadata the
|
|
530
|
-
* NEXT final event spreads in (then cleared — a later, unaudited finish
|
|
531
|
-
* must not inherit it).
|
|
532
|
-
*/
|
|
533
230
|
auditAttempts = 0;
|
|
534
231
|
lastRejectedAudit = null;
|
|
535
|
-
/**
|
|
536
|
-
* What the agent had done BEFORE an audit rejected its first finish.
|
|
537
|
-
*
|
|
538
|
-
* Captured at rejection time because that is the last moment the pre-rejection work is still
|
|
539
|
-
* the bulk of the history. The eventual approved final would otherwise report only the retry,
|
|
540
|
-
* which is a narrow fix-up — the parent would be judging hours of work from its last few
|
|
541
|
-
* minutes.
|
|
542
|
-
*/
|
|
543
232
|
preRejectionTurns = [];
|
|
544
233
|
lastAuditOutcome = null;
|
|
545
234
|
constructor(opts) {
|
|
@@ -550,20 +239,11 @@ export class Agent {
|
|
|
550
239
|
autoContinue: true,
|
|
551
240
|
continueDelayMs: 15_000,
|
|
552
241
|
autoCompact: true,
|
|
553
|
-
// no effort and no catalogue entry means "provider default" — the
|
|
554
|
-
// effort field is then never sent, which is the safe default
|
|
555
242
|
reasoningEffort: "",
|
|
556
|
-
// undefined, NOT []: "the endpoint published no capability list" and "the
|
|
557
|
-
// endpoint published a list that excludes this parameter" are different
|
|
558
|
-
// answers, and only the second is a definite no. An empty array here
|
|
559
|
-
// would make every silent endpoint look like an explicit refusal — measured:
|
|
560
|
-
// commandcode and google publish no list at all, so `[]` hid the very control
|
|
561
|
-
// #150 was about.
|
|
562
243
|
supportedParameters: undefined,
|
|
563
244
|
maxConsecutiveToolErrors: 5,
|
|
564
245
|
maxTurnsPerRound: 200,
|
|
565
|
-
maxAuditRejects: 3,
|
|
566
|
-
// generous: only a model that never acts and never finishes reaches it
|
|
246
|
+
maxAuditRejects: 3,
|
|
567
247
|
maxConsecutiveIdleRounds: 50,
|
|
568
248
|
onError: "stop",
|
|
569
249
|
retryDelayMs: 60_000,
|
|
@@ -581,12 +261,7 @@ export class Agent {
|
|
|
581
261
|
provider: "",
|
|
582
262
|
...opts,
|
|
583
263
|
};
|
|
584
|
-
// remember WHERE the budget came from: config = manual, anything else is
|
|
585
|
-
// derived from a known context window (or stays at the default)
|
|
586
264
|
this.manualCompactBudget = opts.contextTokenBudget !== undefined;
|
|
587
|
-
// a declared window implies its own budget: compact at ~75% of the
|
|
588
|
-
// model's real context unless the config set an explicit one (the 96k
|
|
589
|
-
// default only makes sense for unknown-window models)
|
|
590
265
|
if (this.opts.contextWindowTokens && !opts.contextTokenBudget) {
|
|
591
266
|
this.opts.contextTokenBudget = Math.round(this.opts.contextWindowTokens * 0.75);
|
|
592
267
|
}
|
|
@@ -595,22 +270,12 @@ export class Agent {
|
|
|
595
270
|
{ dir: path.join(opts.workspace, "skills"), source: "workspace" },
|
|
596
271
|
...(opts.globalSkillsDir ? [{ dir: opts.globalSkillsDir, source: "global" }] : []),
|
|
597
272
|
...(opts.pluginSkillRoots ?? []),
|
|
598
|
-
// OTHER HARNESSES' skill directories. The SKILL.md format is shared,
|
|
599
|
-
// so these are readable as-is — verified against real third-party skills
|
|
600
|
-
// (terminalskills/skills, trailofbits/skills, …). They sit below the
|
|
601
|
-
// workspace and teapot's own global dir so a project always wins, and
|
|
602
|
-
// missing directories are skipped silently. Paths are the documented
|
|
603
|
-
// native locations plus the cross-tool ones several tools also read.
|
|
604
273
|
...foreignSkillRoots(opts.workspace),
|
|
605
|
-
// shipped-with-package skills: lowest priority, always discoverable
|
|
606
274
|
...(opts.bundledSkillsDir ? [{ dir: opts.bundledSkillsDir, source: "bundled" }] : []),
|
|
607
275
|
];
|
|
608
276
|
this.toolCtx = {
|
|
609
277
|
cwd: opts.workspace,
|
|
610
|
-
// so a background job records WHICH agent started it: sibling agents share a workspace,
|
|
611
|
-
// and `isLive()` must not count their jobs as this one's work in flight
|
|
612
278
|
agentId: opts.id,
|
|
613
|
-
// the job RECORD lives beside the session, not in a global dir: it is this agent's history
|
|
614
279
|
stateDir: opts.sessionDir,
|
|
615
280
|
defaultTimeoutMs: 120_000,
|
|
616
281
|
maxOutputBytes: 60_000,
|
|
@@ -619,85 +284,31 @@ export class Agent {
|
|
|
619
284
|
readOnly: this.opts.readOnlyTools,
|
|
620
285
|
onIdlePark: (reason) => this.parkForTool(reason),
|
|
621
286
|
onIdleUnpark: () => this.unparkFromTool(),
|
|
622
|
-
// the park must not strand an already-queued prompt
|
|
623
287
|
hasPendingPrompts: () => this.pendingPrompts.length > 0,
|
|
624
288
|
onBackgroundExit: (info) => void this.onBackgroundJobExit(info),
|
|
625
289
|
onFileRead: (p) => this.trackFileRead(p),
|
|
626
290
|
};
|
|
627
|
-
// the session id IS the directory name — one directory per incarnation
|
|
628
291
|
this.mainSession = path.basename(opts.sessionDir);
|
|
629
292
|
this.currentSession = this.mainSession;
|
|
630
293
|
}
|
|
631
294
|
skillRoots;
|
|
632
295
|
skillsCache = [];
|
|
633
|
-
/** fingerprint of the roots that produced skillsCache ("" = not scanned yet) */
|
|
634
296
|
skillsStamp = "";
|
|
635
|
-
/**
|
|
636
|
-
* The notes the LAST compaction produced, if any.
|
|
637
|
-
*
|
|
638
|
-
* Compaction UPDATES these rather than re-deriving notes from scratch: a fresh
|
|
639
|
-
* summary each pass loses whatever the previous one distilled (every
|
|
640
|
-
* implementation surveyed — opencode, Kilo, Pi — updates for this reason).
|
|
641
|
-
* Recovered from the log so the continuity survives a restart.
|
|
642
|
-
*/
|
|
643
297
|
lastCompactSummary;
|
|
644
|
-
/**
|
|
645
|
-
* The accumulated hard rules, kept INDEPENDENTLY of the summary.
|
|
646
|
-
*
|
|
647
|
-
* Codex's `retained_instructions.rs` states the property that matters: retained instructions are
|
|
648
|
-
* selected "independently of compaction", with the host owning storage and lifecycle, so
|
|
649
|
-
* "restrictions are never truncated into partial permissions".
|
|
650
|
-
*
|
|
651
|
-
* The first version of this re-derived the rules from `lastCompactSummary` on each pass, which
|
|
652
|
-
* fails in exactly the case the mechanism exists for: a pass that drops every rule leaves
|
|
653
|
-
* nothing to re-insert. Keeping the set here (and re-seeding it from the log on restart) makes
|
|
654
|
-
* it survive any single pass, and lets a pass add to it without ever removing from it.
|
|
655
|
-
*/
|
|
656
298
|
compactRules = [];
|
|
657
|
-
/**
|
|
658
|
-
* A short human title for this session, derived from the operator's first
|
|
659
|
-
* prompt. Undefined until the log is read (or when there is no usable prompt).
|
|
660
|
-
*
|
|
661
|
-
* A MANUAL rename sets it too, and that must survive: the derivation above only fills it when it
|
|
662
|
-
* is still undefined, so an operator's name is never overwritten by the next restore. Without this
|
|
663
|
-
* path the header kept showing the old label after a rename — the value lives in `config.json`
|
|
664
|
-
* (`AgentConfig.sessionTitle`), and nothing wrote it into the live agent.
|
|
665
|
-
*/
|
|
666
299
|
setSessionTitle(title) {
|
|
667
300
|
this.sessionTitle = title?.trim() || undefined;
|
|
668
301
|
}
|
|
669
302
|
sessionTitle;
|
|
670
|
-
/** overflow recoveries used in the current turn; reset when a turn succeeds */
|
|
671
303
|
overflowRecoveries = 0;
|
|
672
|
-
/**
|
|
673
|
-
* Consecutive auto-compaction failures; at MAX_AUTO_COMPACT_FAILURES auto passes are
|
|
674
|
-
* skipped until one succeeds (ZCode's circuit breaker). Manual and overflow recovery
|
|
675
|
-
* are exempt — those are not "keep trying", they are "the operator/provider asked".
|
|
676
|
-
*/
|
|
677
304
|
consecutiveCompactFailures = 0;
|
|
678
|
-
/**
|
|
679
|
-
* A bounded DIGEST of memory.md: every heading, plus its first line.
|
|
680
|
-
*
|
|
681
|
-
* Memory notes are worth this much because they are what a later session reads instead of
|
|
682
|
-
* re-learning the workspace: they are genuinely long — 2.6KB is the median, and the largest hold
|
|
683
|
-
* full project briefs. A model that never sees them cannot decide to consult them: that is the
|
|
684
|
-
* whole finding of #48 (182 writes, 13 reads after a write). A model that sees the WHOLE file
|
|
685
|
-
* pays for it on every request and breaks the prefix cache.
|
|
686
|
-
*
|
|
687
|
-
* Codex solves this with a `MEMORY_SUMMARY` in the prompt plus a searchable registry on disk.
|
|
688
|
-
* This is the same shape: the digest says what memory HOLDS, `read_memory(query)` fetches the
|
|
689
|
-
* part that matters.
|
|
690
|
-
*
|
|
691
|
-
* The budget is deliberately small. This is an index, and an index that costs as much as the
|
|
692
|
-
* content has no reason to be an index.
|
|
693
|
-
*/
|
|
694
305
|
memoryDigestText() {
|
|
695
306
|
let text;
|
|
696
307
|
try {
|
|
697
308
|
text = readFileSync(this.memoryFile, "utf8");
|
|
698
309
|
}
|
|
699
310
|
catch {
|
|
700
|
-
return "";
|
|
311
|
+
return "";
|
|
701
312
|
}
|
|
702
313
|
if (!text.trim())
|
|
703
314
|
return "";
|
|
@@ -709,18 +320,11 @@ export class Agent {
|
|
|
709
320
|
const line = lines[i].trim();
|
|
710
321
|
if (!line)
|
|
711
322
|
continue;
|
|
712
|
-
// provenance markers are bookkeeping, not content
|
|
713
|
-
// bookkeeping, not content: an old-style provenance comment, or a `_from:_` line — the
|
|
714
|
-
// latter is reachable through read_memory(query), where it is useful, but in a DIGEST it
|
|
715
|
-
// would be noise under every heading
|
|
716
323
|
if (/^<!--/.test(line) || /^_from:/.test(line))
|
|
717
324
|
continue;
|
|
718
325
|
const isHeading = /^#{1,6}\s/.test(line);
|
|
719
326
|
const isBullet = /^[-*]\s/.test(line);
|
|
720
|
-
// headings and the FIRST line under one: enough to recognise a topic without
|
|
721
|
-
// reproducing the note. A bullet is included only as a one-line sample.
|
|
722
327
|
if (!isHeading && !isBullet) {
|
|
723
|
-
// a continuation of the previous line — skip, the heading already names the topic
|
|
724
328
|
if (!out.length)
|
|
725
329
|
out.push(line.slice(0, 100));
|
|
726
330
|
continue;
|
|
@@ -748,29 +352,18 @@ export class Agent {
|
|
|
748
352
|
try {
|
|
749
353
|
const stamp = await skillRootsFingerprint(this.skillRoots);
|
|
750
354
|
if (stamp === this.skillsStamp)
|
|
751
|
-
return;
|
|
355
|
+
return;
|
|
752
356
|
this.skillsCache = await currentSkills(this.toolCtx);
|
|
753
357
|
this.skillsStamp = stamp;
|
|
754
358
|
}
|
|
755
359
|
catch {
|
|
756
|
-
/* keep previous cache */
|
|
757
360
|
}
|
|
758
361
|
}
|
|
759
|
-
/**
|
|
760
|
-
* Request config for a call made in THIS conversation: the session id rides
|
|
761
|
-
* along so OpenCode's endpoints can route and prompt-cache the turn (without
|
|
762
|
-
* it they answer 400 — see providerHeaders()). Read from `currentSession`
|
|
763
|
-
* rather than copied onto opts.llm, because a fork/incarnation switch moves
|
|
764
|
-
* the agent to a new conversation while the endpoint settings stay put.
|
|
765
|
-
*/
|
|
766
362
|
requestConfig(extra = {}) {
|
|
767
363
|
return { ...this.opts.llm, sessionId: this.currentSession, ...extra };
|
|
768
364
|
}
|
|
769
365
|
callLlm(messages, tools, onDelta) {
|
|
770
366
|
const fn = this.opts.chatFn ?? chatStream;
|
|
771
|
-
// carry the effort setting + the model's advertised capabilities into
|
|
772
|
-
// the request config. chat() applies effortForRequest(), which sends the
|
|
773
|
-
// field ONLY for OpenRouter endpoints whose catalogue lists support.
|
|
774
367
|
return fn(this.requestConfig({
|
|
775
368
|
...(this.opts.reasoningEffort ? { reasoningEffort: this.opts.reasoningEffort } : {}),
|
|
776
369
|
...(this.opts.supportedParameters
|
|
@@ -778,31 +371,21 @@ export class Agent {
|
|
|
778
371
|
: {}),
|
|
779
372
|
}), messages, tools, this.abort?.signal, onDelta);
|
|
780
373
|
}
|
|
781
|
-
/** expose id for metrics */
|
|
782
374
|
opts_id() {
|
|
783
375
|
return this.opts.id;
|
|
784
376
|
}
|
|
785
|
-
/** snapshot of the current conversation for fork-by-reference spawning */
|
|
786
377
|
exportMessages() {
|
|
787
378
|
return [...this.messages];
|
|
788
379
|
}
|
|
789
|
-
/** seed a conversation (fork-by-reference sub-agents) — replaces history */
|
|
790
380
|
importMessages(msgs) {
|
|
791
381
|
this.messages = msgs;
|
|
792
382
|
this.compactedAtLen = 0;
|
|
793
383
|
}
|
|
794
|
-
/**
|
|
795
|
-
* Attach USD-per-token pricing for the CURRENT model (provider /models
|
|
796
|
-
* metadata). Late resolution is normal: the metadata lookup races boot.
|
|
797
|
-
* Recomputes the session cost from the log so a restart with pricing now
|
|
798
|
-
* known still shows the full number instead of starting from zero.
|
|
799
|
-
*/
|
|
800
384
|
async setModelPricing(p) {
|
|
801
385
|
const wasUndefined = !this.modelPricing;
|
|
802
386
|
this.modelPricing = p;
|
|
803
387
|
if (!p || !wasUndefined)
|
|
804
|
-
return;
|
|
805
|
-
// recompute from the usage events (cheap: file is mtime-cached)
|
|
388
|
+
return;
|
|
806
389
|
try {
|
|
807
390
|
const events = await readEvents(this.log.filePath);
|
|
808
391
|
let cost = 0;
|
|
@@ -817,30 +400,17 @@ export class Agent {
|
|
|
817
400
|
this.stats.costUsd = cost;
|
|
818
401
|
}
|
|
819
402
|
catch {
|
|
820
|
-
/* no log yet — live accumulation takes over */
|
|
821
403
|
}
|
|
822
404
|
}
|
|
823
405
|
get workspace() {
|
|
824
406
|
return this.opts.workspace;
|
|
825
407
|
}
|
|
826
|
-
/**
|
|
827
|
-
* True while the workspace directory does not exist on disk.
|
|
828
|
-
*
|
|
829
|
-
* Re-checked here rather than trusting the flag set at `init()`: the directory can come BACK
|
|
830
|
-
* (the operator recreates it, another agent in the same project makes it, a new session is
|
|
831
|
-
* created in it), and a boot-time snapshot then leaves every older session flagged 👻 forever.
|
|
832
|
-
* That is the reported symptom: a recreated directory shows its session count, but the sidebar
|
|
833
|
-
* lists only the session that was just created, because all the older ones still say "ghost".
|
|
834
|
-
*/
|
|
835
408
|
get isGhost() {
|
|
836
409
|
const exists = existsSync(this.workspace);
|
|
837
|
-
// only ever CLEAR from here: a sync getter must not claim a directory exists when it does
|
|
838
|
-
// not, but it can safely notice that it does again
|
|
839
410
|
if (exists)
|
|
840
411
|
this.workspaceMissing = false;
|
|
841
412
|
return this.workspaceMissing;
|
|
842
413
|
}
|
|
843
|
-
/** Create the workspace on demand — before any tool touches the fs. */
|
|
844
414
|
async ensureWorkspace() {
|
|
845
415
|
if (!this.workspaceMissing)
|
|
846
416
|
return;
|
|
@@ -848,66 +418,19 @@ export class Agent {
|
|
|
848
418
|
this.workspaceMissing = false;
|
|
849
419
|
bus.emit("update", { kind: "agent-update", agentId: this.opts.id });
|
|
850
420
|
}
|
|
851
|
-
/** true when the operator pinned contextTokenBudget in the config */
|
|
852
421
|
get compactBudgetIsManual() {
|
|
853
422
|
return this.manualCompactBudget;
|
|
854
423
|
}
|
|
855
|
-
/**
|
|
856
|
-
* The context size the prune/compact gates act on.
|
|
857
|
-
*
|
|
858
|
-
* `lastUsage.input` is the provider's own count, but a proxy or shim that under-reports it would
|
|
859
|
-
* make BOTH gates believe the context is nearly empty, switching off the defence entirely. So it
|
|
860
|
-
* is floored at HALF the local estimate: a provider within 2x of our tokenizer is legitimate, a
|
|
861
|
-
* provider reporting ~10 tokens for a large prompt is not.
|
|
862
|
-
*/
|
|
863
424
|
contextSizeForBudgeting() {
|
|
864
425
|
const estimated = this.estimateTokens();
|
|
865
426
|
const reported = this.lastUsage?.input;
|
|
866
427
|
if (reported === undefined)
|
|
867
428
|
return estimated;
|
|
868
|
-
// a provider reporting nothing at all is a different bug; treat it as unknown
|
|
869
429
|
if (!Number.isFinite(reported) || reported <= 0)
|
|
870
430
|
return estimated;
|
|
871
431
|
return Math.max(reported, Math.floor(estimated / 2));
|
|
872
432
|
}
|
|
873
|
-
/**
|
|
874
|
-
* Is this agent doing work that `stop()` would actually stop?
|
|
875
|
-
*
|
|
876
|
-
* `status` cannot answer this on its own. Three states count as live:
|
|
877
|
-
* - `running`: the obvious one;
|
|
878
|
-
* - a PARKED TOOL: `parkForTool()` flips the visible status to "idle" so a
|
|
879
|
-
* multi-minute `wait_children` doesn't look like a running spinner, but
|
|
880
|
-
* the loop is very much alive inside the tool;
|
|
881
|
-
* - QUEUED PROMPTS: prompts handed to a stopped agent are accepted and held,
|
|
882
|
-
* and a later `start()` will consume them.
|
|
883
|
-
*
|
|
884
|
-
* Anything keying "should this button stop or start?" off `status` alone
|
|
885
|
-
* therefore offers **start** on a working agent, and the user cannot stop it
|
|
886
|
-
* — the same trap #38 documented for editing history.
|
|
887
|
-
*/
|
|
888
433
|
isLive() {
|
|
889
|
-
// a MANUAL compaction on an idle agent was rewriting `messages` — a
|
|
890
|
-
// large, non-cancellable operation — while `isLive()` reported false, so the
|
|
891
|
-
// UI offered "start".
|
|
892
|
-
//
|
|
893
|
-
// and the reverse — after a STOP with prompts still queued, `live` stayed
|
|
894
|
-
// true forever, so the controls kept offering "stop" for an agent that was
|
|
895
|
-
// already stopped, and no amount of reloading helped. Measured:
|
|
896
|
-
//
|
|
897
|
-
// after stop : status = stopped live = true pendingPrompts = 1
|
|
898
|
-
//
|
|
899
|
-
// A queued prompt is not work in progress: it is waiting for the operator to
|
|
900
|
-
// press start. #9 settled that a stop must neither deliver nor silently
|
|
901
|
-
// DISCARD those prompts, so they are kept — but keeping them must not make the
|
|
902
|
-
// agent read as running. A stop outranks them.
|
|
903
|
-
//
|
|
904
|
-
// and one more, from the same family. The round-level retry backoff sets
|
|
905
|
-
// status "error" while it sleeps ("… — retrying in 60s"), and `error` is not a
|
|
906
|
-
// live status — so the UI offered "▶ start" for an agent that was waiting to
|
|
907
|
-
// retry, and there was NO stop control at all. The operator pressed what was
|
|
908
|
-
// there and nothing stopped, because nothing could. A scheduled retry is work
|
|
909
|
-
// in flight, so it counts as live; the dedicated flag keeps that independent of
|
|
910
|
-
// the display status, which still has to read "error".
|
|
911
434
|
if (this.stopRequested)
|
|
912
435
|
return false;
|
|
913
436
|
return (this.status === "running" ||
|
|
@@ -915,52 +438,13 @@ export class Agent {
|
|
|
915
438
|
this.pendingPrompts.length > 0 ||
|
|
916
439
|
this.compactPhase !== "" ||
|
|
917
440
|
this.retrySleeping ||
|
|
918
|
-
// a BACKGROUND job is work in flight, and `stop()` now ends it. The
|
|
919
|
-
// round that started it deliberately ended (the loop breaks rather than
|
|
920
|
-
// nagging — see hasRunningBgShells in loop()), so the status reads "idle"
|
|
921
|
-
// while a dev server / watcher / long build is still running. Without this
|
|
922
|
-
// the UI offered "▶ start" and there was no stop control for the one thing
|
|
923
|
-
// the operator most needs to stop. Note this is checked AFTER stopRequested:
|
|
924
|
-
// once stopped, the jobs are already dead, so `live` must go false or the
|
|
925
|
-
// button would offer stop forever.
|
|
926
|
-
// OWNER-SCOPED, not directory-scoped. Sub-agents inherit their parent's workspace, so
|
|
927
|
-
// asking "does this DIRECTORY have background shells?" made every stopped sibling of a
|
|
928
|
-
// busy agent report `live: true`. MEASURED: four STOPPED sub-agents in one workspace
|
|
929
|
-
// showed `· ▶4` in the sidebar because the parent had background jobs, and their stop
|
|
930
|
-
// buttons offered to stop work that was already gone.
|
|
931
|
-
//
|
|
932
|
-
// `hasRunningBgShells(cwd)` is still the right question where the caller wants the
|
|
933
|
-
// DIRECTORY (parking a round, killing a workspace's jobs) — just not for `isLive()`.
|
|
934
441
|
hasRunningBgShellsFor(this.toolCtx.cwd, this.opts.id) ||
|
|
935
|
-
// ask_user: the loop is parked on the operator's answer — stoppable work
|
|
936
|
-
// in flight, not idleness. Without this the UI offered "start", and
|
|
937
|
-
// pressing it cleared awaitingUser, silently discarding the open question
|
|
938
|
-
// while master.waitChildren's own active() kept counting the same child
|
|
939
|
-
// as working (three definitions of "alive" disagreeing about one state).
|
|
940
442
|
this.awaitingUser ||
|
|
941
|
-
// `needs_operator` IS that state now, named. Leaving the check on the flag alone would let
|
|
942
|
-
// the status and `live` disagree — #59's exact class of bug — and it is why a
|
|
943
|
-
// `needs_operator` snapshot reported `live: false` until this was added.
|
|
944
443
|
this.status === "needs_operator");
|
|
945
444
|
}
|
|
946
|
-
/**
|
|
947
|
-
* The text of every prompt still waiting for a turn boundary.
|
|
948
|
-
*
|
|
949
|
-
* Read-only view for callers that need to know WHAT is queued, not just how
|
|
950
|
-
* many — a delivery assertion has to see the text, or "the message arrived"
|
|
951
|
-
* degrades into "something is queued".
|
|
952
|
-
*/
|
|
953
445
|
pendingPromptTexts() {
|
|
954
446
|
return this.pendingPrompts.map((p) => p.text);
|
|
955
447
|
}
|
|
956
|
-
/**
|
|
957
|
-
* Is this agent doing in-flight work that a WAITER (wait_children) should
|
|
958
|
-
* hold for? Deliberately narrower than isLive(): queued prompts are DORMANT
|
|
959
|
-
* work (a stopped/idle child touches them only after it is started), so
|
|
960
|
-
* they must not keep a parent parked on a child that is visibly idle —
|
|
961
|
-
* that wait would hang. isLive() answers "stop vs start" (#59/#133); this
|
|
962
|
-
* answers "is work in flight".
|
|
963
|
-
*/
|
|
964
448
|
isBusy() {
|
|
965
449
|
if (this.stopRequested)
|
|
966
450
|
return false;
|
|
@@ -970,19 +454,10 @@ export class Agent {
|
|
|
970
454
|
this.parkedByTool ||
|
|
971
455
|
this.compactPhase !== "");
|
|
972
456
|
}
|
|
973
|
-
/** recently read workspace files (most recent first) — post-compact restore */
|
|
974
457
|
recentReads = [];
|
|
975
458
|
trackFileRead(p) {
|
|
976
459
|
this.recentReads = [p, ...this.recentReads.filter((x) => x !== p)].slice(0, 5);
|
|
977
460
|
}
|
|
978
|
-
/**
|
|
979
|
-
* A background shell (bash background=true) finished. Two channels:
|
|
980
|
-
* 1. a system_note row in the timeline — the operator sees the outcome
|
|
981
|
-
* without opening anything;
|
|
982
|
-
* 2. a queued harness prompt, delivered at the next turn boundary — the
|
|
983
|
-
* agent learns exit code + output tail WITHOUT having to poll
|
|
984
|
-
* bash_output blindly. Failed jobs say so loudly so the model reacts.
|
|
985
|
-
*/
|
|
986
461
|
onBackgroundJobExit(info) {
|
|
987
462
|
const ok = info.code === 0;
|
|
988
463
|
const one = info.cmd.replace(/\s+/g, " ").slice(0, 80);
|
|
@@ -994,33 +469,17 @@ export class Agent {
|
|
|
994
469
|
cmd: one,
|
|
995
470
|
failed: !ok,
|
|
996
471
|
});
|
|
997
|
-
// queue the report for the next turn boundary (agent-visible channel)
|
|
998
472
|
this.onBackgroundJobExitQueued(info);
|
|
999
473
|
}
|
|
1000
474
|
parkForTool(reason) {
|
|
1001
475
|
if (this.parkedByTool)
|
|
1002
|
-
return true;
|
|
476
|
+
return true;
|
|
1003
477
|
if (this.status !== "running")
|
|
1004
478
|
return false;
|
|
1005
|
-
// a prompt queued BEFORE the park would be stranded by it — the
|
|
1006
|
-
// enqueue-time wake only handles prompts arriving DURING the park. Refuse,
|
|
1007
|
-
// so the tool stands down and the next turn boundary consumes the queue.
|
|
1008
479
|
if (this.pendingPrompts.length > 0)
|
|
1009
480
|
return false;
|
|
1010
481
|
this.parkedByTool = true;
|
|
1011
482
|
this.preParkedStatus = { status: this.status, reason: this.statusReason };
|
|
1012
|
-
/**
|
|
1013
|
-
* `waiting`, NOT `idle`.
|
|
1014
|
-
*
|
|
1015
|
-
* A parked loop is alive and will resume by itself, so showing "idle" contradicted the
|
|
1016
|
-
* stop button beside it: the badge read idle while the header offered ■ stop, and the
|
|
1017
|
-
* operator reasonably concluded the UI was broken. `live` was already true for exactly this
|
|
1018
|
-
* reason (a parked tool counts as work in flight), so only the DISPLAYED status was wrong.
|
|
1019
|
-
*
|
|
1020
|
-
* The two park reasons are named separately because they mean different things to the
|
|
1021
|
-
* operator: a `wait_children` park resumes when a sub-agent settles, and a background-shell
|
|
1022
|
-
* park resumes when a `wait_children`/round-end wakes it.
|
|
1023
|
-
*/
|
|
1024
483
|
this.status = "waiting";
|
|
1025
484
|
this.statusReason = `${reason} — send a message to take over; resumes by itself`;
|
|
1026
485
|
bus.emit("update", { kind: "agent-update", agentId: this.opts.id });
|
|
@@ -1033,69 +492,33 @@ export class Agent {
|
|
|
1033
492
|
const prev = this.preParkedStatus;
|
|
1034
493
|
this.preParkedStatus = null;
|
|
1035
494
|
if (!this.stopRequested) {
|
|
1036
|
-
// restore the pre-park display state (running again)
|
|
1037
495
|
this.status = prev?.status ?? "running";
|
|
1038
496
|
bus.emit("update", { kind: "agent-update", agentId: this.opts.id });
|
|
1039
497
|
}
|
|
1040
498
|
}
|
|
1041
|
-
/** current LLM settings (read-only view) */
|
|
1042
499
|
get llm() {
|
|
1043
500
|
return this.opts.llm;
|
|
1044
501
|
}
|
|
1045
|
-
/** Swap LLM settings mid-flight; the next turn picks them up. */
|
|
1046
502
|
setLlmConfig(llm) {
|
|
1047
503
|
this.opts.llm = llm;
|
|
1048
504
|
}
|
|
1049
505
|
async init() {
|
|
1050
506
|
await this.log.load();
|
|
1051
507
|
await this.migrateMemoryToAgentScope();
|
|
1052
|
-
// NOTE: the workspace is NOT created here. Boot-time mkdir resurrected
|
|
1053
|
-
// deleted project directories on every restart, which operators hated.
|
|
1054
|
-
// It is created lazily in ensureWorkspace() when the agent first needs
|
|
1055
|
-
// to touch the filesystem. Until then a missing workspace marks the
|
|
1056
|
-
// session as "ghost" (visible-but-flagged in the UI).
|
|
1057
508
|
this.workspaceMissing = !(await fs
|
|
1058
509
|
.stat(this.workspace)
|
|
1059
510
|
.then(() => true)
|
|
1060
511
|
.catch(() => false));
|
|
1061
|
-
// goal lives next to the session log (dataDir), NOT in the workspace —
|
|
1062
|
-
// migrate a legacy workspace GOAL.md once, then never touch the workspace
|
|
1063
512
|
await this.migrateGoalFromWorkspace();
|
|
1064
513
|
const stored = await this.readGoalStore();
|
|
1065
514
|
this.goal = stored ?? { text: "", status: "active", updatedAt: new Date().toISOString() };
|
|
1066
|
-
// operator-maintained task list lives beside goal.md
|
|
1067
515
|
this.todo = await fs.readFile(this.todoFile, "utf8").catch(() => "");
|
|
1068
|
-
// NO skill scan here. It was the single largest boot cost — 493 agents
|
|
1069
|
-
// each walking their roots (readdir + stat, recursively) measured 2120 ms of
|
|
1070
|
-
// a 4.7 s boot. Every one of those walks was redundant:
|
|
1071
|
-
//
|
|
1072
|
-
// - the turn loop calls `refreshSkills()` at the TOP of every turn, before
|
|
1073
|
-
// the system prompt (which embeds the catalogue) is built, so the scan is
|
|
1074
|
-
// always current before any LLM request
|
|
1075
|
-
// - the `list_skills` tool calls it itself before reading the cache
|
|
1076
|
-
// - `/api/agents/:id/skills` (what the panel uses) does its own fresh
|
|
1077
|
-
// `currentSkills()` walk and never reads this cache
|
|
1078
|
-
//
|
|
1079
|
-
// Caching the walk was the other option and was WORSE: the fingerprint exists
|
|
1080
|
-
// to notice a NESTED skill's add/edit, which does not change its root's mtime,
|
|
1081
|
-
// so any TTL/mtime key reintroduces the staleness the walk was written to fix
|
|
1082
|
-
// (the skills-fingerprint tests pin exactly that). Skipping boot's copy costs
|
|
1083
|
-
// no freshness at all.
|
|
1084
|
-
//
|
|
1085
|
-
// The conversation is likewise not restored here: boot cost stays O(agents),
|
|
1086
|
-
// not O(history). Both are rebuilt lazily by ensureReady()/the turn loop.
|
|
1087
516
|
if (this.opts.restoreSession) {
|
|
1088
517
|
this.status = "stopped";
|
|
1089
518
|
this.statusReason = "session not loaded — select it or send a prompt";
|
|
1090
519
|
bus.emit("update", { kind: "agent-update", agentId: this.opts.id });
|
|
1091
520
|
}
|
|
1092
521
|
}
|
|
1093
|
-
/**
|
|
1094
|
-
* Resolve the session/workspace this agent operates on.
|
|
1095
|
-
*
|
|
1096
|
-
* Public because `load()` is a thin wrapper over it and a test that reproduces a
|
|
1097
|
-
* restart needs the same step WITHOUT the reload side effects.
|
|
1098
|
-
*/
|
|
1099
522
|
ensureReady() {
|
|
1100
523
|
if (!this.readyPromise) {
|
|
1101
524
|
this.readyPromise = (async () => {
|
|
@@ -1108,18 +531,9 @@ export class Agent {
|
|
|
1108
531
|
}
|
|
1109
532
|
return this.readyPromise;
|
|
1110
533
|
}
|
|
1111
|
-
/** Explicit load (e.g. the user clicked the agent in the UI): stopped → idle. */
|
|
1112
534
|
async load() {
|
|
1113
535
|
await this.ensureReady();
|
|
1114
536
|
}
|
|
1115
|
-
/**
|
|
1116
|
-
* Answer tool_calls in a RESTORED history that have no tool_result.
|
|
1117
|
-
*
|
|
1118
|
-
* The ordinary backstop (`answerUnansweredToolCalls`) works on an assistant
|
|
1119
|
-
* turn inside a live loop. After a restart there is no loop: the call is in
|
|
1120
|
-
* the log, the process that was running it is gone, and nothing else will ever
|
|
1121
|
-
* write its result.
|
|
1122
|
-
*/
|
|
1123
537
|
async answerUnansweredRestoredCalls() {
|
|
1124
538
|
const seen = new Set();
|
|
1125
539
|
for (const ev of await readEvents(this.log.filePath)) {
|
|
@@ -1149,42 +563,14 @@ export class Agent {
|
|
|
1149
563
|
detail: "a restart left these tool calls without a result; they are marked not completed (#54)",
|
|
1150
564
|
});
|
|
1151
565
|
}
|
|
1152
|
-
/** harness-managed files inside the session directory */
|
|
1153
566
|
get goalFile() {
|
|
1154
567
|
return path.join(this.opts.sessionDir, "goal.md");
|
|
1155
568
|
}
|
|
1156
|
-
/**
|
|
1157
|
-
* Durable notes — AGENT-scoped, not session-scoped.
|
|
1158
|
-
*
|
|
1159
|
-
* silently orphaned everything it had learned. Across this machine, 66 agents had more
|
|
1160
|
-
* than one session (median 6 each) and only 5 of them had notes in any session at all —
|
|
1161
|
-
* the notes were written, then abandoned when the next session was minted. A "durable
|
|
1162
|
-
* note" that dies with the timeline is not durable.
|
|
1163
|
-
*
|
|
1164
|
-
* The file lives next to the agent's session DIRECTORY (`sessions/<agent>/memory.md`),
|
|
1165
|
-
* a level above the per-session dirs, so every session of that agent sees the same
|
|
1166
|
-
* notes. A session's own `memory.md` from an older build is migrated on first read.
|
|
1167
|
-
*/
|
|
1168
569
|
get memoryFile() {
|
|
1169
570
|
const perSession = path.join(this.opts.sessionDir, "memory.md");
|
|
1170
571
|
const parent = path.dirname(this.opts.sessionDir);
|
|
1171
|
-
/**
|
|
1172
|
-
* Agent-scoped when the session dir sits inside a `sessions/` ROOT, session-scoped otherwise.
|
|
1173
|
-
*
|
|
1174
|
-
* The previous test was `/-[0-9a-f]{6,}$/` against the basename — "looks like
|
|
1175
|
-
* `<agent>-<hex>`". MEASURED: `mkdtemp` suffixes are 6 alphanumerics, so ~0.2% of them are
|
|
1176
|
-
* all-hex and match. That made the memory file's LOCATION depend on a random temp name, which
|
|
1177
|
-
* is why a test asserting `read_memory(query)` failed in CI and passed locally: the fixture
|
|
1178
|
-
* `mems-AbCdEf` resolved to the agent-wide file, and a sibling test's notes were then in
|
|
1179
|
-
* scope.
|
|
1180
|
-
*
|
|
1181
|
-
* The structural fact is reliable: `Master.resolveSessionDir` always mints session dirs
|
|
1182
|
-
* inside its `sessions/` root, so a parent named `sessions` means "one level down from the
|
|
1183
|
-
* agent". A bare temp dir has no such parent and stays session-scoped.
|
|
1184
|
-
*/
|
|
1185
572
|
return path.basename(parent) === "sessions" ? path.join(parent, "memory.md") : perSession;
|
|
1186
573
|
}
|
|
1187
|
-
/** the pre-migration location, read once so notes are not lost on upgrade */
|
|
1188
574
|
get legacyMemoryFile() {
|
|
1189
575
|
return path.join(this.opts.sessionDir, "memory.md");
|
|
1190
576
|
}
|
|
@@ -1204,7 +590,6 @@ export class Agent {
|
|
|
1204
590
|
const raw = await this.readGoalStoreRaw();
|
|
1205
591
|
return raw === null ? null : this.parseGoalFile(raw);
|
|
1206
592
|
}
|
|
1207
|
-
/** One-time import of a pre-0.6.0 workspace GOAL.md; content is preserved. */
|
|
1208
593
|
async migrateGoalFromWorkspace() {
|
|
1209
594
|
const legacy = path.join(this.workspace, "GOAL.md");
|
|
1210
595
|
let wsText;
|
|
@@ -1212,7 +597,7 @@ export class Agent {
|
|
|
1212
597
|
wsText = await fs.readFile(legacy, "utf8");
|
|
1213
598
|
}
|
|
1214
599
|
catch {
|
|
1215
|
-
return;
|
|
600
|
+
return;
|
|
1216
601
|
}
|
|
1217
602
|
const existing = await this.readGoalStoreRaw();
|
|
1218
603
|
if (existing === null)
|
|
@@ -1224,14 +609,6 @@ export class Agent {
|
|
|
1224
609
|
to: this.goalFile,
|
|
1225
610
|
});
|
|
1226
611
|
}
|
|
1227
|
-
/**
|
|
1228
|
-
* Move notes from the per-session file into the agent-wide one — once.
|
|
1229
|
-
*
|
|
1230
|
-
* Older builds wrote memory.md inside the session dir. Those notes are real knowledge and
|
|
1231
|
-
* must not be abandoned by the upgrade that makes them durable, so the session's file is
|
|
1232
|
-
* APPENDED to the agent-wide one (never replacing it: the agent-wide file may already hold
|
|
1233
|
-
* notes from other sessions) and then removed so a later session does not re-import it.
|
|
1234
|
-
*/
|
|
1235
612
|
async migrateMemoryToAgentScope() {
|
|
1236
613
|
const from = this.legacyMemoryFile;
|
|
1237
614
|
const to = this.memoryFile;
|
|
@@ -1242,7 +619,7 @@ export class Agent {
|
|
|
1242
619
|
legacy = (await fs.readFile(from, "utf8")).trim();
|
|
1243
620
|
}
|
|
1244
621
|
catch {
|
|
1245
|
-
return;
|
|
622
|
+
return;
|
|
1246
623
|
}
|
|
1247
624
|
if (!legacy) {
|
|
1248
625
|
await fs.rm(from, { force: true }).catch(() => { });
|
|
@@ -1257,15 +634,8 @@ export class Agent {
|
|
|
1257
634
|
console.log(`[teapot] memory: migrated ${legacy.length} bytes to ${path.basename(to)} (agent scope)`);
|
|
1258
635
|
}
|
|
1259
636
|
catch {
|
|
1260
|
-
/* keep the session copy if the agent-wide write failed — losing notes is worse */
|
|
1261
637
|
}
|
|
1262
638
|
}
|
|
1263
|
-
/**
|
|
1264
|
-
* Resolve a leading sub_fork header: load the parent session's file (and
|
|
1265
|
-
* recurse if THAT one is itself a sub_fork), take its lineage up to the
|
|
1266
|
-
* recorded event, and return it prepended to our own events. Never copies
|
|
1267
|
-
* bytes into our log — the prefix lives in the parent's file. Cycle-safe.
|
|
1268
|
-
*/
|
|
1269
639
|
async spliceSubForkPrefix(own, baseDir = path.dirname(this.log.filePath)) {
|
|
1270
640
|
const header = own.find((e) => e.type === "sub_fork");
|
|
1271
641
|
if (!header)
|
|
@@ -1275,7 +645,6 @@ export class Agent {
|
|
|
1275
645
|
return own;
|
|
1276
646
|
const parentDir = path.join(baseDir, d.parentSession);
|
|
1277
647
|
let parentEvents = await readEvents(path.join(parentDir, "chat.jsonl")).catch(() => []);
|
|
1278
|
-
// grandchild chains: the parent file may itself open with a sub_fork
|
|
1279
648
|
if (parentEvents.some((e) => e.type === "sub_fork")) {
|
|
1280
649
|
parentEvents = await this.spliceSubForkPrefix(parentEvents, parentDir);
|
|
1281
650
|
}
|
|
@@ -1283,7 +652,6 @@ export class Agent {
|
|
|
1283
652
|
console.warn(`[teapot] sub_fork: parent session ${d.parentSession} unreadable — starting without inherited context`);
|
|
1284
653
|
return own;
|
|
1285
654
|
}
|
|
1286
|
-
// lineage of the parent up to (and including) the recorded fork point
|
|
1287
655
|
const byId = new Map(parentEvents.map((e) => [e.id, e]));
|
|
1288
656
|
const tip = byId.get(d.upToEvent);
|
|
1289
657
|
if (!tip) {
|
|
@@ -1293,7 +661,7 @@ export class Agent {
|
|
|
1293
661
|
const prefix = [];
|
|
1294
662
|
for (let cur = tip; cur; cur = cur.parent ? byId.get(cur.parent) : undefined) {
|
|
1295
663
|
if (prefix.some((p) => p.id === cur.id))
|
|
1296
|
-
break;
|
|
664
|
+
break;
|
|
1297
665
|
prefix.push(cur);
|
|
1298
666
|
}
|
|
1299
667
|
prefix.reverse();
|
|
@@ -1301,39 +669,17 @@ export class Agent {
|
|
|
1301
669
|
prefix.shift();
|
|
1302
670
|
return [...prefix, ...own.filter((e) => e !== header)];
|
|
1303
671
|
}
|
|
1304
|
-
/**
|
|
1305
|
-
* Restore the conversation from the JSONL log exactly once, on demand.
|
|
1306
|
-
* Everything that touches history (prompts, start, fork, UI selection)
|
|
1307
|
-
* funnels through here; boot stays cheap no matter how many sessions exist.
|
|
1308
|
-
*
|
|
1309
|
-
* Strategy: find the most recent event overall (its branch is where the
|
|
1310
|
-
* agent was), walk the `parent` chain backwards (crossing fork points),
|
|
1311
|
-
* then replay events forward into ChatMessages. Meta-tool calls
|
|
1312
|
-
* (finish / report_progress) never produced logged tool results, so we
|
|
1313
|
-
* synthesize their responses to keep the message sequence valid.
|
|
1314
|
-
*/
|
|
1315
672
|
async restoreFromLog() {
|
|
1316
673
|
const own = await readEvents(this.log.filePath);
|
|
1317
674
|
if (own.length === 0)
|
|
1318
675
|
return;
|
|
1319
|
-
// a sub_fork header points at the session this agent branched from —
|
|
1320
|
-
// splice that prefix in from the parent's file (recursively, cycle-safe)
|
|
1321
|
-
// instead of ever copying parent history into our own log
|
|
1322
676
|
const events = await this.spliceSubForkPrefix(own);
|
|
1323
677
|
const lineage = lineageOf(events);
|
|
1324
678
|
if (!lineage.length)
|
|
1325
679
|
return;
|
|
1326
680
|
const last = lineage[lineage.length - 1];
|
|
1327
681
|
const msgs = rebuildMessagesFrom(lineage);
|
|
1328
|
-
// Rebuild lifetime stats from the log: turns/tools/tokens/compactions are
|
|
1329
|
-
// SESSION totals, so a reload must not reset them to zero. Counted over
|
|
1330
|
-
// the whole file (not just the current branch) to match what the runtime
|
|
1331
|
-
// panel showed before the restart.
|
|
1332
682
|
for (const e of own) {
|
|
1333
|
-
// the title comes from the FIRST user prompt, which never changes, so
|
|
1334
|
-
// deriving it here is free — this loop already walks the whole log at
|
|
1335
|
-
// restore. Only the first one is taken; later prompts are a different
|
|
1336
|
-
// subject and would make the sidebar label churn under the reader.
|
|
1337
683
|
if (this.sessionTitle === undefined && e.type === "prompt" && e.data?.source === "user") {
|
|
1338
684
|
this.sessionTitle = sessionTitleFrom(e.data?.text) ?? undefined;
|
|
1339
685
|
}
|
|
@@ -1350,23 +696,11 @@ export class Agent {
|
|
|
1350
696
|
this.stats.inputTokens += u.inputTokens ?? 0;
|
|
1351
697
|
this.stats.outputTokens += u.outputTokens ?? 0;
|
|
1352
698
|
this.stats.cachedInputTokens += u.cachedInputTokens ?? 0;
|
|
1353
|
-
// NO cost accumulation here: setModelPricing() rebuilds costUsd from
|
|
1354
|
-
// this same log with a REPLACE — whichever runs first, adding on top
|
|
1355
|
-
// of a rebuilt number counted every usage event twice (a restart plus
|
|
1356
|
-
// the first interaction showed ~2x). The recompute is authoritative.
|
|
1357
699
|
}
|
|
1358
700
|
else if (e.type === "system_note") {
|
|
1359
701
|
const d = e.data;
|
|
1360
702
|
if (d.event === "context-compacted")
|
|
1361
703
|
this.stats.compactions++;
|
|
1362
|
-
/**
|
|
1363
|
-
* Re-seed the durable rule set so it survives a restart.
|
|
1364
|
-
*
|
|
1365
|
-
* The newest `compaction-rules` note is the accumulated set; without this a reload starts
|
|
1366
|
-
* empty and the first compaction after it has nothing to enforce — the failure the whole
|
|
1367
|
-
* mechanism exists to prevent. Seeded through `extractRules` rather than trusting the
|
|
1368
|
-
* stored shape, so the note is the same one a human can read in the log.
|
|
1369
|
-
*/
|
|
1370
704
|
if (d.event === "compaction-rules" && Array.isArray(d.rules)) {
|
|
1371
705
|
const seeded = d.rules
|
|
1372
706
|
.map((line) => extractRules(`${RULES_HEADING}\n- ${String(line)}`)[0])
|
|
@@ -1376,16 +710,11 @@ export class Agent {
|
|
|
1376
710
|
}
|
|
1377
711
|
}
|
|
1378
712
|
else if (e.type === "compaction") {
|
|
1379
|
-
// keep the NEWEST notes: they are what the next compaction updates, and
|
|
1380
|
-
// without this a restart would silently fall back to re-summarizing
|
|
1381
713
|
const d = e.data;
|
|
1382
714
|
if (typeof d.summary === "string" && d.summary)
|
|
1383
715
|
this.lastCompactSummary = d.summary;
|
|
1384
716
|
}
|
|
1385
717
|
}
|
|
1386
|
-
// newest usage event seeds the context gauge right after reload —
|
|
1387
|
-
// inputTokens from the LAST turn is the live context size, and cached
|
|
1388
|
-
// must ride along or the "⚡️ N% cached" pill disappears after a restart
|
|
1389
718
|
for (let i = own.length - 1; i >= 0; i--) {
|
|
1390
719
|
if (own[i].type === "usage") {
|
|
1391
720
|
const u = own[i].data;
|
|
@@ -1402,35 +731,7 @@ export class Agent {
|
|
|
1402
731
|
if (msgs.length > 0) {
|
|
1403
732
|
this.messages = msgs;
|
|
1404
733
|
this.currentBranch = last.branch;
|
|
1405
|
-
// re-apply pruning to the restored history. `maybePrune` clips
|
|
1406
|
-
// oversized tool output IN PLACE, so a live session keeps its clipped
|
|
1407
|
-
// text while the log still holds the full result — a restart therefore
|
|
1408
|
-
// handed the provider a strictly LARGER prompt than the one that had just
|
|
1409
|
-
// succeeded, which is a size regression that only shows up after a
|
|
1410
|
-
// reload. It is a size fix, not a semantic one: the model has already
|
|
1411
|
-
// seen and acted on that output, and the pruned form keeps the head plus
|
|
1412
|
-
// a marker, exactly as it did live.
|
|
1413
|
-
//
|
|
1414
|
-
// FORCED: this reproduces what the live session had already done, so it must
|
|
1415
|
-
// not consult the "is there room" bar — a restored context that happens to sit
|
|
1416
|
-
// below it still has to match the prompt the provider last accepted.
|
|
1417
734
|
await this.maybePrune(true);
|
|
1418
|
-
// answer tool_calls the log says are still open.
|
|
1419
|
-
//
|
|
1420
|
-
// The restore path rebuilds `msgs` straight from the log, so a call whose
|
|
1421
|
-
// process died mid-command comes back as an assistant turn with
|
|
1422
|
-
// `tool_calls` and NO matching `tool_result`. Nothing ever answers it —
|
|
1423
|
-
// not `stop()` (there is no turn to stop) and not `dispose()` (the process
|
|
1424
|
-
// is already gone). The row then renders as still running for good.
|
|
1425
|
-
//
|
|
1426
|
-
// every one a `bash`, each followed immediately by
|
|
1427
|
-
// `session-restored` / `idle->stopped disposed`. The shape is a restart
|
|
1428
|
-
// (or a crash) landing while a long command was in flight, which is
|
|
1429
|
-
// precisely when a human is most likely to notice "it never finished".
|
|
1430
|
-
//
|
|
1431
|
-
// So close them ON RESTORE, before the restored history is used. This is
|
|
1432
|
-
// the same backstop as the mid-turn paths, reached from the one place
|
|
1433
|
-
// those could not: after the process is gone.
|
|
1434
735
|
await this.answerUnansweredRestoredCalls();
|
|
1435
736
|
await this.log.append("system_note", this.currentSession, this.currentBranch, {
|
|
1436
737
|
event: "session-restored",
|
|
@@ -1439,11 +740,6 @@ export class Agent {
|
|
|
1439
740
|
});
|
|
1440
741
|
}
|
|
1441
742
|
}
|
|
1442
|
-
/**
|
|
1443
|
-
* Force a compaction pass (manual /compact). Serialized on the run chain so
|
|
1444
|
-
* it lands at a safe point relative to a running loop; reports whether
|
|
1445
|
-
* anything was actually compacted.
|
|
1446
|
-
*/
|
|
1447
743
|
async compactNow() {
|
|
1448
744
|
const ran = await this.enqueue(async () => {
|
|
1449
745
|
await this.ensureReady();
|
|
@@ -1453,19 +749,7 @@ export class Agent {
|
|
|
1453
749
|
});
|
|
1454
750
|
return { ran };
|
|
1455
751
|
}
|
|
1456
|
-
/**
|
|
1457
|
-
* Edit a previously-sent prompt: fork the conversation at that point,
|
|
1458
|
-
* replace its text, and optionally fold everything that happened after it
|
|
1459
|
-
* into a summary note on the new branch (ChatGPT-edit style). The agent
|
|
1460
|
-
* must not be running — editing under a live loop would race its history.
|
|
1461
|
-
*/
|
|
1462
752
|
async editPromptAt(eventId, text, tail) {
|
|
1463
|
-
// "running" is not the only live state: parkForTool() rewrites the status to
|
|
1464
|
-
// "idle" while the loop is still very much alive inside a parked tool
|
|
1465
|
-
// (wait_children does this), and queued prompts have not been consumed yet.
|
|
1466
|
-
// Editing underneath either of those replaces the very `messages` array the
|
|
1467
|
-
// agent is about to resume into, so its old-branch work kept running
|
|
1468
|
-
// against the new branch.
|
|
1469
753
|
if (this.status === "running" || this.parkedByTool || this.pendingPrompts.length)
|
|
1470
754
|
throw new Error("agent is running — stop it before editing history");
|
|
1471
755
|
const all = await readEvents(this.log.filePath);
|
|
@@ -1477,7 +761,7 @@ export class Agent {
|
|
|
1477
761
|
if (tIdx === -1)
|
|
1478
762
|
throw new Error("prompt is not on this agent's current lineage");
|
|
1479
763
|
const kept = lineage.slice(0, tIdx);
|
|
1480
|
-
const dropped = lineage.slice(tIdx);
|
|
764
|
+
const dropped = lineage.slice(tIdx);
|
|
1481
765
|
const msgs = rebuildMessagesFrom(kept);
|
|
1482
766
|
if (tail === "summarize" && dropped.length > 0) {
|
|
1483
767
|
try {
|
|
@@ -1494,21 +778,11 @@ export class Agent {
|
|
|
1494
778
|
}
|
|
1495
779
|
}
|
|
1496
780
|
catch {
|
|
1497
|
-
// summarization is best-effort; the fork proceeds without notes
|
|
1498
781
|
}
|
|
1499
782
|
}
|
|
1500
783
|
msgs.push({ role: "user", content: text });
|
|
1501
784
|
const newBranch = `br${this.branchCount()}${Date.now().toString(36).slice(-4)}`;
|
|
1502
|
-
// Quiesce BEFORE rewriting history: an in-flight LLM call, a parked tool or
|
|
1503
|
-
// a queued prompt would otherwise resume into the NEW messages array and
|
|
1504
|
-
// keep running the old branch's work here. Mirrors stop(), which is
|
|
1505
|
-
// the only other place that cancels this state.
|
|
1506
785
|
await this.quiesceForHistoryEdit();
|
|
1507
|
-
// The fork event must be appended while currentBranch is still the OLD
|
|
1508
|
-
// one. EventLog links parents PER BRANCH (lastByBranch), so appending it
|
|
1509
|
-
// under the new branch gave it parent:null — severing the chain, and
|
|
1510
|
-
// lineageOf() then returned only the post-fork events, so a restart rebuilt
|
|
1511
|
-
// the history with none of the pre-edit context.
|
|
1512
786
|
await this.log.append("fork", this.currentSession, this.currentBranch, {
|
|
1513
787
|
fromSession: this.currentSession,
|
|
1514
788
|
fromBranch: this.currentBranch,
|
|
@@ -1518,17 +792,6 @@ export class Agent {
|
|
|
1518
792
|
droppedEvents: dropped.length,
|
|
1519
793
|
tailMode: tail,
|
|
1520
794
|
});
|
|
1521
|
-
// Seed the new branch's parent link so its first event chains across
|
|
1522
|
-
// instead of starting a disconnected parent:null history.
|
|
1523
|
-
//
|
|
1524
|
-
// seed from the CUT POINT, not from this branch's tip. The fork event
|
|
1525
|
-
// records where the edit was made (`fromEvent` = the last KEPT event), but
|
|
1526
|
-
// `lastEventId(currentBranch)` is the newest event on the branch — which is
|
|
1527
|
-
// one of the events this edit just DISCARDED. Seeding from the tip chained
|
|
1528
|
-
// the new branch to the discarded tail, so lineageOf() walked straight back
|
|
1529
|
-
// through it and a restart resurrected every dropped turn.
|
|
1530
|
-
//
|
|
1531
|
-
// The cut point is the right parent: it is the last event that survives.
|
|
1532
795
|
const cutPoint = kept.at(-1)?.id ?? null;
|
|
1533
796
|
this.log.seedBranch(newBranch, cutPoint);
|
|
1534
797
|
this.currentBranch = newBranch;
|
|
@@ -1537,45 +800,31 @@ export class Agent {
|
|
|
1537
800
|
bus.emit("update", { kind: "agent-update", agentId: this.opts.id });
|
|
1538
801
|
return { droppedEvents: dropped.length, branch: newBranch };
|
|
1539
802
|
}
|
|
1540
|
-
/**
|
|
1541
|
-
* Cancel everything that could resume against the history we are about to
|
|
1542
|
-
* replace, then wait for the loop to actually stop.
|
|
1543
|
-
*
|
|
1544
|
-
* An edit rewinds the conversation, so any work still in flight belongs to
|
|
1545
|
-
* the branch being discarded: an LLM call whose result would append to the
|
|
1546
|
-
* new messages, a parked tool, and queued prompts that were never delivered
|
|
1547
|
-
*. stopRequested is set and cleared around the wait so the agent is
|
|
1548
|
-
* usable again once the edit lands.
|
|
1549
|
-
*/
|
|
1550
803
|
async quiesceForHistoryEdit() {
|
|
1551
804
|
if (!this.stopRequested) {
|
|
1552
805
|
this.stopRequested = true;
|
|
1553
|
-
this.abort?.abort();
|
|
1554
|
-
this.toolAbort.abort();
|
|
1555
|
-
this.wake?.();
|
|
806
|
+
this.abort?.abort();
|
|
807
|
+
this.toolAbort.abort();
|
|
808
|
+
this.wake?.();
|
|
1556
809
|
try {
|
|
1557
810
|
await this.settled();
|
|
1558
811
|
}
|
|
1559
812
|
catch {
|
|
1560
|
-
/* the loop is being abandoned; its errors are not ours to report */
|
|
1561
813
|
}
|
|
1562
814
|
this.parkedByTool = false;
|
|
1563
|
-
this.pendingPrompts = [];
|
|
815
|
+
this.pendingPrompts = [];
|
|
1564
816
|
this.stopRequested = false;
|
|
1565
|
-
// a fresh signal: toolCtx holds a copy of the reference, so republish
|
|
1566
817
|
if (this.toolAbort.signal.aborted) {
|
|
1567
818
|
this.toolAbort = new AbortController();
|
|
1568
819
|
this.toolCtx.signal = this.toolAbort.signal;
|
|
1569
820
|
}
|
|
1570
|
-
this.bgExits = [];
|
|
821
|
+
this.bgExits = [];
|
|
1571
822
|
}
|
|
1572
823
|
}
|
|
1573
824
|
parseGoalFile(text) {
|
|
1574
|
-
// humans and agents may append their own status lines — latest wins
|
|
1575
825
|
const all = [...text.matchAll(/status:\s*(\w+)/gi)];
|
|
1576
826
|
const last = all[all.length - 1]?.[1];
|
|
1577
827
|
const status = last === "done" ? "done" : last === "paused" ? "paused" : "active";
|
|
1578
|
-
// keep bookkeeping lines out of the injected goal text
|
|
1579
828
|
let body = text.trim();
|
|
1580
829
|
for (let i = 0; i < 4; i++) {
|
|
1581
830
|
const stripped = body.replace(/\n+(?:status|updated):[^\n]*$/i, "").trimEnd();
|
|
@@ -1583,7 +832,6 @@ export class Agent {
|
|
|
1583
832
|
break;
|
|
1584
833
|
body = stripped;
|
|
1585
834
|
}
|
|
1586
|
-
// verification contract (pi-goal-x style): a `verify:` block at the tail
|
|
1587
835
|
let verify = "";
|
|
1588
836
|
const vm = body.match(/\nverify:\s*\n([\s\S]+)$/i) ?? body.match(/^verify:\s*(.+)$/im);
|
|
1589
837
|
if (vm) {
|
|
@@ -1596,7 +844,6 @@ export class Agent {
|
|
|
1596
844
|
return out;
|
|
1597
845
|
}
|
|
1598
846
|
async writeGoalFile() {
|
|
1599
|
-
// don't let previously-appended bookkeeping lines accumulate in the body
|
|
1600
847
|
let body = this.goal.text.trimEnd();
|
|
1601
848
|
for (let i = 0; i < 4; i++) {
|
|
1602
849
|
const stripped = body.replace(/\n+(?:status|updated):[^\n]*$/i, "").trimEnd();
|
|
@@ -1609,9 +856,6 @@ export class Agent {
|
|
|
1609
856
|
}
|
|
1610
857
|
async setGoal(text) {
|
|
1611
858
|
this.goal = { text, status: "active", updatedAt: new Date().toISOString() };
|
|
1612
|
-
// a new goal is a new audit contract: the previous goal's rejection must
|
|
1613
|
-
// not ride along as the new goal's "previous" lineage, and the attempt
|
|
1614
|
-
// counters restart from zero (theirs counts rejects of THIS goal)
|
|
1615
859
|
this.consecutiveAuditRejects = 0;
|
|
1616
860
|
this.auditUnavailableStreak = 0;
|
|
1617
861
|
this.lastRejectedAudit = null;
|
|
@@ -1620,29 +864,11 @@ export class Agent {
|
|
|
1620
864
|
await this.writeGoalFile();
|
|
1621
865
|
await this.log.append("goal", this.currentSession, this.currentBranch, { event: "set", text });
|
|
1622
866
|
}
|
|
1623
|
-
/** Persist the operator-maintained task list (todo.md). */
|
|
1624
867
|
async setTodo(text, by = "human") {
|
|
1625
868
|
this.todo = text;
|
|
1626
869
|
await fs.writeFile(this.todoFile, text, "utf8");
|
|
1627
870
|
await this.log.append("todo", this.currentSession, this.currentBranch, { event: "set", by });
|
|
1628
871
|
}
|
|
1629
|
-
/**
|
|
1630
|
-
* Apply surgical changes to todo.md.
|
|
1631
|
-
*
|
|
1632
|
-
* Each entry names an item by its exact (normalised) text and says what to do with it:
|
|
1633
|
-
* - `done` / `reopen` / `remove` act on an EXISTING checkbox line;
|
|
1634
|
-
* - `add` appends a new unchecked line, even when nothing matches.
|
|
1635
|
-
*
|
|
1636
|
-
* `action` is optional and defaults to `done`, so every existing caller and every model
|
|
1637
|
-
* that only sends `{item, done}` keeps working.
|
|
1638
|
-
*
|
|
1639
|
-
* Why not whole-list replacement, which Codex (`update_plan`) and DeepSeek Harness
|
|
1640
|
-
* (`todo/write`) both use: `todo.md` is a file the OPERATOR also edits, and it holds
|
|
1641
|
-
* headings, notes and prose — not only checkboxes. A model re-emitting "the list" would
|
|
1642
|
-
* silently drop anything it did not repeat, which is exactly the data loss `set_memory`
|
|
1643
|
-
* caused before `#48` (measured: 1028 → 83 bytes). The `content` argument still exists
|
|
1644
|
-
* for a deliberate restructure.
|
|
1645
|
-
*/
|
|
1646
872
|
async updateTodoItems(updates, by = "agent") {
|
|
1647
873
|
const norm = (s) => s.replace(/\s+/g, " ").trim().toLowerCase();
|
|
1648
874
|
const lines = this.todo.split("\n");
|
|
@@ -1653,7 +879,6 @@ export class Agent {
|
|
|
1653
879
|
for (const u of updates) {
|
|
1654
880
|
const action = u.action ?? (u.done === false ? "reopen" : "done");
|
|
1655
881
|
const target = norm(u.item);
|
|
1656
|
-
// find the LAST matching checkbox line (later duplicates win)
|
|
1657
882
|
let idx = -1;
|
|
1658
883
|
for (let i = lines.length - 1; i >= 0; i--) {
|
|
1659
884
|
const m = lines[i].match(/^(\s*[-*+] \[)([ xX])(\].*)$/);
|
|
@@ -1665,9 +890,6 @@ export class Agent {
|
|
|
1665
890
|
}
|
|
1666
891
|
}
|
|
1667
892
|
if (action === "add") {
|
|
1668
|
-
// An ADD repeats: the caller may add several items with the same wording, and
|
|
1669
|
-
// refusing because one already exists would make "add" unpredictable. But adding
|
|
1670
|
-
// the SAME text twice in one call is a mistake, so collapse it.
|
|
1671
893
|
if (idx !== -1) {
|
|
1672
894
|
unmatched.push(`${u.item} (already present — not added twice)`);
|
|
1673
895
|
continue;
|
|
@@ -1690,7 +912,6 @@ export class Agent {
|
|
|
1690
912
|
matched++;
|
|
1691
913
|
}
|
|
1692
914
|
if (matched > 0 || added > 0 || removed > 0) {
|
|
1693
|
-
// trailing blank lines from a removal are tidied so the file does not grow a gap
|
|
1694
915
|
while (lines.length > 1 && lines[lines.length - 1].trim() === "" && lines[lines.length - 2].trim() === "")
|
|
1695
916
|
lines.pop();
|
|
1696
917
|
this.todo = lines.join("\n");
|
|
@@ -1717,14 +938,6 @@ export class Agent {
|
|
|
1717
938
|
parts.push(`NOT FOUND in todo.md: ${unmatched.map((s) => `"${s}"`).join(", ")} — check get_todo and retry with the exact wording, or use action:"add" to create it`);
|
|
1718
939
|
return parts.join(". ");
|
|
1719
940
|
}
|
|
1720
|
-
/**
|
|
1721
|
-
* `by` records WHY the status changed, because "active" alone is ambiguous.
|
|
1722
|
-
*
|
|
1723
|
-
* Two very different things produce it: the operator resuming a paused goal, and the auditor
|
|
1724
|
-
* rejecting a `finish(goalComplete=true)`. The timeline rendered the second wording for both, so
|
|
1725
|
-
* pressing ▶ working on a paused goal said "the audit rejected the finish" — reported as
|
|
1726
|
-
* "pausedからの移動はrejectじゃないのでは?".
|
|
1727
|
-
*/
|
|
1728
941
|
async setGoalStatus(status, by = "operator") {
|
|
1729
942
|
this.goal = { ...this.goal, status, updatedAt: new Date().toISOString() };
|
|
1730
943
|
await this.writeGoalFile();
|
|
@@ -1734,7 +947,6 @@ export class Agent {
|
|
|
1734
947
|
by,
|
|
1735
948
|
});
|
|
1736
949
|
}
|
|
1737
|
-
/** Set the goal's verification contract (completion requirements). */
|
|
1738
950
|
async setGoalVerify(verify) {
|
|
1739
951
|
this.goal = { ...this.goal, verify: verify.slice(0, 4000), updatedAt: new Date().toISOString() };
|
|
1740
952
|
await this.writeGoalFile();
|
|
@@ -1743,7 +955,6 @@ export class Agent {
|
|
|
1743
955
|
verify: verify.slice(0, 2000),
|
|
1744
956
|
});
|
|
1745
957
|
}
|
|
1746
|
-
/** Record a verification-contract audit outcome (pi-goal-x style review). */
|
|
1747
958
|
async setGoalAudit(verdict, feedback) {
|
|
1748
959
|
this.goal = {
|
|
1749
960
|
...this.goal,
|
|
@@ -1752,14 +963,6 @@ export class Agent {
|
|
|
1752
963
|
};
|
|
1753
964
|
if (verdict === "approved")
|
|
1754
965
|
this.goal.status = "done";
|
|
1755
|
-
/**
|
|
1756
|
-
* A rejection REOPENS the goal, and that is logged as its own status event.
|
|
1757
|
-
*
|
|
1758
|
-
* It used to flip `status` to "active" with no event at all, so the timeline only ever saw a
|
|
1759
|
-
* bare "active" and could not tell this apart from an operator resuming a paused goal — which
|
|
1760
|
-
* is why the `by` field exists. Logged here rather than through `setGoalStatus` because the
|
|
1761
|
-
* audit entry below must be written in the same order the state changed.
|
|
1762
|
-
*/
|
|
1763
966
|
const reopened = verdict !== "approved" && this.goal.status === "done";
|
|
1764
967
|
if (reopened) {
|
|
1765
968
|
this.goal.status = "active";
|
|
@@ -1779,54 +982,16 @@ export class Agent {
|
|
|
1779
982
|
snapshot() {
|
|
1780
983
|
return {
|
|
1781
984
|
id: this.opts.id,
|
|
1782
|
-
/**
|
|
1783
|
-
* The DISPLAYED status, which must never contradict `live`.
|
|
1784
|
-
*
|
|
1785
|
-
* idle なのにコントロールは stop ボタンが表示されてて微妙" — and the same for
|
|
1786
|
-
* `wait_children`. `live` was already true for both (a parked tool and a running
|
|
1787
|
-
* background shell are work in flight, and stop() ends them), so only the badge was
|
|
1788
|
-
* wrong: it said idle while the header offered ■ stop.
|
|
1789
|
-
*
|
|
1790
|
-
* Rather than patch each site, the contradiction is fixed HERE: if there is work in
|
|
1791
|
-
* flight that stop would stop, the status says "waiting", never "idle". Callers that
|
|
1792
|
-
* need the raw state for logic use `live`, which is unchanged.
|
|
1793
|
-
*/
|
|
1794
985
|
status: this.status === "idle" && this.isLive() ? "waiting" : this.status,
|
|
1795
986
|
statusReason: this.statusReason,
|
|
1796
|
-
/**
|
|
1797
|
-
* Is there work in flight that `stop` would actually stop?
|
|
1798
|
-
*
|
|
1799
|
-
* `status` alone is not enough to answer that, and the UI needs it: a
|
|
1800
|
-
* parked tool (`wait_children`) rewrites the status to "idle" while the
|
|
1801
|
-
* loop is very much alive, so a client keying "stop vs start" off
|
|
1802
|
-
* `status === "running"` offered **start** on a running agent and the
|
|
1803
|
-
* user could not stop it. This is the same "running is not the
|
|
1804
|
-
* only live state" fact #38 had to be taught about editing.
|
|
1805
|
-
*/
|
|
1806
987
|
live: this.isLive(),
|
|
1807
988
|
workspace: this.workspace,
|
|
1808
|
-
// through the getter, so a workspace that came back stops being reported as 👻
|
|
1809
989
|
workspaceMissing: this.isGhost,
|
|
1810
990
|
session: this.currentSession,
|
|
1811
|
-
// a human label for this session, so the sidebar does not ask the
|
|
1812
|
-
// operator to read `teapot-10-16854917`. Absent when the first prompt had
|
|
1813
|
-
// no usable line — the UI then falls back to the id.
|
|
1814
991
|
...(this.sessionTitle ? { sessionTitle: this.sessionTitle } : {}),
|
|
1815
992
|
branch: this.currentBranch,
|
|
1816
993
|
goal: {
|
|
1817
994
|
status: this.goal.status,
|
|
1818
|
-
/**
|
|
1819
|
-
* The goal is sent WHOLE.
|
|
1820
|
-
*
|
|
1821
|
-
* This used to be `slice(0, 400)`, which reads as a sane payload cap and behaves as a
|
|
1822
|
-
* silent data loss: the right panel's goal card is the only place the operator sees the
|
|
1823
|
-
* text they wrote, so a long goal stopped mid-sentence. Reported as "goalに設定されている
|
|
1824
|
-
* 文字列が長いとフロントエンドのgoal欄で後半が切られる? 全部表示されてほしい".
|
|
1825
|
-
*
|
|
1826
|
-
* payload argument does not survive the numbers either — this rides one `/api/agents`
|
|
1827
|
-
* response that is already carrying every agent's stats, and a goal is a few KB at worst.
|
|
1828
|
-
* `verify` keeps its own cap below, where the auditor writes it.
|
|
1829
|
-
*/
|
|
1830
995
|
text: this.goal.text,
|
|
1831
996
|
...(this.goal.verify ? { verify: this.goal.verify.slice(0, 1000) } : {}),
|
|
1832
997
|
...(this.goal.audit ? { audit: this.goal.audit } : {}),
|
|
@@ -1835,51 +1000,19 @@ export class Agent {
|
|
|
1835
1000
|
stats: { ...this.stats, costUsd: this.modelPricing ? this.stats.costUsd : undefined },
|
|
1836
1001
|
model: this.opts.llm.model,
|
|
1837
1002
|
provider: this.opts.provider,
|
|
1838
|
-
// #47/#150: the effort in force, and whether this model can take one at all
|
|
1839
1003
|
...(this.opts.reasoningEffort ? { reasoningEffort: this.opts.reasoningEffort } : {}),
|
|
1840
1004
|
effortSupported: effortChoosable(this.opts.supportedParameters),
|
|
1841
|
-
// the levels to OFFER, when the endpoint publishes them: a model with
|
|
1842
|
-
// ["max","high","low"] must not be shown minimal/medium, which would either be
|
|
1843
|
-
// refused or silently clamped
|
|
1844
1005
|
...(this.opts.supportedEfforts?.length ? { effortOptions: this.opts.supportedEfforts } : {}),
|
|
1845
1006
|
sessionDir: this.opts.sessionDir,
|
|
1846
1007
|
ctx: {
|
|
1847
1008
|
usedTokens: this.lastUsage?.input ?? this.estimateTokens(),
|
|
1848
1009
|
compactAt: this.opts.contextTokenBudget,
|
|
1849
|
-
// false when the budget was derived (75% of a known window) instead of
|
|
1850
|
-
// being pinned in config — lets the UI say "75% of window" rather than
|
|
1851
|
-
// mislabeling every inferred-window model "manual override"
|
|
1852
1010
|
compactAtIsManual: this.compactBudgetIsManual,
|
|
1853
1011
|
window: this.opts.contextWindowTokens || 0,
|
|
1854
|
-
// live compaction phase ("summarizing"/"harvesting"/"done") — lets any
|
|
1855
|
-
// client show progress even if it missed the bus event
|
|
1856
1012
|
...(this.compactPhase ? { compacting: this.compactPhase } : {}),
|
|
1857
1013
|
},
|
|
1858
1014
|
pendingPrompts: this.pendingPrompts.length,
|
|
1859
|
-
// the IDS as well as the count. The web UI reconciles its local echo
|
|
1860
|
-
// list against the live queue, and it was doing that by POSITION
|
|
1861
|
-
// (slice(0, queued)) because only a count was published — so whenever the
|
|
1862
|
-
// count dipped below the true queue length (a mid-queue cancellation, or a
|
|
1863
|
-
// momentary gap), the NEWEST still-queued echoes were dropped and their
|
|
1864
|
-
// undelivered log rows reappeared as settled "sent" rows. Identity needs
|
|
1865
|
-
// the ids to travel; the count alone cannot express "these two".
|
|
1866
|
-
//
|
|
1867
|
-
// Bounded like the rest of the snapshot, and ids are opaque, so this
|
|
1868
|
-
// cannot carry prompt text.
|
|
1869
1015
|
pendingPromptIds: this.pendingPrompts.map((p) => p.id).filter((x) => !!x).slice(0, 64),
|
|
1870
|
-
// the QUEUE itself, not just its ids. The UI's echo rows are
|
|
1871
|
-
// memory-only (like liveByAgent and timelineCache), so after a reload the
|
|
1872
|
-
// queue survives on the server while the UI has nothing to render — the
|
|
1873
|
-
// queued message then appears only as its log row, i.e. already sent.
|
|
1874
|
-
//
|
|
1875
|
-
// Ids alone cannot fix that: they identify a prompt, they do not carry its
|
|
1876
|
-
// text, so the UI cannot rebuild an echo from them. This is the queue the
|
|
1877
|
-
// operator is looking at, already on screen in the ⏳ badge, so shipping it
|
|
1878
|
-
// is not new exposure — it is the same data in a shape the timeline can
|
|
1879
|
-
// use.
|
|
1880
|
-
//
|
|
1881
|
-
// `at` is the enqueue time so the echo keeps a stable position and id
|
|
1882
|
-
// across rebuilds; `sent` mirrors the echo flag the UI already tracks.
|
|
1883
1016
|
pendingPromptQueue: this.pendingPrompts
|
|
1884
1017
|
.filter((p) => !!p.id)
|
|
1885
1018
|
.slice(0, 64)
|
|
@@ -1890,59 +1023,27 @@ export class Agent {
|
|
|
1890
1023
|
at: Date.now() - (this.pendingPrompts.length - i),
|
|
1891
1024
|
...(p.images?.length ? { images: p.images.slice(0, 8) } : {}),
|
|
1892
1025
|
})),
|
|
1893
|
-
todo: this.todo.slice(0, 32_000),
|
|
1026
|
+
todo: this.todo.slice(0, 32_000),
|
|
1894
1027
|
parent: this.opts.parent,
|
|
1895
1028
|
awaiting: this.awaitingUser,
|
|
1896
1029
|
autoContinue: this.opts.autoContinue,
|
|
1897
1030
|
autoCompact: this.opts.autoCompact,
|
|
1898
1031
|
};
|
|
1899
1032
|
}
|
|
1900
|
-
/**
|
|
1901
|
-
* Queue a user prompt. Returns immediately: the event is logged right away
|
|
1902
|
-
* (so every connected UI sees it instantly) and the text is handed to the
|
|
1903
|
-
* model at the next turn boundary — never mid-turn, and never blocked by
|
|
1904
|
-
* the running loop. The very first prompt on a fresh boot also triggers the
|
|
1905
|
-
* lazy session restore (before the mailbox is filled, so no duplicates).
|
|
1906
|
-
*/
|
|
1907
1033
|
enqueuePrompt(text, source = "user", images) {
|
|
1908
|
-
// a prompt during a tool park (wait_children) takes over instantly:
|
|
1909
|
-
// unpark fixes the DISPLAY, aborting the park signal actually ends the
|
|
1910
|
-
// wait — the run chain resumes with this prompt drained at the boundary
|
|
1911
1034
|
if (this.parkedByTool) {
|
|
1912
1035
|
this.toolAbort.abort();
|
|
1913
|
-
// the abort signal is one-shot — rearm it AND republish on toolCtx
|
|
1914
|
-
// (toolCtx holds a COPY of the signal reference, so replacing only
|
|
1915
|
-
// this.toolAbort left the park listening to the dead controller)
|
|
1916
1036
|
this.toolAbort = new AbortController();
|
|
1917
1037
|
this.toolCtx.signal = this.toolAbort.signal;
|
|
1918
1038
|
}
|
|
1919
1039
|
this.unparkFromTool();
|
|
1920
1040
|
this.wake?.();
|
|
1921
|
-
// a user message means the operator JUST checked in — they obviously know
|
|
1922
|
-
// the state, so don't nag with a progress report right after
|
|
1923
1041
|
if (source === "user") {
|
|
1924
1042
|
this.lastProgressAt = Date.now();
|
|
1925
1043
|
this.activityChars = 0;
|
|
1926
1044
|
this.turnsSinceProgress = 0;
|
|
1927
1045
|
}
|
|
1928
|
-
// stable id shared by the log event and the UI's pending echo — the UI
|
|
1929
|
-
// flips its echo to "sent" when prompt-delivered carries this id back
|
|
1930
1046
|
const promptId = source === "user" ? `u${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}` : "";
|
|
1931
|
-
// the queue entry is registered SYNCHRONOUSLY, before the async work.
|
|
1932
|
-
//
|
|
1933
|
-
// It used to be pushed inside the `.then()`, so it only existed once
|
|
1934
|
-
// `ensureReady()` resolved — while `cancelPrompt` reads the array
|
|
1935
|
-
// synchronously. A cancel issued in that window found nothing, returned null,
|
|
1936
|
-
// got a 409, and the UI discarded the draft: the operator saw a withdrawn
|
|
1937
|
-
// prompt that the model then received and acted on.
|
|
1938
|
-
//
|
|
1939
|
-
// The wide case is worse than the same-tick one: an agent still restoring a
|
|
1940
|
-
// 4,000-event session log holds that promise for real wall-clock time, so the
|
|
1941
|
-
// cancel essentially always loses.
|
|
1942
|
-
//
|
|
1943
|
-
// Registering first is safe because the queue is drained only at a turn
|
|
1944
|
-
// boundary (`drainPendingPrompts`), which cannot run inside this
|
|
1945
|
-
// synchronous stretch. The log append still happens in order, after.
|
|
1946
1047
|
const entry = { source, text, images, ...(promptId ? { id: promptId } : {}) };
|
|
1947
1048
|
this.pendingPrompts.push(entry);
|
|
1948
1049
|
void this.ensureReady()
|
|
@@ -1953,7 +1054,6 @@ export class Agent {
|
|
|
1953
1054
|
...(promptId ? { promptId } : {}),
|
|
1954
1055
|
}))
|
|
1955
1056
|
.then(() => {
|
|
1956
|
-
// a session's label comes from its FIRST user prompt; the host stores it once
|
|
1957
1057
|
if (source === "user")
|
|
1958
1058
|
this.opts.onFirstUserPrompt?.(this.currentSession);
|
|
1959
1059
|
})
|
|
@@ -1961,14 +1061,10 @@ export class Agent {
|
|
|
1961
1061
|
.catch(() => { });
|
|
1962
1062
|
return promptId;
|
|
1963
1063
|
}
|
|
1964
|
-
/**
|
|
1965
|
-
* Withdraw a still-pending user prompt (before it reaches an LLM call).
|
|
1966
|
-
* Returns the text so the UI can put it back into the composer draft.
|
|
1967
|
-
*/
|
|
1968
1064
|
cancelPrompt(promptId) {
|
|
1969
1065
|
const i = this.pendingPrompts.findIndex((p) => p.id === promptId);
|
|
1970
1066
|
if (i === -1)
|
|
1971
|
-
return null;
|
|
1067
|
+
return null;
|
|
1972
1068
|
const [p] = this.pendingPrompts.splice(i, 1);
|
|
1973
1069
|
void this.log
|
|
1974
1070
|
.append("system_note", this.currentSession, this.currentBranch, {
|
|
@@ -1980,17 +1076,9 @@ export class Agent {
|
|
|
1980
1076
|
.catch(() => { });
|
|
1981
1077
|
return p.text;
|
|
1982
1078
|
}
|
|
1983
|
-
/**
|
|
1984
|
-
* Hand queued user prompts to the model at a turn boundary. Each consumed
|
|
1985
|
-
* prompt is logged as a system_note (prompt-delivered) so the UI can flip
|
|
1986
|
-
* its pending echo to "sent" at exactly the moment the text enters an LLM
|
|
1987
|
-
* call payload — not merely when it was logged.
|
|
1988
|
-
*/
|
|
1989
1079
|
drainPendingPrompts() {
|
|
1990
1080
|
this.drainBackgroundExits();
|
|
1991
1081
|
for (const p of this.pendingPrompts.splice(0)) {
|
|
1992
|
-
// multimodal: images ride as OpenAI content parts (text first, then
|
|
1993
|
-
// images); plain prompts keep the string form for cache friendliness
|
|
1994
1082
|
if (p.images?.length) {
|
|
1995
1083
|
this.messages.push({
|
|
1996
1084
|
role: "user",
|
|
@@ -2006,30 +1094,12 @@ export class Agent {
|
|
|
2006
1094
|
}
|
|
2007
1095
|
if (p.source === "user") {
|
|
2008
1096
|
const id = p.id ?? `u${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`;
|
|
2009
|
-
// queued here, MARKED at the request boundary instead — see
|
|
2010
|
-
// markDeliveredPrompts(). Text is kept so the marker can carry the same
|
|
2011
|
-
// preview the UI shows.
|
|
2012
1097
|
this.undeliveredPrompts.push({ id, preview: p.text.slice(0, 80) });
|
|
2013
1098
|
}
|
|
2014
1099
|
}
|
|
2015
1100
|
}
|
|
2016
|
-
/** ids of user prompts that have entered an LLM call payload (recent first-capped) */
|
|
2017
1101
|
deliveredPrompts = [];
|
|
2018
|
-
/**
|
|
2019
|
-
* User prompts consumed from the queue but not yet MARKED as delivered.
|
|
2020
|
-
*
|
|
2021
|
-
* They are drained at a turn boundary, but the `prompt-delivered` marker must
|
|
2022
|
-
* wait until the request that carries them actually starts — which is later,
|
|
2023
|
-
* and after `maybeCompact()`, which can itself call the LLM.
|
|
2024
|
-
*/
|
|
2025
1102
|
undeliveredPrompts = [];
|
|
2026
|
-
/**
|
|
2027
|
-
* Log `prompt-delivered` for every prompt whose outbound request is starting.
|
|
2028
|
-
*
|
|
2029
|
-
* the marker's meaning is "the request carrying this prompt has begun".
|
|
2030
|
-
* It used to fire in drainPendingPrompts, i.e. before the request, so the
|
|
2031
|
-
* timeline reordered by a moment the API never saw.
|
|
2032
|
-
*/
|
|
2033
1103
|
async markDeliveredPrompts() {
|
|
2034
1104
|
if (this.undeliveredPrompts.length === 0)
|
|
2035
1105
|
return;
|
|
@@ -2037,7 +1107,6 @@ export class Agent {
|
|
|
2037
1107
|
this.undeliveredPrompts = [];
|
|
2038
1108
|
for (const { id, preview } of pending) {
|
|
2039
1109
|
this.deliveredPrompts.push(id);
|
|
2040
|
-
// cap: the UI only needs recent confirmations
|
|
2041
1110
|
if (this.deliveredPrompts.length > 64)
|
|
2042
1111
|
this.deliveredPrompts.shift();
|
|
2043
1112
|
await this.log.append("system_note", this.currentSession, this.currentBranch, {
|
|
@@ -2048,40 +1117,22 @@ export class Agent {
|
|
|
2048
1117
|
}
|
|
2049
1118
|
bus.emit("update", { kind: "agent-update", agentId: this.opts.id });
|
|
2050
1119
|
}
|
|
2051
|
-
/**
|
|
2052
|
-
* Background jobs that exited since the last LLM call. Their outcomes are
|
|
2053
|
-
* folded into ONE harness message at the turn boundary — the agent learns
|
|
2054
|
-
* exit codes and output tails without polling bash_output.
|
|
2055
|
-
*/
|
|
2056
1120
|
bgExits = [];
|
|
2057
1121
|
onBackgroundJobExitQueued(info) {
|
|
2058
1122
|
this.bgExits.push(info);
|
|
2059
1123
|
if (this.bgExits.length > 32)
|
|
2060
|
-
this.bgExits.shift();
|
|
1124
|
+
this.bgExits.shift();
|
|
2061
1125
|
bus.emit("update", { kind: "agent-update", agentId: this.opts.id });
|
|
2062
|
-
// the round ended BECAUSE this job was running (auto-continue is
|
|
2063
|
-
// suppressed while bg jobs are alive) — its exit IS the wake-up call.
|
|
2064
|
-
// Without this the report would sit unread until the operator poked
|
|
2065
|
-
// the agent. Stopped agents stay stopped (a deliberate human stop).
|
|
2066
|
-
//
|
|
2067
|
-
// Checking status once is NOT enough: the exit can land in the window
|
|
2068
|
-
// between the loop's hasRunningBgShells() check (which breaks the round
|
|
2069
|
-
// out) and the loop actually flipping to idle. The callback then sees
|
|
2070
|
-
// "running", skips start(), and the agent goes idle with the report
|
|
2071
|
-
// unread — wedged until a human pokes it. So remember that the loop was
|
|
2072
|
-
// still winding down, and re-check once it has settled.
|
|
2073
1126
|
const windingDown = this.status === "running";
|
|
2074
1127
|
if (!this.stopRequested && (this.status === "idle" || windingDown)) {
|
|
2075
|
-
// park the decision until the in-flight round has actually settled
|
|
2076
1128
|
void this.enqueue(async () => {
|
|
2077
1129
|
if (this.stopRequested)
|
|
2078
|
-
return;
|
|
1130
|
+
return;
|
|
2079
1131
|
if (this.status === "idle")
|
|
2080
1132
|
this.start(`background job ${info.id} finished`);
|
|
2081
1133
|
});
|
|
2082
1134
|
}
|
|
2083
1135
|
}
|
|
2084
|
-
/** fold queued background-exit reports into the next LLM call (once each) */
|
|
2085
1136
|
drainBackgroundExits() {
|
|
2086
1137
|
if (!this.bgExits.length)
|
|
2087
1138
|
return;
|
|
@@ -2097,7 +1148,6 @@ export class Agent {
|
|
|
2097
1148
|
this.messages.push({ role: "user", content: text });
|
|
2098
1149
|
void this.log.append("prompt", this.currentSession, this.currentBranch, { source: "harness", text }).catch(() => { });
|
|
2099
1150
|
}
|
|
2100
|
-
/** Resolves when all queued work (including a running loop) has settled. */
|
|
2101
1151
|
settled() {
|
|
2102
1152
|
return this.runChain.catch(() => { });
|
|
2103
1153
|
}
|
|
@@ -2106,29 +1156,15 @@ export class Agent {
|
|
|
2106
1156
|
this.runChain = p.then(() => { }, () => { });
|
|
2107
1157
|
return p;
|
|
2108
1158
|
}
|
|
2109
|
-
/** Start (or resume) autonomous operation toward the goal. */
|
|
2110
1159
|
start(reason = "start") {
|
|
2111
|
-
// a stop() immediately followed by start() USED TO BE SILENTLY LOST.
|
|
2112
|
-
//
|
|
2113
|
-
// `stop()` only flips the status via an ENQUEUED task, so between the two
|
|
2114
|
-
// calls the status is still "running" and the guard below returned early —
|
|
2115
|
-
// before `stopRequested = false` could run. The stop then landed, the loop
|
|
2116
|
-
// exited, and the agent ended "stopped" with the restart discarded. The same
|
|
2117
|
-
// window made a stop+restart abort the new round's first tool.
|
|
2118
|
-
//
|
|
2119
|
-
// So a pending stop is not a reason to refuse a start. The loop's own
|
|
2120
|
-
// guards (`stopRequested` re-checked after every await) keep the old turn
|
|
2121
|
-
// from running on, and enqueue() serialises the two tasks, so the restart
|
|
2122
|
-
// is ordered after the stop rather than racing it.
|
|
2123
1160
|
const pendingStop = this.stopRequested;
|
|
2124
1161
|
if (this.status === "running" && !pendingStop)
|
|
2125
1162
|
return;
|
|
2126
1163
|
this.stopRequested = false;
|
|
2127
|
-
// claim the status against any stop write still queued behind us
|
|
2128
1164
|
this.stopSeq++;
|
|
2129
|
-
this.awaitingUser = false;
|
|
1165
|
+
this.awaitingUser = false;
|
|
2130
1166
|
void this.enqueue(async () => {
|
|
2131
|
-
await this.ensureReady();
|
|
1167
|
+
await this.ensureReady();
|
|
2132
1168
|
this.setStatus("running", reason);
|
|
2133
1169
|
this.stats.startedAt ??= new Date().toISOString();
|
|
2134
1170
|
});
|
|
@@ -2136,96 +1172,32 @@ export class Agent {
|
|
|
2136
1172
|
}
|
|
2137
1173
|
stop(reason = "stopped by user") {
|
|
2138
1174
|
this.stopRequested = true;
|
|
2139
|
-
this.parkedByTool = false;
|
|
2140
|
-
// interrupt an in-flight LLM call AND any parked tool (wait_children):
|
|
2141
|
-
// without aborting toolCtx.signal the park ran to its full timeout
|
|
1175
|
+
this.parkedByTool = false;
|
|
2142
1176
|
this.abort?.abort();
|
|
2143
1177
|
this.toolAbort.abort();
|
|
2144
|
-
// and the BACKGROUND shells, which neither signal above reaches. A job
|
|
2145
|
-
// spawned with `background: true` is `detached` and its spawning tool returned
|
|
2146
|
-
// long ago, so `toolAbort` has nothing on the stack to abort: the agent parked
|
|
2147
|
-
// in its "wait for the job" idle state, the job kept running, and `stop()` —
|
|
2148
|
-
// the control the operator pressed for exactly this — ended nothing. MEASURED:
|
|
2149
|
-
// `sleep 300` outlived both stop() and dispose().
|
|
2150
1178
|
killBgShells(this.toolCtx.cwd);
|
|
2151
1179
|
this.wake?.();
|
|
2152
|
-
// the status write is DEFERRED onto the run chain, so it can land
|
|
2153
|
-
// AFTER a start() that followed it. Observed: `idle -> stopped -> running ->
|
|
2154
|
-
// running -> idle`. The agent did resume — but it flashed "stopped" on the
|
|
2155
|
-
// way, and finished idle rather than running, so from the UI the operator's
|
|
2156
|
-
// ▶ start looked like it had done nothing.
|
|
2157
|
-
//
|
|
2158
|
-
// `start()` clears stopRequested synchronously, which is what makes this
|
|
2159
|
-
// detectable: by the time this task runs, a start has already claimed the
|
|
2160
|
-
// agent and the stop no longer owns the status.
|
|
2161
|
-
//
|
|
2162
|
-
// So record WHEN the stop was requested, and let a start that came after it
|
|
2163
|
-
// win. A stop issued while the agent is genuinely still running behaves
|
|
2164
|
-
// exactly as before.
|
|
2165
1180
|
const stopSeq = ++this.stopSeq;
|
|
2166
1181
|
void this.enqueue(() => {
|
|
2167
|
-
// Only a start() that arrived AFTER this stop may claim the status. The
|
|
2168
|
-
// seq check alone does that — no status test, because a genuinely running
|
|
2169
|
-
// agent must still be reported "stopped".
|
|
2170
1182
|
if (this.stopSeq !== stopSeq)
|
|
2171
1183
|
return Promise.resolve();
|
|
2172
1184
|
this.setStatus("stopped", reason);
|
|
2173
1185
|
return Promise.resolve();
|
|
2174
1186
|
});
|
|
2175
1187
|
}
|
|
2176
|
-
/** monotonic counter: a start() bumps it, so a pending stop status write loses */
|
|
2177
1188
|
stopSeq = 0;
|
|
2178
|
-
/** #95: set when this round already injected a harness progress request */
|
|
2179
1189
|
progressRequestedThisRound = false;
|
|
2180
|
-
/**
|
|
2181
|
-
* The turn number at which an auto-continue nudge was injected.
|
|
2182
|
-
*
|
|
2183
|
-
* `progressRequestedThisRound` covers ONE direction (progress asked first, nudge stands down). The
|
|
2184
|
-
* reverse was unguarded: the nudge is appended at the END of a round, and the NEXT round's first
|
|
2185
|
-
* turn calls `maybeRequestProgress()` — so one boundary carried "Continue working toward the
|
|
2186
|
-
* current goal" and then "Please give a brief progress report now", 2ms apart. MEASURED: 21
|
|
2187
|
-
* occurrences after the #95 fix, e.g. teapot-6-cca1d159 at 14:00:50 — the #182 report.
|
|
2188
|
-
*
|
|
2189
|
-
* A TURN NUMBER, not a boolean, because the nudge recurs every round in an auto-continue loop: a
|
|
2190
|
-
* flag set at the end of one round would suppress the report at every later boundary and progress
|
|
2191
|
-
* reports would stop for the whole session (MEASURED as three broken progress tests).
|
|
2192
|
-
*/
|
|
2193
1190
|
nudgedAtTurn = -1;
|
|
2194
|
-
/**
|
|
2195
|
-
* CONTROL PLANE: fired on every status change, immediately, independent of the
|
|
2196
|
-
* log.
|
|
2197
|
-
*
|
|
2198
|
-
* `wait_children` wakeups used to ride `EventLog.onEvent`, so a status
|
|
2199
|
-
* change reached the parent only AFTER the write completed. After the
|
|
2200
|
-
* write-before-notify fix that meant a FAILED or slow write left a parked parent
|
|
2201
|
-
* waiting forever — persistence latency became control flow. Measured with every
|
|
2202
|
-
* write failing: `onEvent fired: 0`, so no wakeup at all.
|
|
2203
|
-
*
|
|
2204
|
-
* Control flow must not depend on durability, so this is separate from the
|
|
2205
|
-
* event pipeline. The log's `onEvent` now means "persisted, tell the world";
|
|
2206
|
-
* this means "my state changed, react now".
|
|
2207
|
-
*/
|
|
2208
1191
|
onControlStatusChange = null;
|
|
2209
1192
|
setStatus(s, reason = "") {
|
|
2210
|
-
// Update the field FIRST. The control-plane hook fires synchronously below
|
|
2211
|
-
// (see onControlStatusChange), and it is what wakes parked wait_children
|
|
2212
|
-
// callers — those
|
|
2213
|
-
// re-check agent.status at that instant. Updating after the append made
|
|
2214
|
-
// every waiter observe the OLD status ("running"), conclude "someone is
|
|
2215
|
-
// still working", and stay parked until timeout even though the child had
|
|
2216
|
-
// just settled.
|
|
2217
1193
|
const prev = this.status;
|
|
2218
1194
|
this.status = s;
|
|
2219
1195
|
this.statusReason = reason;
|
|
2220
1196
|
if (prev !== s) {
|
|
2221
|
-
// control plane FIRST, and not via the log. A parent's wait_children
|
|
2222
|
-
// must wake on the status change itself, not on the durability of the write
|
|
2223
|
-
// that records it.
|
|
2224
1197
|
try {
|
|
2225
1198
|
this.onControlStatusChange?.(this.opts.id, s);
|
|
2226
1199
|
}
|
|
2227
1200
|
catch {
|
|
2228
|
-
/* a control-plane listener must never break a status change */
|
|
2229
1201
|
}
|
|
2230
1202
|
void this.log.append("state", this.currentSession, this.currentBranch, {
|
|
2231
1203
|
from: prev,
|
|
@@ -2235,41 +1207,17 @@ export class Agent {
|
|
|
2235
1207
|
}
|
|
2236
1208
|
bus.emit("update", { kind: "agent-update", agentId: this.opts.id });
|
|
2237
1209
|
}
|
|
2238
|
-
/** Main auto-run loop: turn -> tools -> turn ... -> done/idle/stop. */
|
|
2239
1210
|
async loop() {
|
|
2240
|
-
/**
|
|
2241
|
-
* The ROUND BOUNDARY is where a live update stops this loop.
|
|
2242
|
-
*
|
|
2243
|
-
* `windDownRequested` is set by `windDownForUpdate()`; reaching here means the previous round
|
|
2244
|
-
* ran to completion (its tool returned, its generation finished), which is exactly the "1ターン
|
|
2245
|
-
* が終わる" moment the operator asked for. Nothing is aborted, so the log ends on a complete
|
|
2246
|
-
* round and the next process resumes from a real state.
|
|
2247
|
-
*/
|
|
2248
1211
|
while (!this.stopRequested && !this.windDownRequested) {
|
|
2249
1212
|
try {
|
|
2250
|
-
// stop() aborted the tool signal and nothing re-armed it (only the
|
|
2251
|
-
// parkedByTool path ever did), so EVERY tool after a stop returned
|
|
2252
|
-
// "aborted (harness shutdown)" until process restart. A new round is
|
|
2253
|
-
// new work: give it a live signal (re-arm AND republish — toolCtx
|
|
2254
|
-
// holds a copy of the reference).
|
|
2255
1213
|
if (this.toolAbort.signal.aborted) {
|
|
2256
1214
|
this.toolAbort = new AbortController();
|
|
2257
1215
|
this.toolCtx.signal = this.toolAbort.signal;
|
|
2258
1216
|
}
|
|
2259
|
-
// failures from the previous round must not poison this one — the
|
|
2260
|
-
// counter only ever reset on success, so a tripped limit re-tripped
|
|
2261
|
-
// on the first failure after restart ("6 consecutive" right after start)
|
|
2262
1217
|
this.consecutiveToolErrors = 0;
|
|
2263
|
-
// a round that got as far as an LLM call means the API is reachable
|
|
2264
|
-
// again — reset the error-retry backoff to its base delay
|
|
2265
1218
|
this.consecutiveApiErrors = 0;
|
|
2266
1219
|
const turnsAtStart = this.stats.turns;
|
|
2267
1220
|
let finished = await this.runTurnsUntilIdle();
|
|
2268
|
-
// Round ended on the per-round turn cap. NO model-facing nudge: telling
|
|
2269
|
-
// the model "the round ended, consider finish()" invited premature
|
|
2270
|
-
// finishes that conflicted with the goal. The cap is harness-side only
|
|
2271
|
-
// (logged for observability); continuation goes through the normal
|
|
2272
|
-
// auto-continue path below.
|
|
2273
1221
|
const cap = Math.max(0, this.opts.maxTurnsPerRound ?? 200);
|
|
2274
1222
|
if (!finished && cap > 0 && this.stats.turns - turnsAtStart >= cap && !this.stopRequested) {
|
|
2275
1223
|
await this.log.append("system_note", this.currentSession, this.currentBranch, {
|
|
@@ -2279,22 +1227,6 @@ export class Agent {
|
|
|
2279
1227
|
}
|
|
2280
1228
|
if (this.stopRequested)
|
|
2281
1229
|
break;
|
|
2282
|
-
// fresh user input arrived while we were finishing up — another round now
|
|
2283
|
-
// bound consecutive audit rejections.
|
|
2284
|
-
//
|
|
2285
|
-
// A rejected audit queues a retry prompt, so the round CONTINUES — and
|
|
2286
|
-
// neither existing valve applies: maxConsecutiveIdleRounds counts IDLE
|
|
2287
|
-
// rounds (a rejection is not idle), and maxTurnsPerRound only logs. Measured
|
|
2288
|
-
// before this cap: 381 audit calls in a single turn.
|
|
2289
|
-
//
|
|
2290
|
-
// Deliberately NOT "mark the goal done": #128's whole complaint is that
|
|
2291
|
-
// "could not be audited" silently became "passed". The goal is left ACTIVE
|
|
2292
|
-
// and a loud note is logged, so the operator sees the loop and decides.
|
|
2293
|
-
// `>=` (not `>`): the JSDoc says REACHING 3 stops — `>` allowed a 4th
|
|
2294
|
-
// audit before breaking (off-by-one). The retry prompt is no longer
|
|
2295
|
-
// queued AT the cap (see handleFinish), so the break strands nothing.
|
|
2296
|
-
// The counter resets so ONE capped stretch doesn't brick every future
|
|
2297
|
-
// round-end until setGoal — a fixed model gets a fresh count.
|
|
2298
1230
|
if (this.consecutiveAuditRejects >= (this.opts.maxAuditRejects ?? 3)) {
|
|
2299
1231
|
await this.log.append("system_note", this.currentSession, this.currentBranch, {
|
|
2300
1232
|
event: "audit-retry-cap",
|
|
@@ -2306,12 +1238,6 @@ export class Agent {
|
|
|
2306
1238
|
}
|
|
2307
1239
|
if (this.pendingPrompts.length)
|
|
2308
1240
|
continue;
|
|
2309
|
-
// A round that parked its work on a background bash (dev server,
|
|
2310
|
-
// watcher, long build) ENDED ON PURPOSE — the job's exit notification
|
|
2311
|
-
// is the wake-up. Nagging with "continue working" here just made the
|
|
2312
|
-
// agent spin up duplicate work while it meant to wait. Checked BEFORE
|
|
2313
|
-
// the finished/goal gates: a finish(goalComplete=false) round that
|
|
2314
|
-
// leaves a bg job running is exactly the "wait for it" pattern.
|
|
2315
1241
|
if (hasRunningBgShells(this.toolCtx.cwd)) {
|
|
2316
1242
|
await this.log.append("system_note", this.currentSession, this.currentBranch, {
|
|
2317
1243
|
event: "auto-continue-suppressed",
|
|
@@ -2319,37 +1245,21 @@ export class Agent {
|
|
|
2319
1245
|
});
|
|
2320
1246
|
break;
|
|
2321
1247
|
}
|
|
2322
|
-
// A SUB-AGENT that ended its round on a bare message (no finish call)
|
|
2323
|
-
// still owes the parent a report — nothing else will carry it.
|
|
2324
|
-
// Emitted HERE, after the loop, so a real finish() on a later turn
|
|
2325
|
-
// always wins and this never pre-empts a better summary.
|
|
2326
1248
|
if (this.pendingSubReport) {
|
|
2327
1249
|
const report = this.pendingSubReport;
|
|
2328
1250
|
this.pendingSubReport = "";
|
|
2329
1251
|
if (!finished) {
|
|
2330
1252
|
await this.handleFinish(JSON.stringify({ goalComplete: true, summary: report }));
|
|
2331
|
-
finished = true;
|
|
1253
|
+
finished = true;
|
|
2332
1254
|
}
|
|
2333
1255
|
}
|
|
2334
|
-
// a round that did real work resets the idle counter. `finished`
|
|
2335
|
-
// covers the finish() paths; a round that ran tool calls is caught by the
|
|
2336
|
-
// turn counter below, which is checked before this point.
|
|
2337
1256
|
if (finished)
|
|
2338
1257
|
this.consecutiveIdleRounds = 0;
|
|
2339
|
-
// auto-continue only makes sense with an active goal to continue toward
|
|
2340
1258
|
if (finished ||
|
|
2341
1259
|
!this.opts.autoContinue ||
|
|
2342
1260
|
this.goal.status !== "active" ||
|
|
2343
1261
|
!this.goal.text.trim())
|
|
2344
1262
|
break;
|
|
2345
|
-
// auto-continue: wait quietly, then nudge with a fresh round. Sub-agents get a
|
|
2346
|
-
// SCOPE-ANCHORED nudge — a generic "continue toward the goal" made a forked sub-agent drift
|
|
2347
|
-
// into one of the parent's older tasks.
|
|
2348
|
-
//
|
|
2349
|
-
// Bound CONSECUTIVE rounds that produced neither a finish() nor a tool call. Any tool call
|
|
2350
|
-
// resets it (that is real work, not stalling), as does a stop or a fresh prompt. This is a
|
|
2351
|
-
// safety valve, not a policy: a talk-only root agent ends every round after one turn, so no
|
|
2352
|
-
// per-round cap can ever see it, and `finished` stays false forever.
|
|
2353
1263
|
if (++this.consecutiveIdleRounds > this.opts.maxConsecutiveIdleRounds) {
|
|
2354
1264
|
this.consecutiveIdleRounds = 0;
|
|
2355
1265
|
await this.log.append("system_note", this.currentSession, this.currentBranch, {
|
|
@@ -2363,36 +1273,15 @@ export class Agent {
|
|
|
2363
1273
|
await this.sleepInterruptible(this.opts.continueDelayMs);
|
|
2364
1274
|
if (this.stopRequested)
|
|
2365
1275
|
break;
|
|
2366
|
-
// do NOT pair this with a progress request.
|
|
2367
|
-
//
|
|
2368
|
-
// Both fire at the turn boundary and both inject a user message, so the model received two
|
|
2369
|
-
// harness instructions back to back — "Continue working toward the current goal"
|
|
2370
|
-
// immediately followed by "Please give a brief progress report now", which reads as the
|
|
2371
|
-
// harness talking to itself.
|
|
2372
|
-
//
|
|
2373
|
-
// `maybeRequestProgress` runs its OWN `llmCall` at the top of the turn, so the assistant
|
|
2374
|
-
// message it produces is a progress REPORT, not a continuation. The round then ended,
|
|
2375
|
-
// this branch saw the flag, logged `auto-continue-suppressed`, and BROKE — leaving the
|
|
2376
|
-
// agent idle with the work unfinished. The operator asked "何故 auto-continue が効いてない?"
|
|
2377
|
-
// and the log shows exactly one such note, at the very end.
|
|
2378
|
-
//
|
|
2379
|
-
// The suppression is still right (one instruction per boundary); breaking is not. What is
|
|
2380
|
-
// already in flight IS the continuation, so there is nothing left to add — but the loop
|
|
2381
|
-
// must keep going, or a progress request silently ends the session's work.
|
|
2382
1276
|
if (this.progressRequestedThisRound) {
|
|
2383
1277
|
await this.log.append("system_note", this.currentSession, this.currentBranch, {
|
|
2384
1278
|
event: "auto-continue-suppressed",
|
|
2385
1279
|
detail: "a progress report was already requested this round (#95) — the round that carried it already continues the work, so no nudge is added and the loop goes on",
|
|
2386
1280
|
});
|
|
2387
|
-
// consume the flag: it authorised skipping THIS boundary's nudge, not every later one
|
|
2388
1281
|
this.progressRequestedThisRound = false;
|
|
2389
1282
|
continue;
|
|
2390
1283
|
}
|
|
2391
1284
|
const isSub = !!this.opts.parent;
|
|
2392
|
-
// the ROOT nudge now restates the goal too, exactly as the sub-agent
|
|
2393
|
-
// one always has. Both branches need it — a child drifts back into the
|
|
2394
|
-
// inherited conversation, a root agent loses the thread, and in both cases
|
|
2395
|
-
// "continue toward the current goal" is unactionable without the goal text.
|
|
2396
1285
|
const nudge = isSub
|
|
2397
1286
|
? `[harness] Auto-nudge. Re-anchor on YOUR task — and only that:\n\n${this.goal.text.slice(0, 1500)}\n\nThe inherited conversation is reference only; other agents own any older tasks in it. If your task is complete, call finish() now instead of continuing. If blocked, explain why briefly and finish().`
|
|
2398
1287
|
: autoContinueNudge(this.goal.text);
|
|
@@ -2401,21 +1290,14 @@ export class Agent {
|
|
|
2401
1290
|
text: nudge,
|
|
2402
1291
|
});
|
|
2403
1292
|
this.messages.push({ role: "user", content: nudge });
|
|
2404
|
-
// the nudge is consumed by the NEXT turn, which is the one that must not also ask for a report
|
|
2405
1293
|
this.nudgedAtTurn = this.stats.turns + 1;
|
|
2406
1294
|
}
|
|
2407
1295
|
catch (err) {
|
|
2408
1296
|
const name = err.name;
|
|
2409
|
-
// a stop (user abort or pre-call guard) is control flow, not a failure
|
|
2410
1297
|
if (this.stopRequested || name === "AbortError" || name === "StopRequested" || name === "WaitForUser")
|
|
2411
1298
|
break;
|
|
2412
1299
|
const msg = err.message ?? String(err);
|
|
2413
1300
|
await this.log.append("error", this.currentSession, this.currentBranch, { message: msg });
|
|
2414
|
-
// onError:"retry" keeps an unattended agent alive across failures —
|
|
2415
|
-
// BOTH provider outages (API errors) and runaway trips (a tool that
|
|
2416
|
-
// keeps failing). Back off, then start a fresh round; the delay
|
|
2417
|
-
// doubles per consecutive failure so a hard failure doesn't spin,
|
|
2418
|
-
// and any stop/prompt still interrupts the sleep instantly.
|
|
2419
1301
|
const isRunaway = /runaway detection/.test(msg);
|
|
2420
1302
|
if (this.opts.onError === "retry" && !this.awaitingUser && !this.pendingPrompts.length) {
|
|
2421
1303
|
this.consecutiveApiErrors++;
|
|
@@ -2427,10 +1309,6 @@ export class Agent {
|
|
|
2427
1309
|
kind: isRunaway ? "runaway" : "api",
|
|
2428
1310
|
});
|
|
2429
1311
|
this.setStatus("error", `${msg.slice(0, 220)} — retrying in ${Math.round(wait / 1000)}s`);
|
|
2430
|
-
// while this sleeps the agent is NOT idle — it is going to try again,
|
|
2431
|
-
// and the operator must be able to stop it. Without the flag `isLive()` read
|
|
2432
|
-
// false (status is "error"), so the UI showed "▶ start" and offered no stop
|
|
2433
|
-
// at all while a 5s/5s/30s…30min staircase ran.
|
|
2434
1312
|
this.retrySleeping = true;
|
|
2435
1313
|
try {
|
|
2436
1314
|
await this.sleepInterruptible(wait);
|
|
@@ -2440,32 +1318,18 @@ export class Agent {
|
|
|
2440
1318
|
}
|
|
2441
1319
|
if (this.stopRequested)
|
|
2442
1320
|
break;
|
|
2443
|
-
// the NEXT round is live work again — leaving "error" set made
|
|
2444
|
-
// isLive() false (error is not a live status) while the loop was
|
|
2445
|
-
// actually running, so the UI offered "start" on a working agent
|
|
2446
1321
|
this.setStatus("running", "retrying");
|
|
2447
|
-
continue;
|
|
1322
|
+
continue;
|
|
2448
1323
|
}
|
|
2449
1324
|
this.setStatus("error", msg.slice(0, 300));
|
|
2450
1325
|
return;
|
|
2451
1326
|
}
|
|
2452
1327
|
}
|
|
2453
|
-
// waiting on an ask_user answer is a status of its own — not idle (which
|
|
2454
|
-
// would let auto-continue nag) and not stopped
|
|
2455
1328
|
if (!this.stopRequested && !this.awaitingUser)
|
|
2456
1329
|
this.setStatus("idle", "round complete");
|
|
2457
1330
|
}
|
|
2458
|
-
/**
|
|
2459
|
-
* One LLM call with a fresh abort controller so stop() can interrupt it
|
|
2460
|
-
* immediately, plus loop-level retries for provider flakiness (the SDK
|
|
2461
|
-
* already backsoff 429/5xx; this covers exhausted rate limits and 400s).
|
|
2462
|
-
* When onDelta is given, cumulative stream snapshots are forwarded to the
|
|
2463
|
-
* UI via the bus (reset to empty at the start of every attempt).
|
|
2464
|
-
*/
|
|
2465
1331
|
async llmCall(messages, tools, onDelta) {
|
|
2466
1332
|
const maxAttempts = 4;
|
|
2467
|
-
// fail fast, escalate late: most provider hiccups recover in seconds;
|
|
2468
|
-
// only sustained failure earns a long cooldown (operator request)
|
|
2469
1333
|
const waits = this.opts.llmRetryWaitsMs ?? [5_000, 5_000, 30_000];
|
|
2470
1334
|
for (let attempt = 1;; attempt++) {
|
|
2471
1335
|
if (this.stopRequested)
|
|
@@ -2475,19 +1339,10 @@ export class Agent {
|
|
|
2475
1339
|
bus.emit("update", {
|
|
2476
1340
|
kind: "llm-delta",
|
|
2477
1341
|
agentId: this.opts.id,
|
|
2478
|
-
// the bubble is session-scoped, so the stream must say which session
|
|
2479
1342
|
sessionId: this.currentSession,
|
|
2480
1343
|
text: "",
|
|
2481
1344
|
reasoning: "",
|
|
2482
1345
|
});
|
|
2483
|
-
// `prompt-delivered` means "the outbound request CARRYING this prompt
|
|
2484
|
-
// has started" — which is why it is emitted here and not in
|
|
2485
|
-
// drainPendingPrompts. That runs BEFORE maybeCompact, which can itself make
|
|
2486
|
-
// an LLM call, so the marker preceded the request and the timeline claimed a
|
|
2487
|
-
// delivery order the API never saw.
|
|
2488
|
-
//
|
|
2489
|
-
// Once per request, not per attempt: a retry sends the same payload, so
|
|
2490
|
-
// re-emitting would move the marker past later events on each try.
|
|
2491
1346
|
if (attempt === 1)
|
|
2492
1347
|
await this.markDeliveredPrompts();
|
|
2493
1348
|
try {
|
|
@@ -2498,11 +1353,6 @@ export class Agent {
|
|
|
2498
1353
|
const name = err.name;
|
|
2499
1354
|
if (name === "StopRequested" || name === "AbortError" || this.stopRequested)
|
|
2500
1355
|
throw err;
|
|
2501
|
-
// a context-overflow error is the caller's to handle, not ours to
|
|
2502
|
-
// retry. Retrying the SAME oversized request four times with 5s/5s/30s
|
|
2503
|
-
// waits cannot succeed, and it delays the compact-and-retry that would
|
|
2504
|
-
// — so the overflow path never even ran. This is the same
|
|
2505
|
-
// "present, correct, and inert" shape as #54.
|
|
2506
1356
|
if (isContextOverflow(err))
|
|
2507
1357
|
throw err;
|
|
2508
1358
|
if (attempt >= maxAttempts)
|
|
@@ -2518,51 +1368,11 @@ export class Agent {
|
|
|
2518
1368
|
}
|
|
2519
1369
|
}
|
|
2520
1370
|
}
|
|
2521
|
-
/**
|
|
2522
|
-
* One "round": alternate LLM turns and tool executions until the model
|
|
2523
|
-
* produces a final answer without tool calls. Returns true if the agent
|
|
2524
|
-
* called finish().
|
|
2525
|
-
*/
|
|
2526
|
-
/**
|
|
2527
|
-
* The meta-tool handlers, keyed by the table's own name union.
|
|
2528
|
-
*
|
|
2529
|
-
* `Record<MetaToolName, …>` is the whole point: because `MetaToolName` is derived from
|
|
2530
|
-
* `META_TOOLS`, advertising a tool without handling it here is a TYPE ERROR rather than a
|
|
2531
|
-
* runtime "unknown tool". That failure used to be silent — the call fell through to
|
|
2532
|
-
* `executeTool`, which answered "unknown tool" for a tool the request had just described.
|
|
2533
|
-
*
|
|
2534
|
-
* Each handler mutates session state and answers its own `tool_call`. The return value is
|
|
2535
|
-
* the only control flow the old if-chain had:
|
|
2536
|
-
* - `"next"` — the `continue` it replaced: this call is done, move to the next one in
|
|
2537
|
-
* the assistant batch;
|
|
2538
|
-
* - `"end"` — the `return true` it replaced (`finish` only): end the whole batch, so a
|
|
2539
|
-
* model that emits finish() plus another call cannot keep mutating the workspace
|
|
2540
|
-
* after reporting completion.
|
|
2541
|
-
*/
|
|
2542
1371
|
metaHandlers = {
|
|
2543
1372
|
finish: async (call, m) => {
|
|
2544
|
-
this.pendingSubReport = "";
|
|
2545
|
-
// answer the tool_call BEFORE the audit runs.
|
|
2546
|
-
//
|
|
2547
|
-
// handleFinish() calls the auditor with `buildMessages()`, and at that
|
|
2548
|
-
// moment the history ended at an assistant message carrying
|
|
2549
|
-
// `tool_calls: [finish]` with NO matching `tool` message. Measured, the
|
|
2550
|
-
// auditor received exactly that:
|
|
2551
|
-
//
|
|
2552
|
-
// assistant(tool_calls=[finish])
|
|
2553
|
-
// user(audit prompt) <-- no tool(tool_call_id=…)
|
|
2554
|
-
//
|
|
2555
|
-
// OpenAI-compatible providers require every tool_call to be answered,
|
|
2556
|
-
// and a strict one rejects the request outright — so a CHANGES-REQUIRED
|
|
2557
|
-
// audit would fail with a protocol error rather than a verdict, and
|
|
2558
|
-
// #128's fail-open would then mark the goal done.
|
|
2559
|
-
//
|
|
2560
|
-
// Answering first also makes the log honest: the call really was
|
|
2561
|
-
// completed, it simply did not end the round.
|
|
1373
|
+
this.pendingSubReport = "";
|
|
2562
1374
|
await this.answerMeta(call, "(completion submitted for audit)");
|
|
2563
1375
|
await this.handleFinish(call.function.arguments);
|
|
2564
|
-
// surface still-running children so the operator knows work may
|
|
2565
|
-
// continue after this agent goes idle
|
|
2566
1376
|
try {
|
|
2567
1377
|
const running = (this.toolCtx.subAgents?.list?.() ?? []).filter((k) => k.status === "running");
|
|
2568
1378
|
if (running.length)
|
|
@@ -2571,20 +1381,8 @@ export class Agent {
|
|
|
2571
1381
|
detail: running.map((k) => `${k.id} (${k.status})`).join(", "),
|
|
2572
1382
|
});
|
|
2573
1383
|
}
|
|
2574
|
-
catch {
|
|
2575
|
-
// answer the tool_call so a follow-up round stays API-valid
|
|
1384
|
+
catch { }
|
|
2576
1385
|
await this.answerMeta(call, this.goal.status === "done" ? "(goal complete)" : "(round ended)");
|
|
2577
|
-
// the caller marks the round finished when this returns "end"
|
|
2578
|
-
// `continue` advanced the TOOL-CALL loop, so every later call in
|
|
2579
|
-
// this same assistant message still executed — a model emitting
|
|
2580
|
-
// finish() plus one more call got real filesystem writes AFTER it had
|
|
2581
|
-
// reported `final: true` and the goal was already done. The parent was
|
|
2582
|
-
// told the work finished while the workspace was still being mutated.
|
|
2583
|
-
//
|
|
2584
|
-
// So finish ends the BATCH: answer anything still outstanding (so no
|
|
2585
|
-
// tool_call is left unpaired — the provider rejects the next request
|
|
2586
|
-
// otherwise, #76) and leave the tool loop. The outer loop then returns
|
|
2587
|
-
// on `finished`.
|
|
2588
1386
|
await this.answerUnansweredToolCalls(m);
|
|
2589
1387
|
return "end";
|
|
2590
1388
|
},
|
|
@@ -2598,8 +1396,6 @@ export class Agent {
|
|
|
2598
1396
|
const text = String(a.text ?? "").trim();
|
|
2599
1397
|
if (text)
|
|
2600
1398
|
await this.setGoal(text);
|
|
2601
|
-
// pi-goal-x-style verification contract: plain-text completion
|
|
2602
|
-
// requirements audited before finish(goalComplete=true) is honored
|
|
2603
1399
|
if (typeof a.verify === "string" && a.verify.trim())
|
|
2604
1400
|
await this.setGoalVerify(a.verify.trim().slice(0, 4000));
|
|
2605
1401
|
await this.answerMeta(call, text ? "goal updated" : "empty goal rejected");
|
|
@@ -2617,8 +1413,6 @@ export class Agent {
|
|
|
2617
1413
|
ask_user: async (call, m) => {
|
|
2618
1414
|
const a = safeParse(call.function.arguments);
|
|
2619
1415
|
const question = String(a.question ?? "").slice(0, 2000);
|
|
2620
|
-
// models sometimes pass objects ({label}, {option}, {value}) —
|
|
2621
|
-
// extract the label instead of rendering "[object Object]" buttons
|
|
2622
1416
|
const options = Array.isArray(a.options)
|
|
2623
1417
|
? a.options
|
|
2624
1418
|
.slice(0, 6)
|
|
@@ -2635,8 +1429,6 @@ export class Agent {
|
|
|
2635
1429
|
})
|
|
2636
1430
|
.filter((o) => o.trim())
|
|
2637
1431
|
: [];
|
|
2638
|
-
// the callId lets the UI tell "still open" from "already answered"
|
|
2639
|
-
// (a settled tool_result for this id exists) and disable re-answering
|
|
2640
1432
|
await this.log.append("question", this.currentSession, this.currentBranch, {
|
|
2641
1433
|
question,
|
|
2642
1434
|
options,
|
|
@@ -2646,13 +1438,6 @@ export class Agent {
|
|
|
2646
1438
|
"Their reply (possibly free text, not one of the options) arrives as your next user message.");
|
|
2647
1439
|
this.awaitingUser = true;
|
|
2648
1440
|
this.setStatus("needs_operator", question.slice(0, 80));
|
|
2649
|
-
// control flow: park the loop; the operator's next prompt resumes it
|
|
2650
|
-
//
|
|
2651
|
-
// answer the rest of this assistant batch BEFORE parking. The
|
|
2652
|
-
// throw leaves the whole tool loop, so any EARLIER call in the same
|
|
2653
|
-
// message that was logged but not yet run kept no tool_result — the
|
|
2654
|
-
// comment on the backstop below says as much, and it is exactly the
|
|
2655
|
-
// path that bites in practice because ask_user is common.
|
|
2656
1441
|
await this.answerUnansweredToolCalls(m);
|
|
2657
1442
|
throw Object.assign(new Error("waiting for user"), { name: "WaitForUser" });
|
|
2658
1443
|
},
|
|
@@ -2662,9 +1447,6 @@ export class Agent {
|
|
|
2662
1447
|
},
|
|
2663
1448
|
set_todo: async (call, m) => {
|
|
2664
1449
|
const a = safeParse(call.function.arguments);
|
|
2665
|
-
// Surgical item changes — no full rewrite, so the operator's own headings and
|
|
2666
|
-
// notes in todo.md survive. `action` decides what to do; `done` is kept as
|
|
2667
|
-
// shorthand so a model that only sends {item, done} still works.
|
|
2668
1450
|
const ACTIONS = new Set(["done", "reopen", "add", "remove"]);
|
|
2669
1451
|
const updates = Array.isArray(a.updates)
|
|
2670
1452
|
? a.updates
|
|
@@ -2713,7 +1495,6 @@ export class Agent {
|
|
|
2713
1495
|
this.messages.push({ role: "tool", tool_call_id: call.id, content: "rule required" });
|
|
2714
1496
|
return "next";
|
|
2715
1497
|
}
|
|
2716
|
-
// repeated corrections gain weight: [xN] tag counts occurrences
|
|
2717
1498
|
const existing = await fs.readFile(this.feedbackFile, "utf8").catch(() => "");
|
|
2718
1499
|
const lines = existing.split("\n");
|
|
2719
1500
|
const idx = lines.findIndex((l) => l.includes(rule.slice(0, 60)));
|
|
@@ -2776,19 +1557,6 @@ export class Agent {
|
|
|
2776
1557
|
await this.answerMeta(call, "(memory.md is empty — nothing noted yet)");
|
|
2777
1558
|
return "next";
|
|
2778
1559
|
}
|
|
2779
|
-
// `query` searches instead of dumping the whole file.
|
|
2780
|
-
//
|
|
2781
|
-
// This is the one idea worth taking from the ecosystem. MCP's reference memory
|
|
2782
|
-
// server (the de-facto standard, used by Claude and offered by DSH as a
|
|
2783
|
-
// reference) exposes `search` alongside `read`, and its search is deliberately
|
|
2784
|
-
// a case-insensitive substring match — no embeddings, no model, no provider
|
|
2785
|
-
// dependency. DSH's guidance to the model is the same shape: "when historical
|
|
2786
|
-
// information may be relevant, search memory and use relevant results".
|
|
2787
|
-
//
|
|
2788
|
-
// `read_memory` only 97 times across every session, and only 13 of those reads
|
|
2789
|
-
// came after a write. Dumping a growing file gives the model no way to ask a
|
|
2790
|
-
// QUESTION, so a big file is all-or-nothing — either burn the whole thing into
|
|
2791
|
-
// the context or do not look at all.
|
|
2792
1560
|
if (!query) {
|
|
2793
1561
|
await this.answerMeta(call, mem.trim());
|
|
2794
1562
|
return "next";
|
|
@@ -2796,15 +1564,6 @@ export class Agent {
|
|
|
2796
1564
|
const needle = query.toLowerCase();
|
|
2797
1565
|
const lines = mem.split(/\r?\n/);
|
|
2798
1566
|
const hits = lines.filter((line) => line.toLowerCase().includes(needle));
|
|
2799
|
-
/**
|
|
2800
|
-
* Give each hit its CONTEXT, not just the bare line.
|
|
2801
|
-
*
|
|
2802
|
-
* A matching line on its own is often meaningless — "the cache lies" says nothing
|
|
2803
|
-
* without the heading it sits under. And a note needs to be dateable to be usable: a
|
|
2804
|
-
* lesson harvested from a compaction three weeks ago should not read with the same
|
|
2805
|
-
* authority as one from today. So each hit is reported with its nearest preceding
|
|
2806
|
-
* heading and any `_from:_` provenance line.
|
|
2807
|
-
*/
|
|
2808
1567
|
const headingAt = (idx) => {
|
|
2809
1568
|
for (let i = idx; i >= 0; i--) {
|
|
2810
1569
|
const l = lines[i].trim();
|
|
@@ -2814,7 +1573,6 @@ export class Agent {
|
|
|
2814
1573
|
return "";
|
|
2815
1574
|
};
|
|
2816
1575
|
const provenanceAt = (idx) => {
|
|
2817
|
-
// the `_from:_` line lives within a few lines above its lesson block
|
|
2818
1576
|
for (let i = idx; i >= Math.max(0, idx - 6); i--) {
|
|
2819
1577
|
const l = lines[i].trim();
|
|
2820
1578
|
if (/^_from:/.test(l))
|
|
@@ -2847,9 +1605,6 @@ export class Agent {
|
|
|
2847
1605
|
const a = safeParse(call.function.arguments);
|
|
2848
1606
|
const content = String(a.content ?? "").slice(0, 32_000);
|
|
2849
1607
|
await fs.writeFile(this.memoryFile, content, "utf8");
|
|
2850
|
-
// this used to say "injected into future prompts", which was false —
|
|
2851
|
-
// memory.md is never injected. The model then behaved as if it already held
|
|
2852
|
-
// its notes. Say what actually happened, and what the model must do instead.
|
|
2853
1608
|
await this.answerMeta(call, `memory.md REPLACED (${content.length} bytes). It is not in your prompt — call read_memory when you need it.`);
|
|
2854
1609
|
return "next";
|
|
2855
1610
|
},
|
|
@@ -2860,14 +1615,6 @@ export class Agent {
|
|
|
2860
1615
|
await this.answerMeta(call, "forget_memory needs a `query` — nothing removed");
|
|
2861
1616
|
return "next";
|
|
2862
1617
|
}
|
|
2863
|
-
// notes must be REMOVABLE, not only appendable.
|
|
2864
|
-
//
|
|
2865
|
-
// MCP's reference memory makes observations "added or removed
|
|
2866
|
-
// independently" for exactly this reason: an append-only store accumulates
|
|
2867
|
-
// stale facts ("the build takes 4 minutes" stays long after it takes 40
|
|
2868
|
-
// seconds), and a memory that is confidently wrong is worse than one that is
|
|
2869
|
-
// silent. Without a delete, the only way to correct a file was `set_memory`
|
|
2870
|
-
// with the whole content re-typed — which is how 1028 bytes became 83.
|
|
2871
1618
|
const prev = await fs.readFile(this.memoryFile, "utf8").catch(() => "");
|
|
2872
1619
|
if (!prev.trim()) {
|
|
2873
1620
|
await this.answerMeta(call, "(memory.md is empty — nothing to forget)");
|
|
@@ -2886,8 +1633,6 @@ export class Agent {
|
|
|
2886
1633
|
await this.answerMeta(call, `(no line in memory.md matches ${JSON.stringify(query)} — nothing removed)`);
|
|
2887
1634
|
return "next";
|
|
2888
1635
|
}
|
|
2889
|
-
// collapse the blank runs a deletion leaves behind, so the file does not
|
|
2890
|
-
// grow holes
|
|
2891
1636
|
const next = kept.join("\n").replace(/\n{3,}/g, "\n\n").trimEnd() + "\n";
|
|
2892
1637
|
await fs.writeFile(this.memoryFile, next, "utf8");
|
|
2893
1638
|
await this.answerMeta(call, `removed ${removed.length} line(s) from memory.md:\n${removed.join("\n").slice(0, 1_500)}\n` +
|
|
@@ -2901,11 +1646,6 @@ export class Agent {
|
|
|
2901
1646
|
await this.answerMeta(call, "add_memory needs non-empty `text` — nothing appended");
|
|
2902
1647
|
return "next";
|
|
2903
1648
|
}
|
|
2904
|
-
// APPEND rather than replace. `set_memory` overwrites the whole file,
|
|
2905
|
-
// so a model that had not just read it silently ERASED everything it did not
|
|
2906
|
-
// re-send — MEASURED on a real session: 21 writes, with the content dropping
|
|
2907
|
-
// from 1028 to 83 bytes. Appending is the operation "remember this" actually
|
|
2908
|
-
// wants, and it cannot lose what is already there.
|
|
2909
1649
|
const prev = await fs.readFile(this.memoryFile, "utf8").catch(() => "");
|
|
2910
1650
|
const sep = prev && !prev.endsWith("\n") ? "\n" : "";
|
|
2911
1651
|
const next = `${prev}${sep}${text}\n`;
|
|
@@ -2916,26 +1656,14 @@ export class Agent {
|
|
|
2916
1656
|
};
|
|
2917
1657
|
async runTurnsUntilIdle() {
|
|
2918
1658
|
let finished = false;
|
|
2919
|
-
// maxTurnsPerRound is a SOFT cap (0 disables): reaching it nudges the
|
|
2920
|
-
// model to wrap up instead of hard-failing the round — long autonomous
|
|
2921
|
-
// stretches are normal here, and a throw used to leave status=error with
|
|
2922
|
-
// no auto-recovery even though autoContinue would happily keep going.
|
|
2923
1659
|
const cap = Math.max(0, this.opts.maxTurnsPerRound ?? 200);
|
|
2924
1660
|
for (let guard = 0; cap === 0 || guard < cap; guard++) {
|
|
2925
1661
|
if (this.stopRequested)
|
|
2926
1662
|
return finished;
|
|
2927
|
-
// deliver prompts queued while the previous turn was running
|
|
2928
1663
|
this.drainPendingPrompts();
|
|
2929
|
-
// skills may have been created last turn — refresh the prompt listing
|
|
2930
1664
|
await this.refreshSkills();
|
|
2931
|
-
// periodic progress report at turn boundary (no mid-turn interruption)
|
|
2932
|
-
// the flag is per-TURN, not per-round. `maybeRequestProgress` sits
|
|
2933
|
-
// inside the turn loop and auto-continue is checked after the round
|
|
2934
|
-
// ends, so the flag has to mean "this boundary already carried an
|
|
2935
|
-
// instruction" — reset here, set below if the request fires.
|
|
2936
1665
|
this.progressRequestedThisRound = false;
|
|
2937
1666
|
await this.maybeRequestProgress();
|
|
2938
|
-
// keep the context window bounded before spending tokens on a turn
|
|
2939
1667
|
await this.maybeCompact();
|
|
2940
1668
|
await this.log.append("state", this.currentSession, this.currentBranch, {
|
|
2941
1669
|
from: this.status,
|
|
@@ -2943,22 +1671,19 @@ export class Agent {
|
|
|
2943
1671
|
detail: "llm turn start",
|
|
2944
1672
|
turn: ++this.stats.turns,
|
|
2945
1673
|
});
|
|
2946
|
-
// stream the assistant reply live to connected clients
|
|
2947
1674
|
let res;
|
|
2948
1675
|
try {
|
|
2949
1676
|
res = await this.llmCall(this.buildMessages(), allToolSpecs(), (s) => {
|
|
2950
1677
|
bus.emit("update", {
|
|
2951
1678
|
kind: "llm-delta",
|
|
2952
1679
|
agentId: this.opts.id,
|
|
2953
|
-
sessionId: this.currentSession,
|
|
1680
|
+
sessionId: this.currentSession,
|
|
2954
1681
|
text: s.text,
|
|
2955
1682
|
reasoning: s.reasoning,
|
|
2956
1683
|
});
|
|
2957
1684
|
});
|
|
2958
1685
|
}
|
|
2959
1686
|
catch (err) {
|
|
2960
|
-
// user stop mid-stream: persist the partial output so the timeline
|
|
2961
|
-
// keeps what was already visible (otherwise it silently vanishes)
|
|
2962
1687
|
const partial = err.partial;
|
|
2963
1688
|
if (this.stopRequested && partial && (partial.text || partial.reasoning)) {
|
|
2964
1689
|
await this.log.append("message", this.currentSession, this.currentBranch, {
|
|
@@ -2970,19 +1695,11 @@ export class Agent {
|
|
|
2970
1695
|
this.messages.push({ role: "assistant", content: partial.text ?? "" });
|
|
2971
1696
|
}
|
|
2972
1697
|
else if (this.stopRequested) {
|
|
2973
|
-
// nothing had streamed — leave an explicit marker so the log shows
|
|
2974
|
-
// why this prompt has no reply
|
|
2975
1698
|
await this.log.append("system_note", this.currentSession, this.currentBranch, {
|
|
2976
1699
|
event: "turn-interrupted",
|
|
2977
1700
|
detail: "stopped before any output arrived",
|
|
2978
1701
|
});
|
|
2979
1702
|
}
|
|
2980
|
-
// Provider context overflow → compact and retry (OpenCode-style recovery).
|
|
2981
|
-
// Even with autoCompact off, an actual overflow is proof the estimate
|
|
2982
|
-
// missed. Staged rather than single-shot (opencode: SHRINK_STEPS
|
|
2983
|
-
// [0.7, 0.5, 0.35]): one fixed cut can still be too shallow, and the old
|
|
2984
|
-
// code returned the second overflow as a hard error even though cutting
|
|
2985
|
-
// deeper would have worked.
|
|
2986
1703
|
if (isContextOverflow(err) && !this.stopRequested) {
|
|
2987
1704
|
const attempt = (this.overflowRecoveries += 1);
|
|
2988
1705
|
await this.log.append("system_note", this.currentSession, this.currentBranch, {
|
|
@@ -2991,7 +1708,6 @@ export class Agent {
|
|
|
2991
1708
|
detail: String(err.message).slice(0, 200),
|
|
2992
1709
|
});
|
|
2993
1710
|
const before = this.stats.compactions;
|
|
2994
|
-
// each retry cuts deeper: 1x, then the staged fractions of the budget
|
|
2995
1711
|
const shrink = OVERFLOW_SHRINK_STEPS[Math.min(attempt - 1, OVERFLOW_SHRINK_STEPS.length - 1)];
|
|
2996
1712
|
await this.maybeCompact(true, shrink);
|
|
2997
1713
|
if (this.stats.compactions > before) {
|
|
@@ -2999,23 +1715,10 @@ export class Agent {
|
|
|
2999
1715
|
event: "overflow-recovered",
|
|
3000
1716
|
attempt,
|
|
3001
1717
|
});
|
|
3002
|
-
guard--;
|
|
1718
|
+
guard--;
|
|
3003
1719
|
continue;
|
|
3004
1720
|
}
|
|
3005
1721
|
}
|
|
3006
|
-
/**
|
|
3007
|
-
* A STRUCTURAL cap is not an overflow, and no retry or compaction can clear it.
|
|
3008
|
-
*
|
|
3009
|
-
* Command Code's gateway refuses a body whose JSON node count exceeds its limit while the
|
|
3010
|
-
* session is nowhere near its token window — measured upstream at 22.6% of a 1M-token model
|
|
3011
|
-
* with 1,436 messages and 61 image parts. `isContextOverflow` correctly declines to claim
|
|
3012
|
-
* it, so without this branch the loop would exhaust its retries and surface the raw 400 with
|
|
3013
|
-
* no advice.
|
|
3014
|
-
*
|
|
3015
|
-
* Compacting anyway would be worse than useless: it costs a full-price summarisation pass
|
|
3016
|
-
* and the same structure is refused again. The honest answer is fewer messages, fewer tool
|
|
3017
|
-
* calls, fewer images — or a new session.
|
|
3018
|
-
*/
|
|
3019
1722
|
const nodeCap = isRequestNodeLimit(err);
|
|
3020
1723
|
if (nodeCap.hit && !this.stopRequested) {
|
|
3021
1724
|
await this.log.append("system_note", this.currentSession, this.currentBranch, {
|
|
@@ -3033,8 +1736,6 @@ export class Agent {
|
|
|
3033
1736
|
}
|
|
3034
1737
|
throw err;
|
|
3035
1738
|
}
|
|
3036
|
-
// a request the provider accepted: the current depth is enough, so the next
|
|
3037
|
-
// overflow starts its staircase over rather than cutting from the deepest step
|
|
3038
1739
|
this.overflowRecoveries = 0;
|
|
3039
1740
|
if (res.usage) {
|
|
3040
1741
|
const inTok = res.usage.inputTokens ?? 0;
|
|
@@ -3042,10 +1743,6 @@ export class Agent {
|
|
|
3042
1743
|
this.stats.inputTokens += inTok;
|
|
3043
1744
|
this.stats.cachedInputTokens += cached;
|
|
3044
1745
|
this.stats.outputTokens += res.usage.outputTokens ?? 0;
|
|
3045
|
-
// cost estimate: uncached input at prompt rate, cache hits usually
|
|
3046
|
-
// bill at a fraction (OpenRouter reports the blended prompt price, so
|
|
3047
|
-
// cached tokens are charged separately at ~10% — a common provider
|
|
3048
|
-
// convention; without per-provider detail this is the honest guess)
|
|
3049
1746
|
if (this.modelPricing) {
|
|
3050
1747
|
const p = this.modelPricing;
|
|
3051
1748
|
const uncached = Math.max(0, inTok - cached);
|
|
@@ -3061,9 +1758,6 @@ export class Agent {
|
|
|
3061
1758
|
await this.log.append("usage", this.currentSession, this.currentBranch, res.usage);
|
|
3062
1759
|
}
|
|
3063
1760
|
const m = res.message;
|
|
3064
|
-
// sanitize() fills empty content with "(tool call)" for providers that
|
|
3065
|
-
// reject blank content — that placeholder is request-only and must NOT
|
|
3066
|
-
// reach the timeline as if the agent had said something
|
|
3067
1761
|
const isPlaceholder = m.content === "(tool call)" || m.content === "(no content)";
|
|
3068
1762
|
await this.log.append("message", this.currentSession, this.currentBranch, {
|
|
3069
1763
|
role: "assistant",
|
|
@@ -3074,42 +1768,9 @@ export class Agent {
|
|
|
3074
1768
|
this.messages.push(m);
|
|
3075
1769
|
this.turnsSinceProgress++;
|
|
3076
1770
|
this.activityChars += m.content?.length ?? 0;
|
|
3077
|
-
// A round that ends with a plain assistant message and NO tool calls is
|
|
3078
|
-
// the end of the agent's work. For a ROOT agent that is just "done" — but
|
|
3079
|
-
// for a SUB-AGENT it means the work is over and the parent is waiting.
|
|
3080
|
-
//
|
|
3081
|
-
// Models overwhelmingly prefer writing their report as the final message
|
|
3082
|
-
// over calling finish(), and only handleFinish() emits the `final: true`
|
|
3083
|
-
// event that onChildEvent() forwards to the parent. So that habit silently
|
|
3084
|
-
// cost the parent the ENTIRE result — the report existed only in the
|
|
3085
|
-
// child's own log, which the parent's model context never replays.
|
|
3086
|
-
// Harvest it here so both paths share one code path.
|
|
3087
1771
|
if (!m.tool_calls?.length) {
|
|
3088
|
-
// A turn with no tool calls and real text is how models overwhelmingly
|
|
3089
|
-
// signal "I'm done" — far more often than by calling finish(), which
|
|
3090
|
-
// the spawn directive asks for but they routinely ignore.
|
|
3091
|
-
//
|
|
3092
|
-
// Two things were wrong:
|
|
3093
|
-
// 1. only handleFinish() emits the `final: true` event that
|
|
3094
|
-
// onChildEvent() forwards to the parent, so a sub-agent that ended
|
|
3095
|
-
// with a plain message sent the parent NOTHING — the report lived
|
|
3096
|
-
// only in the child's log, which the parent's context never
|
|
3097
|
-
// replays;
|
|
3098
|
-
// 2. the round then returned "not finished" with an active goal, so
|
|
3099
|
-
// auto-continue re-nudged and the child looped forever (measured:
|
|
3100
|
-
// 257 consecutive turns) re-reporting the same text.
|
|
3101
|
-
//
|
|
3102
|
-
// A bare message might be a mid-task narration though — sub-agents
|
|
3103
|
-
// often say "here's what I found so far…" and call finish() later with
|
|
3104
|
-
// a better summary. So do NOT report yet: remember it and give the child
|
|
3105
|
-
// ONE more turn to call finish() properly. The harness nudge makes that
|
|
3106
|
-
// turn explicit, so a child that just wanted to narrate ends here (and
|
|
3107
|
-
// its held report is forwarded by the loop's end-of-round path), while
|
|
3108
|
-
// a child that has a real summary delivers it instead. A real finish()
|
|
3109
|
-
// clears the held note, so it is forwarded exactly once.
|
|
3110
1772
|
if (this.opts.parent) {
|
|
3111
1773
|
if (this.pendingSubReport) {
|
|
3112
|
-
// already offered a turn after a bare message — take the held report
|
|
3113
1774
|
this.messages.push({
|
|
3114
1775
|
role: "user",
|
|
3115
1776
|
content: "[harness] Report delivered. If this IS your final answer, call finish() now with a summary for your parent; otherwise continue the task.",
|
|
@@ -3122,26 +1783,15 @@ export class Agent {
|
|
|
3122
1783
|
content: "[harness] If you are done, call finish() with a summary for your parent so it receives your report. Otherwise continue the task.",
|
|
3123
1784
|
});
|
|
3124
1785
|
}
|
|
3125
|
-
continue;
|
|
1786
|
+
continue;
|
|
3126
1787
|
}
|
|
3127
1788
|
return finished;
|
|
3128
1789
|
}
|
|
3129
1790
|
for (const call of m.tool_calls) {
|
|
3130
1791
|
if (this.stopRequested) {
|
|
3131
|
-
// this returned straight out of the turn, SKIPPING the
|
|
3132
|
-
// answerUnansweredToolCalls backstop below — so every remaining
|
|
3133
|
-
// tool_call in this assistant message stayed logged with NO
|
|
3134
|
-
// tool_result anywhere, and its row rendered as "waiting for
|
|
3135
|
-
// output…" for the rest of the session.
|
|
3136
1792
|
await this.answerUnansweredToolCalls(m);
|
|
3137
1793
|
return finished;
|
|
3138
1794
|
}
|
|
3139
|
-
// Meta tools dispatch through ONE table (src/agent/meta-tools.ts + metaHandlers
|
|
3140
|
-
// above), so a tool the prompt advertises but the loop does not handle is a
|
|
3141
|
-
// COMPILE error rather than a runtime "unknown tool". The two return kinds are the
|
|
3142
|
-
// two the old if-chain had: "next" is the `continue` it replaced, "end" is
|
|
3143
|
-
// finish() ending the whole batch. Anything not in the table is a real tool
|
|
3144
|
-
// and falls through to executeTool below.
|
|
3145
1795
|
const meta = this.metaHandlers[call.function.name];
|
|
3146
1796
|
if (meta) {
|
|
3147
1797
|
if ((await meta(call, m)) === "end") {
|
|
@@ -3154,19 +1804,9 @@ export class Agent {
|
|
|
3154
1804
|
callId: call.id,
|
|
3155
1805
|
name: call.function.name,
|
|
3156
1806
|
args: safeParse(call.function.arguments),
|
|
3157
|
-
argsRaw: call.function.arguments,
|
|
1807
|
+
argsRaw: call.function.arguments,
|
|
3158
1808
|
});
|
|
3159
1809
|
const t0 = Date.now();
|
|
3160
|
-
// first filesystem touch creates a missing workspace (never at boot)
|
|
3161
|
-
// this is awaited BETWEEN the tool_call append above and the
|
|
3162
|
-
// tool_result append below, and `fs.mkdir` has no catch — so a failure
|
|
3163
|
-
// propagated out of the whole loop, leaving the call logged with no
|
|
3164
|
-
// result ANYWHERE, and its row read "waiting for output…" for good.
|
|
3165
|
-
//
|
|
3166
|
-
// The loop's own backstop could not save it: it sits at the bottom of
|
|
3167
|
-
// the same loop, which a throw skips. So the tool_result is written in a
|
|
3168
|
-
// `finally` — the call is answered whatever happens.
|
|
3169
|
-
// the type is inferred from executeTool, so this cannot drift from it
|
|
3170
1810
|
let result;
|
|
3171
1811
|
try {
|
|
3172
1812
|
await this.ensureWorkspace();
|
|
@@ -3176,20 +1816,7 @@ export class Agent {
|
|
|
3176
1816
|
result = { ok: false, result: `tool could not run: ${err.message}` };
|
|
3177
1817
|
}
|
|
3178
1818
|
this.stats.toolCalls++;
|
|
3179
|
-
// a tool call is real work, so it clears the idle-round counter.
|
|
3180
1819
|
this.consecutiveIdleRounds = 0;
|
|
3181
|
-
// ONE clip, applied to BOTH the log and the live message.
|
|
3182
|
-
//
|
|
3183
|
-
// The log stored `result.slice(0, 8000)` while `messages` kept the full
|
|
3184
|
-
// text, and a restore rebuilds `messages` from the log — so a model
|
|
3185
|
-
// resuming work saw a different (and much smaller) tool result than it
|
|
3186
|
-
// had just seen live. Measured: 8000 bytes logged, 436 after restart.
|
|
3187
|
-
//
|
|
3188
|
-
// The 8000-byte log clip is deliberate — it bounds the log file — so the
|
|
3189
|
-
// fix is not to log everything. It is to clip the live message the SAME
|
|
3190
|
-
// way, so the two views agree and a restart is not a silent downgrade.
|
|
3191
|
-
// Restore is byte-exact everywhere else (`answerMeta` preserves its exact
|
|
3192
|
-
// content); this was the one place the replay diverged.
|
|
3193
1820
|
const capped = result.result.slice(0, 8000);
|
|
3194
1821
|
await this.log.append("tool_result", this.currentSession, this.currentBranch, {
|
|
3195
1822
|
callId: call.id,
|
|
@@ -3201,9 +1828,6 @@ export class Agent {
|
|
|
3201
1828
|
this.messages.push({ role: "tool", tool_call_id: call.id, content: capped });
|
|
3202
1829
|
this.consecutiveToolErrors = result.ok ? 0 : this.consecutiveToolErrors + 1;
|
|
3203
1830
|
if (this.consecutiveToolErrors >= this.opts.maxConsecutiveToolErrors) {
|
|
3204
|
-
// structured, greppable AND visible: the note carries what failed so
|
|
3205
|
-
// the operator (and the UI) can show "WHICH tool, with what error"
|
|
3206
|
-
// instead of a bare counter
|
|
3207
1831
|
await this.log.append("system_note", this.currentSession, this.currentBranch, {
|
|
3208
1832
|
event: "runaway-detected",
|
|
3209
1833
|
failures: this.consecutiveToolErrors,
|
|
@@ -3215,38 +1839,12 @@ export class Agent {
|
|
|
3215
1839
|
`(last: ${call.function.name})`);
|
|
3216
1840
|
}
|
|
3217
1841
|
}
|
|
3218
|
-
// every tool_call an assistant message issued must have a matching
|
|
3219
|
-
// tool result, or the provider REJECTS the next request — which is the
|
|
3220
|
-
// "`200 with no choices`" shape #50 traced to a malformed history.
|
|
3221
|
-
//
|
|
3222
|
-
// Two exits could leave some unanswered: `stopRequested` returns early
|
|
3223
|
-
// mid-batch, and a handler that throws (ask_user parking the loop is the
|
|
3224
|
-
// one that bites in practice) propagates out of the whole loop. Restore
|
|
3225
|
-
// has a hole-filler for exactly this; the LIVE array did not, so a session
|
|
3226
|
-
// that hit it produced an invalid next request and then failed with
|
|
3227
|
-
// something that looked like a provider fault.
|
|
3228
|
-
//
|
|
3229
|
-
// So the invariant is enforced here rather than at each exit: whatever
|
|
3230
|
-
// happened, answer what is still outstanding before moving on.
|
|
3231
1842
|
await this.answerUnansweredToolCalls(m);
|
|
3232
1843
|
if (finished)
|
|
3233
1844
|
return true;
|
|
3234
1845
|
}
|
|
3235
|
-
// cap reached — loop() logs the note, asks for a progress report and
|
|
3236
|
-
// nudges the model; this round simply ends (no hard error)
|
|
3237
1846
|
return finished;
|
|
3238
1847
|
}
|
|
3239
|
-
/**
|
|
3240
|
-
* Append a tool result for every tool_call in `m` that has not been answered.
|
|
3241
|
-
*
|
|
3242
|
-
* Scans the tail of `messages` for `tool_call_id`s, so it is correct whichever
|
|
3243
|
-
* path left the batch unfinished — a stop, a throw, or a normal completion
|
|
3244
|
-
* where a handler returned early. Answered calls are left alone.
|
|
3245
|
-
*
|
|
3246
|
-
* PUBLIC so a test can reproduce "the batch was left half-answered" directly. That
|
|
3247
|
-
* state is what the provider rejects the next request over, and reaching it through a
|
|
3248
|
-
* whole turn would make the assertion depend on everything else in the loop.
|
|
3249
|
-
*/
|
|
3250
1848
|
async answerUnansweredToolCalls(m) {
|
|
3251
1849
|
const calls = m.tool_calls ?? [];
|
|
3252
1850
|
if (!calls.length)
|
|
@@ -3272,86 +1870,46 @@ export class Agent {
|
|
|
3272
1870
|
}
|
|
3273
1871
|
}
|
|
3274
1872
|
buildMessages() {
|
|
3275
|
-
// deliberately static: [system] + append-only history keeps prefix caches hot.
|
|
3276
|
-
// The workspace context block (AGENTS.md + ls snapshot) is appended to the
|
|
3277
|
-
// system message ONCE per session — changing it mid-session would re-price
|
|
3278
|
-
// the whole prefix cache, so it is captured at first use and frozen.
|
|
3279
1873
|
const sys = this.systemPrompt();
|
|
3280
1874
|
const hasSystem = this.messages[0]?.role === "system";
|
|
3281
1875
|
const head = [{ role: "system", content: sys }];
|
|
3282
|
-
// NOTE: tool-call pairing is guaranteed one layer down, in `sanitize()`,
|
|
3283
|
-
// because EVERY request path passes through there (turns, the summarizer, the ACP
|
|
3284
|
-
// adapter) while this builds only an Agent's own turn. A violated pairing is refused
|
|
3285
|
-
// with a bare "invalid request error", which is not recognisable as an overflow and
|
|
3286
|
-
// so burns the whole retry staircase before failing with nothing to act on.
|
|
3287
1876
|
return hasSystem ? [...head, ...this.messages.slice(1)] : [...head, ...this.messages];
|
|
3288
1877
|
}
|
|
3289
|
-
/**
|
|
3290
|
-
* Re-read AGENTS.md and adopt it if it changed. Returns the new text when it
|
|
3291
|
-
* did, or null when there is nothing to do.
|
|
3292
|
-
*
|
|
3293
|
-
* Called on compaction (#64 follow-up) and nowhere else. The one-shot capture
|
|
3294
|
-
* in systemPrompt() exists to keep the system prompt byte-identical for the
|
|
3295
|
-
* session so the provider's prefix cache survives; this is the single point
|
|
3296
|
-
* where breaking that identity is free, because compaction has just rewritten
|
|
3297
|
-
* the history underneath it.
|
|
3298
|
-
*
|
|
3299
|
-
* Failure is deliberately non-destructive: a read error, or a file that
|
|
3300
|
-
* vanished, leaves the previous content in place. Dropping the instructions
|
|
3301
|
-
* because of a transient error would be far worse than running one compaction
|
|
3302
|
-
* on slightly stale rules.
|
|
3303
|
-
*/
|
|
3304
1878
|
reloadAgentsMd() {
|
|
3305
1879
|
if (!this.agentsMdLoaded)
|
|
3306
|
-
return null;
|
|
1880
|
+
return null;
|
|
3307
1881
|
let fresh;
|
|
3308
1882
|
try {
|
|
3309
1883
|
const p = path.join(this.opts.workspace, "AGENTS.md");
|
|
3310
1884
|
if (!existsSync(p))
|
|
3311
|
-
return null;
|
|
1885
|
+
return null;
|
|
3312
1886
|
fresh = readFileSync(p, "utf8").slice(0, 12_000);
|
|
3313
1887
|
}
|
|
3314
1888
|
catch {
|
|
3315
|
-
return null;
|
|
1889
|
+
return null;
|
|
3316
1890
|
}
|
|
3317
1891
|
if (fresh === this.agentsMd)
|
|
3318
1892
|
return null;
|
|
3319
1893
|
this.agentsMd = fresh;
|
|
3320
1894
|
return fresh;
|
|
3321
1895
|
}
|
|
3322
|
-
/** AGENTS.md content cached for the session (empty string = none/read-failed) */
|
|
3323
1896
|
agentsMd = "";
|
|
3324
|
-
/** captured once, with the rest of the session-frozen context */
|
|
3325
1897
|
agentsMdLoaded = false;
|
|
3326
|
-
/** one-time workspace listing injected with the system prompt */
|
|
3327
1898
|
wsSnapshot = "";
|
|
3328
|
-
/**
|
|
3329
|
-
* one-time catalogue of available skills, injected with the system prompt
|
|
3330
|
-
*. Built once alongside wsSnapshot so the prompt stays byte-identical
|
|
3331
|
-
* for the session and the provider prefix cache survives.
|
|
3332
|
-
*/
|
|
3333
1899
|
skillCatalogue = "";
|
|
3334
|
-
/** the compact digest of memory.md put into the frozen prompt (Codex's MEMORY_SUMMARY) */
|
|
3335
1900
|
memoryDigest = "";
|
|
3336
1901
|
systemPrompt() {
|
|
3337
1902
|
if (!this.agentsMdLoaded) {
|
|
3338
1903
|
this.agentsMdLoaded = true;
|
|
3339
|
-
// AGENTS.md + workspace listing are captured ONCE and PERSISTED to the
|
|
3340
|
-
// session dir, so a restart re-issues the identical system prompt (the
|
|
3341
|
-
// prefix cache survives; the snapshot is a session artifact, not live
|
|
3342
|
-
// truth — list_dir shows current state).
|
|
3343
1904
|
const snapPath = path.join(this.opts.sessionDir, "workspace-snapshot.txt");
|
|
3344
1905
|
try {
|
|
3345
|
-
// a previous incarnation already froze a snapshot → reuse it verbatim.
|
|
3346
|
-
// The sentinel covers EMPTY workspaces too ("" would look like "no
|
|
3347
|
-
// file" and make the restart re-list, breaking prefix-cache identity).
|
|
3348
1906
|
this.wsSnapshot = readFileSync(snapPath, "utf8");
|
|
3349
1907
|
}
|
|
3350
1908
|
catch {
|
|
3351
1909
|
this.wsSnapshot = "";
|
|
3352
1910
|
}
|
|
3353
1911
|
if (this.wsSnapshot === "" && existsSync(snapPath)) {
|
|
3354
|
-
this.wsSnapshot = "(empty workspace)";
|
|
1912
|
+
this.wsSnapshot = "(empty workspace)";
|
|
3355
1913
|
}
|
|
3356
1914
|
if (!this.wsSnapshot) {
|
|
3357
1915
|
try {
|
|
@@ -3374,7 +1932,6 @@ export class Agent {
|
|
|
3374
1932
|
size = `${(statSync(path.join(this.opts.workspace, e.name)).size / 1024).toFixed(1)}K`;
|
|
3375
1933
|
}
|
|
3376
1934
|
catch {
|
|
3377
|
-
/* vanished */
|
|
3378
1935
|
}
|
|
3379
1936
|
}
|
|
3380
1937
|
lines.push(e.isDirectory() ? `${e.name}/` : `${e.name} (${size})`);
|
|
@@ -3386,7 +1943,6 @@ export class Agent {
|
|
|
3386
1943
|
void fs.writeFile(snapPath, this.wsSnapshot, "utf8");
|
|
3387
1944
|
}
|
|
3388
1945
|
catch {
|
|
3389
|
-
/* best-effort persistence */
|
|
3390
1946
|
}
|
|
3391
1947
|
}
|
|
3392
1948
|
catch {
|
|
@@ -3403,8 +1959,6 @@ export class Agent {
|
|
|
3403
1959
|
this.agentsMd = "";
|
|
3404
1960
|
}
|
|
3405
1961
|
}
|
|
3406
|
-
// capture the skill catalogue in the SAME one-shot block, so the
|
|
3407
|
-
// system prompt stays byte-identical for the session (prefix cache).
|
|
3408
1962
|
this.skillCatalogue = this.skillCatalogueText();
|
|
3409
1963
|
this.memoryDigest = this.memoryDigestText();
|
|
3410
1964
|
}
|
|
@@ -3415,15 +1969,6 @@ export class Agent {
|
|
|
3415
1969
|
if (this.wsSnapshot) {
|
|
3416
1970
|
sys += `\n\n## Workspace snapshot (top level)\n${this.wsSnapshot}\nUse list_dir/read_file for anything deeper — do NOT list again what is already here.`;
|
|
3417
1971
|
}
|
|
3418
|
-
// the catalogue of AVAILABLE SKILLS. The prompt previously named the
|
|
3419
|
-
// skill TOOLS but never the skills themselves, so the only way to discover
|
|
3420
|
-
// one was to spend a tool call on list_skills() — and a model that does not
|
|
3421
|
-
// know a skill exists will never think to look.
|
|
3422
|
-
//
|
|
3423
|
-
// Appended ONCE, in the same one-shot block as AGENTS.md and the workspace
|
|
3424
|
-
// snapshot, so SYSTEM_TEMPLATE stays byte-identical for the session and the
|
|
3425
|
-
// provider prefix cache survives (#14's constraint). list_skills() remains
|
|
3426
|
-
// for the full detail (a skill saved mid-session shows up there).
|
|
3427
1972
|
if (this.skillCatalogue) {
|
|
3428
1973
|
sys +=
|
|
3429
1974
|
`\n\n## Skills available in this workspace\n${this.skillCatalogue}\n` +
|
|
@@ -3431,19 +1976,6 @@ export class Agent {
|
|
|
3431
1976
|
`pass what you want done so the instructions arrive with the playbook. ` +
|
|
3432
1977
|
`list_skills() shows the full detail.`;
|
|
3433
1978
|
}
|
|
3434
|
-
/**
|
|
3435
|
-
* A DIGEST of the durable notes — Codex's `MEMORY_SUMMARY`, not the notes themselves.
|
|
3436
|
-
*
|
|
3437
|
-
* The full file cannot go here (it is unbounded, and re-injecting it every turn is the
|
|
3438
|
-
* prefix-cache break that made takos' design a bad fit). But leaving the notes entirely
|
|
3439
|
-
* behind a tool call was measured to fail the other way: the model never learned they
|
|
3440
|
-
* existed, so it never asked. Codex resolves exactly this by injecting a compact summary
|
|
3441
|
-
* and telling the model to search the rest — the summary names WHAT is in memory, the tool
|
|
3442
|
-
* fetches the detail.
|
|
3443
|
-
*
|
|
3444
|
-
* Captured in the same one-shot block as AGENTS.md and the skills catalogue, so the prompt
|
|
3445
|
-
* stays byte-identical for the session.
|
|
3446
|
-
*/
|
|
3447
1979
|
if (this.memoryDigest) {
|
|
3448
1980
|
sys +=
|
|
3449
1981
|
`\n\n## Your durable notes (memory.md — a DIGEST, not the whole file)\n${this.memoryDigest}\n` +
|
|
@@ -3454,24 +1986,6 @@ export class Agent {
|
|
|
3454
1986
|
}
|
|
3455
1987
|
return sys;
|
|
3456
1988
|
}
|
|
3457
|
-
/**
|
|
3458
|
-
* The last few conversation turns, for a parent's `final` report.
|
|
3459
|
-
*
|
|
3460
|
-
* Captured BEFORE the final marker is written so the parent gets real
|
|
3461
|
-
* substance (findings, file paths, numbers), not just whatever the model
|
|
3462
|
-
* typed into the finish() summary argument.
|
|
3463
|
-
*
|
|
3464
|
-
* Extracted as its own method because a REJECTED completion audit also needs
|
|
3465
|
-
* it — the retry prompt should carry the same context an approved one
|
|
3466
|
-
* would — while emitting no `final: true`.
|
|
3467
|
-
*/
|
|
3468
|
-
/**
|
|
3469
|
-
* A COMPACT account of what the worker actually did, for the auditor.
|
|
3470
|
-
*
|
|
3471
|
-
* Not the conversation: see the note at the audit call. This is the last few assistant turns plus
|
|
3472
|
-
* the tools they ran, which is what a reviewer needs to see whether the claimed work happened —
|
|
3473
|
-
* and it stays small enough that the auditor can still answer the question it was asked.
|
|
3474
|
-
*/
|
|
3475
1989
|
auditEvidence(summary) {
|
|
3476
1990
|
const lines = [];
|
|
3477
1991
|
for (let i = this.messages.length - 1; i >= 0 && lines.length < 24; i--) {
|
|
@@ -3480,7 +1994,7 @@ export class Agent {
|
|
|
3480
1994
|
if (!text)
|
|
3481
1995
|
continue;
|
|
3482
1996
|
if (m.role === "user")
|
|
3483
|
-
break;
|
|
1997
|
+
break;
|
|
3484
1998
|
const who = m.role === "assistant" ? "worker" : m.role;
|
|
3485
1999
|
lines.push(`${who}: ${text.replace(/\s+/g, " ").slice(0, 400)}`);
|
|
3486
2000
|
}
|
|
@@ -3494,58 +2008,21 @@ export class Agent {
|
|
|
3494
2008
|
for (let i = this.messages.length - 1; i >= 0 && recent.length < 6; i--) {
|
|
3495
2009
|
const m = this.messages[i];
|
|
3496
2010
|
if (m.role === "user")
|
|
3497
|
-
continue;
|
|
2011
|
+
continue;
|
|
3498
2012
|
const text = String(m.content ?? "").trim();
|
|
3499
|
-
// a message whose text is a PLACEHOLDER carries no information.
|
|
3500
|
-
//
|
|
3501
|
-
// `llm.ts` rewrites any assistant/tool message that has no usable text to
|
|
3502
|
-
// the literal "(tool call)" or "(no content)". Those are display
|
|
3503
|
-
// artifacts, and the parent's report was landing them verbatim — in the
|
|
3504
|
-
// real log, TWO of the six entries in an accepted report were literally
|
|
3505
|
-
// "[assistant] (tool call)", so half of what the parent was told about the
|
|
3506
|
-
// work was a placeholder.
|
|
3507
|
-
//
|
|
3508
|
-
// A tool CALL is real work, but it is represented in the tool RESULT that
|
|
3509
|
-
// follows, so dropping the placeholder loses nothing.
|
|
3510
2013
|
if (!text || PLACEHOLDER_TEXT.test(text))
|
|
3511
2014
|
continue;
|
|
3512
|
-
// a window this small must not be spent on BOOKKEEPING. The real log
|
|
3513
|
-
// showed a 6-slot window where two 23-char "(tool call)" placeholders and a
|
|
3514
|
-
// 40-char "decision recorded to decisions.md" took half the slots, pushing
|
|
3515
|
-
// out the reasoning a parent would need to judge the work. Harness
|
|
3516
|
-
// acknowledgements carry no findings, so they cost a slot for nothing.
|
|
3517
2015
|
if (HARNESS_ACK.test(text))
|
|
3518
2016
|
continue;
|
|
3519
2017
|
const who = m.role === "tool" ? "tool" : m.role;
|
|
3520
2018
|
let line = `[${who}] ${text}`;
|
|
3521
2019
|
if (m.role === "tool") {
|
|
3522
|
-
// tool results are prefixed "(failed)" by the logger when they fail —
|
|
3523
|
-
// the raw content is what matters here, and cap hard: these can be huge
|
|
3524
2020
|
line = `[${who}] ${(m.content ?? "").trim().slice(0, 1200)}`;
|
|
3525
2021
|
}
|
|
3526
|
-
// A generous cap, but not unbounded: one runaway turn must not blow up
|
|
3527
|
-
// the parent's context. The old 1500 char clip cut findings in half —
|
|
3528
|
-
// a review's conclusions routinely live past that mark — and this
|
|
3529
|
-
// text is bounded by 6 turns, so the worst case is ~120k chars.
|
|
3530
2022
|
recent.unshift(line.slice(0, 20_000));
|
|
3531
2023
|
}
|
|
3532
2024
|
return recent;
|
|
3533
2025
|
}
|
|
3534
|
-
/**
|
|
3535
|
-
* Run the completion auditor, streaming its steps into the timeline.
|
|
3536
|
-
*
|
|
3537
|
-
* The auditor is a real review, not a single question: pi-goal-x gives it read-only tools so it
|
|
3538
|
-
* can inspect the artifacts instead of trusting the worker's summary, and it reports its progress
|
|
3539
|
-
* as it goes. Both matter here:
|
|
3540
|
-
*
|
|
3541
|
-
* - WITHOUT the tools the auditor could only re-read the claim it was asked to check, which is
|
|
3542
|
-
* how three audits in github-bug-research-1f48715f replied in prose and produced no verdict;
|
|
3543
|
-
* - WITHOUT the progress rows the timeline showed `audit-started` and then nothing until the
|
|
3544
|
-
* verdict, so an audit that produced no verdict was indistinguishable from one that never ran.
|
|
3545
|
-
*
|
|
3546
|
-
* Bounded by `AUDIT_MAX_STEPS`: an auditor that keeps reading is doing work the operator cannot
|
|
3547
|
-
* see the end of, and the worker is blocked while it runs.
|
|
3548
|
-
*/
|
|
3549
2026
|
async runAuditor(first) {
|
|
3550
2027
|
const AUDIT_MAX_STEPS = 12;
|
|
3551
2028
|
const step = (data) => this.log.append("audit_step", this.currentSession, this.currentBranch, data).catch(() => { });
|
|
@@ -3588,7 +2065,6 @@ export class Agent {
|
|
|
3588
2065
|
});
|
|
3589
2066
|
}
|
|
3590
2067
|
}
|
|
3591
|
-
// out of steps: ask once for the verdict alone, with no tools to reach for
|
|
3592
2068
|
const final = await this.callLlm([...messages, { role: "user", content: "Stop investigating. Reply with the verdict line only." }], []);
|
|
3593
2069
|
return String(final.message.content ?? "");
|
|
3594
2070
|
}
|
|
@@ -3596,73 +2072,30 @@ export class Agent {
|
|
|
3596
2072
|
const args = safeParse(argsJson);
|
|
3597
2073
|
if (args.goalComplete === true && this.goal.status !== "done") {
|
|
3598
2074
|
if (this.goal.verify?.trim()) {
|
|
3599
|
-
// pi-goal-x-style INDEPENDENT COMPLETION REVIEW: a separate LLM call
|
|
3600
|
-
// checks the verification contract against the conversation + summary
|
|
3601
|
-
// before "done" is honored. changes-required reopens the goal and
|
|
3602
|
-
// queues the auditor's feedback as the next user prompt.
|
|
3603
2075
|
await this.log.append("goal", this.currentSession, this.currentBranch, {
|
|
3604
2076
|
event: "audit-started",
|
|
3605
2077
|
verify: this.goal.verify.slice(0, 2000),
|
|
3606
2078
|
});
|
|
3607
|
-
// number the attempts — the final carries the attempt that approved it
|
|
3608
2079
|
this.auditAttempts++;
|
|
3609
2080
|
try {
|
|
3610
|
-
/**
|
|
3611
|
-
* The auditor runs in its OWN conversation.
|
|
3612
|
-
*
|
|
3613
|
-
* It used to be `buildMessages()` + one more user turn — the worker's entire conversation,
|
|
3614
|
-
* handed to a model that was then told "you are an auditor, not the worker". MEASURED in
|
|
3615
|
-
* github-bug-research-1f48715f: with ~220k tokens of work log in front of it, the auditor
|
|
3616
|
-
* replied by CONTINUING the work (prose, no verdict) on all three attempts, and the goal
|
|
3617
|
-
* was abandoned with the audit never actually running.
|
|
3618
|
-
*
|
|
3619
|
-
* A review is not a continuation. The auditor gets the objective, the contract and the
|
|
3620
|
-
* worker's claim — everything it needs to judge — and nothing to continue.
|
|
3621
|
-
*/
|
|
3622
2081
|
const evidence = this.auditEvidence(args.summary);
|
|
3623
2082
|
const auditMessages = [
|
|
3624
2083
|
{ role: "user", content: auditPrompt({ goal: this.goal.text, verify: this.goal.verify, summary: String(args.summary ?? ""), evidence }) },
|
|
3625
2084
|
];
|
|
3626
2085
|
const text = await this.runAuditor(auditMessages);
|
|
3627
|
-
/**
|
|
3628
|
-
* The verdict is the reply's LAST line — see `parseAuditVerdict`. Anything else is NO
|
|
3629
|
-
* VERDICT, never a rejection: a reason-less "fix these gaps" carries no information and
|
|
3630
|
-
* only teaches everyone that the audit is noise.
|
|
3631
|
-
*/
|
|
3632
2086
|
const verdict = parseAuditVerdict(text);
|
|
3633
2087
|
if (verdict.kind === "none") {
|
|
3634
|
-
// an unusable reply is NOT an approval. The old path failed
|
|
3635
|
-
// OPEN — recorded the failure and marked the goal done anyway —
|
|
3636
|
-
// so a verification contract was "satisfied" that nobody verified.
|
|
3637
|
-
// Fail CLOSED instead: the goal stays active and the worker is
|
|
3638
|
-
// asked to self-verify, with a bounded streak (see the field).
|
|
3639
2088
|
this.auditUnavailableStreak++;
|
|
3640
2089
|
await this.log.append("system_note", this.currentSession, this.currentBranch, {
|
|
3641
2090
|
event: "audit-failed",
|
|
3642
2091
|
detail: verdict.detail,
|
|
3643
2092
|
attempt: this.auditUnavailableStreak,
|
|
3644
2093
|
});
|
|
3645
|
-
/**
|
|
3646
|
-
* A no-verdict reply gets a bounded number of retries, then the operator decides.
|
|
3647
|
-
*
|
|
3648
|
-
* MEASURED in github-bug-research-1f48715f: three audits in six minutes all failed the
|
|
3649
|
-
* same way, and the retry prompt told the worker to "re-check the contract yourself, then
|
|
3650
|
-
* finish again" — which cannot help, because the worker's re-verification was never what
|
|
3651
|
-
* the auditor was missing. It re-ran the SAME call against the SAME model with the SAME
|
|
3652
|
-
* reply shape, so the loop cost three rounds and ended at the cap anyway.
|
|
3653
|
-
*
|
|
3654
|
-
* The retry is worth keeping for the transient case (a provider hiccup, a truncated
|
|
3655
|
-
* reply), and the prompt now asks for the thing that actually helps — a concise statement
|
|
3656
|
-
* of what was verified. Three attempts, then a loud note.
|
|
3657
|
-
*/
|
|
3658
2094
|
if (this.auditUnavailableStreak < 3) {
|
|
3659
2095
|
this.queueAuditUnavailableRetry();
|
|
3660
2096
|
await this.captureRecentTurns();
|
|
3661
2097
|
return;
|
|
3662
2098
|
}
|
|
3663
|
-
// bounded fallback, same philosophy as the reject bound in the
|
|
3664
|
-
// loop: the goal is left ACTIVE and a loud note stops the loop —
|
|
3665
|
-
// the operator sees it and decides. Never "honored as done".
|
|
3666
2099
|
await this.log.append("system_note", this.currentSession, this.currentBranch, {
|
|
3667
2100
|
event: "audit-unavailable-cap",
|
|
3668
2101
|
attempts: this.auditUnavailableStreak,
|
|
@@ -3672,23 +2105,11 @@ export class Agent {
|
|
|
3672
2105
|
return;
|
|
3673
2106
|
}
|
|
3674
2107
|
const approved = verdict.kind === "approved";
|
|
3675
|
-
// the parser already separated marker from reason
|
|
3676
2108
|
const feedback = verdict.reason;
|
|
3677
|
-
// storing the literal "(no detail)" made the UI draw a card
|
|
3678
|
-
// whose whole content was the absence of a reason, and that same
|
|
3679
|
-
// string landed in goal.md for the right panel. An empty string is
|
|
3680
|
-
// the honest value: the auditor returned a verdict and no reason,
|
|
3681
|
-
// which every consumer can already render as "label only".
|
|
3682
2109
|
await this.setGoalAudit(approved ? "approved" : "changes-required", feedback);
|
|
3683
|
-
// a real verdict lands: the no-verdict streak restarts either way —
|
|
3684
|
-
// the streak's own JSDoc promises "reset by any real verdict", and
|
|
3685
|
-
// without this a flaky auditor (2 unusable, then a real verdict,
|
|
3686
|
-
// then 1 unusable) hit the cap far too early
|
|
3687
2110
|
this.auditUnavailableStreak = 0;
|
|
3688
2111
|
if (approved) {
|
|
3689
2112
|
this.consecutiveAuditRejects = 0;
|
|
3690
|
-
// #91/#94: the final will carry WHAT was approved, on WHICH attempt,
|
|
3691
|
-
// and what the previous attempt was told (if it was rejected first)
|
|
3692
2113
|
this.lastAuditOutcome = {
|
|
3693
2114
|
audited: true,
|
|
3694
2115
|
verdict: "approved",
|
|
@@ -3699,25 +2120,11 @@ export class Agent {
|
|
|
3699
2120
|
};
|
|
3700
2121
|
}
|
|
3701
2122
|
else {
|
|
3702
|
-
// the rejection survives in memory so the eventual approved
|
|
3703
|
-
// final can name what the first attempt was told
|
|
3704
2123
|
this.lastRejectedAudit = { feedback, attempt: this.auditAttempts };
|
|
3705
|
-
/**
|
|
3706
|
-
* …AND capture the work that was just rejected, once. This runs before the retry
|
|
3707
|
-
* prompt is queued, so the history still holds the original task; capturing later
|
|
3708
|
-
* (at the approved finish) would read only the fix-up.
|
|
3709
|
-
*/
|
|
3710
2124
|
if (!this.preRejectionTurns.length) {
|
|
3711
2125
|
this.preRejectionTurns = await this.captureRecentTurns();
|
|
3712
2126
|
}
|
|
3713
2127
|
}
|
|
3714
|
-
// `operatorFacing: true` — the verdict is written for the TIMELINE
|
|
3715
|
-
// only. The audit ran in its own side conversation (a tools-less call
|
|
3716
|
-
// over buildMessages() + an audit prompt), so it was never in
|
|
3717
|
-
// `this.messages` and must not come back through a restore: doing so
|
|
3718
|
-
// injected an assistant turn that answers nothing into the model's
|
|
3719
|
-
// context, which providers answer with a 200 and no choices.
|
|
3720
|
-
// The rejection feedback reaches the agent as a real prompt below.
|
|
3721
2128
|
await this.log.append("message", this.currentSession, this.currentBranch, {
|
|
3722
2129
|
role: "assistant",
|
|
3723
2130
|
operatorFacing: true,
|
|
@@ -3727,24 +2134,11 @@ export class Agent {
|
|
|
3727
2134
|
});
|
|
3728
2135
|
if (!approved) {
|
|
3729
2136
|
this.consecutiveAuditRejects++;
|
|
3730
|
-
// the rejection survives in memory so the eventual approved
|
|
3731
|
-
// final can name what the first attempt was told
|
|
3732
2137
|
this.lastRejectedAudit = { feedback, attempt: this.auditAttempts };
|
|
3733
|
-
// hand the gaps back to the worker as its next instruction.
|
|
3734
|
-
// with the "(no detail)" placeholder gone, an empty verdict
|
|
3735
|
-
// would leave this prompt saying "address these gaps" and then
|
|
3736
|
-
// naming none — so restate the contract as the thing to re-check.
|
|
3737
2138
|
const gaps = feedback.trim()
|
|
3738
2139
|
? feedback.slice(0, 2000)
|
|
3739
2140
|
: `The auditor returned no specific gaps. Re-read the verification contract above and re-run it yourself, item by item, before finishing again:\n\n${this.goal.verify?.slice(0, 2000) || this.goal.text.slice(0, 2000)}`;
|
|
3740
2141
|
const retryText = `[harness] The completion audit REJECTED this finish. Address these gaps, then finish again with goalComplete=true:\n\n${gaps}`;
|
|
3741
|
-
// counted above (consecutiveAuditRejects++ at the verdict) so
|
|
3742
|
-
// a model that cannot satisfy the auditor cannot loop forever — the
|
|
3743
|
-
// retry prompt is queued only while the bound is NOT yet reached,
|
|
3744
|
-
// so the loop's cap break strands nothing (a prompt queued AT the
|
|
3745
|
-
// cap would live on as an invisible no-id entry: no badge, no log
|
|
3746
|
-
// row, but isLive() true). Log the row too: a push-only prompt is
|
|
3747
|
-
// invisible on the timeline and lost to a restart otherwise.
|
|
3748
2142
|
if (this.consecutiveAuditRejects < (this.opts.maxAuditRejects ?? 3)) {
|
|
3749
2143
|
this.pendingPrompts.push({ source: "harness", text: retryText });
|
|
3750
2144
|
await this.log.append("prompt", this.currentSession, this.currentBranch, {
|
|
@@ -3752,25 +2146,11 @@ export class Agent {
|
|
|
3752
2146
|
text: retryText,
|
|
3753
2147
|
});
|
|
3754
2148
|
}
|
|
3755
|
-
// RETURN before the final message is written.
|
|
3756
|
-
//
|
|
3757
|
-
// Falling through emitted `final: true` while the goal was still
|
|
3758
|
-
// active — and that is the event onChildEvent forwards to the
|
|
3759
|
-
// parent. So a REJECTED completion told the parent the work was
|
|
3760
|
-
// DONE, and the child then sat retrying against a parent that had
|
|
3761
|
-
// already moved on and stopped listening.
|
|
3762
|
-
//
|
|
3763
|
-
// The goal stays active and the retry prompt is queued above, so
|
|
3764
|
-
// the loop simply continues; the parent's notification is deferred
|
|
3765
|
-
// until a finish that actually survives the audit.
|
|
3766
2149
|
await this.captureRecentTurns();
|
|
3767
2150
|
return;
|
|
3768
2151
|
}
|
|
3769
2152
|
}
|
|
3770
2153
|
catch (err) {
|
|
3771
|
-
// an auditor that THROWS is not an approval either. Same
|
|
3772
|
-
// fail-closed treatment as a no-verdict reply: active goal, bounded
|
|
3773
|
-
// self-verify retries, then a loud stop for the operator.
|
|
3774
2154
|
this.auditUnavailableStreak++;
|
|
3775
2155
|
await this.log.append("system_note", this.currentSession, this.currentBranch, {
|
|
3776
2156
|
event: "audit-failed",
|
|
@@ -3797,43 +2177,18 @@ export class Agent {
|
|
|
3797
2177
|
}
|
|
3798
2178
|
await this.appendFinalMessage(args);
|
|
3799
2179
|
}
|
|
3800
|
-
/**
|
|
3801
|
-
* The retry prompt for an audit that could not produce a verdict.
|
|
3802
|
-
*
|
|
3803
|
-
* It asks the worker to make the NEXT audit easier to answer, which is the only thing within its
|
|
3804
|
-
* power: a no-verdict reply means the auditor could not judge from what it was given, so a
|
|
3805
|
-
* concise statement of what was verified is the actual fix. It does NOT claim the work is
|
|
3806
|
-
* unverified-and-must-be-redone; the work may be fine and the auditor simply failed, and telling
|
|
3807
|
-
* the worker to redo it produced the repeat loop measured in github-bug-research-1f48715f.
|
|
3808
|
-
*/
|
|
3809
2180
|
queueAuditUnavailableRetry() {
|
|
3810
2181
|
const text = `[harness] The completion audit could not produce a verdict — that is a problem with the ` +
|
|
3811
2182
|
`AUDIT, not a finding about your work. Before finishing again, state plainly and briefly what ` +
|
|
3812
2183
|
`you verified for each item of the contract, so the auditor has something to judge.\n\n` +
|
|
3813
2184
|
`${this.goal.verify?.slice(0, 2000) || this.goal.text.slice(0, 2000)}`;
|
|
3814
2185
|
this.pendingPrompts.push({ source: "harness", text });
|
|
3815
|
-
// log the row: a push-only prompt is invisible on the timeline and lost to
|
|
3816
|
-
// a restart (rebuildMessagesFrom replays prompt rows) — same as enqueuePrompt
|
|
3817
2186
|
void this.log
|
|
3818
2187
|
.append("prompt", this.currentSession, this.currentBranch, { source: "harness", text })
|
|
3819
2188
|
.catch(() => { });
|
|
3820
2189
|
}
|
|
3821
|
-
/** The `final: true` event — the parent's notification (#89: never before the goal settles). */
|
|
3822
2190
|
async appendFinalMessage(args) {
|
|
3823
2191
|
const recent = await this.captureRecentTurns();
|
|
3824
|
-
/**
|
|
3825
|
-
* an APPROVED finish after a REJECTION must describe the WHOLE task.
|
|
3826
|
-
*
|
|
3827
|
-
* `captureRecentTurns()` reads the last six meaningful messages, which after a
|
|
3828
|
-
* changes-required verdict is exactly the RETRY — so the parent was told about the last few
|
|
3829
|
-
* minutes of work and nothing about the hours that came before it. The rejection branch
|
|
3830
|
-
* already queues the auditor's feedback as the retry prompt, so the retry is a narrow fix-up;
|
|
3831
|
-
* reporting only that makes the work look trivial and the parent has no basis to judge it.
|
|
3832
|
-
*
|
|
3833
|
-
* The pre-rejection activity is captured when the reject arrives (`preRejectionTurns`) and is
|
|
3834
|
-
* prepended here, clearly labelled, so the parent reads "this is what was done, and this is
|
|
3835
|
-
* what the audit then required". Bounded to the same six entries so the report stays a report.
|
|
3836
|
-
*/
|
|
3837
2192
|
const before = this.preRejectionTurns;
|
|
3838
2193
|
this.preRejectionTurns = [];
|
|
3839
2194
|
const combined = before.length
|
|
@@ -3844,9 +2199,6 @@ export class Agent {
|
|
|
3844
2199
|
...recent,
|
|
3845
2200
|
]
|
|
3846
2201
|
: recent;
|
|
3847
|
-
// #91/#94: the final says how it relates to the audit — approved on which
|
|
3848
|
-
// attempt, what the previous attempt was told. Cleared on emit: a later
|
|
3849
|
-
// unaudited finish must not inherit stale lineage.
|
|
3850
2202
|
const outcome = this.lastAuditOutcome;
|
|
3851
2203
|
this.lastAuditOutcome = null;
|
|
3852
2204
|
await this.log.append("message", this.currentSession, this.currentBranch, {
|
|
@@ -3874,37 +2226,21 @@ export class Agent {
|
|
|
3874
2226
|
const activityOk = this.activityChars >= this.opts.progressMinChars ||
|
|
3875
2227
|
this.turnsSinceProgress >= this.opts.progressMaxQuietTurns;
|
|
3876
2228
|
if (!elapsedOk || !activityOk)
|
|
3877
|
-
return;
|
|
3878
|
-
/**
|
|
3879
|
-
* ONE harness instruction per boundary, in EITHER order.
|
|
3880
|
-
*
|
|
3881
|
-
* This is the mirror of the `progressRequestedThisRound` check in the auto-continue branch. A
|
|
3882
|
-
* nudge injected at the end of a round is consumed by the NEXT round's first turn, which is
|
|
3883
|
-
* where this runs — so without this the model receives both, back to back.
|
|
3884
|
-
*/
|
|
2229
|
+
return;
|
|
3885
2230
|
if (this.nudgedAtTurn === this.stats.turns)
|
|
3886
2231
|
return;
|
|
3887
|
-
// remember that this boundary already carries an instruction, so the
|
|
3888
|
-
// auto-continue nudge can stand down instead of pairing with it.
|
|
3889
2232
|
this.progressRequestedThisRound = true;
|
|
3890
2233
|
this.lastProgressAt = Date.now();
|
|
3891
2234
|
this.activityChars = 0;
|
|
3892
2235
|
this.turnsSinceProgress = 0;
|
|
3893
2236
|
const request = PROGRESS_REQUEST;
|
|
3894
|
-
// log both sides so a session restore replays this exchange faithfully
|
|
3895
2237
|
await this.log.append("prompt", this.currentSession, this.currentBranch, {
|
|
3896
2238
|
source: "harness",
|
|
3897
2239
|
text: request,
|
|
3898
2240
|
});
|
|
3899
2241
|
this.messages.push({ role: "user", content: request });
|
|
3900
|
-
// Give the model the report_progress TOOL here. The old no-tools call
|
|
3901
|
-
// made the model answer in plain text while its own mental "did I report?"
|
|
3902
|
-
// ledger stayed unsolved — it then re-sent a voluntary report_progress
|
|
3903
|
-
// right after, and the timeline showed every requested report TWICE.
|
|
3904
2242
|
const res = await this.llmCall(this.buildMessages(), allToolSpecs());
|
|
3905
2243
|
const calls = res.message.tool_calls ?? [];
|
|
3906
|
-
// the assistant turn MUST enter history before its tool answers, or the
|
|
3907
|
-
// sequence [user, tool] is invalid and every later request 400s
|
|
3908
2244
|
this.messages.push(res.message);
|
|
3909
2245
|
await this.log.append("message", this.currentSession, this.currentBranch, {
|
|
3910
2246
|
role: "assistant",
|
|
@@ -3918,27 +2254,9 @@ export class Agent {
|
|
|
3918
2254
|
if (call.function.name === "report_progress") {
|
|
3919
2255
|
await this.recordProgress(call.function.arguments);
|
|
3920
2256
|
}
|
|
3921
|
-
/**
|
|
3922
|
-
* EVERY tool call needs a `tool` message, but only `report_progress` was a report.
|
|
3923
|
-
*
|
|
3924
|
-
* This loop used to answer `"progress recorded"` to whatever the model called, so a `bash`
|
|
3925
|
-
* invocation came back with the text "progress recorded" and `durationMs: 0` — it never ran.
|
|
3926
|
-
* call, `bash <npm view polling loop>`, and its result was `"progress recorded"`, while the
|
|
3927
|
-
* very next `progress` row read "(no report produced)". The command's real output appeared
|
|
3928
|
-
* only on the NEXT turn, which is what made it look like a stray duplicate.
|
|
3929
|
-
*
|
|
3930
|
-
* The model did nothing wrong: it was asked for a progress report and chose to run its work
|
|
3931
|
-
* instead. Answering that call with the report text both lies to the model (the tool result
|
|
3932
|
-
* is its only view of what happened) and drops the command's output.
|
|
3933
|
-
*
|
|
3934
|
-
* A non-report call is answered with a plain acknowledgement — it is NOT executed here,
|
|
3935
|
-
* because this path has no tool context and must not run side effects a second time. The
|
|
3936
|
-
* round then continues and the model sees that it still owes a report.
|
|
3937
|
-
*/
|
|
3938
2257
|
const isReport = call.function.name === "report_progress";
|
|
3939
2258
|
const answer = isReport ? "progress recorded" : "not run — this turn was a progress request";
|
|
3940
2259
|
this.messages.push({ role: "tool", tool_call_id: call.id, content: answer });
|
|
3941
|
-
// log both sides so restores replay the exact exchange (meta-style)
|
|
3942
2260
|
await this.log.append("tool_call", this.currentSession, this.currentBranch, {
|
|
3943
2261
|
callId: call.id,
|
|
3944
2262
|
name: call.function.name,
|
|
@@ -3948,25 +2266,15 @@ export class Agent {
|
|
|
3948
2266
|
await this.log.append("tool_result", this.currentSession, this.currentBranch, {
|
|
3949
2267
|
callId: call.id,
|
|
3950
2268
|
name: call.function.name,
|
|
3951
|
-
// a not-run answer is not a FAILURE, so `ok` stays true — the row must not read as an error
|
|
3952
2269
|
ok: true,
|
|
3953
2270
|
durationMs: 0,
|
|
3954
2271
|
result: answer,
|
|
3955
2272
|
});
|
|
3956
2273
|
}
|
|
3957
2274
|
if (!reported && !(res.message.content ?? "").trim()) {
|
|
3958
|
-
// degenerate: neither a tool call nor prose — record a stub so the
|
|
3959
|
-
// operator sees SOMETHING at the requested moment
|
|
3960
2275
|
await this.recordProgress(JSON.stringify({ freeform: "(no report produced)" }));
|
|
3961
2276
|
}
|
|
3962
2277
|
}
|
|
3963
|
-
/* ---------- context compaction ---------- */
|
|
3964
|
-
/**
|
|
3965
|
-
* Rough token estimate good enough to trigger compaction before overflow.
|
|
3966
|
-
* ASCII runs ≈ 4 chars/token; CJK (kana/kanji/hanja and friends) ≈ 1
|
|
3967
|
-
* token/char — the old flat /4 underestimated Japanese sessions ~4x.
|
|
3968
|
-
* Adds a small per-message overhead for role/framing tokens.
|
|
3969
|
-
*/
|
|
3970
2278
|
estimateTokens(msgs = this.messages) {
|
|
3971
2279
|
let ascii = 0;
|
|
3972
2280
|
let wide = 0;
|
|
@@ -3987,12 +2295,6 @@ export class Agent {
|
|
|
3987
2295
|
}
|
|
3988
2296
|
return Math.ceil(ascii / 4 + wide * 1.2 + msgs.length * 8);
|
|
3989
2297
|
}
|
|
3990
|
-
/**
|
|
3991
|
-
* Latest index whose message may START the kept tail of a compacted
|
|
3992
|
-
* history. Anything except a dangling tool result is safe: a kept
|
|
3993
|
-
* assistant-with-tool_calls always has its tool responses after it (they
|
|
3994
|
-
* are never cut apart — we only remove a prefix).
|
|
3995
|
-
*/
|
|
3996
2298
|
safeCut(maxCut) {
|
|
3997
2299
|
for (let i = Math.min(maxCut, this.messages.length - 1); i >= 1; i--) {
|
|
3998
2300
|
if (this.messages[i].role !== "tool")
|
|
@@ -4000,23 +2302,11 @@ export class Agent {
|
|
|
4000
2302
|
}
|
|
4001
2303
|
return -1;
|
|
4002
2304
|
}
|
|
4003
|
-
/**
|
|
4004
|
-
* Answer a meta tool call (finish / get_goal / ask_user / …): into the
|
|
4005
|
-
* in-memory history AND the log as a regular tool_result, so session
|
|
4006
|
-
* restores replay the exact same bytes and provider prefix caches stay
|
|
4007
|
-
* warm across restarts.
|
|
4008
|
-
*/
|
|
4009
2305
|
async answerMeta(call, content) {
|
|
4010
|
-
// one tool_call gets ONE answer: the finish path answers before the audit
|
|
4011
|
-
// AND at round end — a second answerMeta for the same id would push
|
|
4012
|
-
// a duplicate `tool` message AND a duplicate log row, and strict
|
|
4013
|
-
// OpenAI-compatible providers reject two responses for one call with a 400.
|
|
4014
2306
|
if (this.messages.some((m) => m.role === "tool" && m.tool_call_id === call.id))
|
|
4015
2307
|
return;
|
|
4016
2308
|
const raw = call.function.arguments ?? "{}";
|
|
4017
2309
|
this.messages.push({ role: "tool", tool_call_id: call.id, content });
|
|
4018
|
-
// log the call AND its answer so restores replay byte-exact sequences
|
|
4019
|
-
// (meta calls previously went unlogged and restored as bare "{}" args)
|
|
4020
2310
|
await this.log.append("tool_call", this.currentSession, this.currentBranch, {
|
|
4021
2311
|
callId: call.id,
|
|
4022
2312
|
name: call.function.name,
|
|
@@ -4031,29 +2321,14 @@ export class Agent {
|
|
|
4031
2321
|
result: content,
|
|
4032
2322
|
});
|
|
4033
2323
|
}
|
|
4034
|
-
/**
|
|
4035
|
-
* Stage 1 of context management: clip OLD oversized tool outputs before paying for a full
|
|
4036
|
-
* summarise. They are the usual bulk and their details rarely matter once executed; the recent
|
|
4037
|
-
* window is protected so current work keeps its footing.
|
|
4038
|
-
*
|
|
4039
|
-
* It runs as a PRE-STEP of `maybeCompact()`, only once that pass has decided the context is
|
|
4040
|
-
* actually over budget. Clipping is more destructive than summarising — the model cannot see a
|
|
4041
|
-
* clipped result again without re-issuing the call — so doing it while room is still free trades
|
|
4042
|
-
* real information for nothing.
|
|
4043
|
-
*/
|
|
4044
2324
|
async maybePrune(force = false) {
|
|
4045
|
-
const est = this.contextSizeForBudgeting();
|
|
2325
|
+
const est = this.contextSizeForBudgeting();
|
|
4046
2326
|
const budget = this.opts.contextTokenBudget;
|
|
4047
2327
|
if (!budget)
|
|
4048
2328
|
return 0;
|
|
4049
|
-
// Below the bar there is enough room for the next request, so clipping now would
|
|
4050
|
-
// spend information to buy space that is already free.
|
|
4051
2329
|
const pruneShare = this.opts.shortContext ? SHORT_PRUNE_AT_SHARE : PRUNE_AT_SHARE;
|
|
4052
2330
|
if (!force && est < budget * pruneShare)
|
|
4053
2331
|
return 0;
|
|
4054
|
-
// protect the recent tail (~half the budget in chars) from any pruning. Short mode
|
|
4055
|
-
// protects a THIRD of it: a 2x-budget tail on a small window is most of the window,
|
|
4056
|
-
// so pruning would find nothing to do exactly when it is needed.
|
|
4057
2332
|
const protectChars = this.opts.shortContext ? Math.floor(budget * 1.2) : budget * 2;
|
|
4058
2333
|
let seen = 0;
|
|
4059
2334
|
let boundary = this.messages.length;
|
|
@@ -4064,19 +2339,12 @@ export class Agent {
|
|
|
4064
2339
|
break;
|
|
4065
2340
|
}
|
|
4066
2341
|
const PRUNE_MIN = 3_000;
|
|
4067
|
-
// Tool-kind aware pruning (Claude Code MicroCompact style): high-volume,
|
|
4068
|
-
// REPRODUCIBLE results (file reads, shell, grep) are safe to clip because
|
|
4069
|
-
// the model can re-issue them; one-shot results (spawned agent reports,
|
|
4070
|
-
// web fetches) are kept — re-fetching is impossible or expensive.
|
|
4071
2342
|
const PRUNABLE_TOOLS = new Set([
|
|
4072
2343
|
"bash", "read_file", "list_dir", "grep", "glob", "write_file", "edit_file", "apply_patch",
|
|
4073
2344
|
"bash_output",
|
|
4074
2345
|
]);
|
|
4075
2346
|
let saved = 0;
|
|
4076
2347
|
let count = 0;
|
|
4077
|
-
// callId → tool name, from the assistant turn that issued each call
|
|
4078
|
-
// (a tool result's PREVIOUS message is not reliably its caller when
|
|
4079
|
-
// parallel calls interleave)
|
|
4080
2348
|
const callerTool = new Map();
|
|
4081
2349
|
for (const m of this.messages) {
|
|
4082
2350
|
for (const t of m.tool_calls ?? [])
|
|
@@ -4097,10 +2365,6 @@ export class Agent {
|
|
|
4097
2365
|
}
|
|
4098
2366
|
}
|
|
4099
2367
|
if (count > 0)
|
|
4100
|
-
// AWAITED, not fire-and-forget: the caller decides what to do next based on the
|
|
4101
|
-
// count, and a compaction pass that reports its outcome must have its reasons on
|
|
4102
|
-
// disk first — a `void` append could land after the pass returned, so the log
|
|
4103
|
-
// read the order "compacted" → "pruned".
|
|
4104
2368
|
await this.log.append("system_note", this.currentSession, this.currentBranch, {
|
|
4105
2369
|
event: "context-pruned",
|
|
4106
2370
|
outputs: count,
|
|
@@ -4108,37 +2372,15 @@ export class Agent {
|
|
|
4108
2372
|
});
|
|
4109
2373
|
return count;
|
|
4110
2374
|
}
|
|
4111
|
-
/**
|
|
4112
|
-
* Compact history when the real prompt size exceeds the budget.
|
|
4113
|
-
*
|
|
4114
|
-
* @param keepFraction share of the budget to keep verbatim; the overflow path
|
|
4115
|
-
* lowers it on each retry so a too-shallow cut is not fatal
|
|
4116
|
-
*/
|
|
4117
2375
|
async maybeCompact(force = false, keepFraction) {
|
|
4118
|
-
|
|
4119
|
-
// what would overflow the window; the char heuristic is only a fallback
|
|
4120
|
-
// for providers that omit usage
|
|
4121
|
-
const before = this.contextSizeForBudgeting(); // floored — see #73
|
|
4122
|
-
// SHORT-CONTEXT MODE prunes INSIDE the budget, so it must run before the
|
|
4123
|
-
// "are we over budget" return below — otherwise its lower threshold is unreachable
|
|
4124
|
-
// and the mode does nothing at all. This is the one path where pruning deliberately
|
|
4125
|
-
// spends information early, and it only exists because the operator asked for it.
|
|
2376
|
+
const before = this.contextSizeForBudgeting();
|
|
4126
2377
|
if (this.opts.shortContext)
|
|
4127
2378
|
await this.maybePrune();
|
|
4128
2379
|
if (!force && before < this.opts.contextTokenBudget)
|
|
4129
2380
|
return;
|
|
4130
2381
|
if (!force && this.opts.autoCompact === false)
|
|
4131
2382
|
return;
|
|
4132
|
-
// stage 1: clip oversized old tool outputs before considering a summarize.
|
|
4133
|
-
// ONLY reached once this pass is actually over budget — pruning is destructive,
|
|
4134
|
-
// so it must not run on a context that still has room (see maybePrune()).
|
|
4135
2383
|
const pruned = await this.maybePrune(true);
|
|
4136
|
-
// If clipping alone brought the request back under budget, STOP: that is the
|
|
4137
|
-
// cheapest possible compaction (no LLM call, no history rewritten, the tool
|
|
4138
|
-
// results are re-issuable) and everything below would discard context the model
|
|
4139
|
-
// can still use. This is the ordering the operator asked for — "どうしても足りなかったら
|
|
4140
|
-
// 過去履歴落とす程度でいい" — and it is what Anthropic calls the lightest form of
|
|
4141
|
-
// compaction. A manual `/compact` still means "summarize", so it is exempt.
|
|
4142
2384
|
if (pruned > 0 && !force && this.contextSizeForBudgeting() < this.opts.contextTokenBudget) {
|
|
4143
2385
|
await this.log.append("system_note", this.currentSession, this.currentBranch, {
|
|
4144
2386
|
event: "context-pruned-enough",
|
|
@@ -4149,56 +2391,25 @@ export class Agent {
|
|
|
4149
2391
|
this.compactedAtLen = this.messages.length;
|
|
4150
2392
|
return;
|
|
4151
2393
|
}
|
|
4152
|
-
// CIRCUIT BREAKER (ZCode: `max auto failures: 3`). A compaction that keeps
|
|
4153
|
-
// failing would otherwise retry on EVERY turn, re-burning the provider for a pass
|
|
4154
|
-
// that cannot succeed — and the failure is usually environmental (an unavailable
|
|
4155
|
-
// model, a provider outage), so retrying it eagerly achieves nothing. Auto
|
|
4156
|
-
// attempts only: a manual `/compact` is the operator asking, and an overflow
|
|
4157
|
-
// recovery is the provider having just refused, so both must still try.
|
|
4158
2394
|
if (!force && this.consecutiveCompactFailures >= MAX_AUTO_COMPACT_FAILURES)
|
|
4159
2395
|
return;
|
|
4160
|
-
// a forced pass on an already-tiny history would just summarize the
|
|
4161
|
-
// summary — report ran=false instead
|
|
4162
2396
|
if (force && this.messages.length <= this.compactedAtLen)
|
|
4163
2397
|
return;
|
|
4164
|
-
this.lastUsage = undefined;
|
|
4165
|
-
// keep roughly the most recent quarter of the budget as live context
|
|
4166
|
-
// Keep the most recent stretch VERBATIM, as every implementation surveyed
|
|
4167
|
-
// does (Pi: keepRecentTokens 20000, opencode: keep 15000). Recency is what the
|
|
4168
|
-
// model needs to continue accurately, and verbatim beats a summary of it.
|
|
4169
|
-
//
|
|
4170
|
-
// Size: a quarter of the budget, but never less than KEEP_RECENT_TOKENS — a
|
|
4171
|
-
// small budget would otherwise keep so little that the model loses the turn it
|
|
4172
|
-
// is in the middle of. Measured in TOKENS rather than characters, so the choice
|
|
4173
|
-
// matches the published defaults instead of depending on the char heuristic.
|
|
2398
|
+
this.lastUsage = undefined;
|
|
4174
2399
|
const keepTokens = keepFraction !== undefined
|
|
4175
|
-
// an explicit retry (overflow recovery): shrink toward a share of the budget
|
|
4176
2400
|
? Math.max(1, Math.floor(this.opts.contextTokenBudget * keepFraction))
|
|
4177
|
-
// otherwise keep the newest stretch verbatim, but never more than a quarter
|
|
4178
|
-
// of the budget — a fixed floor GREATER than the budget would keep everything
|
|
4179
|
-
// and compaction would never fire at all (measured: a 500-token budget kept
|
|
4180
|
-
// a 7.5k-token history intact)
|
|
4181
|
-
// already scaled: on a small budget a quarter is below the 15k default, so a
|
|
4182
|
-
// 32k-window session keeps ~6k rather than 15k. Short mode does NOT need to
|
|
4183
|
-
// change this — checked while implementing #161 rather than assumed.
|
|
4184
2401
|
: Math.min(KEEP_RECENT_TOKENS, Math.max(1, Math.floor(this.opts.contextTokenBudget / 4)));
|
|
4185
2402
|
let cut = -1;
|
|
4186
2403
|
for (let i = this.messages.length - 1; i >= 1; i--) {
|
|
4187
|
-
// token-aware boundary, using the SAME estimate the budget uses — a raw char
|
|
4188
|
-
// ratio would disagree with it for wide (CJK) text and per-message overhead
|
|
4189
2404
|
if (this.estimateTokens(this.messages.slice(i)) > keepTokens)
|
|
4190
2405
|
break;
|
|
4191
2406
|
cut = i;
|
|
4192
2407
|
}
|
|
4193
2408
|
cut = this.safeCut(cut);
|
|
4194
2409
|
if (cut <= 0)
|
|
4195
|
-
return;
|
|
2410
|
+
return;
|
|
4196
2411
|
let old = this.messages.slice(0, cut);
|
|
4197
2412
|
const oldCount = old.length;
|
|
4198
|
-
// The prefix's share of the size the budget check ACTED on (which floors our
|
|
4199
|
-
// estimate against the provider's reported prompt_tokens). Scaling by character
|
|
4200
|
-
// share rather than message count: messages vary hugely in size, and the point
|
|
4201
|
-
// is to avoid treating an unmeasurably large context as small.
|
|
4202
2413
|
const totalChars = this.messages.reduce((n, m) => n + (m.content?.length ?? 0), 0);
|
|
4203
2414
|
const oldChars = old.reduce((n, m) => n + (m.content?.length ?? 0), 0);
|
|
4204
2415
|
const oldShareOfBefore = totalChars > 0 ? Math.round((before * oldChars) / totalChars) : 0;
|
|
@@ -4206,56 +2417,10 @@ export class Agent {
|
|
|
4206
2417
|
let mode = "summarize";
|
|
4207
2418
|
let summarizedCount = oldCount;
|
|
4208
2419
|
let droppedCount = 0;
|
|
4209
|
-
// COST GATE. The criterion is the ABSOLUTE amount of context the cut would
|
|
4210
|
-
// discard, not how far over the budget we are: a ratio test gets it backwards,
|
|
4211
|
-
// since a small window overflowing slightly is exactly the case where a
|
|
4212
|
-
// summary buys almost nothing (there is little knowledge to lose), while a large
|
|
4213
|
-
// window overflowing slightly still has a lot to lose.
|
|
4214
|
-
//
|
|
4215
|
-
// Since the summarizer now REPLAYS the conversation prefix, the call reuses the
|
|
4216
|
-
// provider's KV cache, so it is far cheaper than it used to be — but it is not
|
|
4217
|
-
// free (the shadowed region is re-read, and the summary itself is generated), so
|
|
4218
|
-
// a shallow cut is still better served by the free drop.
|
|
4219
|
-
//
|
|
4220
|
-
// MANUAL is exempt: an explicit /compact is a user command, not a cost decision,
|
|
4221
|
-
// and opencode draws the line in the same place ("only the user compacts when
|
|
4222
|
-
// automatic compaction is off"). An overflow recovery is exempt too — the
|
|
4223
|
-
// provider just told us the prompt was too big, so the question is not whether
|
|
4224
|
-
// the saving is worth a call.
|
|
4225
|
-
//
|
|
4226
|
-
// Use BOTH sizes. `oldTokens` is our estimate of the prefix being summarized;
|
|
4227
|
-
// `before` is the size the budget check acted on, which already floors that
|
|
4228
|
-
// estimate against the provider's REPORTED prompt_tokens. Gating on the local
|
|
4229
|
-
// estimate alone would silently truncate a context the provider says is large
|
|
4230
|
-
// but we cannot measure (caught by a test: provider reports 5000 tokens for a
|
|
4231
|
-
// 300-token local estimate). The provider tokenizes for real; our count is a
|
|
4232
|
-
// guess, so the larger of the two is the honest figure.
|
|
4233
2420
|
const oldTokens = Math.max(this.estimateTokens(old), oldShareOfBefore);
|
|
4234
|
-
// The bar is a SHARE of the budget with an absolute floor, so it scales with a
|
|
4235
|
-
// realistic window while never sitting above a small session's entire history
|
|
4236
|
-
// (measured: a budget of 200 made a pure-share bar 10 tokens, while the whole
|
|
4237
|
-
// summarizable prefix in that fixture was an 8-token prompt — summarization
|
|
4238
|
-
// would have been silently disabled).
|
|
4239
|
-
//
|
|
4240
|
-
// Below the bar the prefix is a turn or two, and dropping it costs the model
|
|
4241
|
-
// nothing it cannot recover by re-reading the goal or the files; a full-history
|
|
4242
|
-
// LLM call to preserve that is not worth its price.
|
|
4243
2421
|
const summarizeBar = Math.max(SUMMARIZE_MIN_TOKENS, this.opts.contextTokenBudget * SUMMARIZE_MIN_SHARE);
|
|
4244
2422
|
const worthSummarizing = force || oldTokens >= summarizeBar;
|
|
4245
|
-
// UPDATE mode: carry the previous notes forward instead of re-deriving them.
|
|
4246
|
-
// Every implementation surveyed (opencode, Kilo, Pi) updates rather than
|
|
4247
|
-
// re-summarizes, because a fresh summary each time loses whatever the previous
|
|
4248
|
-
// pass had already distilled. Recovered from the LAST compaction event, so it
|
|
4249
|
-
// survives a restart.
|
|
4250
2423
|
const previousSummary = this.lastCompactSummary;
|
|
4251
|
-
// progress is visible: announce the phase up front, then stream the
|
|
4252
|
-
// summarizer's output as it generates (a long summarize used to look like
|
|
4253
|
-
// the agent silently hung — hundreds of thousands of tokens take a while)
|
|
4254
|
-
// the STATE must be established BEFORE the first notification — the
|
|
4255
|
-
// emit used to precede `compactPhase` by an async log write, so the first
|
|
4256
|
-
// progress event observed a snapshot with `compactPhase` still unset:
|
|
4257
|
-
// "idle, not compacting, but a summarizing banner" from three paths at
|
|
4258
|
-
// once. Sync set → emit → log keeps every path consistent.
|
|
4259
2424
|
this.compactPhase = "summarizing";
|
|
4260
2425
|
bus.emit("update", {
|
|
4261
2426
|
kind: "compaction-progress",
|
|
@@ -4263,84 +2428,34 @@ export class Agent {
|
|
|
4263
2428
|
phase: "summarizing",
|
|
4264
2429
|
summarized: oldCount,
|
|
4265
2430
|
});
|
|
4266
|
-
// Everything below may throw — EventLog appends reject on stream errors,
|
|
4267
|
-
// and disk-full is exactly when they will. The phase MUST not survive a
|
|
4268
|
-
// failed compaction: isLive() keys off it, so a leaked "summarizing"
|
|
4269
|
-
// pinned live=true forever (stop offered, never worked).
|
|
4270
2431
|
try {
|
|
4271
|
-
// the START belongs on the timeline too. Only the right panel showed
|
|
4272
|
-
// compaction, and it reports the *outcome*; a long summarize (hundreds of
|
|
4273
|
-
// thousands of tokens) looks exactly like a hung agent on the timeline,
|
|
4274
|
-
// because the first `context-compacted` event is only appended once the
|
|
4275
|
-
// whole pass is over. Log the announcement as a visible system_note so
|
|
4276
|
-
// the operator sees WHERE the conversation was rewritten, even if the
|
|
4277
|
-
// pass later fails — `before`/`oldCount` are known at this point, and the
|
|
4278
|
-
// completion divider still carries the authoritative after-figures.
|
|
4279
2432
|
await this.log.append("system_note", this.currentSession, this.currentBranch, {
|
|
4280
2433
|
event: "context-compaction-started",
|
|
4281
2434
|
tokensBefore: before,
|
|
4282
2435
|
messages: oldCount,
|
|
4283
2436
|
reason: force ? "manual" : "auto",
|
|
4284
|
-
worthSummarizing,
|
|
2437
|
+
worthSummarizing,
|
|
4285
2438
|
updating: Boolean(previousSummary),
|
|
4286
2439
|
});
|
|
4287
2440
|
try {
|
|
4288
2441
|
if (worthSummarizing) {
|
|
4289
|
-
// OVERFLOW RETRY on the summarizer itself. The replayed prefix is the head
|
|
4290
|
-
// of a request that has already succeeded, so it normally fits — but the
|
|
4291
|
-
// cut can land such that the shadowed region is nearly the whole history
|
|
4292
|
-
// (measured: with a recent-keep target smaller than one message, cut lands
|
|
4293
|
-
// at the far end and `old` is everything). When the conversation was over
|
|
4294
|
-
// its window to begin with — the overflow-recovery case — the auxiliary
|
|
4295
|
-
// request can then be over it too, and there is no truncation to save it
|
|
4296
|
-
// any more.
|
|
4297
|
-
//
|
|
4298
|
-
// The fix keeps the property the replay exists for: REDUCE THE CUT, which
|
|
4299
|
-
// sends FEWER LEADING messages, so the request stays a cacheable prefix of
|
|
4300
|
-
// the routed request while shrinking. (Dropping the middle would break
|
|
4301
|
-
// alignment; dropping the tail would discard the newest context.) ZCode
|
|
4302
|
-
// handles the same case with a bounded `prompt-too-long` retry, and this is
|
|
4303
|
-
// bounded for the same reason: an unbounded loop here would burn tokens
|
|
4304
|
-
// against a provider that will refuse every size.
|
|
4305
2442
|
for (let attempt = 0; attempt <= SUMMARIZE_OVERFLOW_RETRIES; attempt++) {
|
|
4306
2443
|
try {
|
|
4307
2444
|
summary = await this.summarize(old, (text) => {
|
|
4308
2445
|
bus.emit("update", {
|
|
4309
2446
|
kind: "llm-delta",
|
|
4310
2447
|
agentId: this.opts.id,
|
|
4311
|
-
// the bubble is session-scoped, so the stream must say which session
|
|
4312
2448
|
sessionId: this.currentSession,
|
|
4313
2449
|
text: `[compact] ${text}`,
|
|
4314
2450
|
reasoning: "",
|
|
4315
2451
|
});
|
|
4316
2452
|
}, previousSummary);
|
|
4317
|
-
/**
|
|
4318
|
-
* Rules a pass dropped are put BACK.
|
|
4319
|
-
*
|
|
4320
|
-
* The summarizer is asked for one keyword per line, but it is still generated text:
|
|
4321
|
-
* a pass can merge three constraints into a sentence, or simply not mention one.
|
|
4322
|
-
* Facts survive that (the code can be re-read); a rule cannot — "never install this
|
|
4323
|
-
* into Wine" was stated once and is nowhere else. Reported as "compact時にルール
|
|
4324
|
-
* (must/must notが中心だけど、shouldレベルのものも)が確実に引き継げる機構があると良いね".
|
|
4325
|
-
*
|
|
4326
|
-
* So the rules known BEFORE this pass are compared against the ones it produced, and
|
|
4327
|
-
* any missing are appended. A pass may add and reword freely; it cannot lose one.
|
|
4328
|
-
*/
|
|
4329
2453
|
{
|
|
4330
|
-
/**
|
|
4331
|
-
* Seed from the previous summary ONCE, for a session that compacted before this
|
|
4332
|
-
* mechanism existed — after that the stored set is the authority.
|
|
4333
|
-
*
|
|
4334
|
-
* The set lives in `this.compactRules`, not in the previous summary: a pass that
|
|
4335
|
-
* dropped every rule would otherwise leave nothing to restore from, which is the
|
|
4336
|
-
* exact failure this exists to prevent.
|
|
4337
|
-
*/
|
|
4338
2454
|
if (!this.compactRules.length && previousSummary) {
|
|
4339
2455
|
this.compactRules = extractRules(previousSummary);
|
|
4340
2456
|
}
|
|
4341
2457
|
const enforced = enforceRules(summary, this.compactRules);
|
|
4342
2458
|
summary = enforced.summary;
|
|
4343
|
-
// a pass may ADD rules (the operator said something new); it may never remove one
|
|
4344
2459
|
this.compactRules = extractRules(summary);
|
|
4345
2460
|
if (enforced.restored.length) {
|
|
4346
2461
|
await this.log.append("system_note", this.currentSession, this.currentBranch, {
|
|
@@ -4363,7 +2478,7 @@ export class Agent {
|
|
|
4363
2478
|
throw e;
|
|
4364
2479
|
const smaller = this.safeCut(Math.max(1, Math.floor(old.length * SUMMARIZE_SHRINK)));
|
|
4365
2480
|
if (smaller <= 0 || smaller >= old.length)
|
|
4366
|
-
throw e;
|
|
2481
|
+
throw e;
|
|
4367
2482
|
await this.log.append("system_note", this.currentSession, this.currentBranch, {
|
|
4368
2483
|
event: "compaction-summarize-overflow",
|
|
4369
2484
|
attempt: attempt + 1,
|
|
@@ -4376,8 +2491,6 @@ export class Agent {
|
|
|
4376
2491
|
}
|
|
4377
2492
|
}
|
|
4378
2493
|
else {
|
|
4379
|
-
// shallow saving: truncation reclaims the budget for free, so skip the
|
|
4380
|
-
// one full-history LLM call the summary would cost
|
|
4381
2494
|
await this.log.append("system_note", this.currentSession, this.currentBranch, {
|
|
4382
2495
|
event: "context-compaction-skipped-summarize",
|
|
4383
2496
|
reason: "shallow: truncation reclaims the budget without a full-history LLM call",
|
|
@@ -4388,47 +2501,26 @@ export class Agent {
|
|
|
4388
2501
|
this.compactPhase = "harvesting";
|
|
4389
2502
|
bus.emit("update", { kind: "compaction-progress", agentId: this.opts.id, phase: "harvesting" });
|
|
4390
2503
|
if (summary)
|
|
4391
|
-
await this.harvestLessons(summary);
|
|
2504
|
+
await this.harvestLessons(summary);
|
|
4392
2505
|
}
|
|
4393
2506
|
catch (err) {
|
|
4394
2507
|
await this.log.append("error", this.currentSession, this.currentBranch, {
|
|
4395
2508
|
message: `compaction summarize failed, falling back to truncation: ${err.message}`,
|
|
4396
2509
|
});
|
|
4397
|
-
// ZCode's circuit breaker counts the failure and never fails the turn for it
|
|
4398
|
-
// ("Auto compact 失败不应让当前用户 turn 失败"): the pass still falls back to a
|
|
4399
|
-
// truncation below, so work continues, and after MAX_AUTO_COMPACT_FAILURES
|
|
4400
|
-
// consecutive auto failures the retry stops burning the provider.
|
|
4401
2510
|
this.consecutiveCompactFailures += 1;
|
|
4402
2511
|
}
|
|
4403
2512
|
if (!summary) {
|
|
4404
|
-
// fallback: drop the oldest half without LLM help so work can continue
|
|
4405
2513
|
mode = "truncate";
|
|
4406
2514
|
summarizedCount = 0;
|
|
4407
|
-
// Cut at a safe boundary, but never at index 0 — that would drop the
|
|
4408
|
-
// ENTIRE history (including the prompt just delivered) and leave the agent
|
|
4409
|
-
// with nothing. On a short history floor(oldCount/2) can round to 0, and
|
|
4410
|
-
// the old code then bailed out silently: compaction "ran", reported
|
|
4411
|
-
// nothing, and the context stayed over budget, so the very next provider
|
|
4412
|
-
// call overflowed — the "unstable after compact" report. Always drop
|
|
4413
|
-
// at least one message, and say so.
|
|
4414
2515
|
const halfCut = this.safeCut(Math.max(1, Math.floor(oldCount / 2)));
|
|
4415
2516
|
if (halfCut <= 0) {
|
|
4416
|
-
// nothing in the prefix is safe to drop (e.g. it is all tool results).
|
|
4417
|
-
// Report it instead of pretending compaction happened.
|
|
4418
2517
|
await this.log.append("system_note", this.currentSession, this.currentBranch, {
|
|
4419
2518
|
event: "context-compaction-skipped",
|
|
4420
2519
|
reason: "summarize failed and no safe prefix to truncate",
|
|
4421
2520
|
});
|
|
4422
|
-
return;
|
|
2521
|
+
return;
|
|
4423
2522
|
}
|
|
4424
2523
|
droppedCount = halfCut;
|
|
4425
|
-
// Tell the model its history was cut. Dropping a prefix SILENTLY is the
|
|
4426
|
-
// one failure it cannot detect or work around: it would see a conversation
|
|
4427
|
-
// that simply starts mid-way and could not distinguish "I forgot this" from
|
|
4428
|
-
// "this never happened", then confidently act on a gap. Truncation is now
|
|
4429
|
-
// the COMMON path (the cost gate keeps the summarize call for deep cuts
|
|
4430
|
-
// only), which makes this the difference between a known gap and a wrong
|
|
4431
|
-
// belief. The dropped text stays in the log — nothing durable is lost.
|
|
4432
2524
|
this.messages = [
|
|
4433
2525
|
{
|
|
4434
2526
|
role: "user",
|
|
@@ -4454,28 +2546,10 @@ export class Agent {
|
|
|
4454
2546
|
}
|
|
4455
2547
|
const after = this.estimateTokens();
|
|
4456
2548
|
this.stats.compactions++;
|
|
4457
|
-
// A pass that got here reclaimed the budget, so autos may try again — ZCode
|
|
4458
|
-
// resets its count on success for the same reason. But NOT when the summary
|
|
4459
|
-
// failed: the truncate fallback still lands here, and resetting on it made the
|
|
4460
|
-
// breaker unreachable (measured: 217 consecutive summarize failures, counter
|
|
4461
|
-
// stuck at 0, so the provider was re-burned on every turn).
|
|
4462
2549
|
if (mode === "summarize")
|
|
4463
2550
|
this.consecutiveCompactFailures = 0;
|
|
4464
2551
|
this.compactedAtLen = this.messages.length;
|
|
4465
|
-
// Post-compact file restore (Claude Code style): re-attach the most
|
|
4466
|
-
// recently read files so the model doesn't burn its first turns re-reading
|
|
4467
|
-
// exactly what it had in context a moment ago. Budget: 3 files × 400 lines.
|
|
4468
2552
|
if (mode === "summarize") {
|
|
4469
|
-
// the durable notes go back in HERE, and only here.
|
|
4470
|
-
//
|
|
4471
|
-
// `read_memory` AFTER writing only 13 times — the notes were written and
|
|
4472
|
-
// never consulted, so the whole system was decoration. The prompt made it
|
|
4473
|
-
// worse by claiming memory.md was injected when it never was.
|
|
4474
|
-
//
|
|
4475
|
-
// Compaction is the one moment where re-attaching is free and obviously
|
|
4476
|
-
// right: the history that held the notes has just been summarised away, and
|
|
4477
|
-
// the prompt has to move anyway. Everywhere else they stay behind
|
|
4478
|
-
// `read_memory()` so the prefix cache is never disturbed.
|
|
4479
2553
|
try {
|
|
4480
2554
|
const notes = (await fs.readFile(this.memoryFile, "utf8")).trim();
|
|
4481
2555
|
if (notes) {
|
|
@@ -4487,7 +2561,6 @@ export class Agent {
|
|
|
4487
2561
|
}
|
|
4488
2562
|
}
|
|
4489
2563
|
catch {
|
|
4490
|
-
/* no notes yet — nothing to re-attach */
|
|
4491
2564
|
}
|
|
4492
2565
|
}
|
|
4493
2566
|
if (this.recentReads.length && mode === "summarize") {
|
|
@@ -4502,7 +2575,6 @@ export class Agent {
|
|
|
4502
2575
|
restoredPaths.add(rel);
|
|
4503
2576
|
}
|
|
4504
2577
|
catch {
|
|
4505
|
-
/* file vanished since the read — skip */
|
|
4506
2578
|
}
|
|
4507
2579
|
}
|
|
4508
2580
|
if (restored.length) {
|
|
@@ -4511,30 +2583,9 @@ export class Agent {
|
|
|
4511
2583
|
content: `[harness] These files were recently read and are re-attached for continuity ` +
|
|
4512
2584
|
`(they may have changed — re-read if precision matters):\n\n${restored.join("\n\n")}`,
|
|
4513
2585
|
});
|
|
4514
|
-
// keep exactly what restored, in order — slicing by COUNT kept a
|
|
4515
|
-
// vanished middle entry while dropping the surviving tail
|
|
4516
2586
|
this.recentReads = this.recentReads.filter((rel) => restoredPaths.has(rel));
|
|
4517
2587
|
}
|
|
4518
2588
|
}
|
|
4519
|
-
// #64 follow-up: compaction is the ONE point in a session where re-reading
|
|
4520
|
-
// AGENTS.md is both safe and expected.
|
|
4521
|
-
//
|
|
4522
|
-
// It is expected because compaction exists precisely to summarise work
|
|
4523
|
-
// against the CURRENT instructions: an agent that spent an hour under one
|
|
4524
|
-
// set of project rules, then compacted, would otherwise carry the old rules
|
|
4525
|
-
// over the summary and never see the edit.
|
|
4526
|
-
//
|
|
4527
|
-
// It is safe because the prefix-cache concern that motivates freezing the
|
|
4528
|
-
// prompt does not apply here. That concern is about a prompt changing
|
|
4529
|
-
// mid-turn, re-pricing the whole prefix on every call. A compaction
|
|
4530
|
-
// ALREADY rewrote the history the prompt sits on top of, so the cache is
|
|
4531
|
-
// being re-priced regardless — one more changed block costs nothing extra.
|
|
4532
|
-
//
|
|
4533
|
-
// Only AGENTS.md is re-read, deliberately. The workspace listing and the
|
|
4534
|
-
// skills catalogue stay frozen: both are a snapshot of the workspace as it
|
|
4535
|
-
// was, and re-listing mid-session would contradict the instruction to
|
|
4536
|
-
// "not list again what is already here". AGENTS.md is different — it is
|
|
4537
|
-
// instructions, not a listing, and a stale copy is actively misleading.
|
|
4538
2589
|
const refreshed = this.reloadAgentsMd();
|
|
4539
2590
|
if (refreshed) {
|
|
4540
2591
|
await this.log.append("system_note", this.currentSession, this.currentBranch, {
|
|
@@ -4542,8 +2593,6 @@ export class Agent {
|
|
|
4542
2593
|
bytes: refreshed.length,
|
|
4543
2594
|
});
|
|
4544
2595
|
}
|
|
4545
|
-
// the operator can inspect WHAT was remembered — a dedicated typed event
|
|
4546
|
-
// (not an agent message) keeps it out of the model's own voice
|
|
4547
2596
|
await this.log.append("compaction", this.currentSession, this.currentBranch, {
|
|
4548
2597
|
tokensBefore: before,
|
|
4549
2598
|
tokensAfter: after,
|
|
@@ -4552,7 +2601,6 @@ export class Agent {
|
|
|
4552
2601
|
mode,
|
|
4553
2602
|
...(summary ? { summary: String(summary).slice(0, 20_000) } : {}),
|
|
4554
2603
|
});
|
|
4555
|
-
// remember the notes for the NEXT compaction to update (and for a restore)
|
|
4556
2604
|
if (summary)
|
|
4557
2605
|
this.lastCompactSummary = summary;
|
|
4558
2606
|
await this.log.append("system_note", this.currentSession, this.currentBranch, {
|
|
@@ -4566,88 +2614,22 @@ export class Agent {
|
|
|
4566
2614
|
}
|
|
4567
2615
|
finally {
|
|
4568
2616
|
this.compactPhase = "";
|
|
4569
|
-
// the end signal lives in the FINALLY: a compaction that dies mid-flight
|
|
4570
|
-
// (a log write rejecting on disk-full) used to leave the phase cleared
|
|
4571
|
-
// server-side while the UI banner stayed "summarizing" forever — the
|
|
4572
|
-
// client never got the done event. Emitting here covers every exit.
|
|
4573
2617
|
bus.emit("update", { kind: "compaction-progress", agentId: this.opts.id, phase: "done" });
|
|
4574
2618
|
}
|
|
4575
2619
|
}
|
|
4576
|
-
/**
|
|
4577
|
-
* Summarize `old` into continuation notes.
|
|
4578
|
-
*
|
|
4579
|
-
* PREFIX-CACHE SHAPE (measured): the summarizer request is built as the
|
|
4580
|
-
* conversation's OWN prefix — the session's system prompt, the same tool schemas,
|
|
4581
|
-
* and the messages being shadowed — with the compaction instruction appended as a
|
|
4582
|
-
* trailing `user` message. That makes the auxiliary call a genuine prefix-EXTENSION
|
|
4583
|
-
* of the last routed request, so the provider reuses the KV cache instead of
|
|
4584
|
-
* invalidating it at the first token.
|
|
4585
|
-
*
|
|
4586
|
-
* The alternative (a bespoke summarizer `system` prompt + a flattened transcript)
|
|
4587
|
-
* was measured to share NOTHING with the conversation request: a provider caches on
|
|
4588
|
-
* the leading token sequence, so a different first message discards the whole
|
|
4589
|
-
* prefix and the entire history is re-prefilled at full price — exactly when the
|
|
4590
|
-
* conversation is largest. DeepSeek Harness documents the same finding and the same
|
|
4591
|
-
* fix ("the summarization directive moves from the front of the request to the end
|
|
4592
|
-
* of the conversation"); the reasoning is reproduced here because it is not
|
|
4593
|
-
* obvious, and the previous version of this function did the expensive thing.
|
|
4594
|
-
*
|
|
4595
|
-
* `tools` ride along even though the summarizer never calls one: they are part of
|
|
4596
|
-
* the cached token sequence, so omitting them would misalign everything after them.
|
|
4597
|
-
*/
|
|
4598
2620
|
async summarize(old, onDelta, previousSummary) {
|
|
4599
2621
|
const fn = this.opts.chatFn ?? chat;
|
|
4600
|
-
// the conversation's own head: system prompt + the shadowed region, in order.
|
|
4601
|
-
// `buildMessages()` supplies the live system prompt, so the replayed prefix is
|
|
4602
|
-
// byte-identical to what the last routed request carried.
|
|
4603
2622
|
const prefix = this.buildMessages().slice(0, 1 + old.length);
|
|
4604
|
-
// #127 kept the head: dropping the OLDEST text silently discarded the original
|
|
4605
|
-
// request, which sits at the front of the history and is the one thing compaction
|
|
4606
|
-
// cannot recover from. Replaying the prefix VERBATIM (rather than a flattened,
|
|
4607
|
-
// head+tail-elided transcript) keeps that property AND the cache: the model sees
|
|
4608
|
-
// the real messages, in their real order.
|
|
4609
|
-
//
|
|
4610
|
-
// The older head+tail truncation guarded against a summarizer input larger than
|
|
4611
|
-
// the model accepts; that is now handled by what gets shadowed instead — the
|
|
4612
|
-
// region is what the conversation itself just carried, so it already fits the
|
|
4613
|
-
// window that request fitted.
|
|
4614
2623
|
const instruction = COMPACT_INSTRUCTION + (previousSummary ? UPDATE_INSTRUCTION : "");
|
|
4615
|
-
|
|
4616
|
-
// successive compactions far better than free-form prose — each pass knows
|
|
4617
|
-
// where "pending tasks" vs "user messages" live, so nothing silently
|
|
4618
|
-
// merges away. The <analysis> scratchpad gives the model a thinking space;
|
|
4619
|
-
// it is stripped from the stored summary (formatCompactSummary).
|
|
4620
|
-
//
|
|
4621
|
-
// `tools` are forwarded even though the summarizer never calls one: they are
|
|
4622
|
-
// part of the cached token sequence, so dropping them (or sending a different
|
|
4623
|
-
// set) misaligns everything after them and costs the cache the whole point of
|
|
4624
|
-
// this shape. MEASURED while writing this: `toolSpecs()` returns 15 and the
|
|
4625
|
-
// conversation sends `allToolSpecs()`'s 29, so the obvious call was wrong — the
|
|
4626
|
-
// exact array the main loop uses is what must ride along.
|
|
4627
|
-
const res = await fn(
|
|
4628
|
-
// one-off prompt: ask the provider not to retain it — but it is still the
|
|
4629
|
-
// SAME conversation, so it carries the same session id
|
|
4630
|
-
this.requestConfig({ oneOff: true }), [
|
|
2624
|
+
const res = await fn(this.requestConfig({ oneOff: true }), [
|
|
4631
2625
|
...prefix,
|
|
4632
2626
|
{
|
|
4633
2627
|
role: "user",
|
|
4634
2628
|
content: (previousSummary ? `${SUMMARY_OPEN}${previousSummary}${SUMMARY_CLOSE}\n\n` : "") + instruction,
|
|
4635
2629
|
},
|
|
4636
|
-
], allToolSpecs(), undefined,
|
|
4637
|
-
onDelta
|
|
2630
|
+
], allToolSpecs(), undefined, onDelta
|
|
4638
2631
|
? (s) => onDelta(s.text)
|
|
4639
2632
|
: undefined);
|
|
4640
|
-
// Record what the summarizer itself cost, especially `cachedInputTokens`.
|
|
4641
|
-
//
|
|
4642
|
-
// This is the ONLY way to observe the property the whole replay-the-prefix shape
|
|
4643
|
-
// exists for: the summarizer is supposed to be served from the provider's KV
|
|
4644
|
-
// cache because its input is a byte-identical prefix of the request the
|
|
4645
|
-
// conversation just made. Without this line the claim was UNVERIFIABLE — a
|
|
4646
|
-
// compaction event reported tokensBefore/tokensAfter (the CONVERSATION's size)
|
|
4647
|
-
// and nothing at all about the call that produced the summary, so a shape that
|
|
4648
|
-
// silently stopped sharing the prefix would look identical in the log.
|
|
4649
|
-
//
|
|
4650
|
-
// usage events, none of them the summarizer's.
|
|
4651
2633
|
if (res.usage) {
|
|
4652
2634
|
const u = res.usage;
|
|
4653
2635
|
this.stats.inputTokens += u.inputTokens ?? 0;
|
|
@@ -4655,41 +2637,20 @@ export class Agent {
|
|
|
4655
2637
|
this.stats.cachedInputTokens += u.cachedInputTokens ?? 0;
|
|
4656
2638
|
await this.log.append("usage", this.currentSession, this.currentBranch, {
|
|
4657
2639
|
...u,
|
|
4658
|
-
// distinguishes it from a conversation turn when reading the log back
|
|
4659
2640
|
kind: "summarize",
|
|
4660
2641
|
});
|
|
4661
2642
|
}
|
|
4662
2643
|
return this.formatCompactSummary(res.message.content ?? "");
|
|
4663
2644
|
}
|
|
4664
|
-
/** strip the <analysis> scratchpad — it improves thinking but must not
|
|
4665
|
-
consume post-compact context tokens */
|
|
4666
2645
|
formatCompactSummary(raw) {
|
|
4667
2646
|
const m = raw.match(/<analysis>[\s\S]*?<\/analysis>\s*/);
|
|
4668
2647
|
return m ? raw.slice(m[0].length).trim() : raw.trim();
|
|
4669
2648
|
}
|
|
4670
|
-
/**
|
|
4671
|
-
* Extract the "## Durable lessons" block from a compaction summary and
|
|
4672
|
-
* append it to memory.md — knowledge survives compaction automatically
|
|
4673
|
-
* (inspired by hook-driven CLAUDE.md growers; zero extra LLM calls).
|
|
4674
|
-
*/
|
|
4675
2649
|
async harvestLessons(summary) {
|
|
4676
2650
|
const m = summary.match(/##\s*Durable lessons?\s*\n([\s\S]*?)(?=\n##\s|$)/i);
|
|
4677
2651
|
const lessons = m?.[1]?.trim();
|
|
4678
2652
|
if (!lessons)
|
|
4679
2653
|
return;
|
|
4680
|
-
/**
|
|
4681
|
-
* PROVENANCE, in a form the model can READ.
|
|
4682
|
-
*
|
|
4683
|
-
* The stamp used to be an HTML comment. It recorded exactly the right thing — which
|
|
4684
|
-
* session and which compaction a note came from — and nothing ever read it back: a
|
|
4685
|
-
* `read_memory(query)` returned the lesson with no way to tell whether it was observed
|
|
4686
|
-
* today or inherited from a compaction three weeks ago. A note you cannot date is a note
|
|
4687
|
-
* you cannot safely act on.
|
|
4688
|
-
*
|
|
4689
|
-
* It is now a visible `_from:_` line, which survives search, survives the digest (it is
|
|
4690
|
-
* indented under the heading and skipped as a continuation), and answers "where did this
|
|
4691
|
-
* come from?" in the reply itself.
|
|
4692
|
-
*/
|
|
4693
2654
|
const session = path.basename(this.opts.sessionDir);
|
|
4694
2655
|
const stamped = `\n## lessons from compaction ${new Date().toISOString().slice(0, 16).replace("T", " ")}\n` +
|
|
4695
2656
|
`_from: session ${session}, branch ${this.currentBranch}_\n${lessons}\n`;
|
|
@@ -4705,7 +2666,6 @@ export class Agent {
|
|
|
4705
2666
|
next: str(a.next) || undefined,
|
|
4706
2667
|
ts: new Date().toISOString(),
|
|
4707
2668
|
};
|
|
4708
|
-
// a report (voluntary or requested) restarts the progress gates
|
|
4709
2669
|
this.lastProgressAt = Date.now();
|
|
4710
2670
|
this.activityChars = 0;
|
|
4711
2671
|
this.turnsSinceProgress = 0;
|
|
@@ -4714,26 +2674,6 @@ export class Agent {
|
|
|
4714
2674
|
}
|
|
4715
2675
|
sleepInterruptible(ms) {
|
|
4716
2676
|
return new Promise((resolve) => {
|
|
4717
|
-
// #158-follow-up: a STOP that arrived before this sleep armed must end it.
|
|
4718
|
-
//
|
|
4719
|
-
// `stop()` releases the sleep through `this.wake`, which is a SINGLE slot
|
|
4720
|
-
// created when the sleep starts. So a stop issued during the `await` BEFORE
|
|
4721
|
-
// the sleep — the `log.append` of the retry note, which is a real disk write —
|
|
4722
|
-
// found `wake === null`, did nothing, and the sleep it was meant to cancel
|
|
4723
|
-
// then began on its own and ran to completion.
|
|
4724
|
-
//
|
|
4725
|
-
//
|
|
4726
|
-
// PRE : status=running live=true (the stop lands here)
|
|
4727
|
-
// POST: TIMEOUT after 6000ms status=error live=false
|
|
4728
|
-
//
|
|
4729
|
-
// — the operator's stop was swallowed and the agent sat in a 60s backoff,
|
|
4730
|
-
// which is the reported "LLM APIエラーリトライ中はstopが効かなかった".
|
|
4731
|
-
//
|
|
4732
|
-
// `stopRequested` is exactly the flag that means "do not continue", and it is
|
|
4733
|
-
// set SYNCHRONOUSLY by stop(), so testing it here closes the window without
|
|
4734
|
-
// caring how long the preceding await took. Every dialog through this helper
|
|
4735
|
-
// (the round backoff, the inner 5s/5s/30s staircase, the auto-continue delay)
|
|
4736
|
-
// inherits the fix.
|
|
4737
2677
|
if (this.stopRequested)
|
|
4738
2678
|
return resolve();
|
|
4739
2679
|
const timer = setTimeout(() => {
|
|
@@ -4749,63 +2689,21 @@ export class Agent {
|
|
|
4749
2689
|
}
|
|
4750
2690
|
async dispose() {
|
|
4751
2691
|
this.stop("disposed");
|
|
4752
|
-
// kill any in-flight subprocess group NOW so shutdown never waits out a
|
|
4753
|
-
// long-running command (up to 10 min otherwise)
|
|
4754
2692
|
this.toolAbort.abort();
|
|
4755
2693
|
await this.runChain.catch(() => { });
|
|
4756
2694
|
await this.log.close();
|
|
4757
2695
|
}
|
|
4758
|
-
/**
|
|
4759
|
-
* Stop at the NEXT TURN BOUNDARY, without aborting anything.
|
|
4760
|
-
*
|
|
4761
|
-
* This is the live-update stop. `dispose()`/`stop()` abort the in-flight LLM call and kill the
|
|
4762
|
-
* running tool, which is right for ■ but wrong for an update: a tool cut off mid-write leaves a
|
|
4763
|
-
* half-written file, and a call whose result never arrives means the session resumes from a state
|
|
4764
|
-
* that never existed.
|
|
4765
|
-
*
|
|
4766
|
-
* Asked for as "動作中のエージェントループを適切なタイミング(ツール実行完了や生成終了などで1ターン
|
|
4767
|
-
* が終わる)で停止する". `loop()` checks `windDownRequested` at the top of each round, so setting the
|
|
4768
|
-
* flag is enough for a busy agent — the round in flight finishes normally.
|
|
4769
|
-
*
|
|
4770
|
-
* A PARKED agent is the case the flag alone cannot reach: `wait_children` and a background-shell
|
|
4771
|
-
* wait sit in `await` with no round to finish, so they would hold the update until the timeout.
|
|
4772
|
-
* Those are released by waking them (the wait resolves, the loop comes round, and the flag is
|
|
4773
|
-
* seen at the top); nothing is killed, so the child/job keeps existing and the resumed session
|
|
4774
|
-
* can still reach it.
|
|
4775
|
-
*
|
|
4776
|
-
* `idle` agents are already at a boundary — their `windDownRequested` is simply set, so a prompt
|
|
4777
|
-
* arriving between now and the exec does not start a round that would be cut off.
|
|
4778
|
-
*/
|
|
4779
2696
|
async windDownForUpdate(timeoutMs) {
|
|
4780
2697
|
this.windDownRequested = true;
|
|
4781
|
-
// release a parked wait (wait_children / bg-shell) so the loop can come round to the check
|
|
4782
2698
|
this.wake?.();
|
|
4783
2699
|
if (this.status === "idle") {
|
|
4784
2700
|
this.status = "idle";
|
|
4785
2701
|
this.statusReason = "paused for a live update";
|
|
4786
2702
|
return;
|
|
4787
2703
|
}
|
|
4788
|
-
/**
|
|
4789
|
-
* Wait for the round to end, bounded.
|
|
4790
|
-
*
|
|
4791
|
-
* `settled()` resolves when the run chain drains, which is "the turn finished" — but a
|
|
4792
|
-
* BACKGROUND SHELL is not on that chain. The round deliberately ends while a dev server,
|
|
4793
|
-
* watcher or long build keeps running (`hasRunningBgShells` breaks the loop instead of nagging),
|
|
4794
|
-
* so `settled()` is already resolved and this used to return immediately.
|
|
4795
|
-
*
|
|
4796
|
-
* MEASURED: an update then reached `dispose()`, which calls `killBgShells` — a live update killed
|
|
4797
|
-
* every background job on the machine, with no wait and no notice. A background job is work in
|
|
4798
|
-
* flight, which is the whole reason `isLive()` counts it, so it must be waited for here too.
|
|
4799
|
-
*
|
|
4800
|
-
* The timeout exists only for a wedged agent (an LLM call that never returns, or a job that will
|
|
4801
|
-
* never exit); the caller reports which agents hit it rather than leaving them silently
|
|
4802
|
-
* half-stopped.
|
|
4803
|
-
*/
|
|
4804
2704
|
let timer;
|
|
4805
2705
|
const done = async () => {
|
|
4806
2706
|
await this.settled();
|
|
4807
|
-
// wait out any background job this agent owns, polling the same owner-scoped question
|
|
4808
|
-
// `isLive()` uses — a sibling's job in the shared workspace is not this agent's work
|
|
4809
2707
|
while (hasRunningBgShellsFor(this.toolCtx.cwd, this.opts.id)) {
|
|
4810
2708
|
if (this.stopRequested)
|
|
4811
2709
|
break;
|
|
@@ -4827,38 +2725,22 @@ export class Agent {
|
|
|
4827
2725
|
clearTimeout(timer);
|
|
4828
2726
|
}
|
|
4829
2727
|
}
|
|
4830
|
-
/** true once `windDownForUpdate()` has been asked and the round has ended */
|
|
4831
2728
|
get windingDownForUpdate() {
|
|
4832
2729
|
return this.windDownRequested;
|
|
4833
2730
|
}
|
|
4834
|
-
/* ---------- fork / branch management (session layer) ---------- */
|
|
4835
|
-
/**
|
|
4836
|
-
* Fork the conversation: copy the message history into a fresh branch of the
|
|
4837
|
-
* SAME log file, recording lineage so reconstruction stays possible.
|
|
4838
|
-
*/
|
|
4839
2731
|
async fork(fromEventId) {
|
|
4840
2732
|
const newBranch = `br${this.branchCount()}${Date.now().toString(36).slice(-4)}`;
|
|
4841
2733
|
const parentBranch = this.currentBranch;
|
|
4842
|
-
// Where the fork leaves the source branch. Recorded on the event AND used
|
|
4843
|
-
// to seed the new branch, so the two can never disagree.
|
|
4844
2734
|
const fromEvent = fromEventId ?? this.log.lastEventId(parentBranch);
|
|
4845
|
-
// the fork event must be appended under the SOURCE branch. Appending it
|
|
4846
|
-
// under the new one gave it parent:null (nothing had been written to that
|
|
4847
|
-
// branch yet), so lineageOf() walked an empty chain and a restart rebuilt
|
|
4848
|
-
// the fork with NO history at all — the same class of bug editPromptAt had,
|
|
4849
|
-
// fixed on one path only. editPromptAt already appends under the old
|
|
4850
|
-
// branch for exactly this reason.
|
|
4851
2735
|
await this.log.append("fork", this.currentSession, parentBranch, {
|
|
4852
2736
|
fromSession: this.currentSession,
|
|
4853
2737
|
fromBranch: parentBranch,
|
|
4854
2738
|
fromEvent,
|
|
4855
2739
|
newBranch,
|
|
4856
2740
|
});
|
|
4857
|
-
// Seed the new branch's parent to the point it leaves the source, so its
|
|
4858
|
-
// first event chains across instead of starting at parent:null.
|
|
4859
2741
|
this.log.seedBranch(newBranch, fromEvent);
|
|
4860
2742
|
this.currentBranch = newBranch;
|
|
4861
|
-
this.messages = [...this.messages];
|
|
2743
|
+
this.messages = [...this.messages];
|
|
4862
2744
|
return { session: this.currentSession, branch: newBranch };
|
|
4863
2745
|
}
|
|
4864
2746
|
branchCount() {
|
|
@@ -4869,27 +2751,9 @@ export class Agent {
|
|
|
4869
2751
|
this.currentBranch = branch;
|
|
4870
2752
|
}
|
|
4871
2753
|
}
|
|
4872
|
-
/**
|
|
4873
|
-
* workspace tools + agent-meta tools + every tool from every CONNECTED MCP server.
|
|
4874
|
-
*
|
|
4875
|
-
* The MCP tools are appended LAST and their names are namespaced (`mcp__server__tool`),
|
|
4876
|
-
* so adding one can never shadow a built-in — a server exposing `bash` must not be able
|
|
4877
|
-
* to take over the shell. `mcpSpecs()` is empty when nothing is configured, which is
|
|
4878
|
-
* the common case, so the cost of the feature is one empty spread.
|
|
4879
|
-
*/
|
|
4880
2754
|
function allToolSpecs() {
|
|
4881
|
-
// meta tools come from ONE table (src/agent/meta-tools.ts) so a declared tool always has
|
|
4882
|
-
// a handler; see the note there for the two silent failure modes this removes.
|
|
4883
2755
|
return [...toolSpecs(), ...metaToolSpecs(), ...mcpSpecs()];
|
|
4884
2756
|
}
|
|
4885
|
-
/**
|
|
4886
|
-
* MCP tools as function specs, flattened and namespaced.
|
|
4887
|
-
*
|
|
4888
|
-
* Descriptions are prefixed with their origin so the model can reason about which
|
|
4889
|
-
* server a result came from — with three servers exposing `search`, a bare description
|
|
4890
|
-
* gives it nothing to choose with. The server's own JSON Schema is forwarded as-is:
|
|
4891
|
-
* rewriting it would drop constraints the server enforces.
|
|
4892
|
-
*/
|
|
4893
2757
|
function mcpSpecs() {
|
|
4894
2758
|
return mcp.allTools().map((t) => ({
|
|
4895
2759
|
type: "function",
|
|
@@ -4911,7 +2775,6 @@ function safeParse(json) {
|
|
|
4911
2775
|
return {};
|
|
4912
2776
|
}
|
|
4913
2777
|
}
|
|
4914
|
-
/** Walk parent links backwards from the newest event, then flip forward. */
|
|
4915
2778
|
function lineageOf(events) {
|
|
4916
2779
|
if (events.length === 0)
|
|
4917
2780
|
return [];
|
|
@@ -4926,16 +2789,10 @@ function lineageOf(events) {
|
|
|
4926
2789
|
lineage.push(cur);
|
|
4927
2790
|
}
|
|
4928
2791
|
lineage.reverse();
|
|
4929
|
-
// the trailing fork event itself is bookkeeping, not conversation
|
|
4930
2792
|
while (lineage.length && lineage[0].type === "fork")
|
|
4931
2793
|
lineage.shift();
|
|
4932
2794
|
return lineage;
|
|
4933
2795
|
}
|
|
4934
|
-
/**
|
|
4935
|
-
* Replay ordered events into ChatMessages (shared by session restore and
|
|
4936
|
-
* prompt-edit forks). Prompts logged inside an open tool batch are buffered
|
|
4937
|
-
* until it closes, so user messages never split a tool_call/tool_result pair.
|
|
4938
|
-
*/
|
|
4939
2796
|
function rebuildMessagesFrom(list) {
|
|
4940
2797
|
const msgs = [];
|
|
4941
2798
|
const META_TOOLS = new Set([
|
|
@@ -4943,19 +2800,15 @@ function rebuildMessagesFrom(list) {
|
|
|
4943
2800
|
"read_memory", "set_memory", "add_memory", "forget_memory", "list_skills", "get_todo", "set_todo",
|
|
4944
2801
|
"get_feedback", "add_feedback", "record_decision", "get_decisions",
|
|
4945
2802
|
]);
|
|
4946
|
-
const openCalls = new Map();
|
|
4947
|
-
// callId -> the tool_call entry on its assistant message (fast arg enrichment)
|
|
2803
|
+
const openCalls = new Map();
|
|
4948
2804
|
const assistantCalls = new Map();
|
|
4949
2805
|
const rememberAssistantCalls = (m) => {
|
|
4950
2806
|
if (Array.isArray(m.tool_calls))
|
|
4951
2807
|
for (const t of m.tool_calls)
|
|
4952
2808
|
assistantCalls.set(t.id, t);
|
|
4953
2809
|
};
|
|
4954
|
-
// meta answers are now logged as regular tool_results; the legacy progress
|
|
4955
|
-
// synthesizer below must not duplicate them
|
|
4956
2810
|
const loggedResults = new Set(list.filter((e) => e.type === "tool_result").map((e) => String(e.data.callId ?? "")));
|
|
4957
2811
|
const bufferedUsers = [];
|
|
4958
|
-
// image attachments paired with buffered prompts (multimodal replay)
|
|
4959
2812
|
const bufferedParts = [];
|
|
4960
2813
|
const flushUsers = () => {
|
|
4961
2814
|
if (openCalls.size === 0) {
|
|
@@ -4979,8 +2832,6 @@ function rebuildMessagesFrom(list) {
|
|
|
4979
2832
|
for (const e of list) {
|
|
4980
2833
|
const d = e.data;
|
|
4981
2834
|
if (e.type === "prompt" && typeof d.text === "string") {
|
|
4982
|
-
// restore image attachments so replays stay byte-identical with the
|
|
4983
|
-
// original request (prefix caches stay warm across restarts)
|
|
4984
2835
|
const text = d.text;
|
|
4985
2836
|
const imgs = Array.isArray(d.images) ? d.images : [];
|
|
4986
2837
|
const mk = () => imgs.length
|
|
@@ -4999,24 +2850,11 @@ function rebuildMessagesFrom(list) {
|
|
|
4999
2850
|
msgs.push(mk());
|
|
5000
2851
|
}
|
|
5001
2852
|
else if (e.type === "message") {
|
|
5002
|
-
// Operator-facing rows are TIMELINE content, not conversation: the live
|
|
5003
|
-
// loop never put them in `messages`, so a restore must not either.
|
|
5004
|
-
// `final` is the finish() summary; `operatorFacing` covers harness notes
|
|
5005
|
-
// such as the completion audit verdict. Replaying either produced a
|
|
5006
|
-
// history shape no live run emits, and a provider given that answers
|
|
5007
|
-
// 200 with zero choices.
|
|
5008
2853
|
if (d.final === true || d.operatorFacing === true)
|
|
5009
2854
|
continue;
|
|
5010
2855
|
const role = d.role === "assistant" ? "assistant" : "user";
|
|
5011
2856
|
const content = typeof d.content === "string" ? d.content : "";
|
|
5012
2857
|
const hasCalls = Array.isArray(d.toolCalls) && d.toolCalls.length > 0;
|
|
5013
|
-
// A blank assistant turn that issued NO tool calls is an artefact of a
|
|
5014
|
-
// kill mid-stream: the model streamed nothing, so the live run never had
|
|
5015
|
-
// this turn, yet the restore replays it as a real utterance. sanitize()
|
|
5016
|
-
// then rewrites blank assistant content to the literal "(no content)",
|
|
5017
|
-
// so the agent is told it previously said a sentence it never said — a
|
|
5018
|
-
// history shape no live run produces. A blank turn that DID call
|
|
5019
|
-
// tools is legitimate and must stay, or its results are orphaned.
|
|
5020
2858
|
if (role === "assistant" && !content && !hasCalls)
|
|
5021
2859
|
continue;
|
|
5022
2860
|
const m = { role, content };
|
|
@@ -5026,8 +2864,6 @@ function rebuildMessagesFrom(list) {
|
|
|
5026
2864
|
type: "function",
|
|
5027
2865
|
function: { name: c.name, arguments: "{}" },
|
|
5028
2866
|
}));
|
|
5029
|
-
// meta tools are answered inline by the harness (no logged result);
|
|
5030
|
-
// the hole-filling pass below synthesizes theirs where they belong
|
|
5031
2867
|
for (const t of m.tool_calls)
|
|
5032
2868
|
if (!META_TOOLS.has(t.function.name))
|
|
5033
2869
|
openCalls.set(t.id, t.function.name);
|
|
@@ -5036,11 +2872,6 @@ function rebuildMessagesFrom(list) {
|
|
|
5036
2872
|
msgs.push(m);
|
|
5037
2873
|
}
|
|
5038
2874
|
else if (e.type === "tool_call") {
|
|
5039
|
-
// enrich the preceding assistant tool_calls with real arguments —
|
|
5040
|
-
// prefer the provider's raw string so restored requests stay
|
|
5041
|
-
// byte-identical (prefix caches stay warm across restarts).
|
|
5042
|
-
// Indexed by call id: the old [...msgs].reverse().find() scanned the
|
|
5043
|
-
// whole message list per call — O(n²) on multi-thousand-event logs.
|
|
5044
2875
|
let tc = assistantCalls.get(String(d.callId ?? ""));
|
|
5045
2876
|
if (!tc) {
|
|
5046
2877
|
const prev = [...msgs].reverse().find((x) => x.role === "assistant" && x.tool_calls?.some((t) => t.id === d.callId));
|
|
@@ -5063,32 +2894,12 @@ function rebuildMessagesFrom(list) {
|
|
|
5063
2894
|
flushUsers();
|
|
5064
2895
|
}
|
|
5065
2896
|
else if (e.type === "compaction") {
|
|
5066
|
-
// A compaction REPLACED everything before this point with a summary.
|
|
5067
|
-
// Replaying the raw prefix as well resurrected the full pre-compact
|
|
5068
|
-
// history after every restart/fork (one live session came back at
|
|
5069
|
-
// ~580k tokens after a 580k→207k compaction): the model re-read weeks
|
|
5070
|
-
// of settled work it was told had been summarized, and its behavior
|
|
5071
|
-
// fell apart. Mirror the runtime transform instead — the summary note
|
|
5072
|
-
// becomes the history's head. Compactions only run at turn boundaries
|
|
5073
|
-
// (serialized on the run chain), so no tool batch can be split here.
|
|
5074
2897
|
if (d.mode === "truncate") {
|
|
5075
|
-
// `dropped` IS the number of leading messages the live pass removed
|
|
5076
2898
|
msgs.splice(0, Math.min(msgs.length, Number(d.dropped ?? 0)));
|
|
5077
2899
|
}
|
|
5078
2900
|
else {
|
|
5079
2901
|
const sum = typeof d.summary === "string" ? d.summary.trim() : "";
|
|
5080
2902
|
const summarized = Math.min(msgs.length, Number(d.summarized ?? 0) || msgs.length);
|
|
5081
|
-
// A `summarize` boundary with no summary text is a HALF-WRITTEN record, and
|
|
5082
|
-
// acting on it would delete history to replace it with nothing — the worst
|
|
5083
|
-
// failure compaction can have, because the discarded prefix holds the original
|
|
5084
|
-
// request and the summary is supposed to be its only record.
|
|
5085
|
-
//
|
|
5086
|
-
// So a damaged/absent summary falls back CONSERVATIVELY: the compaction is not
|
|
5087
|
-
// applied at all, the full history stays, and the operator sees why. (ZCode
|
|
5088
|
-
// draws the same line for a damaged preserved segment — "返回保守 fallback,并产生
|
|
5089
|
-
// 结构化 warning" — and refuses to write a half boundary in the first place.)
|
|
5090
|
-
// Reaching this state needs a hand-edited or truncated log, since the live path
|
|
5091
|
-
// only writes `mode: "summarize"` when it has a summary.
|
|
5092
2903
|
if (!sum) {
|
|
5093
2904
|
console.warn(`[teapot] compaction at seq ${e.seq} has no summary text — keeping the full history rather than discarding it`);
|
|
5094
2905
|
}
|
|
@@ -5104,8 +2915,6 @@ function rebuildMessagesFrom(list) {
|
|
|
5104
2915
|
}
|
|
5105
2916
|
}
|
|
5106
2917
|
else if (e.type === "progress") {
|
|
5107
|
-
// progress events may follow an assistant report_progress call that
|
|
5108
|
-
// has no logged tool result — patch it in when present
|
|
5109
2918
|
const lastAssistant = [...msgs].reverse().find((x) => x.role === "assistant" && x.tool_calls?.length);
|
|
5110
2919
|
if (lastAssistant?.tool_calls?.some((t) => t.function.name === "report_progress")) {
|
|
5111
2920
|
for (const t of lastAssistant.tool_calls) {
|
|
@@ -5118,8 +2927,6 @@ function rebuildMessagesFrom(list) {
|
|
|
5118
2927
|
}
|
|
5119
2928
|
}
|
|
5120
2929
|
}
|
|
5121
|
-
// every assistant tool_call must be answered by a tool message, or the
|
|
5122
|
-
// API rejects the sequence — close any holes left by meta tools (finish)
|
|
5123
2930
|
for (let i = 0; i < msgs.length; i++) {
|
|
5124
2931
|
const m = msgs[i];
|
|
5125
2932
|
if (m.role === "assistant" && m.tool_calls?.length) {
|
|
@@ -5135,7 +2942,6 @@ function rebuildMessagesFrom(list) {
|
|
|
5135
2942
|
}
|
|
5136
2943
|
}
|
|
5137
2944
|
}
|
|
5138
|
-
// prompts that were still waiting on a hole-filled tail land here
|
|
5139
2945
|
{
|
|
5140
2946
|
const texts = bufferedUsers.splice(0);
|
|
5141
2947
|
const parts = bufferedParts.splice(0);
|