UNPKG

@sema-agent/core

Version:

Stateless, task-oriented AI agent core

338 lines (337 loc) • 23.9 kB
import type { AgentMessage } from "../internal/harness.js"; import type { Model } from "../internal/llm.js"; import { type ContextWindowOverlay } from "./context-settings.js"; /** * Small-window FLOOR fraction of the clearStale/compaction frontier (see {@link contextEditFrontier}, * which is the single source for both the clearStale budget and the compaction-threshold clamp). * CC's fixed-deduction trigger geometry only exists for ≥100k autocompact windows (its configured- * window floor is 100000), and `W − 33000` goes degenerate below ~47k — small windows keep the * historical fractional trigger instead. */ export declare const EDIT_FRACTION = 0.7; /** * CC-parity trigger geometry (verified against CC 2.1.207): the reactive autocompact trigger sits a * FIXED DISTANCE below the window, not at a fixed fraction — * effectiveWindow = autocompactWindow − min(maxOutputTokens, 20000) (CC `Nye`, cap `uMd`) * trigger = effectiveWindow − 13000 (CC `dSo`, buffer `rMd`) * i.e. trigger = W − 33000 for every model whose max output is ≥ 20k: a 200k window triggers at * 167000 (83.5% of nominal), a 1M window at 967000. * * 1M DIVERGENCE, stated as fact rather than as an equivalence (anchoring re-check 2026-08-23): CC * carries a MODEL-DEFAULT autocompact-window table (2.1.223 `j3u` :242918 — `claude-sonnet-5` * default 967000, and since 223 a surface dimension: `remote_cowork` / `local-agent` 500000), so a * 1M sonnet-5 gets 967000 as its window and 934000 as its trigger WITHOUT anyone configuring * anything. We ship no such table: a model declaring only `contextWindow: 1e6` triggers at 967000 * here, 33000 LATER than CC. `model.autoCompactTokens = 967000` reproduces CC's number — but that * is an EMBEDDER action against CC's product default, not the same posture, and the choice not to * follow 934000 is deliberate (design/146 §1.3 argues against living on that thin a cushion). The * knob itself is real and unchanged: `autoCompactTokens` lowers only this trigger-side window while * the guard and physical request budgeting stay on the 1M window — CC's dual-window shape. We take the * 20000 cap branch unconditionally (flat 33000), i.e. the `min(maxOutputTokens, 20000)` branch is * deliberately NOT ported: the deduction is exact parity only for models with max output ≥ 20k. A * model with a smaller max output would deduct less in CC (trigger LATER); we deduct the full * 20000 and so trigger EARLIER there — the conservative direction (early compaction is bounded * cost; a late trigger risks the window). It also keeps the frontier a pure function of the * window — single source for the death-band clamp below. */ export declare const CONTEXT_OUTPUT_RESERVE_TOKENS = 20000; export declare const COMPACTION_TRIGGER_BUFFER_TOKENS = 13000; /** * The clearStale/compaction frontier for a window — SINGLE SOURCE for two coupled points * (design/64 §26.4 fix 2): the budget at which the request-layer stale-result clearing engages * ({@link editBudget}) AND the ceiling the compaction threshold is clamped to * (`sanitizeCompactionSettings`). Deriving both from one function keeps `compaction ≡ clearStale` * an invariant: a threshold above the clearing frontier would put every request between the two * points on a moving clearing frontier that breaks the prefix cache (~frontier re-prefilled PER * REQUEST — the "death band"). `max()` keeps the frontier monotonic in the window; exact * crossover (integer domain, pinned by test): the fractional floor strictly wins through * W = 109996, the two legs are EQUAL for W = 109997…110000 (floor(0.7W) meets W − 33000 there), * and the fixed deduction strictly wins from W = 110001 up. */ export declare function contextEditFrontier(window: number): number; /** * The `RunnerDeps.microCompact.machine` vocabulary — ONE clearing machine, switchable: * * - `"cc"` (default): the CC 2.1.223 `yId` machine — keep 5, a ≥20000-token minimum-savings gate * (below it the pass is a byte-identical no-op), ONE deep clear of everything beyond the keep * window, the window base counts already-cleared/offloaded occurrences (they hold their keep * slots), and CC's plain marker bytes. * - `"off"`: no proactive frontier clearing; the machine gets its one shot at the blocking point * of the guard chain instead (trace trigger `"blocking"`). * * WHOLE-MACHINE CROSS-CHECK against the 2.1.250 corpus (design/374 slice 4 — the "the knob number * was copied, the machine was not" disease is checked by naming the machine's parts, not the one * number). CC's four load-bearing parts, each against its 250 coordinate, all verified present here: * ① the ≥20000-token minimum-savings gate — {@link MIN_CLEAR_SAVINGS_TOKENS} vs `Mnn` * (cli250.js:41714, `BBn`: `if (tokensSaved < Mnn) return null`). CC gates TWICE (execution + * before asking the server to reject); sema has one point because the "ask for the rejection" * half needs a cooperating server (see ④). * ② the nine-member clearable whitelist — {@link COMPACTABLE_TOOLS} vs `eQt` (cli250.js:41714), * re-resolved member-by-member off the 250 corpus, byte-identical (see that constant). * ③ cleared-but-recoverable — CC replaces the content with a POINTER, not a void * (`<persisted-output>Tool result saved to: {path}\n\nUse Read to view</persisted-output>`, * cli250.js:40044), which is what makes a keep window of 5 survivable at all. Ours is the same * construction in this engine's carrier: the full text is offloaded first and the marker * carries the store ref ({@link ContextEditOptions.offload} → {@link clearOneResult}), read * back through ReadToolResult instead of a filesystem path (registered divergence D-9). The * runner wires it on BOTH arms by default — the frontier pass and the MC-R rejection arm share * one persist closure — so the shipped default clears to a pointer, never to a dead end. * ④ the TRIGGER — CC's client machine fires ONLY on the server's `context_hint` 422/424 refusal * (cli250.js:58375 `context_hint`; the `tengu_time_based_microcompact` event name is historical, its `trigger` * is the constant `"context_hint"`). Under BYOM there is no such server, so the ask half is * unportable and the refusal half is aligned on wire truth (the MC-R arm classifies the * provider's own context-overflow refusal) — registered divergence D-3. sema additionally * keeps a PROACTIVE frontier trigger CC's client does not have, anchored to Anthropic's * platform context-management policy rather than to CC-CLI — registered divergence D-1, and * re-stated at {@link clearStaleToolResults}'s ANCHOR paragraph. * * A closed set (`docs/CLOSED-SETS.md`): the knob door quotes it, so a word outside it is refused by * name rather than folded to the default. */ export declare const MICRO_COMPACT_MACHINES: readonly ["off", "cc"]; export type MicroCompactMachine = (typeof MICRO_COMPACT_MACHINES)[number]; /** * How many most-recent CANDIDATE tool results keep their content when the stale-result pass runs: * 5 (CC 2.1.223 `uAp`; 2.1.250 `var k=5` beside the call site, cli250.js:58373, and 2.1.245 carries * the same value — zero drift across 245→250; same value again on CC 88's `timeBasedMCConfig`, i.e. * stable across all three corpus generations, and it is a hard-coded constant upstream, not a * setting). The #384 adjudication adopted 5 CONDITIONAL on the whole machine coming with it (20k * gate + deep clear + re-ordered backstops); the machine carries that condition (§3.5: the gate * refuses a no-win clear and the in-turn forced compaction owns the request instead of a * message-dropping trim). */ export declare const CC_DEFAULT_KEEP_RECENT_TOOL_RESULTS = 5; /** design/374 §3.1 — CC's minimum-savings gate (`vUs` :397773; 2.1.250 `Mnn` cli250.js:41714, same * value): the cc machine refuses to clear at * all unless the structural estimate of what WOULD be cleared reaches this many tokens. Rationale * (independent of "CC does it"): every clear breaks the provider prefix cache once, so a clear that * saves less than the re-prefill it causes is a net loss; the gate prices that trade. */ export declare const MIN_CLEAR_SAVINGS_TOKENS = 20000; /** Minimum kept results — CC `EUs` :397710 `Math.max(1, keepRecent)`. See * {@link ContextEditOptions.keepRecentToolResults} for why both degenerate ends are unusable. */ export declare const MIN_KEEP_RECENT_TOOL_RESULTS = 1; export interface ContextEditOptions { /** Start clearing once estimated context tokens exceed this. */ budgetTokens: number; /** * Fired AT MOST ONCE per pass, only when the pass cleared at least one result: the cleared * indexes (into the returned array) and the pass's savings estimate (the structural * minimum-savings estimate it gated on). The * request-projection ledger (design/374 §3.2.6) records cleared occurrences through this seat — * observation only, a throwing callback is swallowed and never fails the request build. */ onCleared?: (pass: { clears: ReadonlyArray<{ index: number; }>; tokensSavedEstimate: number; }) => void; /** * Always keep the content of this many most-recent CANDIDATE tool results (candidates = results * from {@link compactableTools}). Default {@link CC_DEFAULT_KEEP_RECENT_TOOL_RESULTS} = 5. * * Floored at 1, which IS CC's rule (`EUs` `Math.max(1, keepRecent)`), whose own source note gives * the reason: 0 leaves the model with zero working tool context, and in CC's `slice(-0)` spelling * it degenerates the other way into keeping everything — two unusable extremes for one value. * Ours degenerated identically (a 0 or negative `keep` cleared EVERY candidate). Values that are * not a non-negative safe integer are REFUSED rather than floored: a NaN used to make this whole * defense silently inert (`slice(0, NaN)` = clear nothing), which is the one outcome a context * defense must never reach quietly, and there is no config door upstream to announce at — this * option only ever arrives as a direct argument from an embedder. */ keepRecentToolResults?: number; /** * roadmap #6② (CC contentReplacementState parity): when set, a result's FULL TEXT is offloaded to * the store before its content is cleared, and the marker carries the ReadToolResult ref — a * cleared result becomes pageable instead of gone-from-view. `persist` must be idempotent per ref * (the store's write-once put) because clearing re-runs on every request build. Absent ⇒ the bare * marker (pre-#6 behavior; the durable session still holds the full result either way). */ offload?: { /** Write-once persist; the returned ref goes into the marker. Sync (in-memory) or async stores both fit. */ persist: (toolCallId: string, fullText: string) => string; }; /** * design/123 D3 (= fable-M5) — ANCHORED trigger: `estimateContextTokens(messages, * charsPerToken).tokens` for the SAME message array. When set, the trigger gate reads this * usage-anchored value instead of the pure structural sum (which underestimates code by ~25% and * CJK by 60–75% — the defense would self-certify and pass over-window requests). The minimum- * savings gate stays CC's structural form either way (design/374 D-5). Absent ⇒ the structural * sum triggers. */ anchoredTotalTokens?: number; /** design/123 D2 — structural coefficient (`Model.charsPerToken`); default 3 (design/374 slice 4; * it was 4 before). Note this does NOT reach the ≥20k gate, which is CC-exactly * hard-coded to 4 — see {@link structuralClearSavings}. */ charsPerToken?: number; /** * checklist #54 — which tools' results are clearable. Defaults to {@link COMPACTABLE_TOOLS} * (CC microCompact.ts:41-50). A result whose `toolName` is not in the set is NEVER cleared by * this pass, no matter how far over budget the request is — expensive subagent reports and * registry state are compaction's job, not a blanking marker's. */ compactableTools?: ReadonlySet<string>; /** The wire name a result's recorded tool name resolves to on the leg's roster (a result is clearable by the tool the * call ran, not the spelling the model used). Absent ⇒ the recorded name. */ toolNameOf?: (recorded: string) => string; /** * design/374 (RB-212 durable half): the PRE-CAP counterpart array of `messages`, position-aligned * (the pipeline's aggregate caps replace in place, never drop). When present, the machine * composes each clear's marker + offload text from `clearSource[idx]` — the same occurrence * bytes the cleared-projection ledger repaints the marker onto at the next session rebuild — so * the durable marker discloses the occurrence's REAL media and the page-back ref resolves to its * real text rather than to a cap-stripped copy. An entry that is not a tool result, or that carries a different `toolCallId` than the * target, is ignored per index (composition falls back to the projection copy — a misaligned * source must never mis-attribute content). */ clearSource?: ReadonlyArray<AgentMessage>; } /** * audit A-1 (view-level heal for HISTORICAL pollution): drop synthetic EMPTY failure assistants — * stopReason aborted/error with no tool calls and no non-whitespace text/thinking. Sessions persisted * before 1.92.0 may carry one parked between an interrupted tool batch and its reconcile tool_results * (`A(tu×3), TR1, TR2, A(aborted,""), TR3`) — an API-invalid sequence on strict providers, and * append-only sessions cannot be rewritten. Dropping the junk from the REQUEST VIEW restores a legal * transcript (TR3 rejoins its batch). Deterministic ⇒ byte-stable per request ⇒ prefix-cache safe. * New sessions never contain these (the loop exits cleanly on abort; the harness skips persisting them). * #506 ㋐ did NOT re-open this class: the API-failure record the harness now writes carries `API Error: …` * TEXT, so it is not an empty failure assistant and this heal correctly leaves it alone. That record is * excluded one layer later, at `convertToLlm`, by its own three-key identity (`isSyntheticApiErrorMessage`). */ export declare function dropEmptyFailureAssistants(messages: AgentMessage[]): AgentMessage[]; export declare function clearStaleToolResults(messages: AgentMessage[], opts: ContextEditOptions): AgentMessage[]; /** design/374 slice 1b (X5) — the resolved trigger-side window and whether the clamp repaired it. */ export interface TriggerWindowResolution { /** The effective trigger-side window: `autoCompactTokens ?? contextTokens ?? contextWindow`, * CLAMPED to the physical window when the declared autocompact window exceeds it. */ window: number; /** True = the declared `autoCompactTokens` was ABOVE the physical window and got clamped down — * a bad deployment value the caller should announce loudly (坏值响亮度默认律). */ clamped: boolean; /** Present on the clamped arm: the declared (refused) value and the physical window it was * clamped to, for the announcement text. */ declaredAutoCompactTokens?: number; physicalWindow?: number; } /** * design/374 slice 1b (X5, r2-M⑤) — SINGLE SOURCE for the trigger-side window chain * (`autoCompactTokens ?? contextTokens ?? contextWindow`), now with the inversion clamp: * `autoCompactTokens` is a TRIGGER-side lowering knob (dual-window semantics — the guard trim and * physical request budgeting stay on `contextTokens ?? contextWindow`), so a declared value ABOVE * the physical window inverts the whole geometry: the frontier (W−33000 of the declared window) * lands HIGHER than the guard (physical−23000), every "the frontier machine runs first, the guard * only backstops" argument silently reverses (967k declared vs 177k physical is the recorded * counterexample), and the misconfiguration announces nothing. Clamp = `min(declared, physical)`, * reported via `clamped` so the ONE announce point (prepare) can be loud about it. Every consumer * of the chain (editBudget here / maybeCompact's window derivation / the compaction-window-hazard * estimate / runtask's prefix-overhead disclosure) reads THIS helper — a fifth copy of the `??` * chain is the drift this function exists to end. Non-finite/≤0 declared values keep their historic * meaning (each consumer's own unknown-window sentinel), deliberately untouched here. */ export declare function resolveTriggerWindow(model: Model, overlay?: ContextWindowOverlay): TriggerWindowResolution; /** The token budget at which context-editing begins for a model (below the harder guard trim). * = {@link contextEditFrontier} of the model's AUTOCOMPACT window (CC-parity fixed deduction with * a 0.7 small-window floor) — the same point the compaction threshold is clamped to. Dual-window * semantics: `autoCompactTokens` (when declared) feeds this trigger-side geometry only; the guard * trim (`guardBudget`) stays on the physical window — which is why the window is read through * {@link resolveTriggerWindow}: an autocompact window declared ABOVE the physical one would put * this frontier above the guard (inverted geometry), so it is clamped there (slice 1b, X5). */ export declare function editBudget(model: Model, overlay?: ContextWindowOverlay): number; /** Content fingerprint of an occurrence — a sha-256 digest over the FULL canonical content form * (per block: type + complete text bytes, and for non-text blocks the mime type + payload length * + a payload head), replay's mis-paint guard (adversarial rounds 1+2): ordinal mapping alone * cannot see an equal-size cut-and-append churn inside a same-(id,timestamp) group — the mapped * slot may hold a DIFFERENT occurrence, and painting it would clear content that was never * cleared. r2 rejected the first cut's {chars, head-64} pair (same-prefix/same-length tails and * media-only churn collide); the digest covers whole text bytes and block structure. A non-marker * replay target must match or the entry prunes (full text returns — always the safe direction). * Content-identical occurrences remain indistinguishable by construction and paint harmlessly. */ export type OccurrenceFingerprint = string; export declare function fingerprintToolResultOccurrence(m: AgentMessage): OccurrenceFingerprint; /** One recorded clear: the exact marker bytes to re-apply, the occurrence-group size at record * time (the shrink-mapping input — see the lifecycle note above), and the original content's * {@link OccurrenceFingerprint} (the mis-paint guard). */ export interface ClearedOccurrence { marker: string; groupCount: number; fp: OccurrenceFingerprint; } /** The per-task ledger. Keys are OPAQUE occurrence coordinates minted by * {@link replayClearedProjection} / {@link OccurrenceIndex} — derived per view, never persisted. */ export interface ClearedProjectionLedger { entries: Map<string, ClearedOccurrence>; } export declare function createClearedProjectionLedger(): ClearedProjectionLedger; /** One minted occurrence coordinate: the opaque ledger key, the group size at mint, the * INDEXED-view fingerprint, and (when the index can hand it back) the indexed-view occurrence * MESSAGE itself — the durable-composition seat (RB-212 durable half): a ledger-bound clear * composes its marker + offload text from this occurrence, never from a cap-transformed copy, so * the recorded marker discloses the occurrence's real media and the page-back ref resolves to its * real text (the same bytes the replay repaints the marker onto). */ export interface OccurrenceKey { key: string; groupCount: number; fp: OccurrenceFingerprint; occurrence?: AgentMessage; } /** Occurrence coordinates for ONE request view (minted fresh per view — see the ledger note). */ export interface OccurrenceIndex { /** Key the tool-result occurrence at this index of the indexed array. `fp` is the occurrence's * fingerprint IN THE INDEXED (pre-cap) view — the same bytes the next session rebuild presents, * which is what the replay guard compares against (r2: fingerprinting the post-cap projection * instead made an MC-R clear of a cap-transformed result prune ITSELF on the retry build — an * advertised clear that never reached the wire). */ keyAt(index: number): OccurrenceKey | undefined; /** Key a tool-result by OBJECT identity against the indexed view (the MC-R seat: the rejected * projection reuses the view's message objects for everything the caps did not replace); falls * back to the unambiguous single-member (id, timestamp) group for cap-replaced copies, and * answers undefined — "not clearable via the ledger" — for anything ambiguous. The returned * `fp` (and `occurrence`) are ALWAYS of the indexed-view occurrence (never of a cap-replaced * copy the caller holds). */ keyOf(m: AgentMessage): OccurrenceKey | undefined; } /** The MC-R planner's verdict — either the entries to record (with the pass's gated savings) or a * typed decline the caller reports on the `context.mc_null` observation face. */ export type RejectionClearPlan = { cleared: Array<{ key: string; groupCount: number; marker: string; fp: OccurrenceFingerprint; }>; tokensSavedEstimate: number; } | { declined: "no_candidates" | "below_min_savings"; }; /** * design/374 §3.2 (slice 2) — plan the MC-R rejection clear over the REJECTED projection: the same * machine (keep 5 default, window base counts cleared occurrences, five-form exclusion, Sby * savings, ≥20k gate) with the TRIGGER replaced by the rejection itself — the provider already said * the input does not fit, so there is no budget coordinate to compare against (an enabled MC-R runs * even with the frontier machine `"off"`). * `keyOf` threads occurrence identity (the ledger is the ONLY way a rejection-arm clear survives * the session rebuild — §1.2's adoption chain discards the in-memory view); a candidate the index * cannot key is skipped from BOTH the clear set and the savings (never promise savings the ledger * cannot deliver). Marker composition (offload persist included) runs only on the accept path — * a declined plan has zero side effects. */ export declare function planRejectionClears(messages: AgentMessage[], opts: { keyOf: OccurrenceIndex["keyOf"]; offload?: ContextEditOptions["offload"]; compactableTools?: ReadonlySet<string>; toolNameOf?: (recorded: string) => string; keepRecentToolResults?: number; }): RejectionClearPlan; /** * Replay the ledger onto a freshly rebuilt request view (design/374 §3.2.6, r2-M⑩: positioned * AFTER the heal, BEFORE the aggregate caps — so every estimator, cap and trigger downstream * accounts the REPLAYED view, and a replayed marker never re-enters the cap store or re-emits a * cap frame). Also the ledger's intersection point: entries that no longer resolve are pruned, and * surviving entries are RE-KEYED to the current view's coordinates. Returns the SAME array instance * when nothing changed (reference inequality is the pipeline's lossy-change signal), plus the * view's occurrence index for the record/MC-R seats. */ export declare function replayClearedProjection(messages: AgentMessage[], ledger: ClearedProjectionLedger): { messages: AgentMessage[]; index: OccurrenceIndex; };