@sema-agent/core
Version:
Stateless, task-oriented AI agent core
963 lines • 51 kB
TypeScript
/**
* Observability seam (1.37). core emits structured, **metadata-only** trace events; a deployment's
* hook bridges them to OTel / Prometheus. core never imports an APM SDK.
*
* Privacy: events carry NO prompt/completion/tool-argument content (OTel SHOULD-NOT-by-default). If
* content capture is ever added it will be a separate opt-in event the deployment redacts at collection.
*
* Delivery: best-effort, fire-and-forget. The hook returns `void` (callers can't synchronously await),
* MUST NOT throw (core wraps it) and MUST NOT do sync I/O on the hot path. Every event carries
* `version` so a hook can gracefully degrade on unknown fields.
*
* OTel GenAI semantic conventions: several fields below note the `gen_ai.*` attribute they feed. Those are
* MAPPING NOTES for whoever writes the bridge, not a compatibility promise — the whole `gen_ai.*` namespace
* is still Development-status upstream (no span/attribute marked Stable, and attribute names have already
* been renamed once), so a bridge must pin the convention version it targets and expect upgrade breakage.
* core's own contract is only the field names here; it deliberately stops at supplying the FACTS a bridge
* needs (ids, turn indices, token counts) rather than adopting the convention's vocabulary internally.
*/
import type { BrainRetryErrClass, TaskStatus, ToolEffect } from "./types.js";
import type { ThinkingLevel } from "../internal/harness-types.js";
import type { ToolManifestRow } from "../prompt-assembly/tool-catalog.js";
import type { PromptEpochActivation, PromptIdentityLayers } from "../prompt-assembly/epoch.js";
import type { ChainLink, SupersededOverlay } from "../prompt-assembly/overlay.js";
import type { HostForm, HostRole, RendererId } from "../prompt-assembly/catalog/schema.js";
/** The run's prompt identity as the manifest reports it: the identity digest, its inputs, and the session record that
* names it (the record's counter and how it came to be). */
export interface PromptManifestIdentity {
artifact: string;
layers: PromptIdentityLayers;
epoch: number;
activatedBy: PromptEpochActivation;
}
/** The run's selector values — the plain form of the identity's `binding` element. */
export interface PromptManifestSelectors {
binding?: string;
traits: string[];
host: {
role: HostRole;
form: HostForm;
};
facts: Record<string, boolean>;
}
/** One rendered section: where it sits, which variant and renderer made it, whose text it carries (`locale`: the
* catalog's canonical text, or text an overlay author wrote), its content digest (`sha256:` unsalted for published
* text, `salted:` for text carrying the task's or the run's own inputs) and its history (`chain`). */
export interface PromptManifestSection {
id: string;
band: string;
rank: number;
variant?: string;
renderer?: {
id: RendererId;
version: number;
};
chars: number;
locale: "canonical" | "overlay";
contentHash: string;
chain: ChainLink[];
}
/**
* R4 (1.285): the scalar face of `TaskResult.stats.mechanisms`, echoed on `task.end` so "did the
* engine machinery fire" is answerable from the trace alone. Counters/flags only —
* `repetitionEvents` carries model-authored text samples and stays result-side (the file-header
* metadata-only rule). Field meanings: see the `stats.mechanisms` JSDoc in core/types.ts.
*/
export interface TraceMechanismsSummary {
finalVerifyInjected?: true;
finalVerifyInjections?: number;
attachmentsInjected?: number;
/** design/164: how many LIMIT-APPROACH frames were injected this run (0..2). */
approachNoticesSent?: number;
repetitionCuts?: number;
repetitionSpared?: number;
}
/**
* design/168 (additive) — the RESOLVED deferred-tool disclosure strategy for one prepared leg, plus the
* seat that chose it. `strategy` is the EFFECTIVE value (after the direct-call-lane interlock), and
* `source` says where it came from: the task spec, the `SEMA_TOOL_MATERIALIZE_STRATEGY` env channel, the
* engine default, or `"degraded_no_direct_lane"` — the interlock narrowed a `"static"` request to
* `"swap"` because `deferSelfResolve: false` left the placeholder permanently uncallable. That last
* value implies an ENV-sourced request: the spec pairing is refused at prepare and the default is not
* `"static"`.
*
* Why it is reported at all: "which strategy did this leg actually run" was previously only answerable
* by re-deriving spec > env > release default by hand — the archaeology the config manifest exists to
* kill — and the two strategies differ in how the schema reaches the model, which is exactly what an
* operator diagnosing a caller that cannot form tool arguments needs to know first.
*/
export interface ToolDisclosureManifest {
/** How many tool names were deferred on this leg (0 ⇒ this section is absent entirely). */
deferredTools: number;
strategy: "swap" | "static";
source: "spec" | "env" | "default" | "degraded_no_direct_lane";
}
export type TraceEvent = {
kind: "task.start";
version: 1;
taskId: string;
/** The ENGINE-minted identity of THIS run (`TaskResult.runId`). `taskId` is
* `spec.taskId ?? sessionId`, so two runs of one session that declare no host task id emit two
* task.start frames under ONE id and a consumer pairing start↔end has no way to say which end
* closes which start. Present on every `task.start` (the frame is emitted after prepare, which
* is where the id is minted).
*
* PAIRING CONTRACT, stated in full because it is not symmetric: a `task.end` may carry a
* `runId` this frame never announced — a run whose PREPARE threw emits the end and no start
* (there was nothing to start), and that end still names its run because prepare-time
* disclosures already did. So: every start has an end; not every end has a start. A consumer
* building spans must treat an unmatched end as a run that never began, not as a lost start.
*
* SCOPE, stated so it is not mistaken for more: only this frame and `task.end` carry the run
* axis today. Every other frame in this union still keys on `taskId` alone, so frames from two
* CONCURRENT runs that share a host `taskId` remain mutually indistinguishable inside their
* windows. Extending the axis across the union is a separate pass. */
runId?: string;
model: string;
/** R4 (1.285): the engine build (package.json version, runtime-resolved) that produced this
* run — binds trace forensics to an exact build. "unknown" if resolution failed. */
engineVersion?: string;
ts: number;
} | {
/**
* The labelled composition of the assembled system prompt (manifest v3), emitted once right after `task.start`:
* which prompt this is (the identity and its selector values), which blocks and sections composed it, and where
* each section's text came from (its chain of overlays, the superseded ones marked). Metadata-only — ids, sizes
* and digests, never prompt text (the file-header privacy rule).
*/
kind: "prompt.assembled";
version: 3;
taskId: string;
identity: PromptManifestIdentity;
selectors: PromptManifestSelectors;
/** Ordered blocks: stable id (e.g. `role.base`, `harness.context`, `memory.safety`, `env.context`,
* `memory.tail`), size in chars, salted short content hash — the block-view id wordlist is a downstream contract. */
blocks: Array<{
id: string;
chars: number;
hash: string;
}>;
sections: PromptManifestSection[];
/** Every overlay the composition superseded (a later one on the same target won, or it changed nothing this
* run); absent when none was. */
superseded?: SupersededOverlay[];
/**
* S2 (additive): the mounted tools plane — per-tool contract identity, durable-replay
* aliases, presentation-invariant shape digest and full wire-schema digest (initial mount face;
* deferred activations announce via tools_delta). Content-addressed digests (schemas are code).
*/
tools?: ToolManifestRow[];
totalChars: number;
/** design/148 S3 (additive, §10.2): the nine-element cache identity — digests only. */
snapshot?: {
cacheIdentity: string;
elements: import("../prompt-assembly/turn-snapshot.js").CacheIdentityElements;
};
/** design/148 S3 (additive, §10.1): the run's lowering record. */
lowering?: {
version: string;
form: string;
intentionalDivergences: readonly string[];
};
/** design/168 (additive): the resolved deferred-tool disclosure strategy + the seat that chose
* it. Absent when the leg deferred nothing. */
toolDisclosure?: ToolDisclosureManifest;
ts: number;
} | {
/** design/148 S3 — a mid-run identity refresh (deferred materialization / RB-31
* adoption / finalize thinking flip): the trace stream must never keep claiming a superseded
* prefix identity. Digests only, never prompt text. */
kind: "prompt.snapshot_changed";
version: 1;
taskId: string;
snapshot: {
cacheIdentity: string;
elements: import("../prompt-assembly/turn-snapshot.js").CacheIdentityElements;
};
ts: number;
} | {
/**
* How a task's requested reasoning intensity RESOLVED against the model's real capability (design/96 S6).
* Emitted at task start when thinking is on, so a deployment can SEE — not silently swallow (§E
* honesty red-line) — that a binary provider ignored the tier (`graded:false`), that an effort endpoint
* clamped it down (`clamped:true`), or that a NON-reasoning model dropped the request entirely
* (`dropped:true` — the frame fires for that model too; it used to be the one arm with no report).
* Metadata-only (tiers + format + endpoint, never prompt content). The resolution is against the
* task's PRIMARY serving model at leg entry (same law as `TaskResult.model`): a mid-run
* degradation does not re-emit this frame — the switch is observed on its own seats
* (`TaskResult.degraded`).
*
* A SECOND frame follows, for the same taskId, in exactly one case: the leg's FIRST
* committed request reported wire facts that CHANGE the resolution the task-start frame claimed
* (today: the anthropic budget path's cap-wins skip, where a hard per-request output cap too small
* to host a legal thinking budget deletes the thinking block — so the honest report is
* `dropped:true`, not the gradient the entry frame guessed). The correction is deduped (a first
* request that changes nothing emits nothing) and capped at one (only the FIRST committed request
* is consumed, so the leg's nested internal calls — compaction summary, side query — cannot
* rewrite the leg's posture). A consumer that keeps only the LATEST frame per taskId is therefore
* always reading the truth, and one that kept only the first now under-reports a drop it could not
* have seen before. The result-face twin `TaskResult.effectiveReasoning` moves with it — the two
* faces are one mint at every instant.
*/
kind: "reasoning.resolved";
version: 1;
taskId: string;
/** The model whose endpoint resolved the intensity. */
model: string;
/** The intensity the task asked for. */
requested: ThinkingLevel;
/** The intensity actually in effect on the wire. */
effective: ThinkingLevel;
/** False = binary enable-only / no-effort endpoint: the tier was NOT honored as a gradient (intent only). */
graded: boolean;
/** True = `effective` differs from `requested` (the endpoint couldn't honor the request exactly). */
clamped: boolean;
/** The wire format the request uses (`openai`/`deepseek`/`qwen`/`budget`/…) — the reason for graded/clamped. */
format: string;
/** Coarse endpoint label (`model.api`, e.g. `openai-completions` / `anthropic-messages`). */
endpoint: string;
/**
* Present (true) only when the model declares NO reasoning capability (`Model.reasoning` falsy): the
* brains send no thinking parameter at all, so the requested tier was DROPPED — not clamped.
* `effective:"off"` states the ENGINE side (nothing was requested), not a measured gateway state:
* on the binary enable-only formats (qwen/zai/qwen-chat-template) parameter absence is
* provider-default and a default-on gateway may still reason at its own tier — read `format` to
* know which family applies. `format`/`endpoint` report the family that WOULD have carried the
* tier. Absent on every reasoning-capable resolution (old consumers see identical frames).
*/
dropped?: true;
/** design/148 S3 (additive, §10.2): the nine-element cache identity — digests only. */
snapshot?: {
cacheIdentity: string;
elements: Record<string, string>;
};
/** design/148 S3 (additive, §10.1): the run's lowering record. */
lowering?: {
version: string;
form: string;
intentionalDivergences: readonly string[];
};
ts: number;
} | {
/**
* Config-catalog manifest (ruled 2026-07-19) — the run's EFFECTIVE task-launch config
* with per-field provenance (default / spec / derived / host-declared), emitted once right
* after `prompt.assembled`. Kills the "which layer set this knob" archaeology: every run
* self-reports its true scale, and host-layer overrides arrive as DECLARED entries
* (`TaskSpec.configOverrides`, reason required) instead of invisible env/adapter state.
*/
kind: "config.assembled";
version: 1;
taskId: string;
catalogVersion: string;
fields: Array<{
key: string;
value: number | boolean | string | null;
provenance: "default" | "spec" | "derived" | "host-declared";
}>;
/** Reasons for host-declared entries, keyed by catalog key (the announcement face). */
overrideReasons?: Record<string, string>;
ts: number;
} | {
/**
* RB-371 ③ — an `additionalDirectories` entry could not be canonicalized at prepare (missing
* directory / dangling symlink / env error) and was SKIPPED fail-closed: the run proceeds with
* a NARROWER allow-root set than the spec asked for. Previously only the side-band
* `onError(phase:"config")` callback saw this; a deployment without an onError sink got a
* silently narrowed fence (the # Environment block advertises only the EFFECTIVE roots, so the
* model never sees the asked-for entry either). One frame per skipped entry.
*/
kind: "config.additional_directory_skipped";
version: 1;
taskId: string;
/** Which spec field the skipped entry came from. Absent = `additionalDirectories` (the
* original face, kept implicit for existing readers); `"additionalReadDirectories"` = the
* read-only whitelist arm (ruled 2026-08-05); `"persistedReadRule"` = a person's persisted `Read(//dir/**)` allow row. */
field?: "additionalReadDirectories" | "persistedReadRule";
/** The spec entry as written (raw, not canonical — canonicalization is what failed). */
entry: string;
/** Why the entry was skipped, `code: message` form: the canonicalization failure, or (a persisted row only)
* `admits_artifact_store` — the in-flight grant's store rule. */
reason: string;
ts: number;
} | {
kind: "task.end";
version: 1;
taskId: string;
/** The run this terminal closes; pairs with the `task.start` frame's `runId`
* (see that field for why `taskId` cannot carry the pairing, and for the ASYMMETRY: this frame
* is also emitted on the prepare-throw backstop, where no `task.start` exists — it still names
* its run, because the id is minted at prepare's first statement and prepare-time disclosures
* already carry it). Absent only where prepare was never entered at all, which is the same
* condition its `taskId: "unknown"` states. */
runId?: string;
status: TaskStatus;
errorCode?: string;
turns: number;
tokens: number;
/** ABSENT when any spend was unpriced (RB-368: no pricing entry + no `Model.cost` — no
* fabricated 0; matches `stats.costMicroUsd`) and on the prepare/finish-throw backstop frame. */
costMicroUsd?: number;
durationMs: number;
/** True when the task finished with a structured output (`submit_output`); 1.41 observability. */
hasStructuredOutput?: boolean;
/** R4 (1.285): scalar echo of `stats.mechanisms` (absent when no deadline mechanism engaged —
* byte-compatible with pre-1.285 frames). */
mechanisms?: TraceMechanismsSummary;
/** 1.296 件2a (TB2.0 run89): how LATE the hard walltime-abort timer fired vs its scheduled
* time, when materially late (> 5s; absent otherwise, and absent when it never fired). A
* large value is EVIDENCE THE ENGINE WAS NOT AT FAULT — setTimeout only runs when the event
* loop breathes, so lateness this size means the environment starved the process (run89: an
* abandoned compile group froze the loop 646s; the 1200s hard abort landed 683s late). */
timerLatenessMs?: number;
/** The run was stopped by the SYNCHRONOUS turn-boundary walltime
* backstop, not the hard-abort timer — i.e. the deadline had passed but the setTimeout leg
* had not fired yet (starved event loop). Pairs with `timerLatenessMs` for attribution. */
walltimeSyncBackstop?: boolean;
ts: number;
} | {
kind: "turn.end";
version: 1;
taskId: string;
turn: number;
ts: number;
} | {
kind: "brain.call";
version: 1;
taskId: string;
model: string;
provider?: string;
/**
* 1-based index of the turn this provider call served, same numbering as `turn.end` and as
* `tool.call.turn`. Lets a bridge put a `chat` span and the `execute_tool` spans of the tools that
* call requested under one parent without re-deriving turn boundaries from event order.
*
* ABSENT on the compaction summarizer's own call: that call fires from the turn-boundary hook and
* is not a loop turn, so any number here would be a different measurement wearing the same name.
*/
turn?: number;
/** Normalized prompt tokens that MISSED the cache (Anthropic-protocol `input_tokens`
* shape) — disjoint from `cacheRead`/`cacheWrite`. Carried the cache-inclusive total up to 2.13.x. */
promptTokens: number;
/** The cache-INCLUSIVE prompt total for this call (`promptTokens + cacheRead +
* cacheWrite`) — the figure `costMicroUsd` is computed from. */
totalInputTokens: number;
completionTokens: number;
cacheRead: number;
cacheWrite: number;
/** Wall time from the brain request to its final message. */
latencyMs: number;
/** Time to the first content delta (excludes thinking) — maps to OTel time_to_first_chunk (×1000). */
firstTokenMs?: number;
/** 1.296 件2b (TB2.0 run89 归因盲区): epoch ms at which the ENGINE ISSUED this provider call
* (the per-call closure evaluation immediately before the stream call — unlike `latencyMs`,
* which anchors on the FIRST DELTA and reads 0 for a zero-delta call). Distinguishes an
* in-flight call cut before its first delta (present, latencyMs 0, stopReason "aborted")
* from a call that was never issued (absent). Compaction-summarizer rows never carry it (they
* circumvent the loop's per-call closures). */
callStartedAt?: number;
/** ABSENT when THIS call's serving model was unpriced (RB-368 — no fabricated 0). */
costMicroUsd?: number;
/** TB telemetry B1: the NORMALIZED finish reason of this call (`StopReason`:
* `"stop"`/`"length"`/`"toolUse"`/`"error"`/`"aborted"`). Post-normalization (Anthropic
* `end_turn`/`stop_sequence`→`"stop"`, `max_tokens`→`"length"`). */
stopReason?: string;
ts: number;
} | {
kind: "tool.call";
version: 1;
taskId: string;
name: string;
/**
* The resolved call's id: the SAME value the wire's `tool_start`/`tool_end` `TaskEvent`
* carries for this call. OTel GenAI names it `gen_ai.tool.call.id` on the `execute_tool` span.
*
* Why it matters: without it the trace face and the event face could only be paired by tool NAME
* plus timestamp, which is ambiguous exactly where it hurts — two same-named calls inside one
* parallel batch. Absent only on frames from a build that predates this field.
*/
toolCallId?: string;
/**
* 1-based index of the turn this call ran in, on the same numbering `turn.end` reports
* (a `tool.call` is emitted BEFORE the `turn.end` that closes its turn, so the two agree without
* look-ahead). No GenAI attribute corresponds; it is the PARENT-span correlation key a bridge
* needs to nest an `execute_tool` span under the `chat` call that requested the tool.
*/
turn?: number;
durationMs: number;
ok: boolean;
effect?: ToolEffect;
ts: number;
} | {
/** Degenerate-repetition detector telemetry (2026-07-10, 2e1c161 observability): a model
* stream was CUT for a degenerate loop, or a repetition landed in a detection window but was
* SPARED by a structural allowance (code-line shape / divider run). One event per detector hit. */
kind: "repetition.detected";
version: 1;
taskId: string;
turn: number;
action: "cut" | "spared";
rule: "char-run" | "unit-loop";
/** Repeating-unit length in chars (1 for a char run). */
period: number;
/** How many times the unit repeated. */
reps: number;
/** ≤120-char sample of the repeated unit/tail. */
segment: string;
ts: number;
} | {
/** A task was **degraded** to a cheaper model (rate_limit / breaker-open / near-budget, plus
* design/126 server_error / last_resort on the chain form). An alert-worthy operational event —
* quality dropped. Emitted once, when first degraded. */
kind: "task.degraded";
version: 1;
taskId: string;
from: string;
to: string;
reason: "breaker_open" | "rate_limit" | "budget" | "server_error" | "last_resort";
atTurn: number;
ts: number;
} | {
/** RB-473 batch 2b — a HOST observer callback threw inside a safe-notify isolation scope
* (bounded: first failure per site per scope). The engine is unaffected (the swallow is the
* contract); this event is the disclosure so a deployment learns its observer plumbing broke.
* `site` is the owner-chosen literal (e.g. "toolGate.onHookError"). */
kind: "observer.notify_failed";
version: 1;
taskId: string;
site: string;
message: string;
ts: number;
} | {
/**
* design/179 — a persisted allow-rule COVERAGE resolved an ask, so no person was asked and no
* checkpoint was minted. The gate has an attribution channel for the deny side and had none for
* the allow side; this is that channel, because "why did this run without asking me?" must be
* answerable. Metadata only (each rule text is the canonical rule spelling, never the command's
* arguments). version 2 (design/375, BREAKING): `rule: string` became `rules: string[]` — a
* compound may now be admitted by the CONJUNCTION of per-segment rules, and the attribution names
* every rule the decision stood on (a single-rule resolution is a one-element array).
*/
kind: "permission.persisted_rule_allowed";
version: 2;
taskId: string;
toolName: string;
toolCallId: string;
rules: readonly string[];
ts: number;
} | {
/**
* The READ-ONLY reader cleared a shell call, so no person and no classifier was asked: the
* allow layer's second attribution channel beside `permission.persisted_rule_allowed`. `command` is
* the FINAL command the gate judged (a policy rewrite included) — model-authored text, carried
* verbatim as the audit fact this frame exists for (a consumer rendering it applies its own
* display sanitizer, as it does for `tool_start` args).
*/
kind: "permission.read_only_allowed";
version: 1;
taskId: string;
toolName: string;
toolCallId: string;
command: string;
ts: number;
} | {
/**
* An active skill's `allowed-tools` row cleared the call, so no person and no classifier was asked: the allow
* layer's third attribution channel beside `permission.persisted_rule_allowed` and `permission.read_only_allowed`.
* `skill` names the skill whose list spoke and `entries` the rows the call stands on, as the skill spells them
* (deployment text; a consumer rendering it applies its own display sanitizer).
*/
kind: "permission.skill_allowed";
version: 1;
taskId: string;
toolName: string;
toolCallId: string;
skill: string;
entries: string[];
ts: number;
} | {
/**
* An entry of the deployment's `RunnerDeps.allowedTools` (`source: "deployment"`) or of this run's
* `TaskSpec.allowedTools` (`source: "request"`) cleared the call, so no person and no classifier was asked: the
* allow layer's attribution channel for its two entry-list members, beside `permission.skill_allowed`. `entries` are
* the entries the call stands on, as the list spells them (deployment or caller text; a consumer rendering it applies
* its own display sanitizer).
*/
kind: "permission.allowed_tools_allowed";
version: 1;
taskId: string;
toolName: string;
toolCallId: string;
source: "deployment" | "request";
entries: string[];
ts: number;
} | {
/**
* design/179 — the person's persisted rule store could not be read (at prepare, or for one call), so
* that read's rows took no part: a call it served was judged without them — and, at a call, asks a person (the
* rows could hold a deny). The operator's per-read record; the person hears it once per run
* (`permission.rule_store_unavailable`).
*/
kind: "permission.rule_store_unreadable";
version: 1;
taskId: string;
message: string;
ts: number;
} | {
/**
* design/182 §4.6 — a cloud-sync round moved a (rule, scope) from REMOVED back to LIVE: another
* replica's add outlived the local tombstone (add-wins is the locked merge semantic, so the
* transition itself is not news — its VISIBILITY is). The person who deleted the rule has to be
* able to learn that it came back, and to delete it again. Metadata only: the canonical rule
* text and the scope KIND — a project scope's root path stays out of the trace.
*/
kind: "permission.rule_sync_resurrected";
version: 1;
/** The bucket this round synced. A local-owner bucket cannot sync, so this is always a principal. */
principal: string;
rule: string;
/** `"session"` is unreachable here in a healthy fleet (design/382 §4.3: a session row never
* lands, so it can never resurrect) — declared because the sibling drop event names it and a
* lockstep union is what keeps the two from drifting. */
scopeKind: "global" | "project" | "session";
ts: number;
} | {
/**
* design/182 §4.3/§5.2 — a cloud-sync round REFUSED an inbound row, or quarantined a local one.
* The reason is a closed code from the governance-codes family (never free text): the machine
* reads the table. One event per dropped row — a silently refused row is indistinguishable from
* a row that never existed, which is the failure this event exists to prevent.
*/
kind: "permission.rule_sync_dropped";
version: 1;
principal: string;
rule: string;
/** `"session"` names the design/382 §4.3 durable-face drop: a peer sent a session-scope row,
* refused per-row with `session_scope_not_durable`. */
scopeKind: "global" | "project" | "session";
/** Closed reason code ({@link import("./governance-codes.js").RuleSyncDropReason}). */
reason: string;
ts: number;
} | {
/**
* design/182 §7.4 — an org-governed deployment could NOT adjudicate against an org snapshot
* (never installed, provider failed past the staleness bound, or the persisted state was
* refused). The gate answered by failing the WHOLE decision boundary closed: every terminal
* allow became a real-approval ask. This is the operator-facing half of that tighten — the
* deployment is paying the "ask about everything" price and has to be able to see why.
*/
kind: "permission.org_snapshot_unavailable";
version: 1;
taskId: string;
toolName: string;
toolCallId: string;
/** The overlay's own disclosure lines (deployment-authored/engine-authored, never model text),
* joined — provider failure, refused snapshot, rollback refusal, missing durable persistence. */
message: string;
ts: number;
} | {
/**
* F-012 L2 — a pending ask was AUTO-ADMITTED by the sandbox-admission leg: the deployment's
* execution env declares an isolated sandbox, every surviving ask on the call was
* engine-classified `sandbox_local`, and the call crosses no declared boundary. This record is
* the durable admission ledger's operator half (the transcript half rides the tool result's
* system-reminder context); a consumer anchors on THIS kind, never on wording.
*/
kind: "permission.sandbox_admitted";
version: 1;
taskId: string;
toolName: string;
toolCallId: string;
/** The engine ask classes of the admitted call's surviving asks (all `sandbox_local` by the
* admission predicate; carried explicitly so the ledger states what was judged, not just that
* judgment passed). */
askClasses: readonly string[];
/** Which layers raised the admitted asks (`caller_policy` / `ancestor_constraint` /
* `gate_safety_tighten`). */
sourceLayers: readonly string[];
/** The boundary classification the admission asserted. */
boundary: "sandbox_internal";
ts: number;
} | {
/**
* The auto-mode classifier decided (or declined to decide) one pending ask — one frame per
* `decide` call, so a consumer can read the gate's wall-clock cost per tool call and the failure
* cause behind a call the classifier could not evaluate. A TRACE frame rather than a task event: it
* fires once per gated call and it is an operator's read (why did that ask take four seconds / why
* was that call denied). `ms` is the wall time from the
* decide call to its verdict (timeout included: a timed-out round reads the deadline).
*/
kind: "auto_mode.classified";
version: 1;
taskId: string;
/** The gated call the classifier judged. */
toolCallId: string;
/** The classifier model (the resolved `classifier` role, or the main model it fell back to). */
model: string;
/** Wall-clock milliseconds the gate waited on this decision (integer, ≥ 0). */
ms: number;
/** The verdict kind the gate acted on (`allow` / `block` / `unavailable` / `parse_error`). */
verdict: "allow" | "block" | "unavailable" | "parse_error";
/** Present iff `verdict === "unavailable"`: `error` / `timeout`. */
cause?: "error" | "timeout";
/** Present exactly when NO MODEL WAS ASKED because the question did not assemble: `timeout` = the
* assembly outran its cap, `failed` = it threw. The verdict reads `unavailable` / `error` either way
* (nothing was dispatched), and `ms` is then time spent BUILDING, not waiting on a provider — which is
* what separates "the transcript store is slow" from "the provider is down". */
notAsked?: "failed" | "timeout";
ts: number;
} | {
/** C1 — the failover brain served this call from a FALLBACK entry (`createFailoverBrain`): the
* primary (and possibly earlier hops) failed cleanly upfront. Without this, same-model gateway
* swaps are indistinguishable in `brain.call` (the model id doesn't change). */
kind: "brain.failover";
version: 1;
taskId: string;
/** Index of the brain that SERVED the call (0 = primary; ≥1 = a fallback hop). */
servedIndex: number;
/** Chain length. */
total: number;
/** Canonical `BrainErrorCode` of the LAST failed hop (the reason the switch happened), if coded. */
errorCode?: string;
ts: number;
} | {
/** C5 — a circuit breaker changed phase (trip / half-open probe window / close). The breaker
* previously mutated state with zero emit; only the fast-fail's generic error text was visible. */
kind: "breaker.transition";
version: 1;
taskId: string;
/** Breaker key (default `<provider>:<modelId>`). */
key: string;
phase: "open" | "half-open" | "closed";
/** Consecutive transient failures at the transition. */
failures: number;
ts: number;
} | {
/** C6 — the connect/mid-stream retry loop retried a provider call. The DISCARDED attempt's cost is
* UNKNOWABLE by construction (the usage frame never arrived — that's why the attempt failed), so
* this reports the attempt count honestly instead of a fabricated cost figure. */
kind: "brain.retry";
version: 1;
taskId: string;
/** 1-based attempt number that was ABANDONED (the retry that follows is attempt+1). */
attempt: number;
phase: "connect" | "midstream";
/** WHY the attempt is being retried, as the closed neutral bucket the user-facing status frame
* also carries ({@link BrainRetryErrClass}) — an operator reading this and a user reading a
* progress line then agree on the reason. Optional: absent on frames minted before it existed. */
errClass?: BrainRetryErrClass;
/** The backoff about to be slept, ms — 0 for an immediate re-send. Together with `attempt` this
* makes a whole retry chain reconstructible from the trace alone, which is what a caller that saw
* only silence needs to explain where the time went. */
nextDelayMs?: number;
ts: number;
} | {
/** C2 — the agent loop drove one of its self-heal recoveries (malformed-tool retry / thinking-only
* retry / truncated continue / degenerate cut / mid-stream partial continue / reactive compact /
* walltime cutoff write-out). Previously test-only (LoopTraceSink was never wired in production);
* the only artifact was a display:false nudge message. */
kind: "loop.recovery";
version: 1;
taskId: string;
reason: string;
ts: number;
} | {
/** C3 — within-task compaction WANTED to fire (over threshold) but the anti-thrash floor
* (design/64 §25.2 `minTokens`) suppressed it. Repeated suppression = the run is drifting into
* the request-trim / prefix-cache-collapse band with zero signal (the success-only onCompaction
* never fires on this path). */
kind: "compaction.suppressed";
version: 1;
taskId: string;
/** Estimated context tokens at the suppressed boundary. */
estTokens: number;
/** The anti-thrash floor that suppressed the pass. */
floor: number;
ts: number;
} | {
/** The auto-compaction trigger could not be EVALUATED: the serving model declares no usable
* context window, so the predicate refuses on the unknown-window sentinel and there is no
* threshold to report. Distinct from a healthy under-threshold pass, which is deliberately
* silent — this one means auto-compaction is inert for the whole run. Emitted once per task
* (the state is configuration, not an event); the host also gets one `phase: "config"` error. */
kind: "compaction.unevaluable";
version: 1;
taskId: string;
/** What the pass could still estimate (structural + prompt overhead) despite the unknown window. */
estTokens: number;
ts: number;
} | {
/** design/134 复审 — a preCompact callback BLOCKED a compaction at a turn boundary (auto/manual
* triggers only; "forced" ignores the block). Deliberate hook decision, NOT a failure: the
* breaker is neither fed nor reset by it. This frame is what tells a blocked boundary apart
* from a structural no-op (under threshold / no cut point). */
kind: "compaction.blocked";
version: 1;
taskId: string;
/** The trigger the blocked pass ran under (never "forced" — forced ignores blocks). */
trigger: "auto" | "manual";
ts: number;
} | {
/** MF-18 修① — a compaction pass FAILED: either a burned summary attempt threw
* (summarizer error / empty summary / oversized-summary guard; counted toward the §17.4
* breaker) or a manual /compact was drained against an ALREADY-OPEN breaker (no attempt).
* Before this frame the only evidence was `Runner.onError(phase:"compaction")` — a service
* that acked the manual verb (202) but saw no `compacted` event had ZERO stream/trace signal
* about why (the fingerprint). */
kind: "compaction.failed";
version: 1;
taskId: string;
/** The trigger of the failed pass. */
trigger: "auto" | "manual" | "forced";
/** Engine-authored failure detail (CompactionError / guard message), clipped. */
reason: string;
ts: number;
} | {
/** MF-18 修① — a manual /compact request was PROCESSED but MOOTED: the loop is dying
* (abort fired / durable suspend / plan review in flight) so no compaction can run. The
* compact() promise resolves "mooted"; this frame is the trace-side counterpart.
* `reason:"cancelled"` (additive) — the caller withdrew an IN-FLIGHT request via
* `compact({signal})` and the pass's summary call was aborted mid-air. A caller cancel is an
* external cause, not summarizer evidence: never counted toward the consecutive-failure
* breaker, never fired through `onError` (a deliberate withdrawal is not an engine error).
* A cancel that lands while the request is still PARKED resolves the caller silently (no
* frame — no boundary ever saw the request, so there is no pass to account for). */
kind: "compaction.mooted";
version: 1;
taskId: string;
reason: "task_ending" | "cancelled";
ts: number;
} | {
/** MF-18 修① — a manual /compact attempt ran but found NOTHING to compact (no valid
* cut point / empty history): `maybeCompact` returned a structural `{compacted:false}` on a
* forced pass. Manual-only by design — an auto pass no-ops at nearly every boundary (spam). */
kind: "compaction.noop";
version: 1;
taskId: string;
ts: number;
} | {
/** MF-18 修① — a manual /compact was processed while compaction is DISABLED by the
* task spec (`compaction.enabled:false`): the request can never be honored this run. */
kind: "compaction.disabled";
version: 1;
taskId: string;
ts: number;
} | {
/** design/145 §2 — static config early-warning, emitted once at task start when the configured
* compaction model's window is SMALLER than the main model's: from `fallbackAt` estimated
* context tokens on, every boundary's summary call will either clamp-truncate its input or
* fall back to the main model. A deployment shell surfaces this as a startup hint. */
kind: "compaction.window_config_warning";
version: 1;
taskId: string;
compactionModelWindow: number;
mainModelWindow: number;
/** ≈ headroom/(1−clampTolerance), in the compaction model's structural-token coordinate
* (estimate). Absent when `clampTolerance ≥ 1` (fallback disabled — clamp-only behavior). */
fallbackAt?: number;
ts: number;
} | {
/** design/145 §3 — a compaction pass's summary call FELL BACK from the independent compaction
* model to the MAIN model: the dry-run clamp showed the conversation would lose more than
* `clampTolerance` of its chars in the small window (and the budget/walltime gates allowed
* the more expensive main-model call). The `compacted` wire event carries the additive
* `modelFallback`/`fallbackReason` mirror. */
kind: "compaction.model_fallback";
version: 1;
taskId: string;
/** Serialized conversation size, MAIN-model structural tokens. */
contentTokens: number;
/** ≈ what fits in the compaction model's window (its structural tokens; estimate). */
headroomTokens: number;
truncationRatio: number;
/** 门A estimate (contentTokens × main input price); absent when pricing was unavailable. */
estCostMicroUsd?: number;
ts: number;
} | {
/** design/145 §3 — a summary input WAS clamp-truncated on the small compaction model and the
* loss is hereby DISCLOSED (the pre-145 silent mid-section drop): reason "budget"/"walltime"
* = the fallback was denied by that gate; "tolerance" = within `clampTolerance` (bounded).
* Fires pre-call (survives a later summary failure). */
kind: "compaction.clamp_disclosure";
version: 1;
taskId: string;
truncationRatio: number;
reason: "budget" | "walltime" | "tolerance";
estCostMicroUsd?: number;
ts: number;
} | {
/** design/145 门B-3 — a manual /compact was honored INSIDE the graceful-finalize window
* (user sovereignty over engine throttling), but the summary call will consume write-out
* time; the soft summary deadline (wall − cushion) still bounds it. Warn-grade. */
kind: "compaction.manual_in_finalize";
version: 1;
taskId: string;
ts: number;
} | {
/** MF-18 修② — the summarization INPUT was truncated to fit the compaction model's
* context window (the 300K-session shape that previously guaranteed a prompt-too-long throw
* at every boundary). Fidelity disclosure: oldest `droppedChars` characters of the serialized
* conversation were omitted from the summary prompt. */
kind: "compaction.input_truncated";
version: 1;
taskId: string;
/** Which summary call was truncated: the history summary or the split-turn prefix summary. */
label: "history" | "turn_prefix";
droppedChars: number;
keptChars: number;
ts: number;
} | {
/** 压缩子阶段遥测 (1.283 批 G 观察项 / design/145 §6 正交后续批) — sub-phase wall-time split of
* ONE landed compaction pass, mirroring `maybeCompact().phaseDurations` 1:1 so a TB "整段 ~55s
* 停顿" reading can be attributed to prepare (context build/cut-point/gates) vs the summary LLM
* call vs persist (attachments/session rewrite/post measurement). Invariant: prepareMs +
* (summaryMs ?? 0) + persistMs === durationMs (exact tiling, shared end mark).
*
* EMITTED by the runtask bridge (F 批, follow-up to the frame-definition batch): each of the
* three `compacted`-event bridge points (boundary/manual, PTL-recovery, end-of-task finish)
* reads `comp.phaseDurations` off the maybeCompact result (next to the
* `...(comp.durationMs !== undefined …)` passthrough — which now also forwards `phaseDurations`
* onto the wire event, additive) and emits this frame right beside the event.
* Metadata-only per the file-header rule (durations + a retry counter, no content). */
kind: "compaction.phase_timings";
version: 1;
taskId: string;
/** Context build → trigger math → cut-point selection → hooks/provider consult → dry-run/gates. */
prepareMs: number;
/** The summary LLM leg (serialization → clamp → completion, incl. PTL retries). ABSENT on a
* provider-reuse pass — no summary call happened. */
summaryMs?: number;
/** Working-file attachments → session rewrite (appendCompaction) → post measurement → observers. */
persistMs: number;
/** Reactive prompt-too-long retries burned inside this pass's summary call(s). */
ptlRetries: number;
/** Whole-pass wall time — equals the `compacted` wire event's `durationMs`. */
durationMs: number;
ts: number;
} | {
/** MF-18 修③ — a compaction landed at the run's FINAL boundary and the one-shot
* post-compact announce latch (background-task snapshot, G1) was never consumed: the
* next collected boundary it waits for never came. The announce is lost for THIS run (a
* follow-up run on the same session re-arms it from the session tail — see the resume
* re-arm seed in runtask). */
kind: "compaction.announce_dropped";
version: 1;
taskId: string;
ts: number;
} | {
/** C9 — the request-layer context guard TRIMMED messages out of a request view (design/123
* request-only trim; the session keeps them, but the model didn't see them this turn). */
kind: "context.trim";
version: 1;
taskId: string;
/** Messages dropped from THIS request view. */
dropped: number;
ts: number;
} | {
/** design/374 (G11) — the microCompact clearing machine FIRED: `clearedCount` tool-result
* occurrences were replaced with markers in one pass. `trigger` names the arm: `"frontier"`
* = the proactive request-build pass (anchored estimate crossed the edit budget),
* `"refusal"` = the MC-R rejection-recovery arm (provider said input-too-long), `"blocking"`
* = the slice-3 guard-chain arm A (the machine's one pre-guard shot when the frontier pass
* is off, `microCompact.machine: "off"`). The `"frontier"` arm requires
* `microCompact.machine: "cc"` (the default), the `"refusal"` arm requires
* `microCompact.clearOnRejection` (also default-on). Cardinality: exactly ONE frame per firing pass — a retry
* chain re-sending an already-cleared view emits none. A `"refusal"` frame also marks one
* attempt of the shared prompt-too-long recovery budget spent (the MC-R arm's retry rides
* the same per-chain account the forced-compaction arm draws on — the knob doc on
* `RunnerDeps.microCompact` carries the full accounting). */
kind: "context.mc_clear";
version: 1;
taskId: string;
clearedCount: number;
/** The pass's structural savings estimate (the ≥20k gate's own number). */
tokensSavedEstimate: number;
trigger: "frontier" | "refusal" | "blocking";
ts: number;
} | {
/** design/374 (G11) — the MC-R rejection arm ran and DECLINED to clear: `reason` says why
* (`"no_candidates"` = nothing clearable beyond the keep window on the rejected projection;
* `"below_min_savings"` = clearable but under the 20k gate), `nextArm` names where the
* recovery chain goes instead (`"forced_compaction"` when the compaction arm is available,
* `"none"` when it is disabled/tripped — the turn will surface the provider error). Emitted
* only on the refusal arm; the frontier pass declines silently every request by design. */
kind: "context.mc_null";
version: 1;
taskId: string;
reason: "no_candidates" | "below_min_savings";
nextArm: "forced_compaction" | "none";
ts: number;
} | {
/** C8 — the per-turn aggregate tool-result budget capped a result (offloaded to the store, or
* degraded to a self-contained truncation preview when the store failed/absent). */
kind: "tool_result.capped";
version: 1;
taskId: string;
tool?: string;
/** Chars of the full result before capping. */
sizeChars: number;
/** True = store.put failed (or no store): the model lost read-back for this result. */
storeFallback: boolean;
ts: number;
} | {
/** C4 — image blocks were replaced with text placeholders because the served model declares no
* vision input (`ModelSpec.input` without "image"). Silent quality loss without this. */
kind: "vision.placeholder";
version: 1;
taskId: string;
/** Image blocks replaced in THIS request. */
count: number;
ts: number;
} | {
/** A provider echo field was refused by {@link import("../brain/errors.js").PROVIDER_ECHO_SHAPE} (visible ASCII,
* 1–128 characters) and dropped: the response's `request-id` / `x-request-id` header values (`field:
* "requestId"`, read at connect — the id a failure would carry as `apiFailure.requestId`), the model name
* its frames report (`field: "responseModel"`), or the word a refusal's error envelope states (`field:
* "apiErrorCode"`). One frame per provider response per field; the value itself is never carried
* (metadata-only). A count of these frames is this engine's drop count. */
kind: "brain.echo_dropped";
version: 1;
taskId: string;
field: "requestId" | "responseModel" | "apiErrorCode";
ts: number;
} | {
/** C10 — a `preemptSignal` fired on a task that is NOT resource-suspend-eligible: the preempt is a
* documented silent no-op, but the SCHEDULER that raised it needs to know it was ignored. */
kind: "preempt.ignored";
version: 1;
taskId: string;
ts: number;
} | {
/** C11 — a tool requested a human plan review (`ctx.requestReview()`) but the deployment cannot
* honor it (no checkpoint store / loop already terminating): the request was consumed + DROPPED
* (headless degrade). The HITL ask vanished with zero signal before this. NO free-text reason
* field (`requestReview({reason})` carries tool/model-authored text — the file-header
* metadata-only rule forbids it here; the fact of the drop is the signal). */
kind: "review.dropped";
version: 1;
taskId: string;
ts: number;
} | {
/** The deployment `RunnerDeps.onError` sink ITSELF failed — threw synchronously, or (an async
* sink) returned a promise that rejected. The failure is swallowed at the Runner's chokepoint
* wrap by contract (a broken observer never breaks the run), so this frame is the only evidence
* a persistently broken sink leaves. No `taskId`: the sink context is session-scoped. */
kind: "task.onerror_sink_failed";
version: 1;
/** Session of the original `onError` invocation whose sink failed. */
sessionId: string;
/** Phase of the original `onError` invocation (e.g. "compaction", "config", "memory"). */
phase: string;
/** Sink-authored failure detail (`Error.message` / `String(value)`), clipped to 256 chars —
* deployment-code-authored, same posture as `compaction.failed.reason` (never model text). */
reason: string;
ts: number;
};
/**
* Best-effort, fire-and-forget trace sink. Returns `void` by contract so a caller cannot synchronously
* await it; the default (absent) hook is a no-op the JIT can elide. Must not throw or do sync I/O.
*/
export type TracerHook = (e: TraceEvent) => void;
/** Invoke a tracer without ever letting it break the task (absent → no-op; throws swallowed). */
export declare function emitTrace(tracer: TracerHook | undefined, build: () => TraceEvent): void;