teapot-coding-agent 0.26.3 → 0.26.5
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 +122 -7
- package/dist/agent/tools.js +44 -0
- package/dist/server/api.js +9 -0
- package/package.json +1 -1
- package/public/assets/{index-B6nvBQLn.js → index-BmEVNR_F.js} +22 -22
- package/public/assets/index-BmEVNR_F.js.map +1 -0
- package/public/assets/{index-CP1dmlDT.css → index-UpRths52.css} +1 -1
- package/public/assets/{shiki-DPtj9Srk.js → shiki-CAu4ne7J.js} +2 -2
- package/public/assets/{shiki-DPtj9Srk.js.map → shiki-CAu4ne7J.js.map} +1 -1
- package/public/index.html +2 -2
- package/public/assets/index-B6nvBQLn.js.map +0 -1
package/dist/agent/agent.js
CHANGED
|
@@ -89,14 +89,25 @@ Most session state is not injected into prompts — fetch it with tools instead:
|
|
|
89
89
|
turn right after the call (a short "reported." is fine).
|
|
90
90
|
- Be frugal: prefer small precise edits, avoid runaway loops.`;
|
|
91
91
|
/**
|
|
92
|
-
*
|
|
93
|
-
* content (`llm.ts`).
|
|
92
|
+
* A message whose ENTIRE text is parenthesised is an acknowledgement, not work.
|
|
94
93
|
*
|
|
95
|
-
* #94:
|
|
96
|
-
*
|
|
97
|
-
*
|
|
98
|
-
|
|
99
|
-
|
|
94
|
+
* #94: the first version of this listed three literals — `(tool call)`,
|
|
95
|
+
* `(no content)`, `(no output)` — and caught 2 of the 10 forms actually reaching
|
|
96
|
+
* a parent. Measured across every session log, the rest are harness
|
|
97
|
+
* acknowledgements written by `answerMeta` and the meta-tool handlers:
|
|
98
|
+
*
|
|
99
|
+
* (goal complete) (round ended) (tool try) (no sub-agents)
|
|
100
|
+
* (tool_call) (nothing running to stop) (no result recorded)
|
|
101
|
+
* (todo.md is empty …) (decisions.md is empty …)
|
|
102
|
+
*
|
|
103
|
+
* So it matches the SHAPE — a whole line wrapped in parentheses — rather than an
|
|
104
|
+
* enumeration that is stale the moment another ack is added.
|
|
105
|
+
*
|
|
106
|
+
* The trade: a genuine message that happens to be entirely parenthesised would
|
|
107
|
+
* also be dropped. That is vanishingly rare in this feed, and the alternative —
|
|
108
|
+
* an enumeration — was measured to be wrong 80% of the time. If it ever matters,
|
|
109
|
+
* the ack should be marked structurally rather than by its punctuation.
|
|
110
|
+
*/ export const PLACEHOLDER_TEXT = /^\(.*\)$/;
|
|
100
111
|
/**
|
|
101
112
|
* Harness acknowledgements — meta-lines about the run itself rather than the
|
|
102
113
|
* work ("decision recorded to decisions.md", "progress recorded", …).
|
|
@@ -525,6 +536,43 @@ export class Agent {
|
|
|
525
536
|
async load() {
|
|
526
537
|
await this.ensureReady();
|
|
527
538
|
}
|
|
539
|
+
/**
|
|
540
|
+
* Answer tool_calls in a RESTORED history that have no tool_result (#54).
|
|
541
|
+
*
|
|
542
|
+
* The ordinary backstop (`answerUnansweredToolCalls`) works on an assistant
|
|
543
|
+
* turn inside a live loop. After a restart there is no loop: the call is in
|
|
544
|
+
* the log, the process that was running it is gone, and nothing else will ever
|
|
545
|
+
* write its result.
|
|
546
|
+
*/
|
|
547
|
+
async answerUnansweredRestoredCalls() {
|
|
548
|
+
const seen = new Set();
|
|
549
|
+
for (const ev of await readEvents(this.log.filePath)) {
|
|
550
|
+
const d = (ev.data ?? {});
|
|
551
|
+
const id = d.callId ? String(d.callId) : null;
|
|
552
|
+
if (!id)
|
|
553
|
+
continue;
|
|
554
|
+
if (ev.type === "tool_call")
|
|
555
|
+
seen.add(id);
|
|
556
|
+
else if (ev.type === "tool_result")
|
|
557
|
+
seen.delete(id);
|
|
558
|
+
}
|
|
559
|
+
for (const id of seen) {
|
|
560
|
+
await this.log.append("tool_result", this.currentSession, this.currentBranch, {
|
|
561
|
+
callId: id,
|
|
562
|
+
name: "unknown",
|
|
563
|
+
ok: false,
|
|
564
|
+
durationMs: 0,
|
|
565
|
+
result: "not completed — the agent restarted while this tool was running",
|
|
566
|
+
synthesized: true,
|
|
567
|
+
});
|
|
568
|
+
}
|
|
569
|
+
if (seen.size)
|
|
570
|
+
await this.log.append("system_note", this.currentSession, this.currentBranch, {
|
|
571
|
+
event: "unfinished-tools-closed",
|
|
572
|
+
count: seen.size,
|
|
573
|
+
detail: "a restart left these tool calls without a result; they are marked not completed (#54)",
|
|
574
|
+
});
|
|
575
|
+
}
|
|
528
576
|
/** harness-managed files inside the session directory */
|
|
529
577
|
get goalFile() {
|
|
530
578
|
return path.join(this.opts.sessionDir, "goal.md");
|
|
@@ -694,6 +742,24 @@ export class Agent {
|
|
|
694
742
|
// seen and acted on that output, and the pruned form keeps the head plus
|
|
695
743
|
// a marker, exactly as it did live.
|
|
696
744
|
this.maybePrune();
|
|
745
|
+
// #54: answer tool_calls the log says are still open.
|
|
746
|
+
//
|
|
747
|
+
// The restore path rebuilds `msgs` straight from the log, so a call whose
|
|
748
|
+
// process died mid-command comes back as an assistant turn with
|
|
749
|
+
// `tool_calls` and NO matching `tool_result`. Nothing ever answers it —
|
|
750
|
+
// not `stop()` (there is no turn to stop) and not `dispose()` (the process
|
|
751
|
+
// is already gone). The row then renders as still running for good.
|
|
752
|
+
//
|
|
753
|
+
// Measured across every session log: 43,483 tool_calls, and 4 unanswered —
|
|
754
|
+
// every one a `bash`, each followed immediately by
|
|
755
|
+
// `session-restored` / `idle->stopped disposed`. The shape is a restart
|
|
756
|
+
// (or a crash) landing while a long command was in flight, which is
|
|
757
|
+
// precisely when a human is most likely to notice "it never finished".
|
|
758
|
+
//
|
|
759
|
+
// So close them ON RESTORE, before the restored history is used. This is
|
|
760
|
+
// the same backstop as the mid-turn paths, reached from the one place
|
|
761
|
+
// those could not: after the process is gone.
|
|
762
|
+
await this.answerUnansweredRestoredCalls();
|
|
697
763
|
await this.log.append("system_note", this.currentSession, this.currentBranch, {
|
|
698
764
|
event: "session-restored",
|
|
699
765
|
branch: last.branch,
|
|
@@ -1018,6 +1084,29 @@ export class Agent {
|
|
|
1018
1084
|
// Bounded like the rest of the snapshot, and ids are opaque, so this
|
|
1019
1085
|
// cannot carry prompt text.
|
|
1020
1086
|
pendingPromptIds: this.pendingPrompts.map((p) => p.id).filter((x) => !!x).slice(0, 64),
|
|
1087
|
+
// #87: the QUEUE itself, not just its ids. The UI's echo rows are
|
|
1088
|
+
// memory-only (like liveByAgent and timelineCache), so after a reload the
|
|
1089
|
+
// queue survives on the server while the UI has nothing to render — the
|
|
1090
|
+
// queued message then appears only as its log row, i.e. already sent.
|
|
1091
|
+
//
|
|
1092
|
+
// Ids alone cannot fix that: they identify a prompt, they do not carry its
|
|
1093
|
+
// text, so the UI cannot rebuild an echo from them. This is the queue the
|
|
1094
|
+
// operator is looking at, already on screen in the ⏳ badge, so shipping it
|
|
1095
|
+
// is not new exposure — it is the same data in a shape the timeline can
|
|
1096
|
+
// use.
|
|
1097
|
+
//
|
|
1098
|
+
// `at` is the enqueue time so the echo keeps a stable position and id
|
|
1099
|
+
// across rebuilds; `sent` mirrors the echo flag the UI already tracks.
|
|
1100
|
+
pendingPromptQueue: this.pendingPrompts
|
|
1101
|
+
.filter((p) => !!p.id)
|
|
1102
|
+
.slice(0, 64)
|
|
1103
|
+
.map((p, i) => ({
|
|
1104
|
+
id: p.id,
|
|
1105
|
+
text: p.text.slice(0, 8000),
|
|
1106
|
+
source: p.source,
|
|
1107
|
+
at: Date.now() - (this.pendingPrompts.length - i),
|
|
1108
|
+
...(p.images?.length ? { images: p.images.slice(0, 8) } : {}),
|
|
1109
|
+
})),
|
|
1021
1110
|
todo: this.todo.slice(0, 32_000), // match set_todo's cap — no silent truncation
|
|
1022
1111
|
parent: this.opts.parent,
|
|
1023
1112
|
awaiting: this.awaitingUser,
|
|
@@ -1252,6 +1341,8 @@ export class Agent {
|
|
|
1252
1341
|
}
|
|
1253
1342
|
/** monotonic counter: a start() bumps it, so a pending stop status write loses */
|
|
1254
1343
|
stopSeq = 0;
|
|
1344
|
+
/** #95: set when this round already injected a harness progress request */
|
|
1345
|
+
progressRequestedThisRound = false;
|
|
1255
1346
|
setStatus(s, reason = "") {
|
|
1256
1347
|
// Update the field FIRST. log.append fires onEvent synchronously, and the
|
|
1257
1348
|
// master's child-event hook wakes parked wait_children callers — those
|
|
@@ -1349,6 +1440,22 @@ export class Agent {
|
|
|
1349
1440
|
await this.sleepInterruptible(this.opts.continueDelayMs);
|
|
1350
1441
|
if (this.stopRequested)
|
|
1351
1442
|
break;
|
|
1443
|
+
// #95: do NOT pair this with a progress request. Both fire at the turn
|
|
1444
|
+
// boundary and both inject a user message, so the model received two
|
|
1445
|
+
// harness instructions back to back — "Continue working toward the
|
|
1446
|
+
// current goal" immediately followed by "Please give a brief progress
|
|
1447
|
+
// report now", which reads as the harness talking to itself.
|
|
1448
|
+
//
|
|
1449
|
+
// The progress request already re-enters the loop, so it IS the
|
|
1450
|
+
// continuation: suppressing the nudge when one is pending costs nothing
|
|
1451
|
+
// and leaves exactly one instruction per boundary.
|
|
1452
|
+
if (this.progressRequestedThisRound) {
|
|
1453
|
+
await this.log.append("system_note", this.currentSession, this.currentBranch, {
|
|
1454
|
+
event: "auto-continue-suppressed",
|
|
1455
|
+
detail: "a progress report was already requested this round (#95)",
|
|
1456
|
+
});
|
|
1457
|
+
break;
|
|
1458
|
+
}
|
|
1352
1459
|
const isSub = !!this.opts.parent;
|
|
1353
1460
|
const nudge = isSub
|
|
1354
1461
|
? `[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().`
|
|
@@ -1472,6 +1579,11 @@ export class Agent {
|
|
|
1472
1579
|
// skills may have been created last turn — refresh the prompt listing
|
|
1473
1580
|
await this.refreshSkills();
|
|
1474
1581
|
// periodic progress report at turn boundary (no mid-turn interruption)
|
|
1582
|
+
// #95: the flag is per-TURN, not per-round. `maybeRequestProgress` sits
|
|
1583
|
+
// inside the turn loop and auto-continue is checked after the round
|
|
1584
|
+
// ends, so the flag has to mean "this boundary already carried an
|
|
1585
|
+
// instruction" — reset here, set below if the request fires.
|
|
1586
|
+
this.progressRequestedThisRound = false;
|
|
1475
1587
|
await this.maybeRequestProgress();
|
|
1476
1588
|
// keep the context window bounded before spending tokens on a turn
|
|
1477
1589
|
await this.maybeCompact();
|
|
@@ -2284,6 +2396,9 @@ export class Agent {
|
|
|
2284
2396
|
this.turnsSinceProgress >= this.opts.progressMaxQuietTurns;
|
|
2285
2397
|
if (!elapsedOk || !activityOk)
|
|
2286
2398
|
return; // stalling provider → don't waste a turn asking
|
|
2399
|
+
// #95: remember that this boundary already carries an instruction, so the
|
|
2400
|
+
// auto-continue nudge can stand down instead of pairing with it.
|
|
2401
|
+
this.progressRequestedThisRound = true;
|
|
2287
2402
|
this.lastProgressAt = Date.now();
|
|
2288
2403
|
this.activityChars = 0;
|
|
2289
2404
|
this.turnsSinceProgress = 0;
|
package/dist/agent/tools.js
CHANGED
|
@@ -414,7 +414,51 @@ function clipText(s, max) {
|
|
|
414
414
|
const n = Math.max(1000, Math.min(max, 80_000));
|
|
415
415
|
return s.length <= n ? s : `${s.slice(0, n)}\n… [truncated, ${s.length} chars total]`;
|
|
416
416
|
}
|
|
417
|
+
/**
|
|
418
|
+
* Patterns that backtrack catastrophically (#93).
|
|
419
|
+
*
|
|
420
|
+
* A nested quantifier — a quantified group whose body is itself quantified, or
|
|
421
|
+
* alternation that can match the same text more than one way — makes the engine
|
|
422
|
+
* explore exponentially many splits. Measured on this codebase: `(a+)+b` against
|
|
423
|
+
* 28 characters of "a" took 3.8s, roughly DOUBLING per character added, and a
|
|
424
|
+
* 40-character line would run for days.
|
|
425
|
+
*
|
|
426
|
+
* There is no timeout on this path: `ctx.defaultTimeoutMs` guards `bash` only,
|
|
427
|
+
* so the scan is a synchronous `re.test(line)` with nothing able to interrupt it.
|
|
428
|
+
* The agent process blocks and takes the UI and every other agent with it.
|
|
429
|
+
*
|
|
430
|
+
* A model is poorly placed to reason about backtracking cost and nothing tells it
|
|
431
|
+
* not to try, so the pattern is checked rather than trusted. This rejects the
|
|
432
|
+
* SHAPE rather than trying to bound the runtime, because a synchronous regex
|
|
433
|
+
* cannot be abandoned once `test` starts — the only reliable protection is to
|
|
434
|
+
* never compile one.
|
|
435
|
+
*/
|
|
436
|
+
const BACKTRACKING_PATTERNS = [
|
|
437
|
+
// (a+)+ (a*)* (a+)* (a?)* … — a quantifier wrapping another quantifier
|
|
438
|
+
{ re: /\([^()]*[+*}][^()]*\)\s*[+*{]/, why: "a quantifier nested inside another quantifier" },
|
|
439
|
+
// (a|a)+ — alternation whose branches can match the SAME text. Distinct
|
|
440
|
+
// branches like (foo|bar)+ are fine and common, so this only fires when two
|
|
441
|
+
// branches are literally identical, which is the shape that explodes.
|
|
442
|
+
{ re: /\(([^()|]*)\|\1\)\s*[+*{]/, why: "an alternation with identical branches inside a quantifier" },
|
|
443
|
+
// (.*)* (.+)+ — a wildcard quantified inside a quantifier
|
|
444
|
+
{ re: /\(\s*[.][*+]\s*\)\s*[+*{]/, why: "a wildcard quantified inside a quantifier" },
|
|
445
|
+
];
|
|
446
|
+
/** Above this, the O(lines x pattern) scan itself becomes the cost. */
|
|
447
|
+
const MAX_PATTERN_LENGTH = 1000;
|
|
448
|
+
/**
|
|
449
|
+
* Compile a model-supplied regex, refusing ones that can backtrack
|
|
450
|
+
* catastrophically.
|
|
451
|
+
*
|
|
452
|
+
* Returns an error STRING rather than a RegExp so every existing call site —
|
|
453
|
+
* which already handles that shape for an invalid pattern — needs no change.
|
|
454
|
+
*/
|
|
417
455
|
function compileRegex(pattern, ignoreCase) {
|
|
456
|
+
if (pattern.length > MAX_PATTERN_LENGTH)
|
|
457
|
+
return `pattern too long (${pattern.length} chars, max ${MAX_PATTERN_LENGTH}) — narrow it, or grep for a literal substring instead`;
|
|
458
|
+
for (const { re, why } of BACKTRACKING_PATTERNS) {
|
|
459
|
+
if (re.test(pattern))
|
|
460
|
+
return `pattern rejected: ${why} — that shape backtracks exponentially and would freeze the agent (e.g. "(a+)+b" against 28 characters takes 3.8s). Use a literal search, or simplify the pattern.`;
|
|
461
|
+
}
|
|
418
462
|
try {
|
|
419
463
|
return new RegExp(pattern, ignoreCase ? "i" : "");
|
|
420
464
|
}
|
package/dist/server/api.js
CHANGED
|
@@ -1027,6 +1027,15 @@ export function buildApp(master) {
|
|
|
1027
1027
|
return c.json({ error: "eventId and text required" }, 400);
|
|
1028
1028
|
try {
|
|
1029
1029
|
const r = await a.editPromptAt(body.eventId, body.text, body.tail === "summarize" ? "summarize" : "discard");
|
|
1030
|
+
// #96: the UI button is labelled "fork & RESEND", but nothing resent.
|
|
1031
|
+
// `editPromptAt` rewrites history and returns, leaving the agent idle —
|
|
1032
|
+
// so the edited prompt sat in the timeline looking sent while the model
|
|
1033
|
+
// had never seen it, and the operator had to notice and press start.
|
|
1034
|
+
//
|
|
1035
|
+
// start() here rather than in the client: the route already knows the edit
|
|
1036
|
+
// succeeded, and a client that failed to call it would leave the agent
|
|
1037
|
+
// silently idle with no error anywhere.
|
|
1038
|
+
a.start("edited prompt resent");
|
|
1030
1039
|
return c.json({ ok: true, ...r });
|
|
1031
1040
|
}
|
|
1032
1041
|
catch (err) {
|