teapot-coding-agent 0.28.2 → 0.29.1
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 +414 -152
- package/dist/agent/skills.js +29 -15
- package/dist/agent/tools.js +22 -1
- package/dist/config-schema.js +2 -1
- package/dist/master.js +37 -7
- package/dist/scheduler/cron.js +5 -3
- package/dist/server/api.js +41 -20
- package/dist/server/term-size.js +13 -1
- package/package.json +14 -14
- package/public/assets/{astro-JHDYjn39.js → astro-BZ97kZFP.js} +2 -2
- package/public/assets/astro-BZ97kZFP.js.map +1 -0
- package/public/assets/bat-qdxAdBWQ.js.map +1 -1
- package/public/assets/c-Cz4nF6n_.js +1 -0
- package/public/assets/c-SSSlmE39.js.map +1 -1
- package/public/assets/clojure-DqKBuwfJ.js.map +1 -1
- package/public/assets/cmake-Bj61d0ZC.js.map +1 -1
- package/public/assets/cpp-B6pWdJV2.js +1 -0
- package/public/assets/cpp-ZGiJKGk8.js.map +1 -1
- package/public/assets/csharp-dmMxLVLW.js +2 -0
- package/public/assets/csharp-dmMxLVLW.js.map +1 -0
- package/public/assets/css-BGIxQoYs.js +2 -0
- package/public/assets/css-BGIxQoYs.js.map +1 -0
- package/public/assets/css-DgaLhXQH.js +1 -0
- package/public/assets/dart-CHZY9Xhz.js +2 -0
- package/public/assets/dart-CHZY9Xhz.js.map +1 -0
- package/public/assets/diff-woXpYk--.js.map +1 -1
- package/public/assets/dockerfile-IyjqRm3v.js.map +1 -1
- package/public/assets/{elixir-IXeIM30B.js → elixir-raGDl1oO.js} +2 -2
- package/public/assets/elixir-raGDl1oO.js.map +1 -0
- package/public/assets/erlang-D0CaQ5Pl.js.map +1 -1
- package/public/assets/fish-BJitypiv.js.map +1 -1
- package/public/assets/fsharp-PZeu-6rm.js.map +1 -1
- package/public/assets/github-dark-C-LZuMrd.js.map +1 -1
- package/public/assets/github-light-EUqPIrTm.js.map +1 -1
- package/public/assets/glsl-CaPTrB3a.js.map +1 -1
- package/public/assets/graphql-BZEhxNwM.js +1 -0
- package/public/assets/graphql-DEIChqxY.js.map +1 -1
- package/public/assets/groovy-CacY0gHj.js.map +1 -1
- package/public/assets/haskell-D8IpX4py.js.map +1 -1
- package/public/assets/hcl-Dh228itO.js.map +1 -1
- package/public/assets/html-BCy9P6D2.js +1 -0
- package/public/assets/{html-B2cKP6UN.js → html-DSw1lh5G.js} +2 -2
- package/public/assets/html-DSw1lh5G.js.map +1 -0
- package/public/assets/index-Bnx0qjHQ.js +30 -0
- package/public/assets/index-Bnx0qjHQ.js.map +1 -0
- package/public/assets/ini-B5eOa1yu.js.map +1 -1
- package/public/assets/java-BtIQNnN0.js.map +1 -1
- package/public/assets/java-_ATy9zCa.js +1 -0
- package/public/assets/javascript-BzbQS41l.js.map +1 -1
- package/public/assets/javascript-CpIGwZ_Y.js +1 -0
- package/public/assets/json-B88Oqtu5.js.map +1 -1
- package/public/assets/json-C3WlssX3.js +1 -0
- package/public/assets/json5-BR5RXkoi.js.map +1 -1
- package/public/assets/jsonc-CYpm1nAK.js.map +1 -1
- package/public/assets/jsx-C4yDKi0A.js +1 -0
- package/public/assets/jsx-D3KKoC5L.js.map +1 -1
- package/public/assets/{julia-BC1A_CJ4.js → julia-DCfl8sf8.js} +2 -2
- package/public/assets/julia-DCfl8sf8.js.map +1 -0
- package/public/assets/{justfile-Ca-8NFG9.js → justfile-CKP9W7x6.js} +2 -2
- package/public/assets/justfile-CKP9W7x6.js.map +1 -0
- package/public/assets/kotlin-DhhofPvG.js.map +1 -1
- package/public/assets/less-DVTAwKKz.js.map +1 -1
- package/public/assets/lua-DjuecEsk.js.map +1 -1
- package/public/assets/lua-fPRBWD-V.js +1 -0
- package/public/assets/makefile-Dixweg8N.js.map +1 -1
- package/public/assets/markdown-CboqBxpY.js.map +1 -1
- package/public/assets/markdown-CjQmuOi1.js +1 -0
- package/public/assets/mdx-DQZ5AkYe.js.map +1 -1
- package/public/assets/{nim-AzTgDUNM.js → nim-VAaFSKwL.js} +2 -2
- package/public/assets/nim-VAaFSKwL.js.map +1 -0
- package/public/assets/objective-c-D1A_Heim.js.map +1 -1
- package/public/assets/perl-BTdIRyOQ.js +1 -0
- package/public/assets/{perl-DYq_hxXC.js → perl-XQDPZEjP.js} +2 -2
- package/public/assets/perl-XQDPZEjP.js.map +1 -0
- package/public/assets/php-DPoYWQt_.js +2 -0
- package/public/assets/php-DPoYWQt_.js.map +1 -0
- package/public/assets/postcss-D_Wz94rJ.js.map +1 -1
- package/public/assets/postcss-DkKB82OS.js +1 -0
- package/public/assets/{powershell-CCSX75yM.js → powershell-QtSnmXcu.js} +2 -2
- package/public/assets/powershell-QtSnmXcu.js.map +1 -0
- package/public/assets/prisma-DMgNhnYS.js.map +1 -1
- package/public/assets/python-C5TF47hQ.js +1 -0
- package/public/assets/python-CzFU0dpx.js.map +1 -1
- package/public/assets/r-BXEss897.js +1 -0
- package/public/assets/r-DWzjseEo.js.map +1 -1
- package/public/assets/ruby-BdDtUACQ.js +1 -0
- package/public/assets/{ruby-BeaszOMm.js → ruby-DV2UBOs2.js} +2 -2
- package/public/assets/ruby-DV2UBOs2.js.map +1 -0
- package/public/assets/rust-DpiIf5JO.js +2 -0
- package/public/assets/rust-DpiIf5JO.js.map +1 -0
- package/public/assets/sass-DXrisJhu.js.map +1 -1
- package/public/assets/scala-BiFt_7mu.js.map +1 -1
- package/public/assets/{scss-DHtrkMkT.js → scss-8bKmll0B.js} +2 -2
- package/public/assets/scss-8bKmll0B.js.map +1 -0
- package/public/assets/shellscript-CVTB3cr6.js +1 -0
- package/public/assets/shellscript-CqHdAlCp.js.map +1 -1
- package/public/assets/shiki-B4EfnMLX.js +156 -0
- package/public/assets/shiki-B4EfnMLX.js.map +1 -0
- package/public/assets/solidity-Bj7E1mRn.js.map +1 -1
- package/public/assets/sql-CNu3Or2W.js +2 -0
- package/public/assets/sql-CNu3Or2W.js.map +1 -0
- package/public/assets/sql-CfM2IJm3.js +1 -0
- package/public/assets/stylus-B6D30XZt.js.map +1 -1
- package/public/assets/{svelte-tEuLYt3E.js → svelte-CdeT-Ich.js} +2 -2
- package/public/assets/svelte-CdeT-Ich.js.map +1 -0
- package/public/assets/swift-CyEgAFGc.js.map +1 -1
- package/public/assets/terraform-DswuEJGm.js.map +1 -1
- package/public/assets/toml-CcmNWLt0.js.map +1 -1
- package/public/assets/tsx-BOvsFOI6.js.map +1 -1
- package/public/assets/tsx-CGosfAUg.js +1 -0
- package/public/assets/typescript-Bb1J8qyy.js +1 -0
- package/public/assets/typescript-j_1H8WHN.js.map +1 -1
- package/public/assets/viml-DvXPmvsu.js.map +1 -1
- package/public/assets/{vue-Bg0byEaI.js → vue-BqdMUtqy.js} +2 -2
- package/public/assets/vue-BqdMUtqy.js.map +1 -0
- package/public/assets/xml-DuKfbPI6.js +1 -0
- package/public/assets/xml-WY0zc4PG.js.map +1 -1
- package/public/assets/yaml-DDA_XMNu.js +1 -0
- package/public/assets/yaml-DijMHRDA.js.map +1 -1
- package/public/assets/zig-CMLA9XwU.js.map +1 -1
- package/public/index.html +1 -1
- package/public/assets/astro-JHDYjn39.js.map +0 -1
- package/public/assets/c-DWGmR78Y.js +0 -1
- package/public/assets/cpp-D0jV2xBx.js +0 -1
- package/public/assets/csharp-oqKa8noW.js +0 -2
- package/public/assets/csharp-oqKa8noW.js.map +0 -1
- package/public/assets/css-CdtkT07m.js +0 -1
- package/public/assets/css-LbU1hoFO.js +0 -2
- package/public/assets/css-LbU1hoFO.js.map +0 -1
- package/public/assets/dart-CnvKMtbv.js +0 -2
- package/public/assets/dart-CnvKMtbv.js.map +0 -1
- package/public/assets/elixir-IXeIM30B.js.map +0 -1
- package/public/assets/graphql-DAzSVzvB.js +0 -1
- package/public/assets/html-B2cKP6UN.js.map +0 -1
- package/public/assets/html-DYtkB_T1.js +0 -1
- package/public/assets/index-j-byVujL.js +0 -30
- package/public/assets/index-j-byVujL.js.map +0 -1
- package/public/assets/java-DxlWrXwe.js +0 -1
- package/public/assets/javascript-Yelw-5ZN.js +0 -1
- package/public/assets/json-CaljABEy.js +0 -1
- package/public/assets/jsx-hEbJ82HF.js +0 -1
- package/public/assets/julia-BC1A_CJ4.js.map +0 -1
- package/public/assets/justfile-Ca-8NFG9.js.map +0 -1
- package/public/assets/lua-BJObVtAx.js +0 -1
- package/public/assets/markdown-Cla8Gbs6.js +0 -1
- package/public/assets/nim-AzTgDUNM.js.map +0 -1
- package/public/assets/perl-CCMzbIRV.js +0 -1
- package/public/assets/perl-DYq_hxXC.js.map +0 -1
- package/public/assets/php-Dl--GmOR.js +0 -2
- package/public/assets/php-Dl--GmOR.js.map +0 -1
- package/public/assets/postcss-e3XvZOFQ.js +0 -1
- package/public/assets/powershell-CCSX75yM.js.map +0 -1
- package/public/assets/python-ORI17Sev.js +0 -1
- package/public/assets/r-Dr1JUpHl.js +0 -1
- package/public/assets/ruby-BeaszOMm.js.map +0 -1
- package/public/assets/ruby-XbVsWxgo.js +0 -1
- package/public/assets/rust-Cfkwpbl8.js +0 -2
- package/public/assets/rust-Cfkwpbl8.js.map +0 -1
- package/public/assets/scss-DHtrkMkT.js.map +0 -1
- package/public/assets/shellscript-BiGZg9Om.js +0 -1
- package/public/assets/shiki-DCpmqeFE.js +0 -156
- package/public/assets/shiki-DCpmqeFE.js.map +0 -1
- package/public/assets/sql-0Qcw9ht3.js +0 -1
- package/public/assets/sql-CP0rg-5b.js +0 -2
- package/public/assets/sql-CP0rg-5b.js.map +0 -1
- package/public/assets/svelte-tEuLYt3E.js.map +0 -1
- package/public/assets/tsx-5f3ulCja.js +0 -1
- package/public/assets/typescript-Utq2Cl8c.js +0 -1
- package/public/assets/vue-Bg0byEaI.js.map +0 -1
- package/public/assets/xml-CoDIzWF0.js +0 -1
- package/public/assets/yaml-CxbubiML.js +0 -1
package/dist/agent/agent.js
CHANGED
|
@@ -222,6 +222,43 @@ export class Agent {
|
|
|
222
222
|
* round that produced neither a tool call nor a finish increments it.
|
|
223
223
|
*/
|
|
224
224
|
consecutiveIdleRounds = 0;
|
|
225
|
+
/**
|
|
226
|
+
* Consecutive completion audits that REJECTED, #128.
|
|
227
|
+
*
|
|
228
|
+
* Measured before this existed: a model that finishes, is rejected, finishes
|
|
229
|
+
* again, is rejected again… ran 381 audit calls in one turn. Nothing bounded it:
|
|
230
|
+
*
|
|
231
|
+
* - `maxConsecutiveIdleRounds` counts IDLE rounds, and a rejected audit is not
|
|
232
|
+
* idle — the agent is actively working
|
|
233
|
+
* - `maxTurnsPerRound` only LOGS `round-turn-cap`; it does not break, and it is
|
|
234
|
+
* evaluated per round, while a reject never ends the round
|
|
235
|
+
*
|
|
236
|
+
* So the agent can spend unbounded provider spend re-finishing. When the cap is
|
|
237
|
+
* hit the goal is marked as needing attention rather than left looping: silently
|
|
238
|
+
* marking it done would be #128's fail-open problem all over again, and silently
|
|
239
|
+
* stopping would strand the operator with no signal.
|
|
240
|
+
*/
|
|
241
|
+
consecutiveAuditRejects = 0;
|
|
242
|
+
/**
|
|
243
|
+
* #128: consecutive completion audits that produced NO verdict (empty /
|
|
244
|
+
* prose / provider throw). Fail-CLOSED — the goal stays active and the
|
|
245
|
+
* worker self-verifies again — but bounded: after 3 the retrying stops with
|
|
246
|
+
* a loud note (the same philosophy as the reject bound above), so a
|
|
247
|
+
* permanently broken auditor cannot trap the worker either. Reset by any
|
|
248
|
+
* real verdict and by setGoal.
|
|
249
|
+
*/
|
|
250
|
+
auditUnavailableStreak = 0;
|
|
251
|
+
/**
|
|
252
|
+
* #91/#94: audit lineage for the FINAL event. `auditAttempts` counts every
|
|
253
|
+
* audit run (incremented at audit-started); `lastRejectedAudit` remembers
|
|
254
|
+
* the most recent CHANGES-REQUIRED verdict so the approving final can carry
|
|
255
|
+
* what the first attempt was told; `lastAuditOutcome` is the metadata the
|
|
256
|
+
* NEXT final event spreads in (then cleared — a later, unaudited finish
|
|
257
|
+
* must not inherit it).
|
|
258
|
+
*/
|
|
259
|
+
auditAttempts = 0;
|
|
260
|
+
lastRejectedAudit = null;
|
|
261
|
+
lastAuditOutcome = null;
|
|
225
262
|
constructor(opts) {
|
|
226
263
|
this.opts = {
|
|
227
264
|
progressIntervalMs: 10 * 60_000,
|
|
@@ -236,6 +273,7 @@ export class Agent {
|
|
|
236
273
|
supportedParameters: [],
|
|
237
274
|
maxConsecutiveToolErrors: 5,
|
|
238
275
|
maxTurnsPerRound: 200,
|
|
276
|
+
maxAuditRejects: 3, // #128: bound the reject loop
|
|
239
277
|
// generous: only a model that never acts and never finishes reaches it
|
|
240
278
|
maxConsecutiveIdleRounds: 50,
|
|
241
279
|
onError: "stop",
|
|
@@ -283,6 +321,8 @@ export class Agent {
|
|
|
283
321
|
readOnly: this.opts.readOnlyTools,
|
|
284
322
|
onIdlePark: (reason) => this.parkForTool(reason),
|
|
285
323
|
onIdleUnpark: () => this.unparkFromTool(),
|
|
324
|
+
// #140: the park must not strand an already-queued prompt
|
|
325
|
+
hasPendingPrompts: () => this.pendingPrompts.length > 0,
|
|
286
326
|
onBackgroundExit: (info) => void this.onBackgroundJobExit(info),
|
|
287
327
|
onFileRead: (p) => this.trackFileRead(p),
|
|
288
328
|
};
|
|
@@ -482,6 +522,28 @@ export class Agent {
|
|
|
482
522
|
return (this.status === "running" ||
|
|
483
523
|
this.parkedByTool ||
|
|
484
524
|
this.pendingPrompts.length > 0 ||
|
|
525
|
+
this.compactPhase !== "" ||
|
|
526
|
+
// ask_user: the loop is parked on the operator's answer — stoppable work
|
|
527
|
+
// in flight, not idleness. Without this the UI offered "start", and
|
|
528
|
+
// pressing it cleared awaitingUser, silently discarding the open question
|
|
529
|
+
// while master.waitChildren's own active() kept counting the same child
|
|
530
|
+
// as working (three definitions of "alive" disagreeing about one state).
|
|
531
|
+
this.awaitingUser);
|
|
532
|
+
}
|
|
533
|
+
/**
|
|
534
|
+
* Is this agent doing in-flight work that a WAITER (wait_children) should
|
|
535
|
+
* hold for? Deliberately narrower than isLive(): queued prompts are DORMANT
|
|
536
|
+
* work (a stopped/idle child touches them only after it is started), so
|
|
537
|
+
* they must not keep a parent parked on a child that is visibly idle —
|
|
538
|
+
* that wait would hang. isLive() answers "stop vs start" (#59/#133); this
|
|
539
|
+
* answers "is work in flight".
|
|
540
|
+
*/
|
|
541
|
+
isBusy() {
|
|
542
|
+
if (this.stopRequested)
|
|
543
|
+
return false;
|
|
544
|
+
return (this.status === "running" ||
|
|
545
|
+
this.status === "waiting" ||
|
|
546
|
+
this.parkedByTool ||
|
|
485
547
|
this.compactPhase !== "");
|
|
486
548
|
}
|
|
487
549
|
/**
|
|
@@ -512,13 +574,21 @@ export class Agent {
|
|
|
512
574
|
this.onBackgroundJobExitQueued(info);
|
|
513
575
|
}
|
|
514
576
|
parkForTool(reason) {
|
|
515
|
-
if (this.parkedByTool
|
|
516
|
-
return;
|
|
577
|
+
if (this.parkedByTool)
|
|
578
|
+
return true; // already parked: the tool may proceed
|
|
579
|
+
if (this.status !== "running")
|
|
580
|
+
return false;
|
|
581
|
+
// #140: a prompt queued BEFORE the park would be stranded by it — the
|
|
582
|
+
// enqueue-time wake only handles prompts arriving DURING the park. Refuse,
|
|
583
|
+
// so the tool stands down and the next turn boundary consumes the queue.
|
|
584
|
+
if (this.pendingPrompts.length > 0)
|
|
585
|
+
return false;
|
|
517
586
|
this.parkedByTool = true;
|
|
518
587
|
this.preParkedStatus = { status: this.status, reason: this.statusReason };
|
|
519
588
|
this.status = "idle";
|
|
520
589
|
this.statusReason = `${reason} (idle — send a message to take over; resumes automatically when a sub-agent settles)`;
|
|
521
590
|
bus.emit("update", { kind: "agent-update", agentId: this.opts.id });
|
|
591
|
+
return true;
|
|
522
592
|
}
|
|
523
593
|
unparkFromTool() {
|
|
524
594
|
if (!this.parkedByTool)
|
|
@@ -750,15 +820,10 @@ export class Agent {
|
|
|
750
820
|
this.stats.inputTokens += u.inputTokens ?? 0;
|
|
751
821
|
this.stats.outputTokens += u.outputTokens ?? 0;
|
|
752
822
|
this.stats.cachedInputTokens += u.cachedInputTokens ?? 0;
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
// same blended-cache model as the live path (cached ≈ 10% of prompt)
|
|
758
|
-
this.stats.costUsd =
|
|
759
|
-
(this.stats.costUsd ?? 0) +
|
|
760
|
-
((inTok - cached) * p.prompt + (u.outputTokens ?? 0) * p.completion + cached * p.prompt * 0.1);
|
|
761
|
-
}
|
|
823
|
+
// NO cost accumulation here: setModelPricing() rebuilds costUsd from
|
|
824
|
+
// this same log with a REPLACE — whichever runs first, adding on top
|
|
825
|
+
// of a rebuilt number counted every usage event twice (a restart plus
|
|
826
|
+
// the first interaction showed ~2x). The recompute is authoritative.
|
|
762
827
|
}
|
|
763
828
|
else if (e.type === "system_note") {
|
|
764
829
|
const d = e.data;
|
|
@@ -989,6 +1054,14 @@ export class Agent {
|
|
|
989
1054
|
}
|
|
990
1055
|
async setGoal(text) {
|
|
991
1056
|
this.goal = { text, status: "active", updatedAt: new Date().toISOString() };
|
|
1057
|
+
// a new goal is a new audit contract: the previous goal's rejection must
|
|
1058
|
+
// not ride along as the new goal's "previous" lineage, and the attempt
|
|
1059
|
+
// counters restart from zero (theirs counts rejects of THIS goal)
|
|
1060
|
+
this.consecutiveAuditRejects = 0;
|
|
1061
|
+
this.auditUnavailableStreak = 0;
|
|
1062
|
+
this.lastRejectedAudit = null;
|
|
1063
|
+
this.lastAuditOutcome = null;
|
|
1064
|
+
this.auditAttempts = 0;
|
|
992
1065
|
await this.writeGoalFile();
|
|
993
1066
|
await this.log.append("goal", this.currentSession, this.currentBranch, { event: "set", text });
|
|
994
1067
|
}
|
|
@@ -1515,6 +1588,30 @@ export class Agent {
|
|
|
1515
1588
|
if (this.stopRequested)
|
|
1516
1589
|
break;
|
|
1517
1590
|
// fresh user input arrived while we were finishing up — another round now
|
|
1591
|
+
// #128: bound consecutive audit rejections.
|
|
1592
|
+
//
|
|
1593
|
+
// A rejected audit queues a retry prompt, so the round CONTINUES — and
|
|
1594
|
+
// neither existing valve applies: maxConsecutiveIdleRounds counts IDLE
|
|
1595
|
+
// rounds (a rejection is not idle), and maxTurnsPerRound only logs. Measured
|
|
1596
|
+
// before this cap: 381 audit calls in a single turn.
|
|
1597
|
+
//
|
|
1598
|
+
// Deliberately NOT "mark the goal done": #128's whole complaint is that
|
|
1599
|
+
// "could not be audited" silently became "passed". The goal is left ACTIVE
|
|
1600
|
+
// and a loud note is logged, so the operator sees the loop and decides.
|
|
1601
|
+
// `>=` (not `>`): the JSDoc says REACHING 3 stops — `>` allowed a 4th
|
|
1602
|
+
// audit before breaking (off-by-one). The retry prompt is no longer
|
|
1603
|
+
// queued AT the cap (see handleFinish), so the break strands nothing.
|
|
1604
|
+
// The counter resets so ONE capped stretch doesn't brick every future
|
|
1605
|
+
// round-end until setGoal — a fixed model gets a fresh count.
|
|
1606
|
+
if (this.consecutiveAuditRejects >= (this.opts.maxAuditRejects ?? 3)) {
|
|
1607
|
+
await this.log.append("system_note", this.currentSession, this.currentBranch, {
|
|
1608
|
+
event: "audit-retry-cap",
|
|
1609
|
+
rejects: this.consecutiveAuditRejects,
|
|
1610
|
+
detail: "the completion audit rejected the same goal repeatedly ? stopping to avoid an unbounded retry loop (#128)",
|
|
1611
|
+
});
|
|
1612
|
+
this.consecutiveAuditRejects = 0;
|
|
1613
|
+
break;
|
|
1614
|
+
}
|
|
1518
1615
|
if (this.pendingPrompts.length)
|
|
1519
1616
|
continue;
|
|
1520
1617
|
// A round that parked its work on a background bash (dev server,
|
|
@@ -1649,6 +1746,10 @@ export class Agent {
|
|
|
1649
1746
|
await this.sleepInterruptible(wait);
|
|
1650
1747
|
if (this.stopRequested)
|
|
1651
1748
|
break;
|
|
1749
|
+
// the NEXT round is live work again — leaving "error" set made
|
|
1750
|
+
// isLive() false (error is not a live status) while the loop was
|
|
1751
|
+
// actually running, so the UI offered "start" on a working agent
|
|
1752
|
+
this.setStatus("running", "retrying");
|
|
1652
1753
|
continue; // fresh round; the loop re-arms the tool signal itself
|
|
1653
1754
|
}
|
|
1654
1755
|
this.setStatus("error", msg.slice(0, 300));
|
|
@@ -1680,6 +1781,8 @@ export class Agent {
|
|
|
1680
1781
|
bus.emit("update", {
|
|
1681
1782
|
kind: "llm-delta",
|
|
1682
1783
|
agentId: this.opts.id,
|
|
1784
|
+
// #146: the bubble is session-scoped, so the stream must say which session
|
|
1785
|
+
sessionId: this.currentSession,
|
|
1683
1786
|
text: "",
|
|
1684
1787
|
reasoning: "",
|
|
1685
1788
|
});
|
|
@@ -1773,6 +1876,7 @@ export class Agent {
|
|
|
1773
1876
|
bus.emit("update", {
|
|
1774
1877
|
kind: "llm-delta",
|
|
1775
1878
|
agentId: this.opts.id,
|
|
1879
|
+
sessionId: this.currentSession, // #146
|
|
1776
1880
|
text: s.text,
|
|
1777
1881
|
reasoning: s.reasoning,
|
|
1778
1882
|
});
|
|
@@ -1922,6 +2026,24 @@ export class Agent {
|
|
|
1922
2026
|
}
|
|
1923
2027
|
if (call.function.name === "finish") {
|
|
1924
2028
|
this.pendingSubReport = ""; // the real summary supersedes it
|
|
2029
|
+
// #128: answer the tool_call BEFORE the audit runs.
|
|
2030
|
+
//
|
|
2031
|
+
// handleFinish() calls the auditor with `buildMessages()`, and at that
|
|
2032
|
+
// moment the history ended at an assistant message carrying
|
|
2033
|
+
// `tool_calls: [finish]` with NO matching `tool` message. Measured, the
|
|
2034
|
+
// auditor received exactly that:
|
|
2035
|
+
//
|
|
2036
|
+
// assistant(tool_calls=[finish])
|
|
2037
|
+
// user(audit prompt) <-- no tool(tool_call_id=…)
|
|
2038
|
+
//
|
|
2039
|
+
// OpenAI-compatible providers require every tool_call to be answered,
|
|
2040
|
+
// and a strict one rejects the request outright — so a CHANGES-REQUIRED
|
|
2041
|
+
// audit would fail with a protocol error rather than a verdict, and
|
|
2042
|
+
// #128's fail-open would then mark the goal done.
|
|
2043
|
+
//
|
|
2044
|
+
// Answering first also makes the log honest: the call really was
|
|
2045
|
+
// completed, it simply did not end the round.
|
|
2046
|
+
await this.answerMeta(call, "(completion submitted for audit)");
|
|
1925
2047
|
await this.handleFinish(call.function.arguments);
|
|
1926
2048
|
// surface still-running children so the operator knows work may
|
|
1927
2049
|
// continue after this agent goes idle
|
|
@@ -2475,6 +2597,8 @@ export class Agent {
|
|
|
2475
2597
|
event: "audit-started",
|
|
2476
2598
|
verify: this.goal.verify.slice(0, 2000),
|
|
2477
2599
|
});
|
|
2600
|
+
// #94: number the attempts — the final carries the attempt that approved it
|
|
2601
|
+
this.auditAttempts++;
|
|
2478
2602
|
try {
|
|
2479
2603
|
const auditPrompt = `You are an independent completion AUDITOR (not the worker). Decide whether the goal is genuinely complete.\n\n` +
|
|
2480
2604
|
`GOAL:\n${this.goal.text.slice(0, 4000)}\n\nVERIFICATION CONTRACT (must ALL hold):\n${this.goal.verify.slice(0, 2000)}\n\n` +
|
|
@@ -2509,13 +2633,33 @@ export class Agent {
|
|
|
2509
2633
|
const usable = verdictLine.length > 0 && !PLACEHOLDER.test(verdictLine);
|
|
2510
2634
|
const decided = usable && /^(APPROVED|CHANGES-REQUIRED)\b/i.test(verdictLine);
|
|
2511
2635
|
if (!decided) {
|
|
2636
|
+
// #128: an unusable reply is NOT an approval. The old path failed
|
|
2637
|
+
// OPEN — recorded the failure and marked the goal done anyway —
|
|
2638
|
+
// so a verification contract was "satisfied" that nobody verified.
|
|
2639
|
+
// Fail CLOSED instead: the goal stays active and the worker is
|
|
2640
|
+
// asked to self-verify, with a bounded streak (see the field).
|
|
2641
|
+
this.auditUnavailableStreak++;
|
|
2512
2642
|
await this.log.append("system_note", this.currentSession, this.currentBranch, {
|
|
2513
2643
|
event: "audit-failed",
|
|
2514
2644
|
detail: usable
|
|
2515
2645
|
? `auditor reply had no verdict: ${verdictLine.slice(0, 200)}`
|
|
2516
2646
|
: "auditor returned no verdict",
|
|
2647
|
+
attempt: this.auditUnavailableStreak,
|
|
2517
2648
|
});
|
|
2518
|
-
|
|
2649
|
+
if (this.auditUnavailableStreak < 3) {
|
|
2650
|
+
this.queueAuditUnavailableRetry();
|
|
2651
|
+
await this.captureRecentTurns();
|
|
2652
|
+
return;
|
|
2653
|
+
}
|
|
2654
|
+
// bounded fallback, same philosophy as the reject bound in the
|
|
2655
|
+
// loop: the goal is left ACTIVE and a loud note stops the loop —
|
|
2656
|
+
// the operator sees it and decides. Never "honored as done".
|
|
2657
|
+
await this.log.append("system_note", this.currentSession, this.currentBranch, {
|
|
2658
|
+
event: "audit-unavailable-cap",
|
|
2659
|
+
attempts: this.auditUnavailableStreak,
|
|
2660
|
+
detail: "the completion audit could not produce a verdict repeatedly — stopping so the operator decides (#128)",
|
|
2661
|
+
});
|
|
2662
|
+
await this.captureRecentTurns();
|
|
2519
2663
|
return;
|
|
2520
2664
|
}
|
|
2521
2665
|
const approved = /^APPROVED\b/i.test(verdictLine);
|
|
@@ -2526,6 +2670,29 @@ export class Agent {
|
|
|
2526
2670
|
// the honest value: the auditor returned a verdict and no reason,
|
|
2527
2671
|
// which every consumer can already render as "label only".
|
|
2528
2672
|
await this.setGoalAudit(approved ? "approved" : "changes-required", feedback);
|
|
2673
|
+
// a real verdict lands: the no-verdict streak restarts either way —
|
|
2674
|
+
// the streak's own JSDoc promises "reset by any real verdict", and
|
|
2675
|
+
// without this a flaky auditor (2 unusable, then a real verdict,
|
|
2676
|
+
// then 1 unusable) hit the cap far too early
|
|
2677
|
+
this.auditUnavailableStreak = 0;
|
|
2678
|
+
if (approved) {
|
|
2679
|
+
this.consecutiveAuditRejects = 0;
|
|
2680
|
+
// #91/#94: the final will carry WHAT was approved, on WHICH attempt,
|
|
2681
|
+
// and what the previous attempt was told (if it was rejected first)
|
|
2682
|
+
this.lastAuditOutcome = {
|
|
2683
|
+
audited: true,
|
|
2684
|
+
verdict: "approved",
|
|
2685
|
+
attempt: this.auditAttempts,
|
|
2686
|
+
...(this.lastRejectedAudit
|
|
2687
|
+
? { previous: { verdict: "changes-required", feedback: this.lastRejectedAudit.feedback } }
|
|
2688
|
+
: {}),
|
|
2689
|
+
};
|
|
2690
|
+
}
|
|
2691
|
+
else {
|
|
2692
|
+
// #94: the rejection survives in memory so the eventual approved
|
|
2693
|
+
// final can name what the first attempt was told
|
|
2694
|
+
this.lastRejectedAudit = { feedback, attempt: this.auditAttempts };
|
|
2695
|
+
}
|
|
2529
2696
|
// `operatorFacing: true` — the verdict is written for the TIMELINE
|
|
2530
2697
|
// only. The audit ran in its own side conversation (a tools-less call
|
|
2531
2698
|
// over buildMessages() + an audit prompt), so it was never in
|
|
@@ -2541,6 +2708,10 @@ export class Agent {
|
|
|
2541
2708
|
: `🔍 completion audit: CHANGES REQUIRED — ${feedback}`,
|
|
2542
2709
|
});
|
|
2543
2710
|
if (!approved) {
|
|
2711
|
+
this.consecutiveAuditRejects++;
|
|
2712
|
+
// #94: the rejection survives in memory so the eventual approved
|
|
2713
|
+
// final can name what the first attempt was told
|
|
2714
|
+
this.lastRejectedAudit = { feedback, attempt: this.auditAttempts };
|
|
2544
2715
|
// hand the gaps back to the worker as its next instruction.
|
|
2545
2716
|
// #51: with the "(no detail)" placeholder gone, an empty verdict
|
|
2546
2717
|
// would leave this prompt saying "address these gaps" and then
|
|
@@ -2548,10 +2719,21 @@ export class Agent {
|
|
|
2548
2719
|
const gaps = feedback.trim()
|
|
2549
2720
|
? feedback.slice(0, 2000)
|
|
2550
2721
|
: `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)}`;
|
|
2551
|
-
this.
|
|
2552
|
-
|
|
2553
|
-
|
|
2554
|
-
|
|
2722
|
+
const retryText = `[harness] The completion audit REJECTED this finish. Address these gaps, then finish again with goalComplete=true:\n\n${gaps}`;
|
|
2723
|
+
// #128: counted above (consecutiveAuditRejects++ at the verdict) so
|
|
2724
|
+
// a model that cannot satisfy the auditor cannot loop forever — the
|
|
2725
|
+
// retry prompt is queued only while the bound is NOT yet reached,
|
|
2726
|
+
// so the loop's cap break strands nothing (a prompt queued AT the
|
|
2727
|
+
// cap would live on as an invisible no-id entry: no badge, no log
|
|
2728
|
+
// row, but isLive() true). Log the row too: a push-only prompt is
|
|
2729
|
+
// invisible on the timeline and lost to a restart otherwise.
|
|
2730
|
+
if (this.consecutiveAuditRejects < (this.opts.maxAuditRejects ?? 3)) {
|
|
2731
|
+
this.pendingPrompts.push({ source: "harness", text: retryText });
|
|
2732
|
+
await this.log.append("prompt", this.currentSession, this.currentBranch, {
|
|
2733
|
+
source: "harness",
|
|
2734
|
+
text: retryText,
|
|
2735
|
+
});
|
|
2736
|
+
}
|
|
2555
2737
|
// #89: RETURN before the final message is written.
|
|
2556
2738
|
//
|
|
2557
2739
|
// Falling through emitted `final: true` while the goal was still
|
|
@@ -2568,25 +2750,78 @@ export class Agent {
|
|
|
2568
2750
|
}
|
|
2569
2751
|
}
|
|
2570
2752
|
catch (err) {
|
|
2571
|
-
//
|
|
2572
|
-
//
|
|
2753
|
+
// #128: an auditor that THROWS is not an approval either. Same
|
|
2754
|
+
// fail-closed treatment as a no-verdict reply: active goal, bounded
|
|
2755
|
+
// self-verify retries, then a loud stop for the operator.
|
|
2756
|
+
this.auditUnavailableStreak++;
|
|
2573
2757
|
await this.log.append("system_note", this.currentSession, this.currentBranch, {
|
|
2574
2758
|
event: "audit-failed",
|
|
2575
2759
|
detail: String(err.message).slice(0, 300),
|
|
2760
|
+
attempt: this.auditUnavailableStreak,
|
|
2576
2761
|
});
|
|
2577
|
-
|
|
2762
|
+
if (this.auditUnavailableStreak < 3) {
|
|
2763
|
+
this.queueAuditUnavailableRetry();
|
|
2764
|
+
await this.captureRecentTurns();
|
|
2765
|
+
return;
|
|
2766
|
+
}
|
|
2767
|
+
await this.log.append("system_note", this.currentSession, this.currentBranch, {
|
|
2768
|
+
event: "audit-unavailable-cap",
|
|
2769
|
+
attempts: this.auditUnavailableStreak,
|
|
2770
|
+
detail: "the completion audit could not produce a verdict repeatedly — stopping so the operator decides (#128)",
|
|
2771
|
+
});
|
|
2772
|
+
await this.captureRecentTurns();
|
|
2773
|
+
return;
|
|
2578
2774
|
}
|
|
2579
2775
|
}
|
|
2580
2776
|
else {
|
|
2581
2777
|
await this.setGoalStatus("done");
|
|
2582
2778
|
}
|
|
2583
2779
|
}
|
|
2780
|
+
await this.appendFinalMessage(args);
|
|
2781
|
+
}
|
|
2782
|
+
/**
|
|
2783
|
+
* #128: the retry prompt for an audit that could not produce a verdict.
|
|
2784
|
+
* Carries a real instruction (unlike a reason-less rejection — #90), so the
|
|
2785
|
+
* worker re-checks the contract itself instead of being told to fix nothing.
|
|
2786
|
+
*/
|
|
2787
|
+
queueAuditUnavailableRetry() {
|
|
2788
|
+
const text = `[harness] The completion audit could not run (the auditor returned no verdict). ` +
|
|
2789
|
+
`Do NOT treat the work as verified: re-check the verification contract yourself, item by item, ` +
|
|
2790
|
+
`then finish with goalComplete=true again.\n\n` +
|
|
2791
|
+
`${this.goal.verify?.slice(0, 2000) || this.goal.text.slice(0, 2000)}`;
|
|
2792
|
+
this.pendingPrompts.push({ source: "harness", text });
|
|
2793
|
+
// log the row: a push-only prompt is invisible on the timeline and lost to
|
|
2794
|
+
// a restart (rebuildMessagesFrom replays prompt rows) — same as enqueuePrompt
|
|
2795
|
+
void this.log
|
|
2796
|
+
.append("prompt", this.currentSession, this.currentBranch, { source: "harness", text })
|
|
2797
|
+
.catch(() => { });
|
|
2798
|
+
}
|
|
2799
|
+
/** The `final: true` event — the parent's notification (#89: never before the goal settles). */
|
|
2800
|
+
async appendFinalMessage(args) {
|
|
2584
2801
|
const recent = await this.captureRecentTurns();
|
|
2802
|
+
// #91/#94: the final says how it relates to the audit — approved on which
|
|
2803
|
+
// attempt, what the previous attempt was told. Cleared on emit: a later
|
|
2804
|
+
// unaudited finish must not inherit stale lineage.
|
|
2805
|
+
const outcome = this.lastAuditOutcome;
|
|
2806
|
+
this.lastAuditOutcome = null;
|
|
2585
2807
|
await this.log.append("message", this.currentSession, this.currentBranch, {
|
|
2586
2808
|
role: "assistant",
|
|
2587
2809
|
final: true,
|
|
2588
2810
|
content: String(args.summary ?? ""),
|
|
2589
2811
|
...(recent.length ? { recentContext: recent } : {}),
|
|
2812
|
+
...(outcome
|
|
2813
|
+
? {
|
|
2814
|
+
audited: outcome.audited,
|
|
2815
|
+
auditVerdict: outcome.verdict,
|
|
2816
|
+
auditAttempt: outcome.attempt,
|
|
2817
|
+
...(outcome.previous
|
|
2818
|
+
? {
|
|
2819
|
+
previousAuditVerdict: outcome.previous.verdict,
|
|
2820
|
+
previousAuditFeedback: outcome.previous.feedback.slice(0, 2000),
|
|
2821
|
+
}
|
|
2822
|
+
: {}),
|
|
2823
|
+
}
|
|
2824
|
+
: {}),
|
|
2590
2825
|
});
|
|
2591
2826
|
}
|
|
2592
2827
|
async maybeRequestProgress() {
|
|
@@ -2698,6 +2933,12 @@ export class Agent {
|
|
|
2698
2933
|
* warm across restarts.
|
|
2699
2934
|
*/
|
|
2700
2935
|
async answerMeta(call, content) {
|
|
2936
|
+
// one tool_call gets ONE answer: the finish path answers before the audit
|
|
2937
|
+
// (#128) AND at round end — a second answerMeta for the same id would push
|
|
2938
|
+
// a duplicate `tool` message AND a duplicate log row, and strict
|
|
2939
|
+
// OpenAI-compatible providers reject two responses for one call with a 400.
|
|
2940
|
+
if (this.messages.some((m) => m.role === "tool" && m.tool_call_id === call.id))
|
|
2941
|
+
return;
|
|
2701
2942
|
const raw = call.function.arguments ?? "{}";
|
|
2702
2943
|
this.messages.push({ role: "tool", tool_call_id: call.id, content });
|
|
2703
2944
|
// log the call AND its answer so restores replay byte-exact sequences
|
|
@@ -2815,157 +3056,178 @@ export class Agent {
|
|
|
2815
3056
|
// progress is visible: announce the phase up front, then stream the
|
|
2816
3057
|
// summarizer's output as it generates (a long summarize used to look like
|
|
2817
3058
|
// the agent silently hung — hundreds of thousands of tokens take a while)
|
|
3059
|
+
// #127: the STATE must be established BEFORE the first notification — the
|
|
3060
|
+
// emit used to precede `compactPhase` by an async log write, so the first
|
|
3061
|
+
// progress event observed a snapshot with `compactPhase` still unset:
|
|
3062
|
+
// "idle, not compacting, but a summarizing banner" from three paths at
|
|
3063
|
+
// once. Sync set → emit → log keeps every path consistent.
|
|
3064
|
+
this.compactPhase = "summarizing";
|
|
2818
3065
|
bus.emit("update", {
|
|
2819
3066
|
kind: "compaction-progress",
|
|
2820
3067
|
agentId: this.opts.id,
|
|
2821
3068
|
phase: "summarizing",
|
|
2822
3069
|
summarized: oldCount,
|
|
2823
3070
|
});
|
|
2824
|
-
//
|
|
2825
|
-
//
|
|
2826
|
-
//
|
|
2827
|
-
//
|
|
2828
|
-
// whole pass is over. Log the announcement as a visible system_note so
|
|
2829
|
-
// the operator sees WHERE the conversation was rewritten, even if the
|
|
2830
|
-
// pass later fails — `before`/`oldCount` are known at this point, and the
|
|
2831
|
-
// completion divider still carries the authoritative after-figures.
|
|
2832
|
-
await this.log.append("system_note", this.currentSession, this.currentBranch, {
|
|
2833
|
-
event: "context-compaction-started",
|
|
2834
|
-
tokensBefore: before,
|
|
2835
|
-
messages: oldCount,
|
|
2836
|
-
reason: force ? "manual" : "auto",
|
|
2837
|
-
});
|
|
2838
|
-
this.compactPhase = "summarizing";
|
|
3071
|
+
// Everything below may throw — EventLog appends reject on stream errors,
|
|
3072
|
+
// and disk-full is exactly when they will. The phase MUST not survive a
|
|
3073
|
+
// failed compaction: isLive() keys off it, so a leaked "summarizing"
|
|
3074
|
+
// pinned live=true forever (stop offered, never worked).
|
|
2839
3075
|
try {
|
|
2840
|
-
|
|
2841
|
-
|
|
2842
|
-
|
|
2843
|
-
|
|
2844
|
-
|
|
2845
|
-
|
|
2846
|
-
|
|
2847
|
-
|
|
2848
|
-
this.
|
|
2849
|
-
|
|
2850
|
-
|
|
2851
|
-
|
|
2852
|
-
|
|
2853
|
-
catch (err) {
|
|
2854
|
-
await this.log.append("error", this.currentSession, this.currentBranch, {
|
|
2855
|
-
message: `compaction summarize failed, falling back to truncation: ${err.message}`,
|
|
3076
|
+
// #56: the START belongs on the timeline too. Only the right panel showed
|
|
3077
|
+
// compaction, and it reports the *outcome*; a long summarize (hundreds of
|
|
3078
|
+
// thousands of tokens) looks exactly like a hung agent on the timeline,
|
|
3079
|
+
// because the first `context-compacted` event is only appended once the
|
|
3080
|
+
// whole pass is over. Log the announcement as a visible system_note so
|
|
3081
|
+
// the operator sees WHERE the conversation was rewritten, even if the
|
|
3082
|
+
// pass later fails — `before`/`oldCount` are known at this point, and the
|
|
3083
|
+
// completion divider still carries the authoritative after-figures.
|
|
3084
|
+
await this.log.append("system_note", this.currentSession, this.currentBranch, {
|
|
3085
|
+
event: "context-compaction-started",
|
|
3086
|
+
tokensBefore: before,
|
|
3087
|
+
messages: oldCount,
|
|
3088
|
+
reason: force ? "manual" : "auto",
|
|
2856
3089
|
});
|
|
2857
|
-
|
|
2858
|
-
|
|
2859
|
-
|
|
2860
|
-
|
|
2861
|
-
|
|
2862
|
-
|
|
2863
|
-
|
|
2864
|
-
|
|
2865
|
-
|
|
2866
|
-
|
|
2867
|
-
// call overflowed — the "unstable after compact" report (#8). Always drop
|
|
2868
|
-
// at least one message, and say so.
|
|
2869
|
-
const halfCut = this.safeCut(Math.max(1, Math.floor(oldCount / 2)));
|
|
2870
|
-
if (halfCut <= 0) {
|
|
2871
|
-
// nothing in the prefix is safe to drop (e.g. it is all tool results).
|
|
2872
|
-
// Report it instead of pretending compaction happened.
|
|
2873
|
-
await this.log.append("system_note", this.currentSession, this.currentBranch, {
|
|
2874
|
-
event: "context-compaction-skipped",
|
|
2875
|
-
reason: "summarize failed and no safe prefix to truncate",
|
|
3090
|
+
try {
|
|
3091
|
+
summary = await this.summarize(old, (text) => {
|
|
3092
|
+
bus.emit("update", {
|
|
3093
|
+
kind: "llm-delta",
|
|
3094
|
+
agentId: this.opts.id,
|
|
3095
|
+
// #146: the bubble is session-scoped, so the stream must say which session
|
|
3096
|
+
sessionId: this.currentSession,
|
|
3097
|
+
text: `[compact] ${text}`,
|
|
3098
|
+
reasoning: "",
|
|
3099
|
+
});
|
|
2876
3100
|
});
|
|
2877
|
-
this.compactPhase = "";
|
|
2878
|
-
bus.emit("update", { kind: "compaction-progress", agentId: this.opts.id, phase: "
|
|
2879
|
-
|
|
3101
|
+
this.compactPhase = "harvesting";
|
|
3102
|
+
bus.emit("update", { kind: "compaction-progress", agentId: this.opts.id, phase: "harvesting" });
|
|
3103
|
+
if (summary)
|
|
3104
|
+
await this.harvestLessons(summary); // durable knowledge → memory.md
|
|
2880
3105
|
}
|
|
2881
|
-
|
|
2882
|
-
|
|
2883
|
-
|
|
2884
|
-
|
|
2885
|
-
|
|
2886
|
-
|
|
2887
|
-
|
|
2888
|
-
|
|
2889
|
-
|
|
2890
|
-
|
|
2891
|
-
|
|
2892
|
-
|
|
2893
|
-
|
|
2894
|
-
|
|
2895
|
-
|
|
2896
|
-
|
|
2897
|
-
|
|
2898
|
-
|
|
2899
|
-
|
|
2900
|
-
|
|
2901
|
-
|
|
2902
|
-
|
|
2903
|
-
|
|
2904
|
-
|
|
2905
|
-
|
|
2906
|
-
const text = await fs.readFile(abs, "utf8");
|
|
2907
|
-
const head = text.split("\n").slice(0, 400).join("\n");
|
|
2908
|
-
restored.push(`--- ${rel}${text.length > head.length ? " (first 400 lines)" : ""} ---\n${head}`);
|
|
3106
|
+
catch (err) {
|
|
3107
|
+
await this.log.append("error", this.currentSession, this.currentBranch, {
|
|
3108
|
+
message: `compaction summarize failed, falling back to truncation: ${err.message}`,
|
|
3109
|
+
});
|
|
3110
|
+
}
|
|
3111
|
+
if (!summary) {
|
|
3112
|
+
// fallback: drop the oldest half without LLM help so work can continue
|
|
3113
|
+
mode = "truncate";
|
|
3114
|
+
summarizedCount = 0;
|
|
3115
|
+
// Cut at a safe boundary, but never at index 0 — that would drop the
|
|
3116
|
+
// ENTIRE history (including the prompt just delivered) and leave the agent
|
|
3117
|
+
// with nothing. On a short history floor(oldCount/2) can round to 0, and
|
|
3118
|
+
// the old code then bailed out silently: compaction "ran", reported
|
|
3119
|
+
// nothing, and the context stayed over budget, so the very next provider
|
|
3120
|
+
// call overflowed — the "unstable after compact" report (#8). Always drop
|
|
3121
|
+
// at least one message, and say so.
|
|
3122
|
+
const halfCut = this.safeCut(Math.max(1, Math.floor(oldCount / 2)));
|
|
3123
|
+
if (halfCut <= 0) {
|
|
3124
|
+
// nothing in the prefix is safe to drop (e.g. it is all tool results).
|
|
3125
|
+
// Report it instead of pretending compaction happened.
|
|
3126
|
+
await this.log.append("system_note", this.currentSession, this.currentBranch, {
|
|
3127
|
+
event: "context-compaction-skipped",
|
|
3128
|
+
reason: "summarize failed and no safe prefix to truncate",
|
|
3129
|
+
});
|
|
3130
|
+
return; // the finally clears the phase and tells the UI it ended
|
|
2909
3131
|
}
|
|
2910
|
-
|
|
2911
|
-
|
|
3132
|
+
droppedCount = halfCut;
|
|
3133
|
+
this.messages = this.messages.slice(halfCut);
|
|
3134
|
+
}
|
|
3135
|
+
else {
|
|
3136
|
+
this.messages = [
|
|
3137
|
+
{
|
|
3138
|
+
role: "user",
|
|
3139
|
+
content: `[harness] Context was compacted: ${oldCount} earlier messages were summarized. ` +
|
|
3140
|
+
"Goal and notes are managed by the harness (AGENTS.md / memory.md are injected into your prompt when present).\n\n" +
|
|
3141
|
+
`## Summary of earlier conversation\n${summary}`,
|
|
3142
|
+
},
|
|
3143
|
+
...this.messages.slice(cut),
|
|
3144
|
+
];
|
|
3145
|
+
}
|
|
3146
|
+
const after = this.estimateTokens();
|
|
3147
|
+
this.stats.compactions++;
|
|
3148
|
+
this.compactedAtLen = this.messages.length;
|
|
3149
|
+
// Post-compact file restore (Claude Code style): re-attach the most
|
|
3150
|
+
// recently read files so the model doesn't burn its first turns re-reading
|
|
3151
|
+
// exactly what it had in context a moment ago. Budget: 3 files × 400 lines.
|
|
3152
|
+
if (this.recentReads.length && mode === "summarize") {
|
|
3153
|
+
const restored = [];
|
|
3154
|
+
const restoredPaths = new Set();
|
|
3155
|
+
for (const rel of this.recentReads.slice(0, 3)) {
|
|
3156
|
+
try {
|
|
3157
|
+
const abs = safeJoin(this.opts.workspace, rel);
|
|
3158
|
+
const text = await fs.readFile(abs, "utf8");
|
|
3159
|
+
const head = text.split("\n").slice(0, 400).join("\n");
|
|
3160
|
+
restored.push(`--- ${rel}${text.length > head.length ? " (first 400 lines)" : ""} ---\n${head}`);
|
|
3161
|
+
restoredPaths.add(rel);
|
|
3162
|
+
}
|
|
3163
|
+
catch {
|
|
3164
|
+
/* file vanished since the read — skip */
|
|
3165
|
+
}
|
|
3166
|
+
}
|
|
3167
|
+
if (restored.length) {
|
|
3168
|
+
this.messages.splice(1, 0, {
|
|
3169
|
+
role: "user",
|
|
3170
|
+
content: `[harness] These files were recently read and are re-attached for continuity ` +
|
|
3171
|
+
`(they may have changed — re-read if precision matters):\n\n${restored.join("\n\n")}`,
|
|
3172
|
+
});
|
|
3173
|
+
// keep exactly what restored, in order — slicing by COUNT kept a
|
|
3174
|
+
// vanished middle entry while dropping the surviving tail
|
|
3175
|
+
this.recentReads = this.recentReads.filter((rel) => restoredPaths.has(rel));
|
|
2912
3176
|
}
|
|
2913
3177
|
}
|
|
2914
|
-
|
|
2915
|
-
|
|
2916
|
-
|
|
2917
|
-
|
|
2918
|
-
|
|
3178
|
+
// #64 follow-up: compaction is the ONE point in a session where re-reading
|
|
3179
|
+
// AGENTS.md is both safe and expected.
|
|
3180
|
+
//
|
|
3181
|
+
// It is expected because compaction exists precisely to summarise work
|
|
3182
|
+
// against the CURRENT instructions: an agent that spent an hour under one
|
|
3183
|
+
// set of project rules, then compacted, would otherwise carry the old rules
|
|
3184
|
+
// over the summary and never see the edit.
|
|
3185
|
+
//
|
|
3186
|
+
// It is safe because the prefix-cache concern that motivates freezing the
|
|
3187
|
+
// prompt does not apply here. That concern is about a prompt changing
|
|
3188
|
+
// mid-turn, re-pricing the whole prefix on every call. A compaction
|
|
3189
|
+
// ALREADY rewrote the history the prompt sits on top of, so the cache is
|
|
3190
|
+
// being re-priced regardless — one more changed block costs nothing extra.
|
|
3191
|
+
//
|
|
3192
|
+
// Only AGENTS.md is re-read, deliberately. The workspace listing and the
|
|
3193
|
+
// skills catalogue stay frozen: both are a snapshot of the workspace as it
|
|
3194
|
+
// was, and re-listing mid-session would contradict the instruction to
|
|
3195
|
+
// "not list again what is already here". AGENTS.md is different — it is
|
|
3196
|
+
// instructions, not a listing, and a stale copy is actively misleading.
|
|
3197
|
+
const refreshed = this.reloadAgentsMd();
|
|
3198
|
+
if (refreshed) {
|
|
3199
|
+
await this.log.append("system_note", this.currentSession, this.currentBranch, {
|
|
3200
|
+
event: "agents-md-reloaded",
|
|
3201
|
+
bytes: refreshed.length,
|
|
2919
3202
|
});
|
|
2920
|
-
this.recentReads = this.recentReads.slice(0, restored.length); // keep order, drop misses
|
|
2921
3203
|
}
|
|
2922
|
-
|
|
2923
|
-
|
|
2924
|
-
|
|
2925
|
-
|
|
2926
|
-
|
|
2927
|
-
|
|
2928
|
-
|
|
2929
|
-
|
|
2930
|
-
|
|
2931
|
-
|
|
2932
|
-
// prompt does not apply here. That concern is about a prompt changing
|
|
2933
|
-
// mid-turn, re-pricing the whole prefix on every call. A compaction
|
|
2934
|
-
// ALREADY rewrote the history the prompt sits on top of, so the cache is
|
|
2935
|
-
// being re-priced regardless — one more changed block costs nothing extra.
|
|
2936
|
-
//
|
|
2937
|
-
// Only AGENTS.md is re-read, deliberately. The workspace listing and the
|
|
2938
|
-
// skills catalogue stay frozen: both are a snapshot of the workspace as it
|
|
2939
|
-
// was, and re-listing mid-session would contradict the instruction to
|
|
2940
|
-
// "not list again what is already here". AGENTS.md is different — it is
|
|
2941
|
-
// instructions, not a listing, and a stale copy is actively misleading.
|
|
2942
|
-
const refreshed = this.reloadAgentsMd();
|
|
2943
|
-
if (refreshed) {
|
|
3204
|
+
// the operator can inspect WHAT was remembered — a dedicated typed event
|
|
3205
|
+
// (not an agent message) keeps it out of the model's own voice
|
|
3206
|
+
await this.log.append("compaction", this.currentSession, this.currentBranch, {
|
|
3207
|
+
tokensBefore: before,
|
|
3208
|
+
tokensAfter: after,
|
|
3209
|
+
summarized: summarizedCount,
|
|
3210
|
+
dropped: droppedCount,
|
|
3211
|
+
mode,
|
|
3212
|
+
...(summary ? { summary: String(summary).slice(0, 20_000) } : {}),
|
|
3213
|
+
});
|
|
2944
3214
|
await this.log.append("system_note", this.currentSession, this.currentBranch, {
|
|
2945
|
-
event: "
|
|
2946
|
-
|
|
3215
|
+
event: "context-compacted",
|
|
3216
|
+
tokensBefore: before,
|
|
3217
|
+
tokensAfter: after,
|
|
3218
|
+
summarized: summarizedCount,
|
|
3219
|
+
dropped: droppedCount,
|
|
3220
|
+
mode,
|
|
2947
3221
|
});
|
|
2948
3222
|
}
|
|
2949
|
-
|
|
2950
|
-
|
|
2951
|
-
|
|
2952
|
-
|
|
2953
|
-
|
|
2954
|
-
|
|
2955
|
-
|
|
2956
|
-
|
|
2957
|
-
...(summary ? { summary: String(summary).slice(0, 20_000) } : {}),
|
|
2958
|
-
});
|
|
2959
|
-
await this.log.append("system_note", this.currentSession, this.currentBranch, {
|
|
2960
|
-
event: "context-compacted",
|
|
2961
|
-
tokensBefore: before,
|
|
2962
|
-
tokensAfter: after,
|
|
2963
|
-
summarized: summarizedCount,
|
|
2964
|
-
dropped: droppedCount,
|
|
2965
|
-
mode,
|
|
2966
|
-
});
|
|
2967
|
-
this.compactPhase = "";
|
|
2968
|
-
bus.emit("update", { kind: "compaction-progress", agentId: this.opts.id, phase: "done" });
|
|
3223
|
+
finally {
|
|
3224
|
+
this.compactPhase = "";
|
|
3225
|
+
// the end signal lives in the FINALLY: a compaction that dies mid-flight
|
|
3226
|
+
// (a log write rejecting on disk-full) used to leave the phase cleared
|
|
3227
|
+
// server-side while the UI banner stayed "summarizing" forever — the
|
|
3228
|
+
// client never got the done event. Emitting here covers every exit.
|
|
3229
|
+
bus.emit("update", { kind: "compaction-progress", agentId: this.opts.id, phase: "done" });
|
|
3230
|
+
}
|
|
2969
3231
|
}
|
|
2970
3232
|
/** Ask the model for dense continuation notes over the compacted range. */
|
|
2971
3233
|
async summarize(old, onDelta) {
|