@sema-agent/core
Version:
Stateless, task-oriented AI agent core
338 lines (337 loc) • 23.9 kB
TypeScript
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; `tengu_time_based_microcompact` is a historical event name, 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` at the call site cli250.js:58375, 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;
};