teapot-coding-agent 0.24.0 → 0.24.2
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/agent/agent.js +92 -11
- package/dist/agent/llm.js +130 -6
- package/dist/agent/tools.js +63 -6
- package/dist/log/events.js +129 -0
- package/dist/server/api.js +22 -5
- package/package.json +1 -1
- package/public/assets/index-BKQtrd-0.css +1 -0
- package/public/assets/index-H2_fgmAt.js +30 -0
- package/public/assets/index-H2_fgmAt.js.map +1 -0
- package/public/assets/{shiki-CG8vVdKn.js → shiki-DcpfEu1c.js} +2 -2
- package/public/assets/{shiki-CG8vVdKn.js.map → shiki-DcpfEu1c.js.map} +1 -1
- package/public/index.html +2 -2
- package/public/assets/index-B_XaPTQR.css +0 -1
- package/public/assets/index-Y_KYu5nW.js +0 -30
- package/public/assets/index-Y_KYu5nW.js.map +0 -1
package/dist/agent/agent.js
CHANGED
|
@@ -346,10 +346,23 @@ export class Agent {
|
|
|
346
346
|
return this.manualCompactBudget;
|
|
347
347
|
}
|
|
348
348
|
/**
|
|
349
|
-
*
|
|
350
|
-
*
|
|
351
|
-
*
|
|
349
|
+
* Is this agent doing work that `stop()` would actually stop?
|
|
350
|
+
*
|
|
351
|
+
* `status` cannot answer this on its own. Three states count as live:
|
|
352
|
+
* - `running`: the obvious one;
|
|
353
|
+
* - a PARKED TOOL: `parkForTool()` flips the visible status to "idle" so a
|
|
354
|
+
* multi-minute `wait_children` doesn't look like a running spinner, but
|
|
355
|
+
* the loop is very much alive inside the tool;
|
|
356
|
+
* - QUEUED PROMPTS: prompts handed to a stopped agent are accepted and held,
|
|
357
|
+
* and a later `start()` will consume them.
|
|
358
|
+
*
|
|
359
|
+
* Anything keying "should this button stop or start?" off `status` alone
|
|
360
|
+
* therefore offers **start** on a working agent, and the user cannot stop it
|
|
361
|
+
* (#59) — the same trap #38 documented for editing history.
|
|
352
362
|
*/
|
|
363
|
+
isLive() {
|
|
364
|
+
return this.status === "running" || this.parkedByTool || this.pendingPrompts.length > 0;
|
|
365
|
+
}
|
|
353
366
|
/**
|
|
354
367
|
* A background shell (bash background=true) finished. Two channels:
|
|
355
368
|
* 1. a system_note row in the timeline — the operator sees the outcome
|
|
@@ -614,6 +627,15 @@ export class Agent {
|
|
|
614
627
|
if (msgs.length > 0) {
|
|
615
628
|
this.messages = msgs;
|
|
616
629
|
this.currentBranch = last.branch;
|
|
630
|
+
// #50: re-apply pruning to the restored history. `maybePrune` clips
|
|
631
|
+
// oversized tool output IN PLACE, so a live session keeps its clipped
|
|
632
|
+
// text while the log still holds the full result — a restart therefore
|
|
633
|
+
// handed the provider a strictly LARGER prompt than the one that had just
|
|
634
|
+
// succeeded, which is a size regression that only shows up after a
|
|
635
|
+
// reload. It is a size fix, not a semantic one: the model has already
|
|
636
|
+
// seen and acted on that output, and the pruned form keeps the head plus
|
|
637
|
+
// a marker, exactly as it did live.
|
|
638
|
+
this.maybePrune();
|
|
617
639
|
await this.log.append("system_note", this.currentSession, this.currentBranch, {
|
|
618
640
|
event: "session-restored",
|
|
619
641
|
branch: last.branch,
|
|
@@ -876,6 +898,17 @@ export class Agent {
|
|
|
876
898
|
id: this.opts.id,
|
|
877
899
|
status: this.status,
|
|
878
900
|
statusReason: this.statusReason,
|
|
901
|
+
/**
|
|
902
|
+
* Is there work in flight that `stop` would actually stop?
|
|
903
|
+
*
|
|
904
|
+
* `status` alone is not enough to answer that, and the UI needs it: a
|
|
905
|
+
* parked tool (`wait_children`) rewrites the status to "idle" while the
|
|
906
|
+
* loop is very much alive, so a client keying "stop vs start" off
|
|
907
|
+
* `status === "running"` offered **start** on a running agent and the
|
|
908
|
+
* user could not stop it (#59). This is the same "running is not the
|
|
909
|
+
* only live state" fact #38 had to be taught about editing.
|
|
910
|
+
*/
|
|
911
|
+
live: this.isLive(),
|
|
879
912
|
workspace: this.workspace,
|
|
880
913
|
workspaceMissing: this.workspaceMissing,
|
|
881
914
|
session: this.currentSession,
|
|
@@ -1864,18 +1897,37 @@ export class Agent {
|
|
|
1864
1897
|
const text = String(res.message.content ?? "");
|
|
1865
1898
|
const approved = /^APPROVED\b/i.test(text.trim());
|
|
1866
1899
|
const feedback = text.replace(/^(APPROVED|CHANGES-REQUIRED)\s*[:—-]?\s*/i, "").trim();
|
|
1867
|
-
|
|
1900
|
+
// #51: storing the literal "(no detail)" made the UI draw a card
|
|
1901
|
+
// whose whole content was the absence of a reason, and that same
|
|
1902
|
+
// string landed in goal.md for the right panel. An empty string is
|
|
1903
|
+
// the honest value: the auditor returned a verdict and no reason,
|
|
1904
|
+
// which every consumer can already render as "label only".
|
|
1905
|
+
await this.setGoalAudit(approved ? "approved" : "changes-required", feedback);
|
|
1906
|
+
// `operatorFacing: true` — the verdict is written for the TIMELINE
|
|
1907
|
+
// only. The audit ran in its own side conversation (a tools-less call
|
|
1908
|
+
// over buildMessages() + an audit prompt), so it was never in
|
|
1909
|
+
// `this.messages` and must not come back through a restore: doing so
|
|
1910
|
+
// injected an assistant turn that answers nothing into the model's
|
|
1911
|
+
// context, which providers answer with a 200 and no choices (#50).
|
|
1912
|
+
// The rejection feedback reaches the agent as a real prompt below.
|
|
1868
1913
|
await this.log.append("message", this.currentSession, this.currentBranch, {
|
|
1869
1914
|
role: "assistant",
|
|
1915
|
+
operatorFacing: true,
|
|
1870
1916
|
content: approved
|
|
1871
1917
|
? `✅ completion audit: APPROVED — ${feedback}`
|
|
1872
1918
|
: `🔍 completion audit: CHANGES REQUIRED — ${feedback}`,
|
|
1873
1919
|
});
|
|
1874
1920
|
if (!approved) {
|
|
1875
|
-
// hand the gaps back to the worker as its next instruction
|
|
1921
|
+
// hand the gaps back to the worker as its next instruction.
|
|
1922
|
+
// #51: with the "(no detail)" placeholder gone, an empty verdict
|
|
1923
|
+
// would leave this prompt saying "address these gaps" and then
|
|
1924
|
+
// naming none — so restate the contract as the thing to re-check.
|
|
1925
|
+
const gaps = feedback.trim()
|
|
1926
|
+
? feedback.slice(0, 2000)
|
|
1927
|
+
: `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)}`;
|
|
1876
1928
|
this.pendingPrompts.push({
|
|
1877
1929
|
source: "harness",
|
|
1878
|
-
text: `[harness] The completion audit REJECTED this finish. Address these gaps, then finish again with goalComplete=true:\n\n${
|
|
1930
|
+
text: `[harness] The completion audit REJECTED this finish. Address these gaps, then finish again with goalComplete=true:\n\n${gaps}`,
|
|
1879
1931
|
});
|
|
1880
1932
|
}
|
|
1881
1933
|
}
|
|
@@ -2152,6 +2204,20 @@ export class Agent {
|
|
|
2152
2204
|
phase: "summarizing",
|
|
2153
2205
|
summarized: oldCount,
|
|
2154
2206
|
});
|
|
2207
|
+
// #56: the START belongs on the timeline too. Only the right panel showed
|
|
2208
|
+
// compaction, and it reports the *outcome*; a long summarize (hundreds of
|
|
2209
|
+
// thousands of tokens) looks exactly like a hung agent on the timeline,
|
|
2210
|
+
// because the first `context-compacted` event is only appended once the
|
|
2211
|
+
// whole pass is over. Log the announcement as a visible system_note so
|
|
2212
|
+
// the operator sees WHERE the conversation was rewritten, even if the
|
|
2213
|
+
// pass later fails — `before`/`oldCount` are known at this point, and the
|
|
2214
|
+
// completion divider still carries the authoritative after-figures.
|
|
2215
|
+
await this.log.append("system_note", this.currentSession, this.currentBranch, {
|
|
2216
|
+
event: "context-compaction-started",
|
|
2217
|
+
tokensBefore: before,
|
|
2218
|
+
messages: oldCount,
|
|
2219
|
+
reason: force ? "manual" : "auto",
|
|
2220
|
+
});
|
|
2155
2221
|
this.compactPhase = "summarizing";
|
|
2156
2222
|
try {
|
|
2157
2223
|
summary = await this.summarize(old, (text) => {
|
|
@@ -2686,13 +2752,28 @@ function rebuildMessagesFrom(list) {
|
|
|
2686
2752
|
msgs.push(mk());
|
|
2687
2753
|
}
|
|
2688
2754
|
else if (e.type === "message") {
|
|
2689
|
-
//
|
|
2690
|
-
// them in
|
|
2691
|
-
|
|
2755
|
+
// Operator-facing rows are TIMELINE content, not conversation: the live
|
|
2756
|
+
// loop never put them in `messages`, so a restore must not either.
|
|
2757
|
+
// `final` is the finish() summary; `operatorFacing` covers harness notes
|
|
2758
|
+
// such as the completion audit verdict. Replaying either produced a
|
|
2759
|
+
// history shape no live run emits, and a provider given that answers
|
|
2760
|
+
// 200 with zero choices (#50).
|
|
2761
|
+
if (d.final === true || d.operatorFacing === true)
|
|
2692
2762
|
continue;
|
|
2693
2763
|
const role = d.role === "assistant" ? "assistant" : "user";
|
|
2694
|
-
const
|
|
2695
|
-
|
|
2764
|
+
const content = typeof d.content === "string" ? d.content : "";
|
|
2765
|
+
const hasCalls = Array.isArray(d.toolCalls) && d.toolCalls.length > 0;
|
|
2766
|
+
// A blank assistant turn that issued NO tool calls is an artefact of a
|
|
2767
|
+
// kill mid-stream: the model streamed nothing, so the live run never had
|
|
2768
|
+
// this turn, yet the restore replays it as a real utterance. sanitize()
|
|
2769
|
+
// then rewrites blank assistant content to the literal "(no content)",
|
|
2770
|
+
// so the agent is told it previously said a sentence it never said — a
|
|
2771
|
+
// history shape no live run produces (#50). A blank turn that DID call
|
|
2772
|
+
// tools is legitimate and must stay, or its results are orphaned.
|
|
2773
|
+
if (role === "assistant" && !content && !hasCalls)
|
|
2774
|
+
continue;
|
|
2775
|
+
const m = { role, content };
|
|
2776
|
+
if (hasCalls) {
|
|
2696
2777
|
m.tool_calls = d.toolCalls.map((c) => ({
|
|
2697
2778
|
id: c.id,
|
|
2698
2779
|
type: "function",
|
package/dist/agent/llm.js
CHANGED
|
@@ -128,6 +128,75 @@ function sanitize(messages) {
|
|
|
128
128
|
return { ...m };
|
|
129
129
|
});
|
|
130
130
|
}
|
|
131
|
+
/**
|
|
132
|
+
* Why did the response carry no usable message?
|
|
133
|
+
*
|
|
134
|
+
* #50 — this used to be a bare "LLM API returned no choices", which is what
|
|
135
|
+
* made the report undiagnosable: an empty `choices` array, a 200 carrying an
|
|
136
|
+
* `error` object, a `delta`-shaped body, and an SSE stream that the SDK
|
|
137
|
+
* handed back as a plain String all produce the identical line, and none of
|
|
138
|
+
* them is a useful thing to read in a log.
|
|
139
|
+
*
|
|
140
|
+
* The leading text is kept stable at "LLM API returned no choices" so any
|
|
141
|
+
* existing matching (and the retry notes operators have learned to read)
|
|
142
|
+
* still works; the diagnosis follows after a colon.
|
|
143
|
+
*/
|
|
144
|
+
function noChoicesDetail(res) {
|
|
145
|
+
const base = "LLM API returned no choices";
|
|
146
|
+
// a gateway that answers 200 with an error object instead of failing: the
|
|
147
|
+
// actionable message is right there and used to be thrown away
|
|
148
|
+
const err = res?.error;
|
|
149
|
+
if (err && typeof err === "object") {
|
|
150
|
+
const msg = typeof err.message === "string" ? err.message : JSON.stringify(err);
|
|
151
|
+
const code = err.code ?? err.type;
|
|
152
|
+
return `${base}: provider returned an error in a 200 response${code ? ` (${String(code)})` : ""} — ${String(msg).slice(0, 300)}`;
|
|
153
|
+
}
|
|
154
|
+
// the SDK returned raw text — e.g. an SSE body served to a non-streaming
|
|
155
|
+
// request. JSON.parse succeeds for a real object, so a failure here means we
|
|
156
|
+
// were handed something that is not a completion at all.
|
|
157
|
+
if (typeof res === "string") {
|
|
158
|
+
const head = res.slice(0, 160).replace(/\s+/g, " ").trim();
|
|
159
|
+
return `${base}: response was not a completion object (${res.length} bytes of ${res.trimStart().startsWith("data:") || res.trimStart().startsWith("event:") ? "SSE" : "text"})${head ? ` — ${head}` : ""}`;
|
|
160
|
+
}
|
|
161
|
+
const choices = res?.choices;
|
|
162
|
+
if (Array.isArray(choices)) {
|
|
163
|
+
const first = choices[0];
|
|
164
|
+
if (!first)
|
|
165
|
+
return `${base}: the provider returned an empty choices array (0 candidates)`;
|
|
166
|
+
if (first.delta && !first.message) {
|
|
167
|
+
return `${base}: the provider returned a streaming delta on a non-streaming request (finish_reason=${String(first.finish_reason)})`;
|
|
168
|
+
}
|
|
169
|
+
if (first.message === null) {
|
|
170
|
+
return `${base}: the provider returned a choice with a null message (finish_reason=${String(first.finish_reason)})`;
|
|
171
|
+
}
|
|
172
|
+
return `${base}: the provider returned a choice with no message field (finish_reason=${String(first.finish_reason)})`;
|
|
173
|
+
}
|
|
174
|
+
return `${base}: the response had no choices array (${safeShape(res)})`;
|
|
175
|
+
}
|
|
176
|
+
/** a short type summary of an unexpected response, for the log line */
|
|
177
|
+
function safeShape(res) {
|
|
178
|
+
try {
|
|
179
|
+
const o = res;
|
|
180
|
+
if (!o || typeof o !== "object")
|
|
181
|
+
return `got ${typeof res}`;
|
|
182
|
+
return `keys: ${Object.keys(o).slice(0, 8).join(", ") || "(none)"}`;
|
|
183
|
+
}
|
|
184
|
+
catch {
|
|
185
|
+
return "unreadable response";
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
/**
|
|
189
|
+
* HTTP statuses that mean "this request shape is not supported here", so
|
|
190
|
+
* re-issuing it without `stream: true` is a genuinely different request.
|
|
191
|
+
*
|
|
192
|
+
* Deliberately an allow-list rather than the whole 4xx range. 408/425/429 are
|
|
193
|
+
* TRANSIENT: replaying them immediately against an already-saturated provider
|
|
194
|
+
* just doubles the request count without changing anything. 401/403 mean the
|
|
195
|
+
* credentials are wrong, which is not fixed by dropping `stream` either — they
|
|
196
|
+
* are excluded on purpose, since a fallback there would replace one honest
|
|
197
|
+
* auth error with the same one and a wasted round trip.
|
|
198
|
+
*/
|
|
199
|
+
const STREAM_REJECTED_STATUS = new Set([400, 404, 405, 406, 415, 422, 501]);
|
|
131
200
|
export async function chat(cfg, messages, tools, signal, onDelta) {
|
|
132
201
|
try {
|
|
133
202
|
const res = await client(cfg).chat.completions.create({
|
|
@@ -136,10 +205,14 @@ export async function chat(cfg, messages, tools, signal, onDelta) {
|
|
|
136
205
|
...(tools.length ? { tools } : {}),
|
|
137
206
|
...effortForRequest(cfg),
|
|
138
207
|
}, { signal });
|
|
139
|
-
|
|
208
|
+
// #50: a gateway may answer with `null` (or any non-object) on some proxy
|
|
209
|
+
// error paths, so the guard is on `rm`, not on `res` being truthy —
|
|
210
|
+
// reading `res.choices` unguarded threw a bare TypeError that named neither
|
|
211
|
+
// the provider nor the shape.
|
|
212
|
+
const raw = res?.choices?.[0];
|
|
140
213
|
const rm = raw?.message;
|
|
141
214
|
if (!rm)
|
|
142
|
-
throw new Error(
|
|
215
|
+
throw new Error(noChoicesDetail(res));
|
|
143
216
|
// normalize: providers attach extra fields (reasoning, refusal, ...) and
|
|
144
217
|
// nullable content — keep only what our protocol understands
|
|
145
218
|
const message = {
|
|
@@ -198,6 +271,13 @@ export async function chat(cfg, messages, tools, signal, onDelta) {
|
|
|
198
271
|
}
|
|
199
272
|
catch (err) {
|
|
200
273
|
const e = err;
|
|
274
|
+
// #50: our own shape-diagnosis already reads as a full sentence and names
|
|
275
|
+
// the response it saw. It carries no HTTP status (the request SUCCEEDED —
|
|
276
|
+
// that is the whole problem), so the generic wrapper below would have
|
|
277
|
+
// relabelled it "LLM API error ?: …", hiding both the diagnosis and the
|
|
278
|
+
// fact that no status was ever involved.
|
|
279
|
+
if (String(err.message ?? "").startsWith("LLM API returned no choices"))
|
|
280
|
+
throw err;
|
|
201
281
|
if (e.status === undefined && !String(err.message).includes("provider returned"))
|
|
202
282
|
throw err; // not an API error (abort, bug, ...)
|
|
203
283
|
const detail = e.error?.message ?? e.message ?? "unknown provider error";
|
|
@@ -284,14 +364,58 @@ export async function chatStream(cfg, messages, tools, signal, onDelta) {
|
|
|
284
364
|
function: { name: c.name, arguments: c.args || "{}" },
|
|
285
365
|
}];
|
|
286
366
|
});
|
|
287
|
-
if (!message.content && !message.tool_calls)
|
|
288
|
-
|
|
367
|
+
if (!message.content && !message.tool_calls) {
|
|
368
|
+
// #50: a stream that opened, produced NOTHING, and closed cleanly. The
|
|
369
|
+
// throw carries a marker so the handler below can tell this apart from a
|
|
370
|
+
// request the provider actively rejected: this one is a broken/flaky
|
|
371
|
+
// proxy, and re-issuing it without `stream` is a genuinely different
|
|
372
|
+
// request that usually succeeds. Throwing a bare Error would make the two
|
|
373
|
+
// indistinguishable and cost us that safety net.
|
|
374
|
+
const e = new Error("LLM API error: empty completion");
|
|
375
|
+
e.emptyStream = true;
|
|
376
|
+
throw e;
|
|
377
|
+
}
|
|
289
378
|
return { message, reasoning: reasoning || undefined, usage };
|
|
290
379
|
}
|
|
291
380
|
catch (err) {
|
|
292
|
-
// provider
|
|
293
|
-
|
|
381
|
+
// #50 — a provider that doesn't support streaming at all still gets ONE
|
|
382
|
+
// clean fallback, but ONLY when the streaming attempt produced nothing for
|
|
383
|
+
// a reason that could plausibly be the stream itself.
|
|
384
|
+
//
|
|
385
|
+
// The old condition was `!gotChunk && !signal?.aborted`, which also matched
|
|
386
|
+
// a request that FAILED — a gateway answering 200 with an SSE `error`
|
|
387
|
+
// frame, or a context-length rejection. In those cases the fallback
|
|
388
|
+
// re-issued the same oversized request in a different shape, and the
|
|
389
|
+
// resulting "LLM API returned no choices" replaced the real diagnosis.
|
|
390
|
+
// That is why a restored 1500-message session reported "no choices" three
|
|
391
|
+
// times and then errored: the genuine "context length exceeded" was
|
|
392
|
+
// discarded on the first attempt and could never be recognised by
|
|
393
|
+
// isContextOverflow(), so the compact-and-retry recovery never ran.
|
|
394
|
+
//
|
|
395
|
+
// Two exclusions, and the second one matters as much as the first:
|
|
396
|
+
//
|
|
397
|
+
// 1. A status that means the REQUEST ITSELF was rejected. Only then is
|
|
398
|
+
// re-issuing it without `stream` a different request rather than the
|
|
399
|
+
// same failing one. `400`/`404`/`405`/`422` are the shapes a provider
|
|
400
|
+
// uses to say "no streaming here" — deliberately NOT the whole 4xx
|
|
401
|
+
// range, because 408/425/429 are transient: replaying those against an
|
|
402
|
+
// already-saturated provider just doubles the request count.
|
|
403
|
+
// 2. A THROWN error, as opposed to a request that simply produced no
|
|
404
|
+
// chunks. A 200 that streams zero chunks and closes cleanly is a
|
|
405
|
+
// broken/flaky proxy, and that is precisely the case the non-streaming
|
|
406
|
+
// retry rescues. Requiring a 4xx here would have deleted that safety
|
|
407
|
+
// net — a regression this file's own tests caught.
|
|
408
|
+
//
|
|
409
|
+
// So the fallback runs when the request was rejected outright, OR when it
|
|
410
|
+
// completed without error and without a single chunk. Everything else —
|
|
411
|
+
// 5xx, an error frame, a mid-stream failure — is the ANSWER, rethrown
|
|
412
|
+
// untouched so overflow recovery can see it.
|
|
413
|
+
const status = err?.status;
|
|
414
|
+
const rejectedByProvider = typeof status === "number" && STREAM_REJECTED_STATUS.has(status);
|
|
415
|
+
const emptyStream = err?.emptyStream === true;
|
|
416
|
+
if (!gotChunk && !signal?.aborted && (rejectedByProvider || emptyStream)) {
|
|
294
417
|
return chat(cfg, messages, tools, signal, onDelta);
|
|
418
|
+
}
|
|
295
419
|
// user interrupt: hand back whatever streamed so far so the harness can
|
|
296
420
|
// keep the partial output visible instead of losing it
|
|
297
421
|
if (signal?.aborted && (text || reasoning)) {
|
package/dist/agent/tools.js
CHANGED
|
@@ -182,7 +182,13 @@ function startBackgroundShell(cmd, ctx) {
|
|
|
182
182
|
* Wording varies by provider (OpenRouter/OpenAI/Anthropic/local servers). */
|
|
183
183
|
export function isContextOverflow(err) {
|
|
184
184
|
const msg = String(err?.message ?? "").toLowerCase();
|
|
185
|
-
return (/context (length|window|limit)|too many tokens|prompt is too long|max.{0,12}tokens.{0,20}(exceed|surpass)|request too large|payload too large|exceeds.{0,20}limit/.test(msg) ||
|
|
185
|
+
return (/context (length|window|limit)|too many tokens|prompt is too long|max.{0,12}tokens.{0,20}(exceed|surpass)|request too large|payload too large|exceeds.{0,20}limit/.test(msg) ||
|
|
186
|
+
/\b(400|413)\b.*token/.test(msg) ||
|
|
187
|
+
// #50: some gateways answer an over-long request with HTTP 200 and put the
|
|
188
|
+
// real diagnosis in the body, which llm.ts now surfaces after the "no
|
|
189
|
+
// choices" prefix. Without this the compact-and-retry recovery never
|
|
190
|
+
// recognised it and the turn simply failed.
|
|
191
|
+
/no choices:[\s\S]{0,300}context (length|window|limit)/.test(msg));
|
|
186
192
|
}
|
|
187
193
|
/** Any background shell still running for this workspace? The auto-continue
|
|
188
194
|
* loop consults this before nudging a finished round: an agent that parked
|
|
@@ -709,10 +715,43 @@ export function hashLine(content) {
|
|
|
709
715
|
}
|
|
710
716
|
/** `LINE:HASH| content` for each line, 1-indexed */
|
|
711
717
|
export function renderHashlines(lines, startLine = 1) {
|
|
712
|
-
|
|
718
|
+
// same trailing-newline rule as renderGutter (#7): a file ending in "\n"
|
|
719
|
+
// splits into a final "" that is not a line, and an anchor for it is a lie
|
|
720
|
+
// the model can copy into an edit that then never verifies
|
|
721
|
+
const body = lines.length && lines[lines.length - 1] === "" ? lines.slice(0, -1) : lines;
|
|
722
|
+
return body
|
|
713
723
|
.map((l, i) => `${startLine + i}:${hashLine(l)}| ${l}`)
|
|
714
724
|
.join("\n");
|
|
715
725
|
}
|
|
726
|
+
/**
|
|
727
|
+
* Width of the `N| ` gutter for a page, so every line in it lines up.
|
|
728
|
+
*
|
|
729
|
+
* #7: the prefix had no padding, so the gutter silently widened at line 1000
|
|
730
|
+
* and the whole block jumped sideways mid-file — the exact "unfriendly line
|
|
731
|
+
* numbers" complaint. Padding to the widest number IN THE PAGE is enough: a
|
|
732
|
+
* page is a bounded window, and aligning to the file's total length would
|
|
733
|
+
* push every line of a 20-line read of a 500k-line file 6 characters right.
|
|
734
|
+
*/
|
|
735
|
+
export function gutterWidth(startLine, count) {
|
|
736
|
+
return String(Math.max(0, startLine - 1) + count).length;
|
|
737
|
+
}
|
|
738
|
+
/**
|
|
739
|
+
* The default `N| ` display, with a stable gutter width and no phantom line.
|
|
740
|
+
*
|
|
741
|
+
* #7, second half: the prefix was added AFTER `text.split("\n")`, so a file
|
|
742
|
+
* ending in a newline produced a final numbered row for a line that does not
|
|
743
|
+
* exist. A model that copies the tail verbatim then hands `edit_file` an
|
|
744
|
+
* old_text with a phantom line in it, and the edit fails for no visible reason.
|
|
745
|
+
* A trailing empty element is dropped, which is what the reader means.
|
|
746
|
+
*/
|
|
747
|
+
export function renderGutter(lines, startLine = 1) {
|
|
748
|
+
// a trailing newline splits into a final "" — not a line of the file
|
|
749
|
+
const body = lines.length && lines[lines.length - 1] === "" ? lines.slice(0, -1) : lines;
|
|
750
|
+
if (!body.length)
|
|
751
|
+
return "";
|
|
752
|
+
const w = gutterWidth(startLine, body.length);
|
|
753
|
+
return body.map((l, i) => `${String(startLine + i).padStart(w)}| ${l}`).join("\n");
|
|
754
|
+
}
|
|
716
755
|
/** parse a `LINE:HASH` anchor; returns null when the text is not an anchor */
|
|
717
756
|
export function parseAnchor(anchor) {
|
|
718
757
|
const m = /^\s*(\d+):([0-9a-z]{4,8}| {4})\s*$/.exec(anchor);
|
|
@@ -913,7 +952,7 @@ export const TOOLS = [
|
|
|
913
952
|
}
|
|
914
953
|
const parts = regions.map(([s, e]) => idMode === "hash"
|
|
915
954
|
? renderHashlines(lines.slice(s, e + 1), s + 1)
|
|
916
|
-
: lines.slice(s, e + 1)
|
|
955
|
+
: renderGutter(lines.slice(s, e + 1), s + 1));
|
|
917
956
|
let result = parts.join("\n--\n");
|
|
918
957
|
if (idxs.length > page.length || off > 0)
|
|
919
958
|
result += `\n(${off + 1}–${off + page.length} of ${idxs.length} matches)`;
|
|
@@ -924,8 +963,11 @@ export const TOOLS = [
|
|
|
924
963
|
off = off < 0 ? Math.max(0, lines.length + off) : Math.max(0, off - 1);
|
|
925
964
|
const lim = num(args.limit, 2000);
|
|
926
965
|
const pageLines = lines.slice(off, off + lim);
|
|
927
|
-
const slice = idMode === "hash" ? renderHashlines(pageLines, off + 1) : pageLines
|
|
928
|
-
|
|
966
|
+
const slice = idMode === "hash" ? renderHashlines(pageLines, off + 1) : renderGutter(pageLines, off + 1);
|
|
967
|
+
// the phantom trailing line is dropped for the count too, or a file that
|
|
968
|
+
// ends in a newline always reports one line more than it has
|
|
969
|
+
const total = lines.length && lines[lines.length - 1] === "" ? lines.length - 1 : lines.length;
|
|
970
|
+
const more = off + lim < total ? `\n... (${total - off - lim} more lines)` : "";
|
|
929
971
|
return { ok: true, result: slice + more };
|
|
930
972
|
},
|
|
931
973
|
},
|
|
@@ -1090,12 +1132,27 @@ export const TOOLS = [
|
|
|
1090
1132
|
// already proved which lines it means, so a block whose content occurs
|
|
1091
1133
|
// twice must still edit the anchored occurrence rather than fail the
|
|
1092
1134
|
// uniqueness check (#4).
|
|
1135
|
+
// The hashline path splits on "\n" to index by resolved line number, which
|
|
1136
|
+
// leaves a trailing "\r" on every line of a CRLF file. Rejoining with
|
|
1137
|
+
// "\n" then DROPS all of them, so a single edit silently rewrote a
|
|
1138
|
+
// Windows file's line endings: the replaced line arrived with the model's
|
|
1139
|
+
// "\n" while every untouched line kept its "\r\n". The file still parsed
|
|
1140
|
+
// and the edit "succeeded", so nothing reported it — but the next
|
|
1141
|
+
// checkout diff shows the whole file changed, and tools that normalise on
|
|
1142
|
+
// EOL (git, prettier, formatters) now rewrite it.
|
|
1143
|
+
//
|
|
1144
|
+
// So the EOL is detected ONCE from the untouched text and re-applied to
|
|
1145
|
+
// the replacement, which also keeps a CRLF file CRLF when the model sends
|
|
1146
|
+
// LF-separated new_text (the overwhelmingly common case — models never
|
|
1147
|
+
// emit \r).
|
|
1148
|
+
const eol = text.includes("\r\n") ? "\r\n" : "\n";
|
|
1093
1149
|
const out = hashSpan
|
|
1094
1150
|
? text
|
|
1095
1151
|
.split("\n")
|
|
1096
1152
|
.slice(0, hashSpan.start)
|
|
1097
|
-
.concat(newText.split("\n"), text.split("\n").slice(hashSpan.end))
|
|
1153
|
+
.concat(newText.replace(/\r\n/g, "\n").split("\n"), text.split("\n").slice(hashSpan.end))
|
|
1098
1154
|
.join("\n")
|
|
1155
|
+
.replace(/(?<!\r)\n/g, eol)
|
|
1099
1156
|
: replaceAll
|
|
1100
1157
|
? text.split(oldText).join(newText)
|
|
1101
1158
|
: text.replace(oldText, newText);
|
package/dist/log/events.js
CHANGED
|
@@ -156,3 +156,132 @@ export async function readEvents(filePath) {
|
|
|
156
156
|
return [];
|
|
157
157
|
}
|
|
158
158
|
}
|
|
159
|
+
/** Default floor for a tail read, so a small `limit` cannot cause a tiny read. */
|
|
160
|
+
const TAIL_MIN_BYTES = 64 * 1024;
|
|
161
|
+
/**
|
|
162
|
+
* Read only the LAST `limit` events from an append-only JSONL log.
|
|
163
|
+
*
|
|
164
|
+
* #59: the web UI polls this endpoint every ~120 ms for the selected agent, and
|
|
165
|
+
* the full re-parse cost 348 ms on a 57 MB / 200 000-event log. The mtime cache
|
|
166
|
+
* could not help — a RUNNING agent appends constantly, so `size` and `mtimeMs`
|
|
167
|
+
* change on every single request and the cache only ever helped stopped agents.
|
|
168
|
+
*
|
|
169
|
+
* The log is strictly append-only: `EventLog.append` is the only writer and
|
|
170
|
+
* always writes a whole `JSON.stringify(evt) + "\n"` under a serialized chain,
|
|
171
|
+
* so an append never rewrites earlier bytes. That makes a tail read safe, and
|
|
172
|
+
* makes byte-offset caching legitimate.
|
|
173
|
+
*
|
|
174
|
+
* Anchored to the tail deliberately. The read starts at a byte offset chosen for
|
|
175
|
+
* the file's current size, so the FIRST line it sees is usually a fragment —
|
|
176
|
+
* dropped. `bytesScanned` is reported so callers (and tests) can assert the read
|
|
177
|
+
* really was partial rather than trusting a wall-clock number.
|
|
178
|
+
*/
|
|
179
|
+
export async function readEventsTail(filePath, limit) {
|
|
180
|
+
const empty = { events: [], total: 0, bytesScanned: 0, truncated: false };
|
|
181
|
+
if (limit <= 0)
|
|
182
|
+
return empty;
|
|
183
|
+
let fh = null;
|
|
184
|
+
try {
|
|
185
|
+
const fsp = await import("node:fs/promises");
|
|
186
|
+
fh = await fsp.open(filePath, "r");
|
|
187
|
+
const st = await fh.stat();
|
|
188
|
+
if (st.size === 0)
|
|
189
|
+
return empty;
|
|
190
|
+
// grow the window until it holds `limit` whole lines. Start at a floor so a
|
|
191
|
+
// small limit still reads a sensible block, then double — bounded by the
|
|
192
|
+
// file size, because a pathological file must not make us read forever.
|
|
193
|
+
let want = Math.min(st.size, Math.max(TAIL_MIN_BYTES, limit * 512));
|
|
194
|
+
for (;;) {
|
|
195
|
+
const r = await readWindow(fh, st.size, want);
|
|
196
|
+
if (r.events.length >= limit || want >= st.size) {
|
|
197
|
+
return {
|
|
198
|
+
events: r.events.slice(-limit),
|
|
199
|
+
// `total` is an APPROXIMATE count: only the window was parsed, so this
|
|
200
|
+
// is "at least this many". /events uses it for the "load older" hint,
|
|
201
|
+
// where approximate is fine — an exact count would mean reading the
|
|
202
|
+
// entire file, which is the cost this function exists to avoid.
|
|
203
|
+
total: r.events.length,
|
|
204
|
+
bytesScanned: r.bytes,
|
|
205
|
+
truncated: r.bytes < st.size,
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
want = Math.min(st.size, want * 4);
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
catch {
|
|
212
|
+
return empty;
|
|
213
|
+
}
|
|
214
|
+
finally {
|
|
215
|
+
await fh?.close().catch(() => { });
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
/** read the last `bytes` of the file, dropping the leading partial line */
|
|
219
|
+
async function readWindow(fh, size, bytes) {
|
|
220
|
+
const start = Math.max(0, size - bytes);
|
|
221
|
+
const buf = Buffer.alloc(size - start);
|
|
222
|
+
if (buf.length)
|
|
223
|
+
await fh.read(buf, 0, buf.length, start);
|
|
224
|
+
const text = buf.toString("utf8");
|
|
225
|
+
const nl = text.indexOf("\n");
|
|
226
|
+
// the first line is a fragment unless we started at 0
|
|
227
|
+
const body = start > 0 ? (nl === -1 ? "" : text.slice(nl + 1)) : text;
|
|
228
|
+
const events = [];
|
|
229
|
+
for (const line of body.split("\n")) {
|
|
230
|
+
if (!line.trim())
|
|
231
|
+
continue;
|
|
232
|
+
try {
|
|
233
|
+
events.push(JSON.parse(line));
|
|
234
|
+
}
|
|
235
|
+
catch {
|
|
236
|
+
/* torn or corrupt line: skip it, never return a fragment */
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
return { events, bytes: buf.length };
|
|
240
|
+
}
|
|
241
|
+
/**
|
|
242
|
+
* Keep one branch's rows out of a timeline, optionally including the history it
|
|
243
|
+
* inherited before it forked.
|
|
244
|
+
*
|
|
245
|
+
* #38. A fork is a new timeline, but the agent's CONTEXT on it still includes
|
|
246
|
+
* everything before the fork point — `rebuildMessagesFrom` walks the parent
|
|
247
|
+
* chain across branch boundaries precisely so a restarted fork keeps its
|
|
248
|
+
* history. Filtering the view by strict equality therefore showed *less* than
|
|
249
|
+
* the model actually reasons over, which is why a filtered fork looked like
|
|
250
|
+
* data loss even though nothing was missing from the log.
|
|
251
|
+
*
|
|
252
|
+
* `includeInherited` walks the parent links out of the branch's own events and
|
|
253
|
+
* keeps everything they reach, so the view matches the model's context. Strict
|
|
254
|
+
* remains the default: a caller that genuinely wants only a branch's own rows
|
|
255
|
+
* (counting, auditing) should not be handed inherited history silently.
|
|
256
|
+
*
|
|
257
|
+
* Pure and exported so the rule can be tested without an HTTP server.
|
|
258
|
+
*/
|
|
259
|
+
export function filterByBranch(events, branch, includeInherited = false) {
|
|
260
|
+
if (!includeInherited)
|
|
261
|
+
return events.filter((e) => e.branch === branch);
|
|
262
|
+
const byId = new Map(events.map((e) => [e.id, e]));
|
|
263
|
+
// every event belonging to the branch, plus everything reachable by `parent`
|
|
264
|
+
const wanted = new Set();
|
|
265
|
+
// A WORKLIST, not a loop over the growing set: iterating `wanted` while
|
|
266
|
+
// adding to it inside the same pass re-visits nodes and, on a parent cycle,
|
|
267
|
+
// never terminates.
|
|
268
|
+
const queue = [];
|
|
269
|
+
for (const e of events) {
|
|
270
|
+
if (e.branch !== branch)
|
|
271
|
+
continue;
|
|
272
|
+
if (wanted.has(e.id))
|
|
273
|
+
continue;
|
|
274
|
+
wanted.add(e.id);
|
|
275
|
+
queue.push(e.id);
|
|
276
|
+
}
|
|
277
|
+
while (queue.length) {
|
|
278
|
+
const cur = byId.get(queue.pop());
|
|
279
|
+
// walk up through the fork boundary — a fork's parent is the last event of
|
|
280
|
+
// the SOURCE branch, and that branch's history is inherited too
|
|
281
|
+
if (cur?.parent && !wanted.has(cur.parent)) {
|
|
282
|
+
wanted.add(cur.parent);
|
|
283
|
+
queue.push(cur.parent);
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
return events.filter((e) => wanted.has(e.id));
|
|
287
|
+
}
|
package/dist/server/api.js
CHANGED
|
@@ -51,7 +51,7 @@ async function listModels(baseUrl, apiKey) {
|
|
|
51
51
|
}
|
|
52
52
|
import path from "node:path";
|
|
53
53
|
import { bus } from "../bus.js";
|
|
54
|
-
import { readEvents } from "../log/events.js";
|
|
54
|
+
import { readEvents, readEventsTail, filterByBranch } from "../log/events.js";
|
|
55
55
|
/** media categories the file tree can preview inline */
|
|
56
56
|
const IMAGE_EXT = new Set(["png", "jpg", "jpeg", "gif", "webp", "svg", "bmp", "ico", "avif"]);
|
|
57
57
|
const VIDEO_EXT = new Set(["mp4", "webm", "mov", "mkv", "avi", "m4v"]);
|
|
@@ -943,7 +943,14 @@ export function buildApp(master) {
|
|
|
943
943
|
const a = master.agents.get(c.req.param("id"));
|
|
944
944
|
if (!a)
|
|
945
945
|
return c.json({ error: "not found" }, 404);
|
|
946
|
-
|
|
946
|
+
// #54: the frontend pairs a tool_call with its tool_result by walking the
|
|
947
|
+
// events it holds, so a window that can contain a result WITHOUT its call
|
|
948
|
+
// strands the bash row at "waiting for output…" forever — the call scrolled
|
|
949
|
+
// out while the command was still running. 200 was small enough to hit this
|
|
950
|
+
// on any moderately long command. The default is now sized to hold a whole
|
|
951
|
+
// pair even when thousands of events land in between (a long stream logs one
|
|
952
|
+
// per delta); the server clamps hard, so a caller asking for more is cheap.
|
|
953
|
+
const limit = Math.min(Number(c.req.query("limit") ?? 2000), 20000);
|
|
947
954
|
let filePath = a.log.filePath;
|
|
948
955
|
// ?session=<internalId> reads ANY session owned by this agent (past
|
|
949
956
|
// incarnations too) — the internal id, not the agent id, is the stable
|
|
@@ -955,12 +962,22 @@ export function buildApp(master) {
|
|
|
955
962
|
return c.json({ error: "not found" }, 404);
|
|
956
963
|
filePath = path.join(rec.dir, "chat.jsonl");
|
|
957
964
|
}
|
|
958
|
-
|
|
965
|
+
// #59: the plain "latest N" request is what the UI polls every ~120 ms for
|
|
966
|
+
// a RUNNING agent, and it is the one that does NOT need history — it serves
|
|
967
|
+
// it from a tail read that scans ~2% of the file. `?before` (older pages)
|
|
968
|
+
// and `?branch` genuinely need the whole log, so they keep the full parse.
|
|
969
|
+
// `total` is a lower bound on the tail path (see readEventsTail) and is
|
|
970
|
+
// only used for the "load older" affordance, where approximate is fine.
|
|
959
971
|
const branch = c.req.query("branch");
|
|
972
|
+
const before = c.req.query("before");
|
|
973
|
+
if (!before && !branch) {
|
|
974
|
+
const tail = await readEventsTail(filePath, limit);
|
|
975
|
+
return c.json({ events: tail.events, total: tail.total, partial: tail.truncated });
|
|
976
|
+
}
|
|
977
|
+
let events = await readEventsCached(filePath);
|
|
960
978
|
if (branch)
|
|
961
|
-
events = events.
|
|
979
|
+
events = filterByBranch(events, branch, c.req.query("lineage") === "true");
|
|
962
980
|
// cursor pagination for older pages: everything strictly BEFORE this id
|
|
963
|
-
const before = c.req.query("before");
|
|
964
981
|
if (before) {
|
|
965
982
|
const idx = events.findIndex((e) => e.id === before);
|
|
966
983
|
events = idx === -1 ? [] : events.slice(0, idx);
|