teapot-coding-agent 0.37.59 → 0.37.61
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 +61 -2247
- 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 +9 -310
- 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 -1180
- 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 +166 -142
- 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-C1eqcSbW.js} +4 -4
- package/public/assets/index-C1eqcSbW.js.map +1 -0
- package/public/assets/{shiki-BwDjoZdu.js → shiki-DaLn_bCe.js} +2 -2
- package/public/assets/shiki-DaLn_bCe.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,27 @@ 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
|
-
|
|
1841
|
-
|
|
1842
|
-
|
|
1843
|
-
|
|
1004
|
+
/**
|
|
1005
|
+
* Offer the control unless the catalog SAYS the model cannot reason.
|
|
1006
|
+
*
|
|
1007
|
+
* `effortChoosable(undefined)` is `true` on purpose: a model missing from models.dev is
|
|
1008
|
+
* UNKNOWN, not unsupported, and the wire recovers from a rejected `reasoning_effort`
|
|
1009
|
+
* (`isEffortUnsupported`). The previous rule gated this on OpenRouter alone, which hid the
|
|
1010
|
+
* control for every other provider whose model did support it.
|
|
1011
|
+
*/
|
|
1012
|
+
effortSupported: this.opts.supportedEfforts?.length ? true : effortChoosable(undefined),
|
|
1844
1013
|
...(this.opts.supportedEfforts?.length ? { effortOptions: this.opts.supportedEfforts } : {}),
|
|
1845
1014
|
sessionDir: this.opts.sessionDir,
|
|
1846
1015
|
ctx: {
|
|
1847
1016
|
usedTokens: this.lastUsage?.input ?? this.estimateTokens(),
|
|
1848
1017
|
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
1018
|
compactAtIsManual: this.compactBudgetIsManual,
|
|
1853
1019
|
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
1020
|
...(this.compactPhase ? { compacting: this.compactPhase } : {}),
|
|
1857
1021
|
},
|
|
1858
1022
|
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
1023
|
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
1024
|
pendingPromptQueue: this.pendingPrompts
|
|
1884
1025
|
.filter((p) => !!p.id)
|
|
1885
1026
|
.slice(0, 64)
|
|
@@ -1890,59 +1031,27 @@ export class Agent {
|
|
|
1890
1031
|
at: Date.now() - (this.pendingPrompts.length - i),
|
|
1891
1032
|
...(p.images?.length ? { images: p.images.slice(0, 8) } : {}),
|
|
1892
1033
|
})),
|
|
1893
|
-
todo: this.todo.slice(0, 32_000),
|
|
1034
|
+
todo: this.todo.slice(0, 32_000),
|
|
1894
1035
|
parent: this.opts.parent,
|
|
1895
1036
|
awaiting: this.awaitingUser,
|
|
1896
1037
|
autoContinue: this.opts.autoContinue,
|
|
1897
1038
|
autoCompact: this.opts.autoCompact,
|
|
1898
1039
|
};
|
|
1899
1040
|
}
|
|
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
1041
|
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
1042
|
if (this.parkedByTool) {
|
|
1912
1043
|
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
1044
|
this.toolAbort = new AbortController();
|
|
1917
1045
|
this.toolCtx.signal = this.toolAbort.signal;
|
|
1918
1046
|
}
|
|
1919
1047
|
this.unparkFromTool();
|
|
1920
1048
|
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
1049
|
if (source === "user") {
|
|
1924
1050
|
this.lastProgressAt = Date.now();
|
|
1925
1051
|
this.activityChars = 0;
|
|
1926
1052
|
this.turnsSinceProgress = 0;
|
|
1927
1053
|
}
|
|
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
1054
|
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
1055
|
const entry = { source, text, images, ...(promptId ? { id: promptId } : {}) };
|
|
1947
1056
|
this.pendingPrompts.push(entry);
|
|
1948
1057
|
void this.ensureReady()
|
|
@@ -1953,7 +1062,6 @@ export class Agent {
|
|
|
1953
1062
|
...(promptId ? { promptId } : {}),
|
|
1954
1063
|
}))
|
|
1955
1064
|
.then(() => {
|
|
1956
|
-
// a session's label comes from its FIRST user prompt; the host stores it once
|
|
1957
1065
|
if (source === "user")
|
|
1958
1066
|
this.opts.onFirstUserPrompt?.(this.currentSession);
|
|
1959
1067
|
})
|
|
@@ -1961,14 +1069,10 @@ export class Agent {
|
|
|
1961
1069
|
.catch(() => { });
|
|
1962
1070
|
return promptId;
|
|
1963
1071
|
}
|
|
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
1072
|
cancelPrompt(promptId) {
|
|
1969
1073
|
const i = this.pendingPrompts.findIndex((p) => p.id === promptId);
|
|
1970
1074
|
if (i === -1)
|
|
1971
|
-
return null;
|
|
1075
|
+
return null;
|
|
1972
1076
|
const [p] = this.pendingPrompts.splice(i, 1);
|
|
1973
1077
|
void this.log
|
|
1974
1078
|
.append("system_note", this.currentSession, this.currentBranch, {
|
|
@@ -1980,17 +1084,9 @@ export class Agent {
|
|
|
1980
1084
|
.catch(() => { });
|
|
1981
1085
|
return p.text;
|
|
1982
1086
|
}
|
|
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
1087
|
drainPendingPrompts() {
|
|
1990
1088
|
this.drainBackgroundExits();
|
|
1991
1089
|
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
1090
|
if (p.images?.length) {
|
|
1995
1091
|
this.messages.push({
|
|
1996
1092
|
role: "user",
|
|
@@ -2006,30 +1102,12 @@ export class Agent {
|
|
|
2006
1102
|
}
|
|
2007
1103
|
if (p.source === "user") {
|
|
2008
1104
|
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
1105
|
this.undeliveredPrompts.push({ id, preview: p.text.slice(0, 80) });
|
|
2013
1106
|
}
|
|
2014
1107
|
}
|
|
2015
1108
|
}
|
|
2016
|
-
/** ids of user prompts that have entered an LLM call payload (recent first-capped) */
|
|
2017
1109
|
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
1110
|
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
1111
|
async markDeliveredPrompts() {
|
|
2034
1112
|
if (this.undeliveredPrompts.length === 0)
|
|
2035
1113
|
return;
|
|
@@ -2037,7 +1115,6 @@ export class Agent {
|
|
|
2037
1115
|
this.undeliveredPrompts = [];
|
|
2038
1116
|
for (const { id, preview } of pending) {
|
|
2039
1117
|
this.deliveredPrompts.push(id);
|
|
2040
|
-
// cap: the UI only needs recent confirmations
|
|
2041
1118
|
if (this.deliveredPrompts.length > 64)
|
|
2042
1119
|
this.deliveredPrompts.shift();
|
|
2043
1120
|
await this.log.append("system_note", this.currentSession, this.currentBranch, {
|
|
@@ -2048,40 +1125,22 @@ export class Agent {
|
|
|
2048
1125
|
}
|
|
2049
1126
|
bus.emit("update", { kind: "agent-update", agentId: this.opts.id });
|
|
2050
1127
|
}
|
|
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
1128
|
bgExits = [];
|
|
2057
1129
|
onBackgroundJobExitQueued(info) {
|
|
2058
1130
|
this.bgExits.push(info);
|
|
2059
1131
|
if (this.bgExits.length > 32)
|
|
2060
|
-
this.bgExits.shift();
|
|
1132
|
+
this.bgExits.shift();
|
|
2061
1133
|
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
1134
|
const windingDown = this.status === "running";
|
|
2074
1135
|
if (!this.stopRequested && (this.status === "idle" || windingDown)) {
|
|
2075
|
-
// park the decision until the in-flight round has actually settled
|
|
2076
1136
|
void this.enqueue(async () => {
|
|
2077
1137
|
if (this.stopRequested)
|
|
2078
|
-
return;
|
|
1138
|
+
return;
|
|
2079
1139
|
if (this.status === "idle")
|
|
2080
1140
|
this.start(`background job ${info.id} finished`);
|
|
2081
1141
|
});
|
|
2082
1142
|
}
|
|
2083
1143
|
}
|
|
2084
|
-
/** fold queued background-exit reports into the next LLM call (once each) */
|
|
2085
1144
|
drainBackgroundExits() {
|
|
2086
1145
|
if (!this.bgExits.length)
|
|
2087
1146
|
return;
|
|
@@ -2097,7 +1156,6 @@ export class Agent {
|
|
|
2097
1156
|
this.messages.push({ role: "user", content: text });
|
|
2098
1157
|
void this.log.append("prompt", this.currentSession, this.currentBranch, { source: "harness", text }).catch(() => { });
|
|
2099
1158
|
}
|
|
2100
|
-
/** Resolves when all queued work (including a running loop) has settled. */
|
|
2101
1159
|
settled() {
|
|
2102
1160
|
return this.runChain.catch(() => { });
|
|
2103
1161
|
}
|
|
@@ -2106,29 +1164,15 @@ export class Agent {
|
|
|
2106
1164
|
this.runChain = p.then(() => { }, () => { });
|
|
2107
1165
|
return p;
|
|
2108
1166
|
}
|
|
2109
|
-
/** Start (or resume) autonomous operation toward the goal. */
|
|
2110
1167
|
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
1168
|
const pendingStop = this.stopRequested;
|
|
2124
1169
|
if (this.status === "running" && !pendingStop)
|
|
2125
1170
|
return;
|
|
2126
1171
|
this.stopRequested = false;
|
|
2127
|
-
// claim the status against any stop write still queued behind us
|
|
2128
1172
|
this.stopSeq++;
|
|
2129
|
-
this.awaitingUser = false;
|
|
1173
|
+
this.awaitingUser = false;
|
|
2130
1174
|
void this.enqueue(async () => {
|
|
2131
|
-
await this.ensureReady();
|
|
1175
|
+
await this.ensureReady();
|
|
2132
1176
|
this.setStatus("running", reason);
|
|
2133
1177
|
this.stats.startedAt ??= new Date().toISOString();
|
|
2134
1178
|
});
|
|
@@ -2136,96 +1180,32 @@ export class Agent {
|
|
|
2136
1180
|
}
|
|
2137
1181
|
stop(reason = "stopped by user") {
|
|
2138
1182
|
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
|
|
1183
|
+
this.parkedByTool = false;
|
|
2142
1184
|
this.abort?.abort();
|
|
2143
1185
|
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
1186
|
killBgShells(this.toolCtx.cwd);
|
|
2151
1187
|
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
1188
|
const stopSeq = ++this.stopSeq;
|
|
2166
1189
|
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
1190
|
if (this.stopSeq !== stopSeq)
|
|
2171
1191
|
return Promise.resolve();
|
|
2172
1192
|
this.setStatus("stopped", reason);
|
|
2173
1193
|
return Promise.resolve();
|
|
2174
1194
|
});
|
|
2175
1195
|
}
|
|
2176
|
-
/** monotonic counter: a start() bumps it, so a pending stop status write loses */
|
|
2177
1196
|
stopSeq = 0;
|
|
2178
|
-
/** #95: set when this round already injected a harness progress request */
|
|
2179
1197
|
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
1198
|
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
1199
|
onControlStatusChange = null;
|
|
2209
1200
|
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
1201
|
const prev = this.status;
|
|
2218
1202
|
this.status = s;
|
|
2219
1203
|
this.statusReason = reason;
|
|
2220
1204
|
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
1205
|
try {
|
|
2225
1206
|
this.onControlStatusChange?.(this.opts.id, s);
|
|
2226
1207
|
}
|
|
2227
1208
|
catch {
|
|
2228
|
-
/* a control-plane listener must never break a status change */
|
|
2229
1209
|
}
|
|
2230
1210
|
void this.log.append("state", this.currentSession, this.currentBranch, {
|
|
2231
1211
|
from: prev,
|
|
@@ -2235,41 +1215,17 @@ export class Agent {
|
|
|
2235
1215
|
}
|
|
2236
1216
|
bus.emit("update", { kind: "agent-update", agentId: this.opts.id });
|
|
2237
1217
|
}
|
|
2238
|
-
/** Main auto-run loop: turn -> tools -> turn ... -> done/idle/stop. */
|
|
2239
1218
|
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
1219
|
while (!this.stopRequested && !this.windDownRequested) {
|
|
2249
1220
|
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
1221
|
if (this.toolAbort.signal.aborted) {
|
|
2256
1222
|
this.toolAbort = new AbortController();
|
|
2257
1223
|
this.toolCtx.signal = this.toolAbort.signal;
|
|
2258
1224
|
}
|
|
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
1225
|
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
1226
|
this.consecutiveApiErrors = 0;
|
|
2266
1227
|
const turnsAtStart = this.stats.turns;
|
|
2267
1228
|
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
1229
|
const cap = Math.max(0, this.opts.maxTurnsPerRound ?? 200);
|
|
2274
1230
|
if (!finished && cap > 0 && this.stats.turns - turnsAtStart >= cap && !this.stopRequested) {
|
|
2275
1231
|
await this.log.append("system_note", this.currentSession, this.currentBranch, {
|
|
@@ -2279,22 +1235,6 @@ export class Agent {
|
|
|
2279
1235
|
}
|
|
2280
1236
|
if (this.stopRequested)
|
|
2281
1237
|
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
1238
|
if (this.consecutiveAuditRejects >= (this.opts.maxAuditRejects ?? 3)) {
|
|
2299
1239
|
await this.log.append("system_note", this.currentSession, this.currentBranch, {
|
|
2300
1240
|
event: "audit-retry-cap",
|
|
@@ -2306,12 +1246,6 @@ export class Agent {
|
|
|
2306
1246
|
}
|
|
2307
1247
|
if (this.pendingPrompts.length)
|
|
2308
1248
|
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
1249
|
if (hasRunningBgShells(this.toolCtx.cwd)) {
|
|
2316
1250
|
await this.log.append("system_note", this.currentSession, this.currentBranch, {
|
|
2317
1251
|
event: "auto-continue-suppressed",
|
|
@@ -2319,37 +1253,21 @@ export class Agent {
|
|
|
2319
1253
|
});
|
|
2320
1254
|
break;
|
|
2321
1255
|
}
|
|
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
1256
|
if (this.pendingSubReport) {
|
|
2327
1257
|
const report = this.pendingSubReport;
|
|
2328
1258
|
this.pendingSubReport = "";
|
|
2329
1259
|
if (!finished) {
|
|
2330
1260
|
await this.handleFinish(JSON.stringify({ goalComplete: true, summary: report }));
|
|
2331
|
-
finished = true;
|
|
1261
|
+
finished = true;
|
|
2332
1262
|
}
|
|
2333
1263
|
}
|
|
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
1264
|
if (finished)
|
|
2338
1265
|
this.consecutiveIdleRounds = 0;
|
|
2339
|
-
// auto-continue only makes sense with an active goal to continue toward
|
|
2340
1266
|
if (finished ||
|
|
2341
1267
|
!this.opts.autoContinue ||
|
|
2342
1268
|
this.goal.status !== "active" ||
|
|
2343
1269
|
!this.goal.text.trim())
|
|
2344
1270
|
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
1271
|
if (++this.consecutiveIdleRounds > this.opts.maxConsecutiveIdleRounds) {
|
|
2354
1272
|
this.consecutiveIdleRounds = 0;
|
|
2355
1273
|
await this.log.append("system_note", this.currentSession, this.currentBranch, {
|
|
@@ -2363,36 +1281,15 @@ export class Agent {
|
|
|
2363
1281
|
await this.sleepInterruptible(this.opts.continueDelayMs);
|
|
2364
1282
|
if (this.stopRequested)
|
|
2365
1283
|
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
1284
|
if (this.progressRequestedThisRound) {
|
|
2383
1285
|
await this.log.append("system_note", this.currentSession, this.currentBranch, {
|
|
2384
1286
|
event: "auto-continue-suppressed",
|
|
2385
1287
|
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
1288
|
});
|
|
2387
|
-
// consume the flag: it authorised skipping THIS boundary's nudge, not every later one
|
|
2388
1289
|
this.progressRequestedThisRound = false;
|
|
2389
1290
|
continue;
|
|
2390
1291
|
}
|
|
2391
1292
|
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
1293
|
const nudge = isSub
|
|
2397
1294
|
? `[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
1295
|
: autoContinueNudge(this.goal.text);
|
|
@@ -2401,21 +1298,14 @@ export class Agent {
|
|
|
2401
1298
|
text: nudge,
|
|
2402
1299
|
});
|
|
2403
1300
|
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
1301
|
this.nudgedAtTurn = this.stats.turns + 1;
|
|
2406
1302
|
}
|
|
2407
1303
|
catch (err) {
|
|
2408
1304
|
const name = err.name;
|
|
2409
|
-
// a stop (user abort or pre-call guard) is control flow, not a failure
|
|
2410
1305
|
if (this.stopRequested || name === "AbortError" || name === "StopRequested" || name === "WaitForUser")
|
|
2411
1306
|
break;
|
|
2412
1307
|
const msg = err.message ?? String(err);
|
|
2413
1308
|
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
1309
|
const isRunaway = /runaway detection/.test(msg);
|
|
2420
1310
|
if (this.opts.onError === "retry" && !this.awaitingUser && !this.pendingPrompts.length) {
|
|
2421
1311
|
this.consecutiveApiErrors++;
|
|
@@ -2427,10 +1317,6 @@ export class Agent {
|
|
|
2427
1317
|
kind: isRunaway ? "runaway" : "api",
|
|
2428
1318
|
});
|
|
2429
1319
|
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
1320
|
this.retrySleeping = true;
|
|
2435
1321
|
try {
|
|
2436
1322
|
await this.sleepInterruptible(wait);
|
|
@@ -2440,32 +1326,18 @@ export class Agent {
|
|
|
2440
1326
|
}
|
|
2441
1327
|
if (this.stopRequested)
|
|
2442
1328
|
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
1329
|
this.setStatus("running", "retrying");
|
|
2447
|
-
continue;
|
|
1330
|
+
continue;
|
|
2448
1331
|
}
|
|
2449
1332
|
this.setStatus("error", msg.slice(0, 300));
|
|
2450
1333
|
return;
|
|
2451
1334
|
}
|
|
2452
1335
|
}
|
|
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
1336
|
if (!this.stopRequested && !this.awaitingUser)
|
|
2456
1337
|
this.setStatus("idle", "round complete");
|
|
2457
1338
|
}
|
|
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
1339
|
async llmCall(messages, tools, onDelta) {
|
|
2466
1340
|
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
1341
|
const waits = this.opts.llmRetryWaitsMs ?? [5_000, 5_000, 30_000];
|
|
2470
1342
|
for (let attempt = 1;; attempt++) {
|
|
2471
1343
|
if (this.stopRequested)
|
|
@@ -2475,19 +1347,10 @@ export class Agent {
|
|
|
2475
1347
|
bus.emit("update", {
|
|
2476
1348
|
kind: "llm-delta",
|
|
2477
1349
|
agentId: this.opts.id,
|
|
2478
|
-
// the bubble is session-scoped, so the stream must say which session
|
|
2479
1350
|
sessionId: this.currentSession,
|
|
2480
1351
|
text: "",
|
|
2481
1352
|
reasoning: "",
|
|
2482
1353
|
});
|
|
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
1354
|
if (attempt === 1)
|
|
2492
1355
|
await this.markDeliveredPrompts();
|
|
2493
1356
|
try {
|
|
@@ -2498,11 +1361,6 @@ export class Agent {
|
|
|
2498
1361
|
const name = err.name;
|
|
2499
1362
|
if (name === "StopRequested" || name === "AbortError" || this.stopRequested)
|
|
2500
1363
|
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
1364
|
if (isContextOverflow(err))
|
|
2507
1365
|
throw err;
|
|
2508
1366
|
if (attempt >= maxAttempts)
|
|
@@ -2518,51 +1376,11 @@ export class Agent {
|
|
|
2518
1376
|
}
|
|
2519
1377
|
}
|
|
2520
1378
|
}
|
|
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
1379
|
metaHandlers = {
|
|
2543
1380
|
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.
|
|
1381
|
+
this.pendingSubReport = "";
|
|
2562
1382
|
await this.answerMeta(call, "(completion submitted for audit)");
|
|
2563
1383
|
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
1384
|
try {
|
|
2567
1385
|
const running = (this.toolCtx.subAgents?.list?.() ?? []).filter((k) => k.status === "running");
|
|
2568
1386
|
if (running.length)
|
|
@@ -2571,20 +1389,8 @@ export class Agent {
|
|
|
2571
1389
|
detail: running.map((k) => `${k.id} (${k.status})`).join(", "),
|
|
2572
1390
|
});
|
|
2573
1391
|
}
|
|
2574
|
-
catch {
|
|
2575
|
-
// answer the tool_call so a follow-up round stays API-valid
|
|
1392
|
+
catch { }
|
|
2576
1393
|
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
1394
|
await this.answerUnansweredToolCalls(m);
|
|
2589
1395
|
return "end";
|
|
2590
1396
|
},
|
|
@@ -2598,8 +1404,6 @@ export class Agent {
|
|
|
2598
1404
|
const text = String(a.text ?? "").trim();
|
|
2599
1405
|
if (text)
|
|
2600
1406
|
await this.setGoal(text);
|
|
2601
|
-
// pi-goal-x-style verification contract: plain-text completion
|
|
2602
|
-
// requirements audited before finish(goalComplete=true) is honored
|
|
2603
1407
|
if (typeof a.verify === "string" && a.verify.trim())
|
|
2604
1408
|
await this.setGoalVerify(a.verify.trim().slice(0, 4000));
|
|
2605
1409
|
await this.answerMeta(call, text ? "goal updated" : "empty goal rejected");
|
|
@@ -2617,8 +1421,6 @@ export class Agent {
|
|
|
2617
1421
|
ask_user: async (call, m) => {
|
|
2618
1422
|
const a = safeParse(call.function.arguments);
|
|
2619
1423
|
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
1424
|
const options = Array.isArray(a.options)
|
|
2623
1425
|
? a.options
|
|
2624
1426
|
.slice(0, 6)
|
|
@@ -2635,8 +1437,6 @@ export class Agent {
|
|
|
2635
1437
|
})
|
|
2636
1438
|
.filter((o) => o.trim())
|
|
2637
1439
|
: [];
|
|
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
1440
|
await this.log.append("question", this.currentSession, this.currentBranch, {
|
|
2641
1441
|
question,
|
|
2642
1442
|
options,
|
|
@@ -2646,13 +1446,6 @@ export class Agent {
|
|
|
2646
1446
|
"Their reply (possibly free text, not one of the options) arrives as your next user message.");
|
|
2647
1447
|
this.awaitingUser = true;
|
|
2648
1448
|
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
1449
|
await this.answerUnansweredToolCalls(m);
|
|
2657
1450
|
throw Object.assign(new Error("waiting for user"), { name: "WaitForUser" });
|
|
2658
1451
|
},
|
|
@@ -2662,9 +1455,6 @@ export class Agent {
|
|
|
2662
1455
|
},
|
|
2663
1456
|
set_todo: async (call, m) => {
|
|
2664
1457
|
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
1458
|
const ACTIONS = new Set(["done", "reopen", "add", "remove"]);
|
|
2669
1459
|
const updates = Array.isArray(a.updates)
|
|
2670
1460
|
? a.updates
|
|
@@ -2713,7 +1503,6 @@ export class Agent {
|
|
|
2713
1503
|
this.messages.push({ role: "tool", tool_call_id: call.id, content: "rule required" });
|
|
2714
1504
|
return "next";
|
|
2715
1505
|
}
|
|
2716
|
-
// repeated corrections gain weight: [xN] tag counts occurrences
|
|
2717
1506
|
const existing = await fs.readFile(this.feedbackFile, "utf8").catch(() => "");
|
|
2718
1507
|
const lines = existing.split("\n");
|
|
2719
1508
|
const idx = lines.findIndex((l) => l.includes(rule.slice(0, 60)));
|
|
@@ -2776,19 +1565,6 @@ export class Agent {
|
|
|
2776
1565
|
await this.answerMeta(call, "(memory.md is empty — nothing noted yet)");
|
|
2777
1566
|
return "next";
|
|
2778
1567
|
}
|
|
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
1568
|
if (!query) {
|
|
2793
1569
|
await this.answerMeta(call, mem.trim());
|
|
2794
1570
|
return "next";
|
|
@@ -2796,15 +1572,6 @@ export class Agent {
|
|
|
2796
1572
|
const needle = query.toLowerCase();
|
|
2797
1573
|
const lines = mem.split(/\r?\n/);
|
|
2798
1574
|
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
1575
|
const headingAt = (idx) => {
|
|
2809
1576
|
for (let i = idx; i >= 0; i--) {
|
|
2810
1577
|
const l = lines[i].trim();
|
|
@@ -2814,7 +1581,6 @@ export class Agent {
|
|
|
2814
1581
|
return "";
|
|
2815
1582
|
};
|
|
2816
1583
|
const provenanceAt = (idx) => {
|
|
2817
|
-
// the `_from:_` line lives within a few lines above its lesson block
|
|
2818
1584
|
for (let i = idx; i >= Math.max(0, idx - 6); i--) {
|
|
2819
1585
|
const l = lines[i].trim();
|
|
2820
1586
|
if (/^_from:/.test(l))
|
|
@@ -2847,9 +1613,6 @@ export class Agent {
|
|
|
2847
1613
|
const a = safeParse(call.function.arguments);
|
|
2848
1614
|
const content = String(a.content ?? "").slice(0, 32_000);
|
|
2849
1615
|
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
1616
|
await this.answerMeta(call, `memory.md REPLACED (${content.length} bytes). It is not in your prompt — call read_memory when you need it.`);
|
|
2854
1617
|
return "next";
|
|
2855
1618
|
},
|
|
@@ -2860,14 +1623,6 @@ export class Agent {
|
|
|
2860
1623
|
await this.answerMeta(call, "forget_memory needs a `query` — nothing removed");
|
|
2861
1624
|
return "next";
|
|
2862
1625
|
}
|
|
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
1626
|
const prev = await fs.readFile(this.memoryFile, "utf8").catch(() => "");
|
|
2872
1627
|
if (!prev.trim()) {
|
|
2873
1628
|
await this.answerMeta(call, "(memory.md is empty — nothing to forget)");
|
|
@@ -2886,8 +1641,6 @@ export class Agent {
|
|
|
2886
1641
|
await this.answerMeta(call, `(no line in memory.md matches ${JSON.stringify(query)} — nothing removed)`);
|
|
2887
1642
|
return "next";
|
|
2888
1643
|
}
|
|
2889
|
-
// collapse the blank runs a deletion leaves behind, so the file does not
|
|
2890
|
-
// grow holes
|
|
2891
1644
|
const next = kept.join("\n").replace(/\n{3,}/g, "\n\n").trimEnd() + "\n";
|
|
2892
1645
|
await fs.writeFile(this.memoryFile, next, "utf8");
|
|
2893
1646
|
await this.answerMeta(call, `removed ${removed.length} line(s) from memory.md:\n${removed.join("\n").slice(0, 1_500)}\n` +
|
|
@@ -2901,11 +1654,6 @@ export class Agent {
|
|
|
2901
1654
|
await this.answerMeta(call, "add_memory needs non-empty `text` — nothing appended");
|
|
2902
1655
|
return "next";
|
|
2903
1656
|
}
|
|
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
1657
|
const prev = await fs.readFile(this.memoryFile, "utf8").catch(() => "");
|
|
2910
1658
|
const sep = prev && !prev.endsWith("\n") ? "\n" : "";
|
|
2911
1659
|
const next = `${prev}${sep}${text}\n`;
|
|
@@ -2916,26 +1664,14 @@ export class Agent {
|
|
|
2916
1664
|
};
|
|
2917
1665
|
async runTurnsUntilIdle() {
|
|
2918
1666
|
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
1667
|
const cap = Math.max(0, this.opts.maxTurnsPerRound ?? 200);
|
|
2924
1668
|
for (let guard = 0; cap === 0 || guard < cap; guard++) {
|
|
2925
1669
|
if (this.stopRequested)
|
|
2926
1670
|
return finished;
|
|
2927
|
-
// deliver prompts queued while the previous turn was running
|
|
2928
1671
|
this.drainPendingPrompts();
|
|
2929
|
-
// skills may have been created last turn — refresh the prompt listing
|
|
2930
1672
|
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
1673
|
this.progressRequestedThisRound = false;
|
|
2937
1674
|
await this.maybeRequestProgress();
|
|
2938
|
-
// keep the context window bounded before spending tokens on a turn
|
|
2939
1675
|
await this.maybeCompact();
|
|
2940
1676
|
await this.log.append("state", this.currentSession, this.currentBranch, {
|
|
2941
1677
|
from: this.status,
|
|
@@ -2943,22 +1679,19 @@ export class Agent {
|
|
|
2943
1679
|
detail: "llm turn start",
|
|
2944
1680
|
turn: ++this.stats.turns,
|
|
2945
1681
|
});
|
|
2946
|
-
// stream the assistant reply live to connected clients
|
|
2947
1682
|
let res;
|
|
2948
1683
|
try {
|
|
2949
1684
|
res = await this.llmCall(this.buildMessages(), allToolSpecs(), (s) => {
|
|
2950
1685
|
bus.emit("update", {
|
|
2951
1686
|
kind: "llm-delta",
|
|
2952
1687
|
agentId: this.opts.id,
|
|
2953
|
-
sessionId: this.currentSession,
|
|
1688
|
+
sessionId: this.currentSession,
|
|
2954
1689
|
text: s.text,
|
|
2955
1690
|
reasoning: s.reasoning,
|
|
2956
1691
|
});
|
|
2957
1692
|
});
|
|
2958
1693
|
}
|
|
2959
1694
|
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
1695
|
const partial = err.partial;
|
|
2963
1696
|
if (this.stopRequested && partial && (partial.text || partial.reasoning)) {
|
|
2964
1697
|
await this.log.append("message", this.currentSession, this.currentBranch, {
|
|
@@ -2970,19 +1703,11 @@ export class Agent {
|
|
|
2970
1703
|
this.messages.push({ role: "assistant", content: partial.text ?? "" });
|
|
2971
1704
|
}
|
|
2972
1705
|
else if (this.stopRequested) {
|
|
2973
|
-
// nothing had streamed — leave an explicit marker so the log shows
|
|
2974
|
-
// why this prompt has no reply
|
|
2975
1706
|
await this.log.append("system_note", this.currentSession, this.currentBranch, {
|
|
2976
1707
|
event: "turn-interrupted",
|
|
2977
1708
|
detail: "stopped before any output arrived",
|
|
2978
1709
|
});
|
|
2979
1710
|
}
|
|
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
1711
|
if (isContextOverflow(err) && !this.stopRequested) {
|
|
2987
1712
|
const attempt = (this.overflowRecoveries += 1);
|
|
2988
1713
|
await this.log.append("system_note", this.currentSession, this.currentBranch, {
|
|
@@ -2991,7 +1716,6 @@ export class Agent {
|
|
|
2991
1716
|
detail: String(err.message).slice(0, 200),
|
|
2992
1717
|
});
|
|
2993
1718
|
const before = this.stats.compactions;
|
|
2994
|
-
// each retry cuts deeper: 1x, then the staged fractions of the budget
|
|
2995
1719
|
const shrink = OVERFLOW_SHRINK_STEPS[Math.min(attempt - 1, OVERFLOW_SHRINK_STEPS.length - 1)];
|
|
2996
1720
|
await this.maybeCompact(true, shrink);
|
|
2997
1721
|
if (this.stats.compactions > before) {
|
|
@@ -2999,23 +1723,10 @@ export class Agent {
|
|
|
2999
1723
|
event: "overflow-recovered",
|
|
3000
1724
|
attempt,
|
|
3001
1725
|
});
|
|
3002
|
-
guard--;
|
|
1726
|
+
guard--;
|
|
3003
1727
|
continue;
|
|
3004
1728
|
}
|
|
3005
1729
|
}
|
|
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
1730
|
const nodeCap = isRequestNodeLimit(err);
|
|
3020
1731
|
if (nodeCap.hit && !this.stopRequested) {
|
|
3021
1732
|
await this.log.append("system_note", this.currentSession, this.currentBranch, {
|
|
@@ -3033,8 +1744,6 @@ export class Agent {
|
|
|
3033
1744
|
}
|
|
3034
1745
|
throw err;
|
|
3035
1746
|
}
|
|
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
1747
|
this.overflowRecoveries = 0;
|
|
3039
1748
|
if (res.usage) {
|
|
3040
1749
|
const inTok = res.usage.inputTokens ?? 0;
|
|
@@ -3042,10 +1751,6 @@ export class Agent {
|
|
|
3042
1751
|
this.stats.inputTokens += inTok;
|
|
3043
1752
|
this.stats.cachedInputTokens += cached;
|
|
3044
1753
|
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
1754
|
if (this.modelPricing) {
|
|
3050
1755
|
const p = this.modelPricing;
|
|
3051
1756
|
const uncached = Math.max(0, inTok - cached);
|
|
@@ -3061,9 +1766,6 @@ export class Agent {
|
|
|
3061
1766
|
await this.log.append("usage", this.currentSession, this.currentBranch, res.usage);
|
|
3062
1767
|
}
|
|
3063
1768
|
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
1769
|
const isPlaceholder = m.content === "(tool call)" || m.content === "(no content)";
|
|
3068
1770
|
await this.log.append("message", this.currentSession, this.currentBranch, {
|
|
3069
1771
|
role: "assistant",
|
|
@@ -3074,42 +1776,9 @@ export class Agent {
|
|
|
3074
1776
|
this.messages.push(m);
|
|
3075
1777
|
this.turnsSinceProgress++;
|
|
3076
1778
|
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
1779
|
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
1780
|
if (this.opts.parent) {
|
|
3111
1781
|
if (this.pendingSubReport) {
|
|
3112
|
-
// already offered a turn after a bare message — take the held report
|
|
3113
1782
|
this.messages.push({
|
|
3114
1783
|
role: "user",
|
|
3115
1784
|
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 +1791,15 @@ export class Agent {
|
|
|
3122
1791
|
content: "[harness] If you are done, call finish() with a summary for your parent so it receives your report. Otherwise continue the task.",
|
|
3123
1792
|
});
|
|
3124
1793
|
}
|
|
3125
|
-
continue;
|
|
1794
|
+
continue;
|
|
3126
1795
|
}
|
|
3127
1796
|
return finished;
|
|
3128
1797
|
}
|
|
3129
1798
|
for (const call of m.tool_calls) {
|
|
3130
1799
|
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
1800
|
await this.answerUnansweredToolCalls(m);
|
|
3137
1801
|
return finished;
|
|
3138
1802
|
}
|
|
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
1803
|
const meta = this.metaHandlers[call.function.name];
|
|
3146
1804
|
if (meta) {
|
|
3147
1805
|
if ((await meta(call, m)) === "end") {
|
|
@@ -3154,19 +1812,9 @@ export class Agent {
|
|
|
3154
1812
|
callId: call.id,
|
|
3155
1813
|
name: call.function.name,
|
|
3156
1814
|
args: safeParse(call.function.arguments),
|
|
3157
|
-
argsRaw: call.function.arguments,
|
|
1815
|
+
argsRaw: call.function.arguments,
|
|
3158
1816
|
});
|
|
3159
1817
|
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
1818
|
let result;
|
|
3171
1819
|
try {
|
|
3172
1820
|
await this.ensureWorkspace();
|
|
@@ -3176,20 +1824,7 @@ export class Agent {
|
|
|
3176
1824
|
result = { ok: false, result: `tool could not run: ${err.message}` };
|
|
3177
1825
|
}
|
|
3178
1826
|
this.stats.toolCalls++;
|
|
3179
|
-
// a tool call is real work, so it clears the idle-round counter.
|
|
3180
1827
|
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
1828
|
const capped = result.result.slice(0, 8000);
|
|
3194
1829
|
await this.log.append("tool_result", this.currentSession, this.currentBranch, {
|
|
3195
1830
|
callId: call.id,
|
|
@@ -3201,9 +1836,6 @@ export class Agent {
|
|
|
3201
1836
|
this.messages.push({ role: "tool", tool_call_id: call.id, content: capped });
|
|
3202
1837
|
this.consecutiveToolErrors = result.ok ? 0 : this.consecutiveToolErrors + 1;
|
|
3203
1838
|
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
1839
|
await this.log.append("system_note", this.currentSession, this.currentBranch, {
|
|
3208
1840
|
event: "runaway-detected",
|
|
3209
1841
|
failures: this.consecutiveToolErrors,
|
|
@@ -3215,38 +1847,12 @@ export class Agent {
|
|
|
3215
1847
|
`(last: ${call.function.name})`);
|
|
3216
1848
|
}
|
|
3217
1849
|
}
|
|
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
1850
|
await this.answerUnansweredToolCalls(m);
|
|
3232
1851
|
if (finished)
|
|
3233
1852
|
return true;
|
|
3234
1853
|
}
|
|
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
1854
|
return finished;
|
|
3238
1855
|
}
|
|
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
1856
|
async answerUnansweredToolCalls(m) {
|
|
3251
1857
|
const calls = m.tool_calls ?? [];
|
|
3252
1858
|
if (!calls.length)
|
|
@@ -3272,86 +1878,46 @@ export class Agent {
|
|
|
3272
1878
|
}
|
|
3273
1879
|
}
|
|
3274
1880
|
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
1881
|
const sys = this.systemPrompt();
|
|
3280
1882
|
const hasSystem = this.messages[0]?.role === "system";
|
|
3281
1883
|
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
1884
|
return hasSystem ? [...head, ...this.messages.slice(1)] : [...head, ...this.messages];
|
|
3288
1885
|
}
|
|
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
1886
|
reloadAgentsMd() {
|
|
3305
1887
|
if (!this.agentsMdLoaded)
|
|
3306
|
-
return null;
|
|
1888
|
+
return null;
|
|
3307
1889
|
let fresh;
|
|
3308
1890
|
try {
|
|
3309
1891
|
const p = path.join(this.opts.workspace, "AGENTS.md");
|
|
3310
1892
|
if (!existsSync(p))
|
|
3311
|
-
return null;
|
|
1893
|
+
return null;
|
|
3312
1894
|
fresh = readFileSync(p, "utf8").slice(0, 12_000);
|
|
3313
1895
|
}
|
|
3314
1896
|
catch {
|
|
3315
|
-
return null;
|
|
1897
|
+
return null;
|
|
3316
1898
|
}
|
|
3317
1899
|
if (fresh === this.agentsMd)
|
|
3318
1900
|
return null;
|
|
3319
1901
|
this.agentsMd = fresh;
|
|
3320
1902
|
return fresh;
|
|
3321
1903
|
}
|
|
3322
|
-
/** AGENTS.md content cached for the session (empty string = none/read-failed) */
|
|
3323
1904
|
agentsMd = "";
|
|
3324
|
-
/** captured once, with the rest of the session-frozen context */
|
|
3325
1905
|
agentsMdLoaded = false;
|
|
3326
|
-
/** one-time workspace listing injected with the system prompt */
|
|
3327
1906
|
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
1907
|
skillCatalogue = "";
|
|
3334
|
-
/** the compact digest of memory.md put into the frozen prompt (Codex's MEMORY_SUMMARY) */
|
|
3335
1908
|
memoryDigest = "";
|
|
3336
1909
|
systemPrompt() {
|
|
3337
1910
|
if (!this.agentsMdLoaded) {
|
|
3338
1911
|
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
1912
|
const snapPath = path.join(this.opts.sessionDir, "workspace-snapshot.txt");
|
|
3344
1913
|
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
1914
|
this.wsSnapshot = readFileSync(snapPath, "utf8");
|
|
3349
1915
|
}
|
|
3350
1916
|
catch {
|
|
3351
1917
|
this.wsSnapshot = "";
|
|
3352
1918
|
}
|
|
3353
1919
|
if (this.wsSnapshot === "" && existsSync(snapPath)) {
|
|
3354
|
-
this.wsSnapshot = "(empty workspace)";
|
|
1920
|
+
this.wsSnapshot = "(empty workspace)";
|
|
3355
1921
|
}
|
|
3356
1922
|
if (!this.wsSnapshot) {
|
|
3357
1923
|
try {
|
|
@@ -3374,7 +1940,6 @@ export class Agent {
|
|
|
3374
1940
|
size = `${(statSync(path.join(this.opts.workspace, e.name)).size / 1024).toFixed(1)}K`;
|
|
3375
1941
|
}
|
|
3376
1942
|
catch {
|
|
3377
|
-
/* vanished */
|
|
3378
1943
|
}
|
|
3379
1944
|
}
|
|
3380
1945
|
lines.push(e.isDirectory() ? `${e.name}/` : `${e.name} (${size})`);
|
|
@@ -3386,7 +1951,6 @@ export class Agent {
|
|
|
3386
1951
|
void fs.writeFile(snapPath, this.wsSnapshot, "utf8");
|
|
3387
1952
|
}
|
|
3388
1953
|
catch {
|
|
3389
|
-
/* best-effort persistence */
|
|
3390
1954
|
}
|
|
3391
1955
|
}
|
|
3392
1956
|
catch {
|
|
@@ -3403,8 +1967,6 @@ export class Agent {
|
|
|
3403
1967
|
this.agentsMd = "";
|
|
3404
1968
|
}
|
|
3405
1969
|
}
|
|
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
1970
|
this.skillCatalogue = this.skillCatalogueText();
|
|
3409
1971
|
this.memoryDigest = this.memoryDigestText();
|
|
3410
1972
|
}
|
|
@@ -3415,15 +1977,6 @@ export class Agent {
|
|
|
3415
1977
|
if (this.wsSnapshot) {
|
|
3416
1978
|
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
1979
|
}
|
|
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
1980
|
if (this.skillCatalogue) {
|
|
3428
1981
|
sys +=
|
|
3429
1982
|
`\n\n## Skills available in this workspace\n${this.skillCatalogue}\n` +
|
|
@@ -3431,19 +1984,6 @@ export class Agent {
|
|
|
3431
1984
|
`pass what you want done so the instructions arrive with the playbook. ` +
|
|
3432
1985
|
`list_skills() shows the full detail.`;
|
|
3433
1986
|
}
|
|
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
1987
|
if (this.memoryDigest) {
|
|
3448
1988
|
sys +=
|
|
3449
1989
|
`\n\n## Your durable notes (memory.md — a DIGEST, not the whole file)\n${this.memoryDigest}\n` +
|
|
@@ -3454,24 +1994,6 @@ export class Agent {
|
|
|
3454
1994
|
}
|
|
3455
1995
|
return sys;
|
|
3456
1996
|
}
|
|
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
1997
|
auditEvidence(summary) {
|
|
3476
1998
|
const lines = [];
|
|
3477
1999
|
for (let i = this.messages.length - 1; i >= 0 && lines.length < 24; i--) {
|
|
@@ -3480,7 +2002,7 @@ export class Agent {
|
|
|
3480
2002
|
if (!text)
|
|
3481
2003
|
continue;
|
|
3482
2004
|
if (m.role === "user")
|
|
3483
|
-
break;
|
|
2005
|
+
break;
|
|
3484
2006
|
const who = m.role === "assistant" ? "worker" : m.role;
|
|
3485
2007
|
lines.push(`${who}: ${text.replace(/\s+/g, " ").slice(0, 400)}`);
|
|
3486
2008
|
}
|
|
@@ -3494,58 +2016,21 @@ export class Agent {
|
|
|
3494
2016
|
for (let i = this.messages.length - 1; i >= 0 && recent.length < 6; i--) {
|
|
3495
2017
|
const m = this.messages[i];
|
|
3496
2018
|
if (m.role === "user")
|
|
3497
|
-
continue;
|
|
2019
|
+
continue;
|
|
3498
2020
|
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
2021
|
if (!text || PLACEHOLDER_TEXT.test(text))
|
|
3511
2022
|
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
2023
|
if (HARNESS_ACK.test(text))
|
|
3518
2024
|
continue;
|
|
3519
2025
|
const who = m.role === "tool" ? "tool" : m.role;
|
|
3520
2026
|
let line = `[${who}] ${text}`;
|
|
3521
2027
|
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
2028
|
line = `[${who}] ${(m.content ?? "").trim().slice(0, 1200)}`;
|
|
3525
2029
|
}
|
|
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
2030
|
recent.unshift(line.slice(0, 20_000));
|
|
3531
2031
|
}
|
|
3532
2032
|
return recent;
|
|
3533
2033
|
}
|
|
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
2034
|
async runAuditor(first) {
|
|
3550
2035
|
const AUDIT_MAX_STEPS = 12;
|
|
3551
2036
|
const step = (data) => this.log.append("audit_step", this.currentSession, this.currentBranch, data).catch(() => { });
|
|
@@ -3588,7 +2073,6 @@ export class Agent {
|
|
|
3588
2073
|
});
|
|
3589
2074
|
}
|
|
3590
2075
|
}
|
|
3591
|
-
// out of steps: ask once for the verdict alone, with no tools to reach for
|
|
3592
2076
|
const final = await this.callLlm([...messages, { role: "user", content: "Stop investigating. Reply with the verdict line only." }], []);
|
|
3593
2077
|
return String(final.message.content ?? "");
|
|
3594
2078
|
}
|
|
@@ -3596,73 +2080,30 @@ export class Agent {
|
|
|
3596
2080
|
const args = safeParse(argsJson);
|
|
3597
2081
|
if (args.goalComplete === true && this.goal.status !== "done") {
|
|
3598
2082
|
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
2083
|
await this.log.append("goal", this.currentSession, this.currentBranch, {
|
|
3604
2084
|
event: "audit-started",
|
|
3605
2085
|
verify: this.goal.verify.slice(0, 2000),
|
|
3606
2086
|
});
|
|
3607
|
-
// number the attempts — the final carries the attempt that approved it
|
|
3608
2087
|
this.auditAttempts++;
|
|
3609
2088
|
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
2089
|
const evidence = this.auditEvidence(args.summary);
|
|
3623
2090
|
const auditMessages = [
|
|
3624
2091
|
{ role: "user", content: auditPrompt({ goal: this.goal.text, verify: this.goal.verify, summary: String(args.summary ?? ""), evidence }) },
|
|
3625
2092
|
];
|
|
3626
2093
|
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
2094
|
const verdict = parseAuditVerdict(text);
|
|
3633
2095
|
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
2096
|
this.auditUnavailableStreak++;
|
|
3640
2097
|
await this.log.append("system_note", this.currentSession, this.currentBranch, {
|
|
3641
2098
|
event: "audit-failed",
|
|
3642
2099
|
detail: verdict.detail,
|
|
3643
2100
|
attempt: this.auditUnavailableStreak,
|
|
3644
2101
|
});
|
|
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
2102
|
if (this.auditUnavailableStreak < 3) {
|
|
3659
2103
|
this.queueAuditUnavailableRetry();
|
|
3660
2104
|
await this.captureRecentTurns();
|
|
3661
2105
|
return;
|
|
3662
2106
|
}
|
|
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
2107
|
await this.log.append("system_note", this.currentSession, this.currentBranch, {
|
|
3667
2108
|
event: "audit-unavailable-cap",
|
|
3668
2109
|
attempts: this.auditUnavailableStreak,
|
|
@@ -3672,23 +2113,11 @@ export class Agent {
|
|
|
3672
2113
|
return;
|
|
3673
2114
|
}
|
|
3674
2115
|
const approved = verdict.kind === "approved";
|
|
3675
|
-
// the parser already separated marker from reason
|
|
3676
2116
|
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
2117
|
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
2118
|
this.auditUnavailableStreak = 0;
|
|
3688
2119
|
if (approved) {
|
|
3689
2120
|
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
2121
|
this.lastAuditOutcome = {
|
|
3693
2122
|
audited: true,
|
|
3694
2123
|
verdict: "approved",
|
|
@@ -3699,25 +2128,11 @@ export class Agent {
|
|
|
3699
2128
|
};
|
|
3700
2129
|
}
|
|
3701
2130
|
else {
|
|
3702
|
-
// the rejection survives in memory so the eventual approved
|
|
3703
|
-
// final can name what the first attempt was told
|
|
3704
2131
|
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
2132
|
if (!this.preRejectionTurns.length) {
|
|
3711
2133
|
this.preRejectionTurns = await this.captureRecentTurns();
|
|
3712
2134
|
}
|
|
3713
2135
|
}
|
|
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
2136
|
await this.log.append("message", this.currentSession, this.currentBranch, {
|
|
3722
2137
|
role: "assistant",
|
|
3723
2138
|
operatorFacing: true,
|
|
@@ -3727,24 +2142,11 @@ export class Agent {
|
|
|
3727
2142
|
});
|
|
3728
2143
|
if (!approved) {
|
|
3729
2144
|
this.consecutiveAuditRejects++;
|
|
3730
|
-
// the rejection survives in memory so the eventual approved
|
|
3731
|
-
// final can name what the first attempt was told
|
|
3732
2145
|
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
2146
|
const gaps = feedback.trim()
|
|
3738
2147
|
? feedback.slice(0, 2000)
|
|
3739
2148
|
: `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
2149
|
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
2150
|
if (this.consecutiveAuditRejects < (this.opts.maxAuditRejects ?? 3)) {
|
|
3749
2151
|
this.pendingPrompts.push({ source: "harness", text: retryText });
|
|
3750
2152
|
await this.log.append("prompt", this.currentSession, this.currentBranch, {
|
|
@@ -3752,25 +2154,11 @@ export class Agent {
|
|
|
3752
2154
|
text: retryText,
|
|
3753
2155
|
});
|
|
3754
2156
|
}
|
|
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
2157
|
await this.captureRecentTurns();
|
|
3767
2158
|
return;
|
|
3768
2159
|
}
|
|
3769
2160
|
}
|
|
3770
2161
|
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
2162
|
this.auditUnavailableStreak++;
|
|
3775
2163
|
await this.log.append("system_note", this.currentSession, this.currentBranch, {
|
|
3776
2164
|
event: "audit-failed",
|
|
@@ -3797,43 +2185,18 @@ export class Agent {
|
|
|
3797
2185
|
}
|
|
3798
2186
|
await this.appendFinalMessage(args);
|
|
3799
2187
|
}
|
|
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
2188
|
queueAuditUnavailableRetry() {
|
|
3810
2189
|
const text = `[harness] The completion audit could not produce a verdict — that is a problem with the ` +
|
|
3811
2190
|
`AUDIT, not a finding about your work. Before finishing again, state plainly and briefly what ` +
|
|
3812
2191
|
`you verified for each item of the contract, so the auditor has something to judge.\n\n` +
|
|
3813
2192
|
`${this.goal.verify?.slice(0, 2000) || this.goal.text.slice(0, 2000)}`;
|
|
3814
2193
|
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
2194
|
void this.log
|
|
3818
2195
|
.append("prompt", this.currentSession, this.currentBranch, { source: "harness", text })
|
|
3819
2196
|
.catch(() => { });
|
|
3820
2197
|
}
|
|
3821
|
-
/** The `final: true` event — the parent's notification (#89: never before the goal settles). */
|
|
3822
2198
|
async appendFinalMessage(args) {
|
|
3823
2199
|
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
2200
|
const before = this.preRejectionTurns;
|
|
3838
2201
|
this.preRejectionTurns = [];
|
|
3839
2202
|
const combined = before.length
|
|
@@ -3844,9 +2207,6 @@ export class Agent {
|
|
|
3844
2207
|
...recent,
|
|
3845
2208
|
]
|
|
3846
2209
|
: 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
2210
|
const outcome = this.lastAuditOutcome;
|
|
3851
2211
|
this.lastAuditOutcome = null;
|
|
3852
2212
|
await this.log.append("message", this.currentSession, this.currentBranch, {
|
|
@@ -3874,37 +2234,21 @@ export class Agent {
|
|
|
3874
2234
|
const activityOk = this.activityChars >= this.opts.progressMinChars ||
|
|
3875
2235
|
this.turnsSinceProgress >= this.opts.progressMaxQuietTurns;
|
|
3876
2236
|
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
|
-
*/
|
|
2237
|
+
return;
|
|
3885
2238
|
if (this.nudgedAtTurn === this.stats.turns)
|
|
3886
2239
|
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
2240
|
this.progressRequestedThisRound = true;
|
|
3890
2241
|
this.lastProgressAt = Date.now();
|
|
3891
2242
|
this.activityChars = 0;
|
|
3892
2243
|
this.turnsSinceProgress = 0;
|
|
3893
2244
|
const request = PROGRESS_REQUEST;
|
|
3894
|
-
// log both sides so a session restore replays this exchange faithfully
|
|
3895
2245
|
await this.log.append("prompt", this.currentSession, this.currentBranch, {
|
|
3896
2246
|
source: "harness",
|
|
3897
2247
|
text: request,
|
|
3898
2248
|
});
|
|
3899
2249
|
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
2250
|
const res = await this.llmCall(this.buildMessages(), allToolSpecs());
|
|
3905
2251
|
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
2252
|
this.messages.push(res.message);
|
|
3909
2253
|
await this.log.append("message", this.currentSession, this.currentBranch, {
|
|
3910
2254
|
role: "assistant",
|
|
@@ -3918,27 +2262,9 @@ export class Agent {
|
|
|
3918
2262
|
if (call.function.name === "report_progress") {
|
|
3919
2263
|
await this.recordProgress(call.function.arguments);
|
|
3920
2264
|
}
|
|
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
2265
|
const isReport = call.function.name === "report_progress";
|
|
3939
2266
|
const answer = isReport ? "progress recorded" : "not run — this turn was a progress request";
|
|
3940
2267
|
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
2268
|
await this.log.append("tool_call", this.currentSession, this.currentBranch, {
|
|
3943
2269
|
callId: call.id,
|
|
3944
2270
|
name: call.function.name,
|
|
@@ -3948,25 +2274,15 @@ export class Agent {
|
|
|
3948
2274
|
await this.log.append("tool_result", this.currentSession, this.currentBranch, {
|
|
3949
2275
|
callId: call.id,
|
|
3950
2276
|
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
2277
|
ok: true,
|
|
3953
2278
|
durationMs: 0,
|
|
3954
2279
|
result: answer,
|
|
3955
2280
|
});
|
|
3956
2281
|
}
|
|
3957
2282
|
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
2283
|
await this.recordProgress(JSON.stringify({ freeform: "(no report produced)" }));
|
|
3961
2284
|
}
|
|
3962
2285
|
}
|
|
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
2286
|
estimateTokens(msgs = this.messages) {
|
|
3971
2287
|
let ascii = 0;
|
|
3972
2288
|
let wide = 0;
|
|
@@ -3987,12 +2303,6 @@ export class Agent {
|
|
|
3987
2303
|
}
|
|
3988
2304
|
return Math.ceil(ascii / 4 + wide * 1.2 + msgs.length * 8);
|
|
3989
2305
|
}
|
|
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
2306
|
safeCut(maxCut) {
|
|
3997
2307
|
for (let i = Math.min(maxCut, this.messages.length - 1); i >= 1; i--) {
|
|
3998
2308
|
if (this.messages[i].role !== "tool")
|
|
@@ -4000,23 +2310,11 @@ export class Agent {
|
|
|
4000
2310
|
}
|
|
4001
2311
|
return -1;
|
|
4002
2312
|
}
|
|
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
2313
|
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
2314
|
if (this.messages.some((m) => m.role === "tool" && m.tool_call_id === call.id))
|
|
4015
2315
|
return;
|
|
4016
2316
|
const raw = call.function.arguments ?? "{}";
|
|
4017
2317
|
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
2318
|
await this.log.append("tool_call", this.currentSession, this.currentBranch, {
|
|
4021
2319
|
callId: call.id,
|
|
4022
2320
|
name: call.function.name,
|
|
@@ -4031,29 +2329,14 @@ export class Agent {
|
|
|
4031
2329
|
result: content,
|
|
4032
2330
|
});
|
|
4033
2331
|
}
|
|
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
2332
|
async maybePrune(force = false) {
|
|
4045
|
-
const est = this.contextSizeForBudgeting();
|
|
2333
|
+
const est = this.contextSizeForBudgeting();
|
|
4046
2334
|
const budget = this.opts.contextTokenBudget;
|
|
4047
2335
|
if (!budget)
|
|
4048
2336
|
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
2337
|
const pruneShare = this.opts.shortContext ? SHORT_PRUNE_AT_SHARE : PRUNE_AT_SHARE;
|
|
4052
2338
|
if (!force && est < budget * pruneShare)
|
|
4053
2339
|
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
2340
|
const protectChars = this.opts.shortContext ? Math.floor(budget * 1.2) : budget * 2;
|
|
4058
2341
|
let seen = 0;
|
|
4059
2342
|
let boundary = this.messages.length;
|
|
@@ -4064,19 +2347,12 @@ export class Agent {
|
|
|
4064
2347
|
break;
|
|
4065
2348
|
}
|
|
4066
2349
|
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
2350
|
const PRUNABLE_TOOLS = new Set([
|
|
4072
2351
|
"bash", "read_file", "list_dir", "grep", "glob", "write_file", "edit_file", "apply_patch",
|
|
4073
2352
|
"bash_output",
|
|
4074
2353
|
]);
|
|
4075
2354
|
let saved = 0;
|
|
4076
2355
|
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
2356
|
const callerTool = new Map();
|
|
4081
2357
|
for (const m of this.messages) {
|
|
4082
2358
|
for (const t of m.tool_calls ?? [])
|
|
@@ -4097,10 +2373,6 @@ export class Agent {
|
|
|
4097
2373
|
}
|
|
4098
2374
|
}
|
|
4099
2375
|
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
2376
|
await this.log.append("system_note", this.currentSession, this.currentBranch, {
|
|
4105
2377
|
event: "context-pruned",
|
|
4106
2378
|
outputs: count,
|
|
@@ -4108,37 +2380,15 @@ export class Agent {
|
|
|
4108
2380
|
});
|
|
4109
2381
|
return count;
|
|
4110
2382
|
}
|
|
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
2383
|
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.
|
|
2384
|
+
const before = this.contextSizeForBudgeting();
|
|
4126
2385
|
if (this.opts.shortContext)
|
|
4127
2386
|
await this.maybePrune();
|
|
4128
2387
|
if (!force && before < this.opts.contextTokenBudget)
|
|
4129
2388
|
return;
|
|
4130
2389
|
if (!force && this.opts.autoCompact === false)
|
|
4131
2390
|
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
2391
|
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
2392
|
if (pruned > 0 && !force && this.contextSizeForBudgeting() < this.opts.contextTokenBudget) {
|
|
4143
2393
|
await this.log.append("system_note", this.currentSession, this.currentBranch, {
|
|
4144
2394
|
event: "context-pruned-enough",
|
|
@@ -4149,56 +2399,25 @@ export class Agent {
|
|
|
4149
2399
|
this.compactedAtLen = this.messages.length;
|
|
4150
2400
|
return;
|
|
4151
2401
|
}
|
|
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
2402
|
if (!force && this.consecutiveCompactFailures >= MAX_AUTO_COMPACT_FAILURES)
|
|
4159
2403
|
return;
|
|
4160
|
-
// a forced pass on an already-tiny history would just summarize the
|
|
4161
|
-
// summary — report ran=false instead
|
|
4162
2404
|
if (force && this.messages.length <= this.compactedAtLen)
|
|
4163
2405
|
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.
|
|
2406
|
+
this.lastUsage = undefined;
|
|
4174
2407
|
const keepTokens = keepFraction !== undefined
|
|
4175
|
-
// an explicit retry (overflow recovery): shrink toward a share of the budget
|
|
4176
2408
|
? 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
2409
|
: Math.min(KEEP_RECENT_TOKENS, Math.max(1, Math.floor(this.opts.contextTokenBudget / 4)));
|
|
4185
2410
|
let cut = -1;
|
|
4186
2411
|
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
2412
|
if (this.estimateTokens(this.messages.slice(i)) > keepTokens)
|
|
4190
2413
|
break;
|
|
4191
2414
|
cut = i;
|
|
4192
2415
|
}
|
|
4193
2416
|
cut = this.safeCut(cut);
|
|
4194
2417
|
if (cut <= 0)
|
|
4195
|
-
return;
|
|
2418
|
+
return;
|
|
4196
2419
|
let old = this.messages.slice(0, cut);
|
|
4197
2420
|
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
2421
|
const totalChars = this.messages.reduce((n, m) => n + (m.content?.length ?? 0), 0);
|
|
4203
2422
|
const oldChars = old.reduce((n, m) => n + (m.content?.length ?? 0), 0);
|
|
4204
2423
|
const oldShareOfBefore = totalChars > 0 ? Math.round((before * oldChars) / totalChars) : 0;
|
|
@@ -4206,56 +2425,10 @@ export class Agent {
|
|
|
4206
2425
|
let mode = "summarize";
|
|
4207
2426
|
let summarizedCount = oldCount;
|
|
4208
2427
|
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
2428
|
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
2429
|
const summarizeBar = Math.max(SUMMARIZE_MIN_TOKENS, this.opts.contextTokenBudget * SUMMARIZE_MIN_SHARE);
|
|
4244
2430
|
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
2431
|
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
2432
|
this.compactPhase = "summarizing";
|
|
4260
2433
|
bus.emit("update", {
|
|
4261
2434
|
kind: "compaction-progress",
|
|
@@ -4263,84 +2436,34 @@ export class Agent {
|
|
|
4263
2436
|
phase: "summarizing",
|
|
4264
2437
|
summarized: oldCount,
|
|
4265
2438
|
});
|
|
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
2439
|
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
2440
|
await this.log.append("system_note", this.currentSession, this.currentBranch, {
|
|
4280
2441
|
event: "context-compaction-started",
|
|
4281
2442
|
tokensBefore: before,
|
|
4282
2443
|
messages: oldCount,
|
|
4283
2444
|
reason: force ? "manual" : "auto",
|
|
4284
|
-
worthSummarizing,
|
|
2445
|
+
worthSummarizing,
|
|
4285
2446
|
updating: Boolean(previousSummary),
|
|
4286
2447
|
});
|
|
4287
2448
|
try {
|
|
4288
2449
|
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
2450
|
for (let attempt = 0; attempt <= SUMMARIZE_OVERFLOW_RETRIES; attempt++) {
|
|
4306
2451
|
try {
|
|
4307
2452
|
summary = await this.summarize(old, (text) => {
|
|
4308
2453
|
bus.emit("update", {
|
|
4309
2454
|
kind: "llm-delta",
|
|
4310
2455
|
agentId: this.opts.id,
|
|
4311
|
-
// the bubble is session-scoped, so the stream must say which session
|
|
4312
2456
|
sessionId: this.currentSession,
|
|
4313
2457
|
text: `[compact] ${text}`,
|
|
4314
2458
|
reasoning: "",
|
|
4315
2459
|
});
|
|
4316
2460
|
}, 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
2461
|
{
|
|
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
2462
|
if (!this.compactRules.length && previousSummary) {
|
|
4339
2463
|
this.compactRules = extractRules(previousSummary);
|
|
4340
2464
|
}
|
|
4341
2465
|
const enforced = enforceRules(summary, this.compactRules);
|
|
4342
2466
|
summary = enforced.summary;
|
|
4343
|
-
// a pass may ADD rules (the operator said something new); it may never remove one
|
|
4344
2467
|
this.compactRules = extractRules(summary);
|
|
4345
2468
|
if (enforced.restored.length) {
|
|
4346
2469
|
await this.log.append("system_note", this.currentSession, this.currentBranch, {
|
|
@@ -4363,7 +2486,7 @@ export class Agent {
|
|
|
4363
2486
|
throw e;
|
|
4364
2487
|
const smaller = this.safeCut(Math.max(1, Math.floor(old.length * SUMMARIZE_SHRINK)));
|
|
4365
2488
|
if (smaller <= 0 || smaller >= old.length)
|
|
4366
|
-
throw e;
|
|
2489
|
+
throw e;
|
|
4367
2490
|
await this.log.append("system_note", this.currentSession, this.currentBranch, {
|
|
4368
2491
|
event: "compaction-summarize-overflow",
|
|
4369
2492
|
attempt: attempt + 1,
|
|
@@ -4376,8 +2499,6 @@ export class Agent {
|
|
|
4376
2499
|
}
|
|
4377
2500
|
}
|
|
4378
2501
|
else {
|
|
4379
|
-
// shallow saving: truncation reclaims the budget for free, so skip the
|
|
4380
|
-
// one full-history LLM call the summary would cost
|
|
4381
2502
|
await this.log.append("system_note", this.currentSession, this.currentBranch, {
|
|
4382
2503
|
event: "context-compaction-skipped-summarize",
|
|
4383
2504
|
reason: "shallow: truncation reclaims the budget without a full-history LLM call",
|
|
@@ -4388,47 +2509,26 @@ export class Agent {
|
|
|
4388
2509
|
this.compactPhase = "harvesting";
|
|
4389
2510
|
bus.emit("update", { kind: "compaction-progress", agentId: this.opts.id, phase: "harvesting" });
|
|
4390
2511
|
if (summary)
|
|
4391
|
-
await this.harvestLessons(summary);
|
|
2512
|
+
await this.harvestLessons(summary);
|
|
4392
2513
|
}
|
|
4393
2514
|
catch (err) {
|
|
4394
2515
|
await this.log.append("error", this.currentSession, this.currentBranch, {
|
|
4395
2516
|
message: `compaction summarize failed, falling back to truncation: ${err.message}`,
|
|
4396
2517
|
});
|
|
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
2518
|
this.consecutiveCompactFailures += 1;
|
|
4402
2519
|
}
|
|
4403
2520
|
if (!summary) {
|
|
4404
|
-
// fallback: drop the oldest half without LLM help so work can continue
|
|
4405
2521
|
mode = "truncate";
|
|
4406
2522
|
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
2523
|
const halfCut = this.safeCut(Math.max(1, Math.floor(oldCount / 2)));
|
|
4415
2524
|
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
2525
|
await this.log.append("system_note", this.currentSession, this.currentBranch, {
|
|
4419
2526
|
event: "context-compaction-skipped",
|
|
4420
2527
|
reason: "summarize failed and no safe prefix to truncate",
|
|
4421
2528
|
});
|
|
4422
|
-
return;
|
|
2529
|
+
return;
|
|
4423
2530
|
}
|
|
4424
2531
|
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
2532
|
this.messages = [
|
|
4433
2533
|
{
|
|
4434
2534
|
role: "user",
|
|
@@ -4454,28 +2554,10 @@ export class Agent {
|
|
|
4454
2554
|
}
|
|
4455
2555
|
const after = this.estimateTokens();
|
|
4456
2556
|
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
2557
|
if (mode === "summarize")
|
|
4463
2558
|
this.consecutiveCompactFailures = 0;
|
|
4464
2559
|
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
2560
|
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
2561
|
try {
|
|
4480
2562
|
const notes = (await fs.readFile(this.memoryFile, "utf8")).trim();
|
|
4481
2563
|
if (notes) {
|
|
@@ -4487,7 +2569,6 @@ export class Agent {
|
|
|
4487
2569
|
}
|
|
4488
2570
|
}
|
|
4489
2571
|
catch {
|
|
4490
|
-
/* no notes yet — nothing to re-attach */
|
|
4491
2572
|
}
|
|
4492
2573
|
}
|
|
4493
2574
|
if (this.recentReads.length && mode === "summarize") {
|
|
@@ -4502,7 +2583,6 @@ export class Agent {
|
|
|
4502
2583
|
restoredPaths.add(rel);
|
|
4503
2584
|
}
|
|
4504
2585
|
catch {
|
|
4505
|
-
/* file vanished since the read — skip */
|
|
4506
2586
|
}
|
|
4507
2587
|
}
|
|
4508
2588
|
if (restored.length) {
|
|
@@ -4511,30 +2591,9 @@ export class Agent {
|
|
|
4511
2591
|
content: `[harness] These files were recently read and are re-attached for continuity ` +
|
|
4512
2592
|
`(they may have changed — re-read if precision matters):\n\n${restored.join("\n\n")}`,
|
|
4513
2593
|
});
|
|
4514
|
-
// keep exactly what restored, in order — slicing by COUNT kept a
|
|
4515
|
-
// vanished middle entry while dropping the surviving tail
|
|
4516
2594
|
this.recentReads = this.recentReads.filter((rel) => restoredPaths.has(rel));
|
|
4517
2595
|
}
|
|
4518
2596
|
}
|
|
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
2597
|
const refreshed = this.reloadAgentsMd();
|
|
4539
2598
|
if (refreshed) {
|
|
4540
2599
|
await this.log.append("system_note", this.currentSession, this.currentBranch, {
|
|
@@ -4542,8 +2601,6 @@ export class Agent {
|
|
|
4542
2601
|
bytes: refreshed.length,
|
|
4543
2602
|
});
|
|
4544
2603
|
}
|
|
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
2604
|
await this.log.append("compaction", this.currentSession, this.currentBranch, {
|
|
4548
2605
|
tokensBefore: before,
|
|
4549
2606
|
tokensAfter: after,
|
|
@@ -4552,7 +2609,6 @@ export class Agent {
|
|
|
4552
2609
|
mode,
|
|
4553
2610
|
...(summary ? { summary: String(summary).slice(0, 20_000) } : {}),
|
|
4554
2611
|
});
|
|
4555
|
-
// remember the notes for the NEXT compaction to update (and for a restore)
|
|
4556
2612
|
if (summary)
|
|
4557
2613
|
this.lastCompactSummary = summary;
|
|
4558
2614
|
await this.log.append("system_note", this.currentSession, this.currentBranch, {
|
|
@@ -4566,88 +2622,22 @@ export class Agent {
|
|
|
4566
2622
|
}
|
|
4567
2623
|
finally {
|
|
4568
2624
|
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
2625
|
bus.emit("update", { kind: "compaction-progress", agentId: this.opts.id, phase: "done" });
|
|
4574
2626
|
}
|
|
4575
2627
|
}
|
|
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
2628
|
async summarize(old, onDelta, previousSummary) {
|
|
4599
2629
|
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
2630
|
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
2631
|
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 }), [
|
|
2632
|
+
const res = await fn(this.requestConfig({ oneOff: true }), [
|
|
4631
2633
|
...prefix,
|
|
4632
2634
|
{
|
|
4633
2635
|
role: "user",
|
|
4634
2636
|
content: (previousSummary ? `${SUMMARY_OPEN}${previousSummary}${SUMMARY_CLOSE}\n\n` : "") + instruction,
|
|
4635
2637
|
},
|
|
4636
|
-
], allToolSpecs(), undefined,
|
|
4637
|
-
onDelta
|
|
2638
|
+
], allToolSpecs(), undefined, onDelta
|
|
4638
2639
|
? (s) => onDelta(s.text)
|
|
4639
2640
|
: 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
2641
|
if (res.usage) {
|
|
4652
2642
|
const u = res.usage;
|
|
4653
2643
|
this.stats.inputTokens += u.inputTokens ?? 0;
|
|
@@ -4655,41 +2645,20 @@ export class Agent {
|
|
|
4655
2645
|
this.stats.cachedInputTokens += u.cachedInputTokens ?? 0;
|
|
4656
2646
|
await this.log.append("usage", this.currentSession, this.currentBranch, {
|
|
4657
2647
|
...u,
|
|
4658
|
-
// distinguishes it from a conversation turn when reading the log back
|
|
4659
2648
|
kind: "summarize",
|
|
4660
2649
|
});
|
|
4661
2650
|
}
|
|
4662
2651
|
return this.formatCompactSummary(res.message.content ?? "");
|
|
4663
2652
|
}
|
|
4664
|
-
/** strip the <analysis> scratchpad — it improves thinking but must not
|
|
4665
|
-
consume post-compact context tokens */
|
|
4666
2653
|
formatCompactSummary(raw) {
|
|
4667
2654
|
const m = raw.match(/<analysis>[\s\S]*?<\/analysis>\s*/);
|
|
4668
2655
|
return m ? raw.slice(m[0].length).trim() : raw.trim();
|
|
4669
2656
|
}
|
|
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
2657
|
async harvestLessons(summary) {
|
|
4676
2658
|
const m = summary.match(/##\s*Durable lessons?\s*\n([\s\S]*?)(?=\n##\s|$)/i);
|
|
4677
2659
|
const lessons = m?.[1]?.trim();
|
|
4678
2660
|
if (!lessons)
|
|
4679
2661
|
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
2662
|
const session = path.basename(this.opts.sessionDir);
|
|
4694
2663
|
const stamped = `\n## lessons from compaction ${new Date().toISOString().slice(0, 16).replace("T", " ")}\n` +
|
|
4695
2664
|
`_from: session ${session}, branch ${this.currentBranch}_\n${lessons}\n`;
|
|
@@ -4705,7 +2674,6 @@ export class Agent {
|
|
|
4705
2674
|
next: str(a.next) || undefined,
|
|
4706
2675
|
ts: new Date().toISOString(),
|
|
4707
2676
|
};
|
|
4708
|
-
// a report (voluntary or requested) restarts the progress gates
|
|
4709
2677
|
this.lastProgressAt = Date.now();
|
|
4710
2678
|
this.activityChars = 0;
|
|
4711
2679
|
this.turnsSinceProgress = 0;
|
|
@@ -4714,26 +2682,6 @@ export class Agent {
|
|
|
4714
2682
|
}
|
|
4715
2683
|
sleepInterruptible(ms) {
|
|
4716
2684
|
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
2685
|
if (this.stopRequested)
|
|
4738
2686
|
return resolve();
|
|
4739
2687
|
const timer = setTimeout(() => {
|
|
@@ -4749,63 +2697,21 @@ export class Agent {
|
|
|
4749
2697
|
}
|
|
4750
2698
|
async dispose() {
|
|
4751
2699
|
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
2700
|
this.toolAbort.abort();
|
|
4755
2701
|
await this.runChain.catch(() => { });
|
|
4756
2702
|
await this.log.close();
|
|
4757
2703
|
}
|
|
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
2704
|
async windDownForUpdate(timeoutMs) {
|
|
4780
2705
|
this.windDownRequested = true;
|
|
4781
|
-
// release a parked wait (wait_children / bg-shell) so the loop can come round to the check
|
|
4782
2706
|
this.wake?.();
|
|
4783
2707
|
if (this.status === "idle") {
|
|
4784
2708
|
this.status = "idle";
|
|
4785
2709
|
this.statusReason = "paused for a live update";
|
|
4786
2710
|
return;
|
|
4787
2711
|
}
|
|
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
2712
|
let timer;
|
|
4805
2713
|
const done = async () => {
|
|
4806
2714
|
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
2715
|
while (hasRunningBgShellsFor(this.toolCtx.cwd, this.opts.id)) {
|
|
4810
2716
|
if (this.stopRequested)
|
|
4811
2717
|
break;
|
|
@@ -4827,38 +2733,22 @@ export class Agent {
|
|
|
4827
2733
|
clearTimeout(timer);
|
|
4828
2734
|
}
|
|
4829
2735
|
}
|
|
4830
|
-
/** true once `windDownForUpdate()` has been asked and the round has ended */
|
|
4831
2736
|
get windingDownForUpdate() {
|
|
4832
2737
|
return this.windDownRequested;
|
|
4833
2738
|
}
|
|
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
2739
|
async fork(fromEventId) {
|
|
4840
2740
|
const newBranch = `br${this.branchCount()}${Date.now().toString(36).slice(-4)}`;
|
|
4841
2741
|
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
2742
|
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
2743
|
await this.log.append("fork", this.currentSession, parentBranch, {
|
|
4852
2744
|
fromSession: this.currentSession,
|
|
4853
2745
|
fromBranch: parentBranch,
|
|
4854
2746
|
fromEvent,
|
|
4855
2747
|
newBranch,
|
|
4856
2748
|
});
|
|
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
2749
|
this.log.seedBranch(newBranch, fromEvent);
|
|
4860
2750
|
this.currentBranch = newBranch;
|
|
4861
|
-
this.messages = [...this.messages];
|
|
2751
|
+
this.messages = [...this.messages];
|
|
4862
2752
|
return { session: this.currentSession, branch: newBranch };
|
|
4863
2753
|
}
|
|
4864
2754
|
branchCount() {
|
|
@@ -4869,27 +2759,9 @@ export class Agent {
|
|
|
4869
2759
|
this.currentBranch = branch;
|
|
4870
2760
|
}
|
|
4871
2761
|
}
|
|
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
2762
|
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
2763
|
return [...toolSpecs(), ...metaToolSpecs(), ...mcpSpecs()];
|
|
4884
2764
|
}
|
|
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
2765
|
function mcpSpecs() {
|
|
4894
2766
|
return mcp.allTools().map((t) => ({
|
|
4895
2767
|
type: "function",
|
|
@@ -4911,7 +2783,6 @@ function safeParse(json) {
|
|
|
4911
2783
|
return {};
|
|
4912
2784
|
}
|
|
4913
2785
|
}
|
|
4914
|
-
/** Walk parent links backwards from the newest event, then flip forward. */
|
|
4915
2786
|
function lineageOf(events) {
|
|
4916
2787
|
if (events.length === 0)
|
|
4917
2788
|
return [];
|
|
@@ -4926,16 +2797,10 @@ function lineageOf(events) {
|
|
|
4926
2797
|
lineage.push(cur);
|
|
4927
2798
|
}
|
|
4928
2799
|
lineage.reverse();
|
|
4929
|
-
// the trailing fork event itself is bookkeeping, not conversation
|
|
4930
2800
|
while (lineage.length && lineage[0].type === "fork")
|
|
4931
2801
|
lineage.shift();
|
|
4932
2802
|
return lineage;
|
|
4933
2803
|
}
|
|
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
2804
|
function rebuildMessagesFrom(list) {
|
|
4940
2805
|
const msgs = [];
|
|
4941
2806
|
const META_TOOLS = new Set([
|
|
@@ -4943,19 +2808,15 @@ function rebuildMessagesFrom(list) {
|
|
|
4943
2808
|
"read_memory", "set_memory", "add_memory", "forget_memory", "list_skills", "get_todo", "set_todo",
|
|
4944
2809
|
"get_feedback", "add_feedback", "record_decision", "get_decisions",
|
|
4945
2810
|
]);
|
|
4946
|
-
const openCalls = new Map();
|
|
4947
|
-
// callId -> the tool_call entry on its assistant message (fast arg enrichment)
|
|
2811
|
+
const openCalls = new Map();
|
|
4948
2812
|
const assistantCalls = new Map();
|
|
4949
2813
|
const rememberAssistantCalls = (m) => {
|
|
4950
2814
|
if (Array.isArray(m.tool_calls))
|
|
4951
2815
|
for (const t of m.tool_calls)
|
|
4952
2816
|
assistantCalls.set(t.id, t);
|
|
4953
2817
|
};
|
|
4954
|
-
// meta answers are now logged as regular tool_results; the legacy progress
|
|
4955
|
-
// synthesizer below must not duplicate them
|
|
4956
2818
|
const loggedResults = new Set(list.filter((e) => e.type === "tool_result").map((e) => String(e.data.callId ?? "")));
|
|
4957
2819
|
const bufferedUsers = [];
|
|
4958
|
-
// image attachments paired with buffered prompts (multimodal replay)
|
|
4959
2820
|
const bufferedParts = [];
|
|
4960
2821
|
const flushUsers = () => {
|
|
4961
2822
|
if (openCalls.size === 0) {
|
|
@@ -4979,8 +2840,6 @@ function rebuildMessagesFrom(list) {
|
|
|
4979
2840
|
for (const e of list) {
|
|
4980
2841
|
const d = e.data;
|
|
4981
2842
|
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
2843
|
const text = d.text;
|
|
4985
2844
|
const imgs = Array.isArray(d.images) ? d.images : [];
|
|
4986
2845
|
const mk = () => imgs.length
|
|
@@ -4999,24 +2858,11 @@ function rebuildMessagesFrom(list) {
|
|
|
4999
2858
|
msgs.push(mk());
|
|
5000
2859
|
}
|
|
5001
2860
|
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
2861
|
if (d.final === true || d.operatorFacing === true)
|
|
5009
2862
|
continue;
|
|
5010
2863
|
const role = d.role === "assistant" ? "assistant" : "user";
|
|
5011
2864
|
const content = typeof d.content === "string" ? d.content : "";
|
|
5012
2865
|
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
2866
|
if (role === "assistant" && !content && !hasCalls)
|
|
5021
2867
|
continue;
|
|
5022
2868
|
const m = { role, content };
|
|
@@ -5026,8 +2872,6 @@ function rebuildMessagesFrom(list) {
|
|
|
5026
2872
|
type: "function",
|
|
5027
2873
|
function: { name: c.name, arguments: "{}" },
|
|
5028
2874
|
}));
|
|
5029
|
-
// meta tools are answered inline by the harness (no logged result);
|
|
5030
|
-
// the hole-filling pass below synthesizes theirs where they belong
|
|
5031
2875
|
for (const t of m.tool_calls)
|
|
5032
2876
|
if (!META_TOOLS.has(t.function.name))
|
|
5033
2877
|
openCalls.set(t.id, t.function.name);
|
|
@@ -5036,11 +2880,6 @@ function rebuildMessagesFrom(list) {
|
|
|
5036
2880
|
msgs.push(m);
|
|
5037
2881
|
}
|
|
5038
2882
|
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
2883
|
let tc = assistantCalls.get(String(d.callId ?? ""));
|
|
5045
2884
|
if (!tc) {
|
|
5046
2885
|
const prev = [...msgs].reverse().find((x) => x.role === "assistant" && x.tool_calls?.some((t) => t.id === d.callId));
|
|
@@ -5063,32 +2902,12 @@ function rebuildMessagesFrom(list) {
|
|
|
5063
2902
|
flushUsers();
|
|
5064
2903
|
}
|
|
5065
2904
|
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
2905
|
if (d.mode === "truncate") {
|
|
5075
|
-
// `dropped` IS the number of leading messages the live pass removed
|
|
5076
2906
|
msgs.splice(0, Math.min(msgs.length, Number(d.dropped ?? 0)));
|
|
5077
2907
|
}
|
|
5078
2908
|
else {
|
|
5079
2909
|
const sum = typeof d.summary === "string" ? d.summary.trim() : "";
|
|
5080
2910
|
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
2911
|
if (!sum) {
|
|
5093
2912
|
console.warn(`[teapot] compaction at seq ${e.seq} has no summary text — keeping the full history rather than discarding it`);
|
|
5094
2913
|
}
|
|
@@ -5104,8 +2923,6 @@ function rebuildMessagesFrom(list) {
|
|
|
5104
2923
|
}
|
|
5105
2924
|
}
|
|
5106
2925
|
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
2926
|
const lastAssistant = [...msgs].reverse().find((x) => x.role === "assistant" && x.tool_calls?.length);
|
|
5110
2927
|
if (lastAssistant?.tool_calls?.some((t) => t.function.name === "report_progress")) {
|
|
5111
2928
|
for (const t of lastAssistant.tool_calls) {
|
|
@@ -5118,8 +2935,6 @@ function rebuildMessagesFrom(list) {
|
|
|
5118
2935
|
}
|
|
5119
2936
|
}
|
|
5120
2937
|
}
|
|
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
2938
|
for (let i = 0; i < msgs.length; i++) {
|
|
5124
2939
|
const m = msgs[i];
|
|
5125
2940
|
if (m.role === "assistant" && m.tool_calls?.length) {
|
|
@@ -5135,7 +2950,6 @@ function rebuildMessagesFrom(list) {
|
|
|
5135
2950
|
}
|
|
5136
2951
|
}
|
|
5137
2952
|
}
|
|
5138
|
-
// prompts that were still waiting on a hole-filled tail land here
|
|
5139
2953
|
{
|
|
5140
2954
|
const texts = bufferedUsers.splice(0);
|
|
5141
2955
|
const parts = bufferedParts.splice(0);
|