teapot-coding-agent 0.26.8 → 0.26.10
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 +98 -12
- package/dist/server/api.js +21 -4
- package/package.json +1 -1
- package/public/assets/{index-DY4PCOSt.js → index-CReFV-5O.js} +20 -20
- package/public/assets/index-CReFV-5O.js.map +1 -0
- package/public/assets/{shiki-CL1ssh-2.js → shiki-BV9IzXRY.js} +2 -2
- package/public/assets/{shiki-CL1ssh-2.js.map → shiki-BV9IzXRY.js.map} +1 -1
- package/public/index.html +1 -1
- package/public/assets/index-DY4PCOSt.js.map +0 -1
package/dist/agent/agent.js
CHANGED
|
@@ -191,6 +191,13 @@ export class Agent {
|
|
|
191
191
|
consecutiveToolErrors = 0;
|
|
192
192
|
/** consecutive round-fatal API errors (drives the retry backoff) */
|
|
193
193
|
consecutiveApiErrors = 0;
|
|
194
|
+
/**
|
|
195
|
+
* Consecutive auto-continued rounds that did no work (#106).
|
|
196
|
+
*
|
|
197
|
+
* Reset by any tool call, any finish, a stop, or fresh operator input — only a
|
|
198
|
+
* round that produced neither a tool call nor a finish increments it.
|
|
199
|
+
*/
|
|
200
|
+
consecutiveIdleRounds = 0;
|
|
194
201
|
constructor(opts) {
|
|
195
202
|
this.opts = {
|
|
196
203
|
progressIntervalMs: 10 * 60_000,
|
|
@@ -205,6 +212,8 @@ export class Agent {
|
|
|
205
212
|
supportedParameters: [],
|
|
206
213
|
maxConsecutiveToolErrors: 5,
|
|
207
214
|
maxTurnsPerRound: 200,
|
|
215
|
+
// generous: only a model that never acts and never finishes reaches it
|
|
216
|
+
maxConsecutiveIdleRounds: 50,
|
|
208
217
|
onError: "stop",
|
|
209
218
|
retryDelayMs: 60_000,
|
|
210
219
|
contextTokenBudget: 96_000,
|
|
@@ -1145,16 +1154,30 @@ export class Agent {
|
|
|
1145
1154
|
// stable id shared by the log event and the UI's pending echo — the UI
|
|
1146
1155
|
// flips its echo to "sent" when prompt-delivered carries this id back
|
|
1147
1156
|
const promptId = source === "user" ? `u${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}` : "";
|
|
1157
|
+
// #108: the queue entry is registered SYNCHRONOUSLY, before the async work.
|
|
1158
|
+
//
|
|
1159
|
+
// It used to be pushed inside the `.then()`, so it only existed once
|
|
1160
|
+
// `ensureReady()` resolved — while `cancelPrompt` reads the array
|
|
1161
|
+
// synchronously. A cancel issued in that window found nothing, returned null,
|
|
1162
|
+
// got a 409, and the UI discarded the draft: the operator saw a withdrawn
|
|
1163
|
+
// prompt that the model then received and acted on.
|
|
1164
|
+
//
|
|
1165
|
+
// The wide case is worse than the same-tick one: an agent still restoring a
|
|
1166
|
+
// 4,000-event session log holds that promise for real wall-clock time, so the
|
|
1167
|
+
// cancel essentially always loses.
|
|
1168
|
+
//
|
|
1169
|
+
// Registering first is safe because the queue is drained only at a turn
|
|
1170
|
+
// boundary (`drainPendingPrompts`), which cannot run inside this
|
|
1171
|
+
// synchronous stretch. The log append still happens in order, after.
|
|
1172
|
+
const entry = { source, text, images, ...(promptId ? { id: promptId } : {}) };
|
|
1173
|
+
this.pendingPrompts.push(entry);
|
|
1148
1174
|
void this.ensureReady()
|
|
1149
|
-
.then(() => {
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
...(promptId ? { promptId } : {}),
|
|
1156
|
-
});
|
|
1157
|
-
})
|
|
1175
|
+
.then(() => this.log.append("prompt", this.currentSession, this.currentBranch, {
|
|
1176
|
+
source,
|
|
1177
|
+
text,
|
|
1178
|
+
...(images?.length ? { images: images.map((i) => ({ url: i.url, name: i.name })) } : {}),
|
|
1179
|
+
...(promptId ? { promptId } : {}),
|
|
1180
|
+
}))
|
|
1158
1181
|
.then(() => bus.emit("update", { kind: "agent-update", agentId: this.opts.id }))
|
|
1159
1182
|
.catch(() => { });
|
|
1160
1183
|
return promptId;
|
|
@@ -1426,6 +1449,11 @@ export class Agent {
|
|
|
1426
1449
|
finished = true; // it IS finished now — don't auto-nudge a reporter
|
|
1427
1450
|
}
|
|
1428
1451
|
}
|
|
1452
|
+
// #106: a round that did real work resets the idle counter. `finished`
|
|
1453
|
+
// covers the finish() paths; a round that ran tool calls is caught by the
|
|
1454
|
+
// turn counter below, which is checked before this point.
|
|
1455
|
+
if (finished)
|
|
1456
|
+
this.consecutiveIdleRounds = 0;
|
|
1429
1457
|
// auto-continue only makes sense with an active goal to continue toward
|
|
1430
1458
|
if (finished ||
|
|
1431
1459
|
!this.opts.autoContinue ||
|
|
@@ -1437,6 +1465,38 @@ export class Agent {
|
|
|
1437
1465
|
// "Continue working toward the current goal" made a forked sub-agent
|
|
1438
1466
|
// re-read its inherited context and drift into one of the parent's
|
|
1439
1467
|
// older tasks — it would keep going forever without ever finish()ing.
|
|
1468
|
+
// #106: bound CONSECUTIVE auto-continued rounds.
|
|
1469
|
+
//
|
|
1470
|
+
// maxTurnsPerRound cannot bound this loop, and the reason is structural:
|
|
1471
|
+
// a ROOT agent that answers with a bare message ends its round after ONE
|
|
1472
|
+
// turn (`return finished` in runTurnsUntilIdle), so no single round ever
|
|
1473
|
+
// reaches the per-round cap. `finished` stays false, the goal stays
|
|
1474
|
+
// active, and auto-continue starts the next round — forever.
|
|
1475
|
+
//
|
|
1476
|
+
// Measured: maxTurnsPerRound=40 produced 997 turns and ZERO
|
|
1477
|
+
// `round-turn-cap` notes, because the cap is only ever evaluated once a
|
|
1478
|
+
// round ENDS, and these rounds end at turn 1. A tool-calling model does
|
|
1479
|
+
// trip it (8 turns, 1 note) — the gap is specific to the talk-only shape.
|
|
1480
|
+
//
|
|
1481
|
+
// So the bound belongs here, where the real loop is: consecutive rounds
|
|
1482
|
+
// that produced neither a finish() nor a tool call. Any tool call resets
|
|
1483
|
+
// it — that is real work, not stalling — and so does a stop, a fresh
|
|
1484
|
+
// user prompt, or an operator reply.
|
|
1485
|
+
//
|
|
1486
|
+
// This is a SAFETY VALVE, not a policy. It is deliberately generous
|
|
1487
|
+
// (default 50) because a long autonomous stretch that keeps calling tools
|
|
1488
|
+
// resets the counter on every turn and is never affected; only a model
|
|
1489
|
+
// that never acts and never finishes trips it.
|
|
1490
|
+
if (++this.consecutiveIdleRounds > this.opts.maxConsecutiveIdleRounds) {
|
|
1491
|
+
this.consecutiveIdleRounds = 0;
|
|
1492
|
+
await this.log.append("system_note", this.currentSession, this.currentBranch, {
|
|
1493
|
+
event: "auto-continue-bounded",
|
|
1494
|
+
rounds: this.opts.maxConsecutiveIdleRounds,
|
|
1495
|
+
detail: "the agent produced no tool calls and no finish() across this many " +
|
|
1496
|
+
"consecutive rounds — stopping the loop rather than letting it spin (#106)",
|
|
1497
|
+
});
|
|
1498
|
+
break;
|
|
1499
|
+
}
|
|
1440
1500
|
await this.sleepInterruptible(this.opts.continueDelayMs);
|
|
1441
1501
|
if (this.stopRequested)
|
|
1442
1502
|
break;
|
|
@@ -1764,7 +1824,18 @@ export class Agent {
|
|
|
1764
1824
|
// answer the tool_call so a follow-up round stays API-valid
|
|
1765
1825
|
await this.answerMeta(call, this.goal.status === "done" ? "(goal complete)" : "(round ended)");
|
|
1766
1826
|
finished = true;
|
|
1767
|
-
continue
|
|
1827
|
+
// #105: `continue` advanced the TOOL-CALL loop, so every later call in
|
|
1828
|
+
// this same assistant message still executed — a model emitting
|
|
1829
|
+
// finish() plus one more call got real filesystem writes AFTER it had
|
|
1830
|
+
// reported `final: true` and the goal was already done. The parent was
|
|
1831
|
+
// told the work finished while the workspace was still being mutated.
|
|
1832
|
+
//
|
|
1833
|
+
// So finish ends the BATCH: answer anything still outstanding (so no
|
|
1834
|
+
// tool_call is left unpaired — the provider rejects the next request
|
|
1835
|
+
// otherwise, #76) and leave the tool loop. The outer loop then returns
|
|
1836
|
+
// on `finished`.
|
|
1837
|
+
await this.answerUnansweredToolCalls(m);
|
|
1838
|
+
return true;
|
|
1768
1839
|
}
|
|
1769
1840
|
if (call.function.name === "report_progress") {
|
|
1770
1841
|
await this.recordProgress(call.function.arguments);
|
|
@@ -1981,14 +2052,29 @@ export class Agent {
|
|
|
1981
2052
|
result = { ok: false, result: `tool could not run: ${err.message}` };
|
|
1982
2053
|
}
|
|
1983
2054
|
this.stats.toolCalls++;
|
|
2055
|
+
// #106: a tool call is real work, so it clears the idle-round counter.
|
|
2056
|
+
this.consecutiveIdleRounds = 0;
|
|
2057
|
+
// #109: ONE clip, applied to BOTH the log and the live message.
|
|
2058
|
+
//
|
|
2059
|
+
// The log stored `result.slice(0, 8000)` while `messages` kept the full
|
|
2060
|
+
// text, and a restore rebuilds `messages` from the log — so a model
|
|
2061
|
+
// resuming work saw a different (and much smaller) tool result than it
|
|
2062
|
+
// had just seen live. Measured: 8000 bytes logged, 436 after restart.
|
|
2063
|
+
//
|
|
2064
|
+
// The 8000-byte log clip is deliberate — it bounds the log file — so the
|
|
2065
|
+
// fix is not to log everything. It is to clip the live message the SAME
|
|
2066
|
+
// way, so the two views agree and a restart is not a silent downgrade.
|
|
2067
|
+
// Restore is byte-exact everywhere else (`answerMeta` preserves its exact
|
|
2068
|
+
// content); this was the one place the replay diverged.
|
|
2069
|
+
const capped = result.result.slice(0, 8000);
|
|
1984
2070
|
await this.log.append("tool_result", this.currentSession, this.currentBranch, {
|
|
1985
2071
|
callId: call.id,
|
|
1986
2072
|
name: call.function.name,
|
|
1987
2073
|
ok: result.ok,
|
|
1988
2074
|
durationMs: Date.now() - t0,
|
|
1989
|
-
result:
|
|
2075
|
+
result: capped,
|
|
1990
2076
|
});
|
|
1991
|
-
this.messages.push({ role: "tool", tool_call_id: call.id, content:
|
|
2077
|
+
this.messages.push({ role: "tool", tool_call_id: call.id, content: capped });
|
|
1992
2078
|
this.consecutiveToolErrors = result.ok ? 0 : this.consecutiveToolErrors + 1;
|
|
1993
2079
|
if (this.consecutiveToolErrors >= this.opts.maxConsecutiveToolErrors) {
|
|
1994
2080
|
// structured, greppable AND visible: the note carries what failed so
|
package/dist/server/api.js
CHANGED
|
@@ -368,7 +368,15 @@ export function buildApp(master) {
|
|
|
368
368
|
app.get("/api/agents", (c) => c.json({ agents: [...master.agents.values()].map((a) => a.snapshot()) }));
|
|
369
369
|
// create + start an agent on an arbitrary directory
|
|
370
370
|
app.post("/api/agents", async (c) => {
|
|
371
|
-
|
|
371
|
+
// #110: `.catch(() => null)` like every other body read in this file. An
|
|
372
|
+
// unparseable body used to throw inside the handler and surface as a bare
|
|
373
|
+
// 500 — a client bug reported to the operator as a server fault, and it made
|
|
374
|
+
// the route's own `400 "workspace required"` unreachable.
|
|
375
|
+
const body = await c.req
|
|
376
|
+
.json()
|
|
377
|
+
.catch(() => null);
|
|
378
|
+
if (!body)
|
|
379
|
+
return c.json({ error: "invalid JSON" }, 400);
|
|
372
380
|
if (!body.workspace?.trim())
|
|
373
381
|
return c.json({ error: "workspace required" }, 400);
|
|
374
382
|
const ws = resolveWorkspace(body.workspace, path.dirname(master.configPath));
|
|
@@ -757,7 +765,11 @@ export function buildApp(master) {
|
|
|
757
765
|
const a = master.agents.get(c.req.param("id"));
|
|
758
766
|
if (!a)
|
|
759
767
|
return c.json({ error: "not found" }, 404);
|
|
760
|
-
const body = await c.req
|
|
768
|
+
const body = await c.req
|
|
769
|
+
.json()
|
|
770
|
+
.catch(() => null); // #110
|
|
771
|
+
if (!body)
|
|
772
|
+
return c.json({ error: "invalid JSON" }, 400);
|
|
761
773
|
if (!body.text?.trim() && !body.images?.length)
|
|
762
774
|
return c.json({ error: "text or images required" }, 400);
|
|
763
775
|
// validate images: data URLs or http(s), size-capped (a 20MB base64 blob
|
|
@@ -782,7 +794,9 @@ export function buildApp(master) {
|
|
|
782
794
|
const a = master.agents.get(c.req.param("id"));
|
|
783
795
|
if (!a)
|
|
784
796
|
return c.json({ error: "not found" }, 404);
|
|
785
|
-
const body = await c.req.json();
|
|
797
|
+
const body = await c.req.json().catch(() => null); // #110
|
|
798
|
+
if (!body)
|
|
799
|
+
return c.json({ error: "invalid JSON" }, 400);
|
|
786
800
|
if (!body.promptId)
|
|
787
801
|
return c.json({ error: "promptId required" }, 400);
|
|
788
802
|
const text = a.cancelPrompt(body.promptId);
|
|
@@ -864,7 +878,10 @@ export function buildApp(master) {
|
|
|
864
878
|
const a = master.agents.get(c.req.param("id"));
|
|
865
879
|
if (!a)
|
|
866
880
|
return c.json({ error: "not found" }, 404);
|
|
867
|
-
const body = await c.req.json()
|
|
881
|
+
const body = await c.req.json()
|
|
882
|
+
.catch(() => null); // #110
|
|
883
|
+
if (!body)
|
|
884
|
+
return c.json({ error: "invalid JSON" }, 400);
|
|
868
885
|
if (body.text) {
|
|
869
886
|
await a.setGoal(body.text);
|
|
870
887
|
if (typeof body.verify === "string" && body.verify.trim())
|