@iowarp/clio-coder 0.3.3 → 0.3.4
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/CHANGELOG.md +39 -0
- package/CONTRIBUTING.md +1 -1
- package/README.md +3 -3
- package/dist/{acp-P2AQILE2.js → acp-S5R4RR5B.js} +7 -6
- package/dist/{agents-72W3BI7I.js → agents-P6DMMVZY.js} +24 -21
- package/dist/assets/codewiki.json +1 -1
- package/dist/{auth-5TWEIYDN.js → auth-2XCZLPKS.js} +12 -8
- package/dist/{chunk-GGXXDWE4.js → chunk-22NAGB7X.js} +2 -2
- package/dist/{chunk-2DJ2KNFG.js → chunk-2LZI5CAG.js} +133 -13
- package/dist/{chunk-OAO4GE4M.js → chunk-2TZWSW76.js} +2 -2
- package/dist/{chunk-OOJYHWRB.js → chunk-34475P3I.js} +2 -2
- package/dist/{chunk-4XUGQOHA.js → chunk-35MKKU5R.js} +4 -4
- package/dist/{chunk-LM5TQCJZ.js → chunk-3HZ5RWN2.js} +5 -5
- package/dist/{chunk-AGYYIBLL.js → chunk-3JLKSKD7.js} +2 -2
- package/dist/{chunk-KZWTDYJF.js → chunk-4JUF2NNX.js} +7 -7
- package/dist/{chunk-ZDOOVTXZ.js → chunk-4OC57DA6.js} +27 -4
- package/dist/chunk-5M54SPOL.js +926 -0
- package/dist/{chunk-STBPMHSX.js → chunk-7RXG6QRZ.js} +51 -11
- package/dist/{chunk-X6IAEBZR.js → chunk-A2GZF7DC.js} +5 -5
- package/dist/{chunk-A3CYT5EX.js → chunk-AD2SYQYC.js} +55 -2
- package/dist/chunk-AOCYTWAV.js +449 -0
- package/dist/chunk-BEY543CS.js +258 -0
- package/dist/{chunk-6N5PTWMY.js → chunk-BP4OYD6A.js} +32 -13
- package/dist/chunk-BPGS2WCQ.js +612 -0
- package/dist/{chunk-V6RTAOC2.js → chunk-BRXQQJFP.js} +8 -8
- package/dist/chunk-CFGTUFWB.js +67 -0
- package/dist/{chunk-CBCAPZAA.js → chunk-E25LMLRW.js} +2 -2
- package/dist/{chunk-DUYJ5IO6.js → chunk-EDRHSCIE.js} +4 -4
- package/dist/{chunk-XBXAASKX.js → chunk-EFADSJET.js} +2 -2
- package/dist/{chunk-M6SHUN7Q.js → chunk-FO5ZOVUY.js} +2 -2
- package/dist/chunk-FYYLNIL5.js +313 -0
- package/dist/{chunk-FNTMWMX5.js → chunk-HV5X7OR2.js} +14 -12
- package/dist/{chunk-TZTZS7QK.js → chunk-HXG4IURW.js} +5 -3
- package/dist/{chunk-5UFT4SUX.js → chunk-K6WL7QZT.js} +3 -3
- package/dist/chunk-K7VKOLQQ.js +15 -0
- package/dist/{chunk-BMEMKKIT.js → chunk-KOHPCX4K.js} +2 -2
- package/dist/{chunk-OC7FIQPC.js → chunk-KRPY7NTG.js} +10 -7
- package/dist/chunk-LL4KHSZI.js +22 -0
- package/dist/{chunk-DSELYM6W.js → chunk-MEQ45TQ4.js} +15 -9
- package/dist/{chunk-5UUP6MWO.js → chunk-MV3K5QF2.js} +5 -436
- package/dist/{chunk-6SGHMWE3.js → chunk-N4CZJQRK.js} +5 -5
- package/dist/{chunk-TZK7PACC.js → chunk-NILBFAPG.js} +14 -8
- package/dist/chunk-OZNBF4L3.js +23 -0
- package/dist/{verify-375KUB3Y.js → chunk-PCZJO5TI.js} +127 -42
- package/dist/chunk-QQK64KLB.js +1360 -0
- package/dist/{chunk-SRDMMSEP.js → chunk-QQL5RT5M.js} +979 -1619
- package/dist/{chunk-LZSJBIVT.js → chunk-QWU7ZBO7.js} +70 -720
- package/dist/{chunk-2TLUCQVG.js → chunk-RD5U66HV.js} +3 -3
- package/dist/{chunk-OKGUZO2U.js → chunk-SPULKLCF.js} +4 -3
- package/dist/{chunk-OQ33BKR3.js → chunk-TTNYS3EA.js} +3 -60
- package/dist/chunk-TW3WDMVS.js +677 -0
- package/dist/chunk-TZSKNMZG.js +434 -0
- package/dist/{chunk-7MNJORFF.js → chunk-UL3WSD3F.js} +6 -1
- package/dist/{chunk-PIWWS5BL.js → chunk-UZHIZC5S.js} +7 -7
- package/dist/{chunk-UFIIWP2H.js → chunk-VAWWTKDP.js} +8 -8
- package/dist/{chunk-COU2UHX6.js → chunk-VEZEGCGW.js} +170 -2
- package/dist/{chunk-LW6DSM3M.js → chunk-VMNQ6OZA.js} +98 -202
- package/dist/chunk-VSNATDE6.js +122 -0
- package/dist/chunk-W6GROXXM.js +69 -0
- package/dist/chunk-WPQLXFOZ.js +375 -0
- package/dist/{chunk-ORBHGJC5.js → chunk-WR67VIZY.js} +3 -3
- package/dist/{chunk-PAJK6MAQ.js → chunk-X6COSD2O.js} +5 -5
- package/dist/chunk-ZGVHUX3M.js +66 -0
- package/dist/{chunk-LWLEKMDQ.js → chunk-ZYKPLLNQ.js} +510 -547
- package/dist/cli/index.js +27 -23
- package/dist/{clio-JOU4FXVA.js → clio-J5JIOIDS.js} +7 -6
- package/dist/{code-nav-7AX6FYE6.js → code-nav-AXCXSBHX.js} +5 -3
- package/dist/{config-XCDVKR23.js → config-OEBMIN2U.js} +37 -27
- package/dist/{configure-4GAP54ZW.js → configure-PUQOSIXQ.js} +16 -13
- package/dist/{context-5VKGUVJJ.js → context-EKDCKUUZ.js} +82 -7
- package/dist/{context-4UOGGLQ5.js → context-MGSE4Z2T.js} +33 -23
- package/dist/{context-77FM5DV5.js → context-URSXPBCK.js} +17 -9
- package/dist/{context-clear-XXJRLCJJ.js → context-clear-KDAJRNUK.js} +33 -23
- package/dist/context-working-set-SBKMPPI2.js +1552 -0
- package/dist/{dispatch-runner-QPRDDBDX.js → dispatch-runner-MSWN72NK.js} +43 -29
- package/dist/{doctor-HR46URBJ.js → doctor-7BSE27PJ.js} +10 -10
- package/dist/{eval-XSSNATB4.js → eval-IZGDOO4H.js} +9 -8
- package/dist/{evidence-6HG2PY2B.js → evidence-SR7WXB5B.js} +51 -23
- package/dist/{evolve-K7YU3NCY.js → evolve-K7VE2CBX.js} +30 -20
- package/dist/{fleet-VY3HHKN6.js → fleet-7XMJNQNF.js} +48 -38
- package/dist/{fleet-preflight-DDN536IT.js → fleet-preflight-AQNAH644.js} +3 -3
- package/dist/{init-JYGXI3FK.js → init-JGNPAYXT.js} +41 -31
- package/dist/{memory-WFZMGYHX.js → memory-4ALKDJ4Q.js} +32 -22
- package/dist/{models-I5QWSEOM.js → models-ZMMLFJNN.js} +22 -19
- package/dist/{monitor-GE4ID3IA.js → monitor-2F3T5KHP.js} +55 -43
- package/dist/{orchestrator-EM5MC3HM.js → orchestrator-ORHT43JB.js} +572 -388
- package/dist/{reset-L2FQEE3E.js → reset-NXGTYNUO.js} +4 -3
- package/dist/{run-ZU3QMZPZ.js → run-RF4WJGMT.js} +51 -41
- package/dist/{share-S5BZQC5I.js → share-UT3W6E4M.js} +5 -4
- package/dist/{skills-X5VXCRNQ.js → skills-PSACKC5Q.js} +2 -2
- package/dist/{skills-eval-WKIHWTHR.js → skills-eval-WJSI55RZ.js} +34 -24
- package/dist/{targets-SNCPI2NR.js → targets-PIIRAOYS.js} +23 -20
- package/dist/{terminal-lease-BNAHVHBS.js → terminal-lease-ULWXWNVY.js} +4 -3
- package/dist/{upgrade-JQHHPQ4K.js → upgrade-346TZ6AV.js} +18 -17
- package/dist/{usage-OR4O5SMZ.js → usage-6KKXR32N.js} +34 -24
- package/dist/verifiers-4UUM6TEE.js +1214 -0
- package/dist/verify-X5HDROLA.js +25 -0
- package/dist/{wiki-generate-UEXP2ARI.js → wiki-generate-7STOCIFZ.js} +42 -31
- package/dist/worker/entry.js +33 -24
- package/docs/README.md +7 -6
- package/docs/acp.md +1 -1
- package/docs/alcf-provider.md +1 -1
- package/docs/architecture.md +2 -2
- package/docs/artifact-versions.md +1 -1
- package/docs/built-in-agents.md +1 -1
- package/docs/capacity-and-scheduling.md +1 -1
- package/docs/commands-and-modes.md +53 -21
- package/docs/config-knobs-audit.md +1 -2
- package/docs/configuration-and-targets.md +15 -1
- package/docs/context-engine.md +64 -12
- package/docs/context-working-set.md +194 -0
- package/docs/development-pipeline.md +1 -1
- package/docs/documentation-coverage.md +5 -5
- package/docs/documentation-guide.md +6 -5
- package/docs/environment-variables.md +2 -1
- package/docs/eval-runner.md +1 -1
- package/docs/evals-internal.md +14 -1
- package/docs/evidence-and-memory.md +74 -2
- package/docs/evolution.md +1 -1
- package/docs/exit-codes-and-output.md +1 -1
- package/docs/extensions-and-sharing.md +2 -2
- package/docs/fleet-dispatch.md +22 -7
- package/docs/glossary.md +21 -1
- package/docs/installation-and-lifecycle.md +2 -2
- package/docs/middleware-and-components.md +1 -1
- package/docs/model-catalog.md +7 -9
- package/docs/observability.md +4 -4
- package/docs/proactive-memory.md +1 -1
- package/docs/prompt-envelope-and-tools.md +4 -4
- package/docs/provider-adapter-cookbook.md +1 -1
- package/docs/release-cut-checklist.md +31 -31
- package/docs/safety-model.md +23 -4
- package/docs/scientific-validation.md +21 -3
- package/docs/session-lifecycle.md +3 -3
- package/docs/skills-marketplace.md +1 -1
- package/docs/tool-usage.md +79 -12
- package/docs/trace-store.md +1 -1
- package/docs/troubleshooting.md +1 -1
- package/docs/tui-design.md +1 -1
- package/docs/worker-dispatch-mechanics.md +11 -1
- package/package.json +8 -11
- package/skills/meta/clio-test/SKILL.md +20 -17
- package/skills/meta/clio-test/evals.md +3 -3
- package/skills/meta/clio-test/references/harness.md +35 -6
- package/skills/meta/clio-test/references/test-map.md +20 -10
- package/skills/registry.yaml +2 -2
- package/skills/skill-marketplace.json +1 -1
- package/src/cli/context-working-set.ts +513 -0
- package/src/cli/context.ts +8 -0
- package/src/cli/evidence.ts +20 -2
- package/src/cli/index.ts +4 -0
- package/src/cli/verifiers.ts +325 -0
- package/src/core/bash-exec.ts +39 -14
- package/src/core/bus-events.ts +19 -4
- package/src/core/config.ts +54 -0
- package/src/core/defaults.ts +50 -3
- package/src/core/verification-scripts.ts +6 -0
- package/src/domains/agents/builtins/verifier.md +3 -0
- package/src/domains/config/classify.ts +1 -0
- package/src/domains/context/working-set/contract.ts +161 -0
- package/src/domains/context/working-set/defaults.ts +28 -0
- package/src/domains/context/working-set/engine.ts +203 -0
- package/src/domains/context/working-set/fold.ts +62 -0
- package/src/domains/context/working-set/horizon.ts +38 -0
- package/src/domains/context/working-set/marker.ts +103 -0
- package/src/domains/context/working-set/path-index.ts +436 -0
- package/src/domains/context/working-set/payload.ts +152 -0
- package/src/domains/context/working-set/policies/age-horizon.ts +55 -0
- package/src/domains/context/working-set/policies/index.ts +21 -0
- package/src/domains/context/working-set/policies/structural.ts +160 -0
- package/src/domains/context/working-set/project.ts +132 -0
- package/src/domains/context/working-set/protect.ts +109 -0
- package/src/domains/context/working-set/recall.ts +177 -0
- package/src/domains/context/working-set/replay/controls.ts +112 -0
- package/src/domains/context/working-set/replay/load-clio.ts +199 -0
- package/src/domains/context/working-set/replay/metrics.ts +185 -0
- package/src/domains/context/working-set/replay/reference-graph.ts +79 -0
- package/src/domains/context/working-set/replay/report.ts +139 -0
- package/src/domains/context/working-set/replay/runner.ts +325 -0
- package/src/domains/context/working-set/replay/synthetic.ts +422 -0
- package/src/domains/context/working-set/replay/trace.ts +21 -0
- package/src/domains/context/working-set/visible.ts +54 -0
- package/src/domains/evidence/build.ts +112 -45
- package/src/domains/evidence/eval.ts +24 -7
- package/src/domains/evidence/index.ts +53 -0
- package/src/domains/evidence/ordering.ts +12 -0
- package/src/domains/evidence/run-trust.ts +221 -0
- package/src/domains/evidence/store.ts +46 -6
- package/src/domains/evidence/trust-status.ts +854 -0
- package/src/domains/evidence/types.ts +26 -0
- package/src/domains/middleware/memory-intervention.ts +3 -0
- package/src/domains/middleware/stalled-turn.ts +165 -4
- package/src/domains/safety/autonomy.ts +1 -1
- package/src/domains/safety/default-path-policy.ts +8 -0
- package/src/domains/safety/finish-contract.ts +4 -3
- package/src/domains/safety/policy-engine.ts +48 -6
- package/src/domains/session/compaction/compact.ts +23 -1
- package/src/domains/session/compaction/cut-point.ts +2 -0
- package/src/domains/session/compaction/tokens.ts +16 -1
- package/src/domains/session/context-ledger.ts +2 -0
- package/src/domains/session/entries.ts +107 -1
- package/src/domains/session/manager.ts +9 -2
- package/src/domains/session/migrations/index.ts +22 -3
- package/src/engine/acp/server.ts +3 -0
- package/src/engine/agent.ts +18 -1
- package/src/engine/session.ts +9 -3
- package/src/entry/orchestrator.ts +16 -4
- package/src/interactive/chat-loop-messages.ts +18 -6
- package/src/interactive/chat-panel.ts +17 -1
- package/src/interactive/chat-renderer.ts +30 -21
- package/src/interactive/context-meter.ts +10 -0
- package/src/interactive/context-overlay.ts +81 -6
- package/src/interactive/context-recall-command.ts +110 -0
- package/src/interactive/interactive-slash-runtime.ts +37 -1
- package/src/interactive/model-session-replay.ts +21 -0
- package/src/interactive/overlay-general-openers.ts +6 -0
- package/src/interactive/overlay-session-lifecycle.ts +8 -4
- package/src/interactive/renderers/tool-execution.ts +18 -2
- package/src/interactive/session-transcript.ts +2 -2
- package/src/interactive/slash-commands.ts +29 -2
- package/src/interactive/turn-context.ts +238 -88
- package/src/interactive/turn-middleware.ts +6 -6
- package/src/tools/agent-tools.ts +11 -4
- package/src/tools/bash.ts +144 -82
- package/src/tools/builtin-tool-catalog.ts +11 -5
- package/src/tools/context/index.ts +105 -3
- package/src/tools/context/surface.ts +3 -2
- package/src/tools/core-bootstrap.ts +21 -0
- package/src/tools/dispatch-runner.ts +9 -7
- package/src/tools/monitor.ts +28 -20
- package/src/tools/registry.ts +59 -7
- package/src/tools/result-disposition.ts +550 -0
- package/src/tools/result-shaping.ts +262 -19
- package/src/tools/safe-exec.ts +2 -0
- package/src/tools/verify/authoring.ts +1119 -0
- package/src/tools/verify/catalog.ts +346 -0
- package/src/tools/verify/index.ts +13 -3
- package/src/tools/verify/scripts.ts +135 -37
- package/src/tools/verify/surface.ts +9 -5
- package/src/tools/worker-evidence.ts +35 -12
- package/dist/chunk-J7CWMCQD.js +0 -255
- package/dist/chunk-T6YILFSB.js +0 -80
- package/dist/chunk-VAKQQHWR.js +0 -434
- package/dist/chunk-VPAYEGVX.js +0 -184
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `structural-v1`: evict what the session has structurally finished with.
|
|
3
|
+
*
|
|
4
|
+
* The age rule asks how old a result is. This one asks what happened to it
|
|
5
|
+
* since: was the file rewritten, did a later read cover the same lines, did the
|
|
6
|
+
* failure get resolved, has the listing been walked. Those are facts the ledger
|
|
7
|
+
* already records, and they are the facts a human would use to decide what is
|
|
8
|
+
* still worth carrying. Age is the last rung, not the first, and it only runs
|
|
9
|
+
* when the structural rungs did not free enough.
|
|
10
|
+
*
|
|
11
|
+
* Rule order is the policy. Each rung emits candidates newest-first, every
|
|
12
|
+
* candidate passes `isProtected`, and no unit is claimed twice, so a read that
|
|
13
|
+
* is both stale and superseded is evicted for the reason that came first and
|
|
14
|
+
* carries the ref that explains it. Rungs 1 to 5 are unconditional: redundant
|
|
15
|
+
* content is free to drop, whatever the pressure. Rung 6 is the only one that
|
|
16
|
+
* looks at token counts, and it stops the moment the projection reaches
|
|
17
|
+
* `target`.
|
|
18
|
+
*
|
|
19
|
+
* Deterministic by construction: the index is a pure function of the entries,
|
|
20
|
+
* every loop runs in index order, and nothing here reads a clock, a size
|
|
21
|
+
* ranking, or a recency score.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import type { EvictionCandidate, EvictionReason, PolicyInput, WorkingSetPolicy } from "../contract.js";
|
|
25
|
+
import { tokensFreedByEviction } from "../engine.js";
|
|
26
|
+
import { protectionCutoffIndex } from "../horizon.js";
|
|
27
|
+
import { buildPathIndex, callPathsByToolCallId, covers, type PathIndex, type PathObservation } from "../path-index.js";
|
|
28
|
+
import { hasThinking } from "../payload.js";
|
|
29
|
+
import { findLaterSuccess, isProtected } from "../protect.js";
|
|
30
|
+
|
|
31
|
+
/** Ops that observe content rather than change it. */
|
|
32
|
+
const READ_CLASS = new Set<PathObservation["op"]>(["read", "grep", "find", "ls", "code_nav"]);
|
|
33
|
+
const MUTATING = new Set<PathObservation["op"]>(["write", "edit"]);
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* The mutation that invalidated this observation: the first successful one
|
|
37
|
+
* after it. A failed edit (`oldText not found`, permission denied) changed
|
|
38
|
+
* nothing, and the read it was aimed at is exactly what the model needs to fix
|
|
39
|
+
* the edit.
|
|
40
|
+
*/
|
|
41
|
+
function firstMutationAfter(observation: PathObservation, index: PathIndex): PathObservation | null {
|
|
42
|
+
for (const other of index.byPath.get(observation.path) ?? []) {
|
|
43
|
+
if (other.entryIndex > observation.entryIndex && MUTATING.has(other.op) && !other.isError) return other;
|
|
44
|
+
}
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** The most recent later read of the same file that covers this one's lines. */
|
|
49
|
+
function lastCoveringRead(observation: PathObservation, index: PathIndex): PathObservation | null {
|
|
50
|
+
let found: PathObservation | null = null;
|
|
51
|
+
for (const other of index.byPath.get(observation.path) ?? []) {
|
|
52
|
+
if (other.entryIndex <= observation.entryIndex || other.op !== "read" || other.isError) continue;
|
|
53
|
+
if (covers(other.range, observation.range)) found = other;
|
|
54
|
+
}
|
|
55
|
+
return found;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Every surfaced path went on to be read. A path nobody read is an unread path. */
|
|
59
|
+
function isListingConsumed(observation: PathObservation, index: PathIndex): boolean {
|
|
60
|
+
if (observation.surfaced.length === 0) return false;
|
|
61
|
+
for (const path of observation.surfaced) {
|
|
62
|
+
const readLater = (index.byPath.get(path) ?? []).some(
|
|
63
|
+
(other) => other.op === "read" && !other.isError && other.entryIndex > observation.entryIndex,
|
|
64
|
+
);
|
|
65
|
+
if (!readLater) return false;
|
|
66
|
+
}
|
|
67
|
+
return true;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export const structuralPolicy: WorkingSetPolicy = {
|
|
71
|
+
id: "structural-v1",
|
|
72
|
+
select(input: PolicyInput): ReadonlyArray<EvictionCandidate> {
|
|
73
|
+
const { entries, view, settings, pressure, estimateTokens } = input;
|
|
74
|
+
const index = buildPathIndex(entries, { cwd: input.cwd });
|
|
75
|
+
const callPaths = callPathsByToolCallId(entries);
|
|
76
|
+
const cutoffIndex = protectionCutoffIndex(entries, settings.protectLastTurns);
|
|
77
|
+
const candidates: EvictionCandidate[] = [];
|
|
78
|
+
const claimed = new Set<string>();
|
|
79
|
+
let freed = 0;
|
|
80
|
+
|
|
81
|
+
const entryIndexOf = new Map<string, number>();
|
|
82
|
+
for (let i = 0; i < entries.length; i += 1) {
|
|
83
|
+
const entry = entries[i];
|
|
84
|
+
if (entry !== undefined) entryIndexOf.set(entry.turnId, i);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const emit = (turnId: string, reason: EvictionReason, by?: string): boolean => {
|
|
88
|
+
if (claimed.has(turnId) || view.evicted.has(turnId)) return false;
|
|
89
|
+
const entryIndex = entryIndexOf.get(turnId);
|
|
90
|
+
if (entryIndex === undefined) return false;
|
|
91
|
+
const entry = entries[entryIndex];
|
|
92
|
+
if (entry === undefined) return false;
|
|
93
|
+
if (isProtected(entry, { entryIndex, cutoffIndex, input, index })) return false;
|
|
94
|
+
const candidate: EvictionCandidate = { ref: { entry: turnId }, reason, ...(by === undefined ? {} : { by }) };
|
|
95
|
+
claimed.add(turnId);
|
|
96
|
+
candidates.push(candidate);
|
|
97
|
+
freed += tokensFreedByEviction(estimateTokens, entry, candidate, callPaths);
|
|
98
|
+
return true;
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
// Newest-first within every rung, for the cost reason in charter 4.6:
|
|
102
|
+
// evicting the youngest safe unit keeps the cold region after the
|
|
103
|
+
// eviction point small, so the turn that pays for the event pays least.
|
|
104
|
+
const newestFirst = [...index.observations].reverse();
|
|
105
|
+
|
|
106
|
+
// 1. The file changed under it. Whatever the body said is now a claim
|
|
107
|
+
// about a file that no longer exists in that form.
|
|
108
|
+
for (const observation of newestFirst) {
|
|
109
|
+
if (!READ_CLASS.has(observation.op) || observation.path.length === 0) continue;
|
|
110
|
+
const mutation = firstMutationAfter(observation, index);
|
|
111
|
+
if (mutation !== null) emit(observation.ref.entry, "stale_after_mutation", mutation.ref.entry);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// 2. The agent asked for the same lines again. It already decided this
|
|
115
|
+
// content was worth re-fetching, and the newer copy is the live one.
|
|
116
|
+
for (const observation of newestFirst) {
|
|
117
|
+
if (observation.op !== "read" || observation.path.length === 0) continue;
|
|
118
|
+
const superseding = lastCoveringRead(observation, index);
|
|
119
|
+
if (superseding !== null) emit(observation.ref.entry, "superseded_read", superseding.ref.entry);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// 3. The failure was resolved. The marker keeps its first line, because
|
|
123
|
+
// a failure that happened is evidence even once it is fixed.
|
|
124
|
+
for (const observation of newestFirst) {
|
|
125
|
+
if (!observation.isError) continue;
|
|
126
|
+
const success = findLaterSuccess(observation, index);
|
|
127
|
+
if (success !== null) emit(observation.ref.entry, "failure_resolved", success.ref.entry);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// 4. The listing has been walked. One surfaced path still unread and it
|
|
131
|
+
// stays: that is the path the agent comes back to.
|
|
132
|
+
for (const observation of newestFirst) {
|
|
133
|
+
if (isListingConsumed(observation, index)) emit(observation.ref.entry, "listing_consumed");
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// 5. Reasoning from a closed turn, the same rule age-horizon applies.
|
|
137
|
+
for (let i = cutoffIndex - 1; i >= 0; i -= 1) {
|
|
138
|
+
const entry = entries[i];
|
|
139
|
+
if (entry?.kind !== "message" || entry.role !== "assistant") continue;
|
|
140
|
+
if (hasThinking(entry.payload)) emit(entry.turnId, "thinking_turn_closed");
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// 6. Age, and only under pressure. Everything above is redundancy the
|
|
144
|
+
// session can lose for free; this rung loses content that is still
|
|
145
|
+
// good, so it runs only when the projection is still over threshold
|
|
146
|
+
// and stops the moment it reaches target.
|
|
147
|
+
const window = pressure.contextWindow;
|
|
148
|
+
if (window <= 0) return candidates;
|
|
149
|
+
let projected = pressure.tokens - freed;
|
|
150
|
+
if (projected <= pressure.threshold * window) return candidates;
|
|
151
|
+
const targetTokens = pressure.target * window;
|
|
152
|
+
for (let i = cutoffIndex - 1; i >= 0 && projected > targetTokens; i -= 1) {
|
|
153
|
+
const entry = entries[i];
|
|
154
|
+
if (entry?.kind !== "message" || entry.role !== "tool_result") continue;
|
|
155
|
+
const before = freed;
|
|
156
|
+
if (emit(entry.turnId, "age_horizon")) projected -= freed - before;
|
|
157
|
+
}
|
|
158
|
+
return candidates;
|
|
159
|
+
},
|
|
160
|
+
};
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Apply a `WorkingSetView` to a ledger slice as an in-memory projection.
|
|
3
|
+
*
|
|
4
|
+
* This is the whole point of the layer: the ledger keeps every byte the tools
|
|
5
|
+
* produced, and the model sees a narrower view of it. Nothing here writes, and
|
|
6
|
+
* nothing here decides what leaves; `fold.ts` says what is out and this module
|
|
7
|
+
* renders that decision onto the entries the replay builder consumes.
|
|
8
|
+
*
|
|
9
|
+
* Pure and idempotent. Projecting an already-projected slice reproduces it
|
|
10
|
+
* byte for byte, because the marker comes from the ledger entry rather than
|
|
11
|
+
* from the body being replaced. Entries the view does not name are returned by
|
|
12
|
+
* reference, and a named entry is a shallow copy with a new payload: nothing
|
|
13
|
+
* below the payload is ever mutated, so a deep clone of the body about to be
|
|
14
|
+
* replaced would only cost the pricing loop the body's size.
|
|
15
|
+
*
|
|
16
|
+
* Callers may pass raw ledger entries: only entries whose `turnId` is a key in
|
|
17
|
+
* `view.evicted` change, and the view was already narrowed to the active path
|
|
18
|
+
* by the fold, so an eviction recorded on an abandoned branch cannot reach a
|
|
19
|
+
* live one (issue #94).
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import type { MessageEntry, SessionEntry } from "../../session/entries.js";
|
|
23
|
+
import type { EvictedState, WorkingSetView } from "./contract.js";
|
|
24
|
+
import { hasThinking, isRecord, toolResultPayload, withoutThinkingBlocks } from "./payload.js";
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Replace the observation body with its marker. Tool pairing (`toolCallId`,
|
|
28
|
+
* `toolName`) and `details` survive untouched, so replay still matches the
|
|
29
|
+
* result to its call and the renderer still knows what the call was; only the
|
|
30
|
+
* text the model reads changes. The `workingSet` stamp on `details` is how a
|
|
31
|
+
* reader tells a marker from a genuinely tiny tool result.
|
|
32
|
+
*/
|
|
33
|
+
function projectToolResult(entry: MessageEntry, state: EvictedState): MessageEntry {
|
|
34
|
+
const next = { ...entry };
|
|
35
|
+
const { obj, result } = toolResultPayload(next.payload);
|
|
36
|
+
const details = isRecord(result) && isRecord(result.details) ? result.details : {};
|
|
37
|
+
next.payload = {
|
|
38
|
+
...obj,
|
|
39
|
+
result: {
|
|
40
|
+
content: [{ type: "text", text: state.marker }],
|
|
41
|
+
details: {
|
|
42
|
+
...details,
|
|
43
|
+
workingSet: { evicted: true, reason: state.reason, ref: entry.turnId },
|
|
44
|
+
},
|
|
45
|
+
},
|
|
46
|
+
output: undefined,
|
|
47
|
+
out: undefined,
|
|
48
|
+
content: undefined,
|
|
49
|
+
};
|
|
50
|
+
return next;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Drop reasoning from a closed turn. No marker replaces it: thinking is
|
|
55
|
+
* model-internal, the Anthropic API discards it after every turn anyway, and a
|
|
56
|
+
* marker would spend tokens to say that something the model cannot act on is
|
|
57
|
+
* gone. Both persisted shapes go: `thinking` content blocks and the
|
|
58
|
+
* payload-level string the local engine adapters write.
|
|
59
|
+
*
|
|
60
|
+
* A turn that was nothing but reasoning keeps it. Projected, it would reach
|
|
61
|
+
* the provider as an assistant message with no content, or vanish from the
|
|
62
|
+
* replay and leave two user messages adjacent; either is worse than the
|
|
63
|
+
* tokens. `planEviction` then prices such a turn at zero and records nothing.
|
|
64
|
+
*/
|
|
65
|
+
function projectAssistant(entry: MessageEntry): MessageEntry {
|
|
66
|
+
const obj = isRecord(entry.payload) ? entry.payload : null;
|
|
67
|
+
if (obj === null || !hasThinking(obj)) return entry;
|
|
68
|
+
const content = withoutThinkingBlocks(obj.content);
|
|
69
|
+
if (!hasVisibleContent(obj, content)) return entry;
|
|
70
|
+
const next = { ...entry };
|
|
71
|
+
next.payload = {
|
|
72
|
+
...obj,
|
|
73
|
+
...(content !== undefined ? { content } : {}),
|
|
74
|
+
thinking: undefined,
|
|
75
|
+
};
|
|
76
|
+
return next;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** What the replay builder would still send: a payload-level text or at least one surviving block. */
|
|
80
|
+
function hasVisibleContent(obj: Record<string, unknown>, content: unknown[] | undefined): boolean {
|
|
81
|
+
if (typeof obj.text === "string" && obj.text.length > 0) return true;
|
|
82
|
+
return content !== undefined && content.length > 0;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Usage recorded before the projection existed described a longer prompt than
|
|
87
|
+
* the model will now receive. `calculateContextTokens` anchors on the newest
|
|
88
|
+
* assistant usage it trusts, so leaving those anchors in place would report the
|
|
89
|
+
* pre-eviction size forever and the pressure estimator would never see the
|
|
90
|
+
* space the eviction freed. Mirrors `invalidateUsage()` in
|
|
91
|
+
* mask-observations.ts, bounded to the entries that precede the event.
|
|
92
|
+
*/
|
|
93
|
+
function invalidateUsage(entry: SessionEntry): SessionEntry {
|
|
94
|
+
if (entry.kind !== "message" || entry.role !== "assistant") return entry;
|
|
95
|
+
const obj = isRecord(entry.payload) ? entry.payload : null;
|
|
96
|
+
if (obj === null || obj.contextUsageInvalidated === true) return entry;
|
|
97
|
+
const next = { ...entry };
|
|
98
|
+
next.payload = { ...obj, contextUsageInvalidated: true };
|
|
99
|
+
return next;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Index of the newest eviction event within this slice. An event the slice does
|
|
104
|
+
* not contain (a caller that truncated before it) is treated as later than
|
|
105
|
+
* everything here: every usage anchor in the slice predates the projection.
|
|
106
|
+
*/
|
|
107
|
+
function eventIndex(entries: ReadonlyArray<SessionEntry>, lastEvictionTurnId: string | null): number {
|
|
108
|
+
if (lastEvictionTurnId === null) return entries.length;
|
|
109
|
+
const index = entries.findIndex((entry) => entry.turnId === lastEvictionTurnId);
|
|
110
|
+
return index < 0 ? entries.length : index;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export function projectWorkingSet(entries: ReadonlyArray<SessionEntry>, view: WorkingSetView): SessionEntry[] {
|
|
114
|
+
if (view.evicted.size === 0 && view.evictionEvents === 0) return [...entries];
|
|
115
|
+
const cutoff = view.evictionEvents > 0 ? eventIndex(entries, view.lastEvictionTurnId) : -1;
|
|
116
|
+
const out: SessionEntry[] = [];
|
|
117
|
+
for (let index = 0; index < entries.length; index += 1) {
|
|
118
|
+
const entry = entries[index];
|
|
119
|
+
if (entry === undefined) continue;
|
|
120
|
+
let next = entry;
|
|
121
|
+
if (entry.kind === "message") {
|
|
122
|
+
const state = view.evicted.get(entry.turnId);
|
|
123
|
+
if (state !== undefined) {
|
|
124
|
+
if (entry.role === "tool_result") next = projectToolResult(entry, state);
|
|
125
|
+
else if (entry.role === "assistant") next = projectAssistant(entry);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
if (index < cutoff) next = invalidateUsage(next);
|
|
129
|
+
out.push(next);
|
|
130
|
+
}
|
|
131
|
+
return out;
|
|
132
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Protection predicates: what the working set never gives up, whatever a rule
|
|
3
|
+
* concludes.
|
|
4
|
+
*
|
|
5
|
+
* These run before every rule and are absolute. A policy is allowed to be
|
|
6
|
+
* wrong about relevance (that is what the replay table measures); it is not
|
|
7
|
+
* allowed to drop the operator's words, the last few turns of work, a failure
|
|
8
|
+
* nobody has resolved, or a mutation the current turn is still standing on.
|
|
9
|
+
* Charter 4.5 lists them; this module is that list, and `structural.ts` calls
|
|
10
|
+
* it on every candidate rather than reimplementing any of it.
|
|
11
|
+
*
|
|
12
|
+
* Pure over the entry, the index, and the policy input.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import type { SessionEntry } from "../../session/entries.js";
|
|
16
|
+
import type { PolicyInput } from "./contract.js";
|
|
17
|
+
import type { PathIndex, PathObservation } from "./path-index.js";
|
|
18
|
+
import { hasLegacyCompactionMarker, isRecord, toolResultBodyTokens } from "./payload.js";
|
|
19
|
+
|
|
20
|
+
export interface ProtectionContext {
|
|
21
|
+
entryIndex: number;
|
|
22
|
+
/** First entry of the protected recent window, from `protectionCutoffIndex`. */
|
|
23
|
+
cutoffIndex: number;
|
|
24
|
+
input: PolicyInput;
|
|
25
|
+
index: PathIndex;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Ops whose identity is the file they touched, so a retry on the same path counts as the same call. */
|
|
29
|
+
const PATH_IDENTIFIED_OPS = new Set(["read", "grep", "find"]);
|
|
30
|
+
|
|
31
|
+
function isBlockedResult(payload: unknown): boolean {
|
|
32
|
+
if (!isRecord(payload)) return false;
|
|
33
|
+
// The registry's admission verdict, persisted by turn-persistence. A call
|
|
34
|
+
// the safety rails refused is a decision the session made, not an
|
|
35
|
+
// observation it can re-fetch.
|
|
36
|
+
return payload.outcome === "blocked" || typeof payload.blockReason === "string";
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function isErrorResult(payload: unknown): boolean {
|
|
40
|
+
if (!isRecord(payload)) return false;
|
|
41
|
+
return payload.isError === true || payload.error === true;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* The later call that resolved this failure: same tool with byte-identical
|
|
46
|
+
* arguments, or, for the path-identified ops, the same file by any route. Null
|
|
47
|
+
* when nothing after it succeeded, which is what keeps the failure protected.
|
|
48
|
+
* A refused call is not a success: the safety rails returned a verdict, not
|
|
49
|
+
* the observation the failure was trying to make.
|
|
50
|
+
*
|
|
51
|
+
* Shared with `structural.ts` rung 3 on purpose: the rule that evicts a
|
|
52
|
+
* resolved failure and the predicate that protects an unresolved one must
|
|
53
|
+
* answer the same question, or a failure could be both.
|
|
54
|
+
*/
|
|
55
|
+
export function findLaterSuccess(observation: PathObservation, index: PathIndex): PathObservation | null {
|
|
56
|
+
for (const candidate of index.observations) {
|
|
57
|
+
if (candidate.entryIndex <= observation.entryIndex || candidate.isError || candidate.isBlocked) continue;
|
|
58
|
+
if (candidate.toolName === observation.toolName && observation.argsKey.length > 0) {
|
|
59
|
+
if (candidate.argsKey === observation.argsKey) return candidate;
|
|
60
|
+
}
|
|
61
|
+
if (
|
|
62
|
+
PATH_IDENTIFIED_OPS.has(observation.op) &&
|
|
63
|
+
candidate.op === observation.op &&
|
|
64
|
+
observation.path.length > 0 &&
|
|
65
|
+
candidate.path === observation.path
|
|
66
|
+
) {
|
|
67
|
+
return candidate;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** A write or edit the turn in flight is still standing on. */
|
|
74
|
+
function isActiveTurnMutation(observation: PathObservation, index: PathIndex): boolean {
|
|
75
|
+
if (observation.op !== "write" && observation.op !== "edit") return false;
|
|
76
|
+
return observation.turnIndex >= index.turnCount;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function isProtected(entry: SessionEntry, ctx: ProtectionContext): boolean {
|
|
80
|
+
// Only two things ever leave the working set: a tool result's body and an
|
|
81
|
+
// assistant turn's thinking. Everything else (operator words, summaries,
|
|
82
|
+
// skill activations, ledgers, worker runs, bash executions) is the session's
|
|
83
|
+
// own record of itself.
|
|
84
|
+
if (entry.kind !== "message") return true;
|
|
85
|
+
if (entry.role !== "tool_result" && entry.role !== "assistant") return true;
|
|
86
|
+
|
|
87
|
+
// The recent window is untouchable for both kinds.
|
|
88
|
+
if (ctx.entryIndex >= ctx.cutoffIndex) return true;
|
|
89
|
+
if (entry.role === "assistant") return false;
|
|
90
|
+
|
|
91
|
+
// The floor protects low-yield bodies from churn. The engine separately
|
|
92
|
+
// rejects any candidate whose marker would free zero or negative tokens, so
|
|
93
|
+
// this setting may stay above the literal marker break-even point. The floor
|
|
94
|
+
// is the body's size, not the payload's: details never reach the model.
|
|
95
|
+
if (toolResultBodyTokens(entry.payload) < ctx.input.settings.minEvictableTokens) return true;
|
|
96
|
+
// A body the legacy destructive stage already replaced has nothing left to evict.
|
|
97
|
+
if (hasLegacyCompactionMarker(entry.payload)) return true;
|
|
98
|
+
if (isBlockedResult(entry.payload)) return true;
|
|
99
|
+
|
|
100
|
+
const observation = ctx.index.byRef.get(entry.turnId);
|
|
101
|
+
// No observation means no way to ask whether a failure was resolved, so an
|
|
102
|
+
// unindexed failure stays. Everything else unindexed is an ordinary result
|
|
103
|
+
// the age rung may still take under pressure.
|
|
104
|
+
if (observation === undefined) return isErrorResult(entry.payload);
|
|
105
|
+
|
|
106
|
+
if (isActiveTurnMutation(observation, ctx.index)) return true;
|
|
107
|
+
if (observation.isError && findLaterSuccess(observation, ctx.index) === null) return true;
|
|
108
|
+
return false;
|
|
109
|
+
}
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Exact recall by ref.
|
|
3
|
+
*
|
|
4
|
+
* A `contextEviction` entry removes a tool-result body from the projection
|
|
5
|
+
* and leaves a marker naming the ref. Recall is the reverse move: given a ref
|
|
6
|
+
* on the active path whose key the fold still lists as evicted, hand back the
|
|
7
|
+
* original body byte-exact and describe the `contextRecall` entry the caller
|
|
8
|
+
* appends. The ref stays evicted in the fold: the body rides the recall tool
|
|
9
|
+
* result at the tail of the working set, so the marker and the prefix cache
|
|
10
|
+
* are untouched and a repeat recall is the churn signal. Pure over entries:
|
|
11
|
+
* nothing here reads the session, writes the ledger, or calls a model.
|
|
12
|
+
*
|
|
13
|
+
* The body is read through the same `payload.ts` readers the projection and
|
|
14
|
+
* the marker use, so what recall returns is exactly what the model saw before
|
|
15
|
+
* eviction. No truncation happens here; the observation envelope applies the
|
|
16
|
+
* per-turn caps.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { ceilChars } from "../../session/context-accounting.js";
|
|
20
|
+
import type { MessageEntry, SessionEntry } from "../../session/entries.js";
|
|
21
|
+
import { filterEntriesToActivePath } from "../../session/tree/active-path.js";
|
|
22
|
+
import {
|
|
23
|
+
type ContextRecallFields,
|
|
24
|
+
EMPTY_WORKING_SET_VIEW,
|
|
25
|
+
type RecallError,
|
|
26
|
+
type RecallResult,
|
|
27
|
+
type RecallTrigger,
|
|
28
|
+
type WorkingSetView,
|
|
29
|
+
} from "./contract.js";
|
|
30
|
+
import { parseRefKey, refKey } from "./fold.js";
|
|
31
|
+
import { callPathsByToolCallId } from "./path-index.js";
|
|
32
|
+
import { offloadPathOf, primaryPathOf, toolResultPayload, toolResultText } from "./payload.js";
|
|
33
|
+
|
|
34
|
+
export type RecallOutcome = { ok: true; result: RecallResult } | { ok: false; error: RecallError };
|
|
35
|
+
|
|
36
|
+
function isThinkingEntry(entry: SessionEntry): boolean {
|
|
37
|
+
return entry.kind === "message" && entry.role === "assistant";
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function isToolResultEntry(entry: SessionEntry): entry is MessageEntry {
|
|
41
|
+
return entry.kind === "message" && entry.role === "tool_result";
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function resolveRecall(
|
|
45
|
+
entries: ReadonlyArray<SessionEntry>,
|
|
46
|
+
view: WorkingSetView,
|
|
47
|
+
ref: string,
|
|
48
|
+
activeLeafTurnId?: string,
|
|
49
|
+
): RecallOutcome {
|
|
50
|
+
const parsed = parseRefKey(ref);
|
|
51
|
+
if (parsed === null) return { ok: false, error: { kind: "invalid_ref", ref } };
|
|
52
|
+
const key = refKey(parsed);
|
|
53
|
+
const active = filterEntriesToActivePath(entries, activeLeafTurnId);
|
|
54
|
+
const entry = active.find((candidate) => candidate.turnId === key);
|
|
55
|
+
if (entry === undefined) {
|
|
56
|
+
return { ok: false, error: { kind: "not_on_active_path", ref: key } };
|
|
57
|
+
}
|
|
58
|
+
// Thinking leaves the working set without a marker and is not recallable
|
|
59
|
+
// in this slice; `recallErrorMessage` names that case from the entry.
|
|
60
|
+
if (isThinkingEntry(entry) || !view.evicted.has(key) || !isToolResultEntry(entry)) {
|
|
61
|
+
return { ok: false, error: { kind: "not_evicted", ref: key } };
|
|
62
|
+
}
|
|
63
|
+
const payload = toolResultPayload(entry.payload);
|
|
64
|
+
const body = toolResultText(payload.result);
|
|
65
|
+
const offloadPath = offloadPathOf(payload);
|
|
66
|
+
return {
|
|
67
|
+
ok: true,
|
|
68
|
+
result: {
|
|
69
|
+
ref: parsed,
|
|
70
|
+
entry,
|
|
71
|
+
body,
|
|
72
|
+
tokens: ceilChars(body.length),
|
|
73
|
+
...(offloadPath !== undefined ? { offloadPath } : {}),
|
|
74
|
+
},
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* The turn a `contextRecall` entry parents onto: the newest message on the
|
|
80
|
+
* active path. Every recall caller needs this and they must agree, because a
|
|
81
|
+
* record anchored anywhere else folds onto the wrong branch and a `/tree`
|
|
82
|
+
* switch would then show a recall the branch never made.
|
|
83
|
+
*/
|
|
84
|
+
export function recallParentTurnId(entries: ReadonlyArray<SessionEntry>, activeLeafTurnId?: string): string | null {
|
|
85
|
+
const active = filterEntriesToActivePath(entries, activeLeafTurnId);
|
|
86
|
+
for (let i = active.length - 1; i >= 0; i -= 1) {
|
|
87
|
+
const candidate = active[i];
|
|
88
|
+
if (candidate?.kind === "message") return candidate.turnId;
|
|
89
|
+
}
|
|
90
|
+
return null;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function buildRecallFields(
|
|
94
|
+
result: RecallResult,
|
|
95
|
+
meta: { trigger: RecallTrigger; toolCallId?: string },
|
|
96
|
+
): ContextRecallFields {
|
|
97
|
+
return {
|
|
98
|
+
kind: "contextRecall",
|
|
99
|
+
ref: { entry: result.ref.entry },
|
|
100
|
+
trigger: meta.trigger,
|
|
101
|
+
tokensReadmitted: result.tokens,
|
|
102
|
+
...(meta.toolCallId !== undefined ? { toolCallId: meta.toolCallId } : {}),
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Refs listed in a recall failure before the list is cut with an ellipsis. */
|
|
107
|
+
const MAX_LISTED_REFS = 8;
|
|
108
|
+
|
|
109
|
+
export interface RecallableRefListing {
|
|
110
|
+
/** Already-rendered `ref (tool path)` rows, bounded for prompt use. */
|
|
111
|
+
refs: string[];
|
|
112
|
+
/** Recallable refs omitted after the bounded prefix. */
|
|
113
|
+
remaining: number;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* The refs a recall can actually bring back, so the next call can name one of
|
|
118
|
+
* them. Thinking refs are evicted too but are not recallable, so listing them
|
|
119
|
+
* would hand the caller a ref that fails for a different reason. A guessed
|
|
120
|
+
* "nearest" ref was tried first and dropped: over time-ordered ids a prefix
|
|
121
|
+
* match names an unrelated result, and the listing is what helps.
|
|
122
|
+
*/
|
|
123
|
+
export function recallableRefListing(entries: ReadonlyArray<SessionEntry>, view: WorkingSetView): RecallableRefListing {
|
|
124
|
+
const byTurnId = new Map<string, SessionEntry>();
|
|
125
|
+
for (const entry of entries) byTurnId.set(entry.turnId, entry);
|
|
126
|
+
const callPaths = callPathsByToolCallId(entries);
|
|
127
|
+
const refs: string[] = [];
|
|
128
|
+
for (const key of view.evicted.keys()) {
|
|
129
|
+
const entry = byTurnId.get(key);
|
|
130
|
+
if (entry === undefined || !isToolResultEntry(entry)) continue;
|
|
131
|
+
// After a summary compaction the markers before the cut are gone from
|
|
132
|
+
// the working set, so the listing is the only place the caller learns
|
|
133
|
+
// what a ref was. Tool and path are what it needs to pick one.
|
|
134
|
+
const payload = toolResultPayload(entry.payload);
|
|
135
|
+
const toolCallId = typeof payload.obj.toolCallId === "string" ? payload.obj.toolCallId : undefined;
|
|
136
|
+
const path = primaryPathOf(payload) ?? (toolCallId === undefined ? undefined : callPaths.get(toolCallId));
|
|
137
|
+
refs.push(`${key} (${payload.toolName}${path === undefined ? "" : ` ${path}`})`);
|
|
138
|
+
}
|
|
139
|
+
return {
|
|
140
|
+
refs: refs.slice(0, MAX_LISTED_REFS),
|
|
141
|
+
remaining: Math.max(0, refs.length - MAX_LISTED_REFS),
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function recallableRefMessage(entries: ReadonlyArray<SessionEntry>, view: WorkingSetView): string {
|
|
146
|
+
const listing = recallableRefListing(entries, view);
|
|
147
|
+
if (listing.refs.length === 0) return "No recallable refs on the active path.";
|
|
148
|
+
const more = listing.remaining > 0 ? `, and ${listing.remaining} more` : "";
|
|
149
|
+
return `Recallable refs on the active path: ${listing.refs.join(", ")}${more}.`;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* One-line operator/model-facing message for a recall failure. Says why an
|
|
154
|
+
* assistant turn is refused instead of calling it "not evicted", and ends with
|
|
155
|
+
* the refs that can be recalled. `entries` is the active path the view was
|
|
156
|
+
* folded over; without it the listing is empty.
|
|
157
|
+
*/
|
|
158
|
+
export function recallErrorMessage(
|
|
159
|
+
error: RecallError,
|
|
160
|
+
entries: ReadonlyArray<SessionEntry> = [],
|
|
161
|
+
view: WorkingSetView = EMPTY_WORKING_SET_VIEW,
|
|
162
|
+
): string {
|
|
163
|
+
const listing = ` ${recallableRefMessage(entries, view)}`;
|
|
164
|
+
switch (error.kind) {
|
|
165
|
+
case "invalid_ref":
|
|
166
|
+
return `recall ref must be a single turnId without whitespace; got '${error.ref}'.`;
|
|
167
|
+
case "not_on_active_path":
|
|
168
|
+
return `ref ${error.ref} is not on the active path of this session (unknown or on an abandoned branch).${listing}`;
|
|
169
|
+
case "not_evicted": {
|
|
170
|
+
const entry = entries.find((candidate) => candidate.turnId === error.ref);
|
|
171
|
+
if (entry !== undefined && isThinkingEntry(entry)) {
|
|
172
|
+
return `ref ${error.ref} is an assistant turn; thinking is not recallable.${listing}`;
|
|
173
|
+
}
|
|
174
|
+
return `ref ${error.ref} is not evicted; its content is already in context.${listing}`;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
}
|