UNPKG

agentfootprint

Version:

The explainable agent framework — backtrack a wrong answer to the exact context that caused it (evidence, not guesses). Built on footprintjs.

1,721 lines • 89.7 kB
"use strict";
/**
 * otelObservability — OpenTelemetry distributed-tracing adapter.
 *
 * Ships every agentfootprint event as OpenTelemetry spans + span events
 * via a consumer-supplied OTel API, following the OpenTelemetry **GenAI
 * semantic conventions** (`gen_ai.*` attribute namespace) plus
 * agentfootprint-specific explainability attributes (`agentfootprint.*`).
 * Same hierarchical mapping as the X-Ray adapter, but the destination is
 * whichever OTel-compat backend the consumer's SDK exports to:
 *
 *   - **Honeycomb** (OTLP/HTTP)
 *   - **Grafana Cloud / Tempo / Mimir** (OTLP)
 *   - **AWS Distro for OTel** → AWS X-Ray (alternative to xrayObservability)
 *   - **Datadog APM** (OTLP endpoint)
 *   - **Splunk Observability Cloud** (OTLP)
 *   - **New Relic** (OTLP endpoint)
 *   - **Lightstep / ServiceNow Cloud Observability** (OTLP)
 *   - any custom OTel collector / processor pipeline
 *
 * Subpath:  `agentfootprint/observe`
 * Peer dep: `@opentelemetry/api` (OPTIONAL — installed only when
 *           this adapter is used. The consumer ALSO installs the
 *           OTel SDK + exporter of their choice — that's the BYO
 *           contract that makes this adapter backend-agnostic.).
 *
 * **Why BYO SDK:** OTel's SDK is heavyweight and exporter-specific
 * (each backend has its own exporter package). Forcing a particular
 * exporter would defeat the "OTel is portable" guarantee. Consumers
 * configure the SDK + exporter once at app startup; we just speak
 * the typed OTel API.
 *
 * ## Event → span/attribute mapping
 *
 *   agent.turn_start          ↦  start root span (one trace per turn) —
 *                                `gen_ai.operation.name: 'invoke_agent'`
 *   agent.turn_end            ↦  end root span (+ turn-total `gen_ai.usage.*`)
 *   agent.iteration_start     ↦  start child span under root
 *   agent.iteration_end       ↦  end iteration span
 *   stream.llm_start          ↦  start child span (inference) — `gen_ai.*`
 *                                request attrs (`chat` operation)
 *   stream.llm_end            ↦  end llm span (+ `gen_ai.usage.*`,
 *                                `gen_ai.response.*`)
 *   stream.tool_start         ↦  start child span — `execute_tool` operation,
 *                                `gen_ai.tool.name` / `gen_ai.tool.call.id`
 *   stream.tool_end           ↦  end tool span (ERROR status + `error.type`
 *                                if errored). Correlated by toolCallId so
 *                                PARALLEL tool calls close the right span.
 *   a bracket carrying        ↦  NO span: the call never executed (9.113.0).
 *   `notDispatched`              Its tool_start adds ONE span event
 *                                `agentfootprint.tool.not_dispatched` to the
 *                                active span — SYNTHESIZED name — with
 *                                `gen_ai.tool.name`, `gen_ai.tool.call.id` and
 *                                the paused call's id and name; its tool_end
 *                                closes nothing. KNOWN LIMIT: only on a leg
 *                                this adapter traces, and a trace opens on
 *                                `agent.turn_start` only. A resumed leg emits
 *                                none and carries a new `meta.runId`, so none
 *                                of it is traced today — and a settled bracket
 *                                only ever rides a resumed leg, so no run
 *                                records this span event yet
 *   cost.tick                 ↦  setAttribute on topmost active span
 *   error.fatal               ↦  ERROR status on root + defensive unwind
 *   context.evaluated         ↦  N span events `agentfootprint.skill.routing`
 *                                — SYNTHESIZED name (one per routing entry),
 *                                not a registry-verbatim forward; apart from
 *                                it, `agentfootprint.tool.not_dispatched`
 *                                above and `agentfootprint.integrity.check`
 *                                below, span events use the registry name
 *                                verbatim
 *
 * ## What an evaluation tool reads
 *
 *   meta.sessionId            ↦  `gen_ai.conversation.id` on the agent, chat
 *                                and execute_tool spans of a session-bound run
 *                                — the SHA-256 DIGEST of the session id by
 *                                default (`conversationId: 'raw'` for the id
 *                                itself, safe only behind a verifying door);
 *                                never the runId in its place
 *   eval.score                ↦  `gen_ai.evaluation.result` — the spec's
 *                                evaluation event, carried as a SPAN event
 *                                (the spec's reference emitter logs it; this
 *                                adapter speaks the Tracer API only). On the
 *                                span it evaluates while the turn is open; on
 *                                a short `agentfootprint.evaluation` span
 *                                under the turn once it closed — and PARENTED
 *                                only when the score's own meta places it in
 *                                that run's session. Otherwise (every
 *                                `agent.emit` after the run) it rides an
 *                                `agentfootprint.evaluation` span of its own,
 *                                unparented, naming the ref. `explanation`
 *                                only under `captureContent`; `evidence` never
 *   tools.absent /            ↦  span event on the call's own span: a COUNT
 *   tools.coverage_declared      per list (checked / not_checked /
 *                                cannot_cover), the suggested tool's name only
 *                                when this strategy has seen that tool run —
 *                                never a sentence the tool author wrote
 *   agent.evidence_checked    ↦  span event: verdict + unsupported COUNT; the
 *                                values only under `captureContent`
 *   findings.standing         ↦  span event per row (ids, enums, counts; an
 *                                unknown id as a flag only), at most
 *                                {@link MAX_STANDING_EVENTS_PER_TURN} per turn
 *                                then one `agentfootprint.findings.standing_overflow`
 *                                (SYNTHESIZED); a count per standing on the
 *                                agent span when the turn closes
 *   integrity.disposition     ↦  filed AFTER turn_end. By default exported
 *                                only when a row is actionable (findings, or
 *                                wiring rot): a short
 *                                `agentfootprint.integrity.disposition` span
 *                                under the turn, one
 *                                `agentfootprint.integrity.check` span event
 *                                (SYNTHESIZED) per actionable row.
 *                                `checkAccounting: 'all'` for every row
 *   (captureToolContent only) ↦  `gen_ai.tool.call.arguments` /
 *                                `gen_ai.tool.call.result` on the tool span of
 *                                a call that ran and returned — the proposal
 *                                snapshot with every rule-changed key
 *                                withheld, and what the model READ; objects
 *                                only; omitted over `maxContentChars`; never a
 *                                code runner's program
 *
 * The structure events above ride the `explainability` switch; the
 * evaluation event does not (it is a `gen_ai.*` signal, like the attributes).
 * A span started after its parent ended (the disposition, a late score) is
 * valid OTel; most trace views draw it outside the parent's bar.
 *
 * ## Decisions = SPAN EVENTS, not attributes (design decision)
 *
 * Explainability signals (route decisions, skill routing, validation
 * rejections, permission checks, credential lifecycle) are emitted as
 * **span events** on the currently-active span rather than attributes:
 *
 *   1. MULTIPLICITY — an iteration span can carry several decisions
 *      (route + N skill routings + M permission checks). Attributes are
 *      last-write-wins and would clobber; span events accumulate.
 *   2. ORDERING — span events carry their own timestamps, preserving the
 *      decision sequence inside one span. Compliance review (EU AI Act
 *      Art. 12 record-keeping) needs the order decisions were made.
 *   3. ROUND-TRIP — OTLP backends (and agentThinkingUI's `fromOTLP`
 *      ingestion) surface span events as first-class timeline entries.
 *
 * When the consumer-injected tracer's spans don't implement `addEvent`
 * (minimal test doubles), the adapter falls back to flattened
 * `${eventName}.${key}` attributes — degraded (last-write-wins) but
 * never silently dropped.
 *
 * ## PII discipline
 *
 * Mirrors the #9 validation contract: by default attribute values NEVER echo
 * runtime VALUES that can carry PII —
 *   - tool args  → top-level key NAMES only (`agentfootprint.tool.args.keys`)
 *     (by default; see `captureToolContent`)
 *   - tool results → `typeof` only (`agentfootprint.tool.result.type`)
 *     (by default; see `captureToolContent`)
 *   - validation issues → path / expected / got TYPES (bounded upstream)
 *   - decide() evidence → rule labels, operators, thresholds (developer
 *     constants) and the engine's redaction-aware value SUMMARIES
 *   - userPrompt / llm content / thinking → never emitted (by default; see
 *     `captureContent`)
 *   - error.fatal → stage + scope only (error MESSAGES can echo values)
 *   - credential events carry no secrets by construction (registry contract)
 *   - the session id → its SHA-256 digest (`conversationId`)
 *   - absence / coverage events → list COUNTS, and the suggested tool's NAME
 *     only when this strategy has seen that tool run; never
 *     `looked_for`, a `what` / `why`, `tryInstead` or an unregistered
 *     `tryInsteadTool.tool` — any field a tool author writes can interpolate
 *     an argument (coverage README, "What that does not close"). The EVENT
 *     never carries them, content on or off; under `captureToolContent` an
 *     `absent()` call's result (the envelope itself) does, as its
 *     `gen_ai.tool.call.result`, beside the arguments any interpolation came
 *     from
 *   - evidence verdict → the unsupported COUNT; findings → ids, enums,
 *     counts, never the model's lines (an id the model wrote that names no
 *     result is a flag, not the id); disposition → check names + counts
 *   - eval.score → metric name, score, label (a vocabulary word by contract),
 *     target words from the payload's closed vocabularies; `evidence` never
 *   - EVERY string attribute outside the content switches is capped at 256
 *     characters and every list at 20 items plus an overflow marker, in one
 *     funnel (`capAttrs`) —
 *     whoever chose the value, a caller-sized session id or a model-written
 *     id cannot choose the size of an attribute. A capped value is cut with
 *     `…` (never mid surrogate pair): a cut id is no longer the id. Span
 *     NAMES carry a tool or model name at most 64 characters
 *     (`spanNamePart`), since a tool name is the model's text when it names
 *     no registered tool
 *   - the session digest hashes the id's UTF-8 bytes, where every lone
 *     surrogate becomes U+FFFD: two ids differing only in a lone surrogate
 *     share a digest. Library-minted ids are UUIDs; documented, not handled
 *
 * Two content switches, each off by default: `captureContent` (the turn's
 * prompt and answer, the evidence gate's unsupported values, an evaluation's
 * explanation) and `captureToolContent` (tool arguments and results). The
 * adapter redacts nothing of its own: it exports what the events carry. A
 * call's arguments are the proposal `tool_start` carried, SERIALIZED THEN
 * (the engine hands the same object to the tool, which may write into it),
 * with every key `tool_end` names in `changedArgKeys` — a rule rewrote it,
 * or the tool's own `redact` policy hides it — withheld as `'REDACTED'`; the
 * result is what the model read (`modelResult`, after the `onToolResult`
 * scrub and the removal of the record-only coverage fields `short` / `kind`).
 * Never a raw value a rule replaced, never a value a rule added.
 *
 * @example Basic — Honeycomb via OTLP
 * ```ts
 * import { NodeTracerProvider } from '@opentelemetry/sdk-trace-node';
 * import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-http';
 * import { BatchSpanProcessor } from '@opentelemetry/sdk-trace-base';
 * import { otelObservability } from 'agentfootprint/observe';
 *
 * // Set up OTel ONCE at app startup.
 * const provider = new NodeTracerProvider();
 * provider.addSpanProcessor(new BatchSpanProcessor(new OTLPTraceExporter({
 *   url: 'https://api.honeycomb.io/v1/traces',
 *   headers: { 'x-honeycomb-team': process.env.HONEYCOMB_KEY },
 * })));
 * provider.register();
 *
 * const otel = otelObservability({
 *   serviceName: 'my-agent',
 *   // genAiSpanNames: true,  // opt-in spec span names ('chat gpt-4', …)
 * });
 * agent.enable.observability({ strategy: otel });
 * // Optional — operator-level decide()/select() evidence as span events:
 * // Agent.create({...}).watch(otel.decisionEvidenceRecorder())
 * ```
 *
 * @example Test injection
 * ```ts
 * otelObservability({
 *   serviceName: 'test',
 *   tracer: mockTracer, // anything matching the OTel Tracer interface
 * });
 * ```
 */
Object.defineProperty(exports, "__esModule", { value: true });
exports.otelObservability = void 0;
const ledger_js_1 = require("../../integrity/disposition/ledger.js");
const lazyRequire_js_1 = require("../../lib/lazyRequire.js");
const sha256_js_1 = require("../../lib/sha256.js");
const wireJson_js_1 = require("../../lib/wireJson.js");
const deliveryErrors_js_1 = require("./deliveryErrors.js");
// ─── Bounding helpers (PII / cardinality discipline) ─────────────────
/** Hard caps for attribute payloads. Evidence is bounded upstream
 *  (#5 `maxFieldChars`); these are defense-in-depth for the OTLP wire. */
const MAX_ATTR_CHARS = 256;
const MAX_LIST_ITEMS = 20;
function bound(value) {
    const s = typeof value === 'string' ? value : (0, wireJson_js_1.toWireJson)(value) ?? String(value);
    return s.length > MAX_ATTR_CHARS ? `${cutAt(s, MAX_ATTR_CHARS - 1)}…` : s;
}
/**
 * The first `n` UTF-16 units of `s`, one fewer when the cut would split a
 * surrogate pair — a lone high surrogate is not valid Unicode, and a strict
 * OTLP/JSON decoder rejects it.
 */
function cutAt(s, n) {
    const code = s.charCodeAt(n - 1);
    return s.slice(0, code >= 0xd800 && code <= 0xdbff ? n - 1 : n);
}
/** The longest model- or caller-supplied text a span NAME carries. */
const MAX_SPAN_NAME_PART = 64;
/**
 * A tool or model name as a span-name component. Span names never pass
 * {@link capAttrs}, and a tool name is the MODEL's text when it names no
 * registered tool — so it is capped here (registered names are at most 64
 * characters by the providers' own rule, and pass unchanged).
 */
function spanNamePart(name) {
    return name.length > MAX_SPAN_NAME_PART ? `${cutAt(name, MAX_SPAN_NAME_PART - 1)}…` : name;
}
function boundList(items) {
    const capped = items.slice(0, MAX_LIST_ITEMS).map(bound);
    return items.length > MAX_LIST_ITEMS
        ? [...capped, `…+${items.length - MAX_LIST_ITEMS} more`]
        : capped;
}
/**
 * The CONTENT attributes — the ones a content switch opened on purpose, each
 * with its own size rule (tool content and the explanation: omitted over
 * `maxContentChars`, never cut; `gen_ai.task.*`: exported as they always
 * were). Every OTHER string leaving this adapter is capped by
 * {@link capAttrs}.
 */
const CONTENT_KEYS = new Set([
    'gen_ai.task.input',
    'gen_ai.task.output',
    'gen_ai.tool.call.arguments',
    'gen_ai.tool.call.result',
    'agentfootprint.tool.args.content',
    'agentfootprint.tool.result.content',
    'gen_ai.evaluation.explanation',
]);
/**
 * The ONE cap on the way out: every string attribute — span, span event, the
 * flattened fallback — at most {@link MAX_ATTR_CHARS}, every string list at
 * most {@link MAX_LIST_ITEMS} items, except {@link CONTENT_KEYS}. The
 * projections bound what they build; this funnel is what makes "every
 * exported string is capped" true of fields nobody thought to bound — a
 * caller-sized session id, a principal, a label. Returns the SAME object
 * when nothing needed a cap, so an unaffected span is byte-identical.
 */
function capAttrs(attrs) {
    let out;
    for (const [key, value] of Object.entries(attrs)) {
        if (CONTENT_KEYS.has(key))
            continue;
        let capped = value;
        if (typeof value === 'string' && value.length > MAX_ATTR_CHARS)
            capped = bound(value);
        else if (Array.isArray(value) &&
            (value.length > MAX_LIST_ITEMS || value.some((item) => item.length > MAX_ATTR_CHARS)))
            capped = boundList(value);
        if (capped !== value) {
            out ??= { ...attrs };
            out[key] = capped;
        }
    }
    return out ?? attrs;
}
/** Render one rule's operator-level conditions as compact strings:
 *  `creditScore gt 700 → 750 (true)`. Value summaries come from the
 *  engine already bounded + redaction-aware — we only re-cap length. */
function renderConditions(rule) {
    if (rule.conditions !== undefined && rule.conditions.length > 0) {
        return boundList(rule.conditions.map((c) => `${c.key} ${c.op} ${bound(c.threshold)} → ${c.actualSummary} (${c.result})`));
    }
    if (rule.inputs !== undefined && rule.inputs.length > 0) {
        return boundList(rule.inputs.map((i) => `${i.key} = ${i.valueSummary}`));
    }
    return [];
}
/** Flatten decide()/select() evidence into span-event attributes. */
function renderEvidenceAttrs(evidence) {
    const attrs = {};
    if (evidence.chosen !== undefined)
        attrs['agentfootprint.decision.chosen'] = bound(evidence.chosen);
    if (evidence.default !== undefined)
        attrs['agentfootprint.decision.default'] = bound(evidence.default);
    if (evidence.selected !== undefined)
        attrs['agentfootprint.decision.selected'] = boundList(evidence.selected.map(String));
    const rules = evidence.rules ?? [];
    if (rules.length > 0)
        attrs['agentfootprint.decision.rules_evaluated'] = rules.length;
    const matched = rules.find((r) => r.matched === true);
    if (matched !== undefined) {
        if (matched.label !== undefined)
            attrs['agentfootprint.decision.rule.label'] = bound(matched.label);
        if (matched.ruleIndex !== undefined)
            attrs['agentfootprint.decision.rule.index'] = matched.ruleIndex;
        if (matched.branch !== undefined)
            attrs['agentfootprint.decision.rule.branch'] = bound(matched.branch);
        const conditions = renderConditions(matched);
        if (conditions.length > 0)
            attrs['agentfootprint.decision.conditions'] = conditions;
    }
    return attrs;
}
/** Is this object shaped like decide()/select() evidence? */
function looksLikeDecideEvidence(value) {
    return (typeof value === 'object' &&
        value !== null &&
        Array.isArray(value.rules));
}
/** Name the closed standing vocabulary once — the per-turn counts key on it. */
const STANDINGS = ['fact', 'open', 'noise', 'ruled-out'];
/**
 * Standing span events written per turn. The MODEL chooses how many standing
 * rows it files, and a real OTel SDK keeps 128 events per span by evicting
 * the OLDEST: unbounded rows would let a model (or a prompt injection in a
 * tool result) push the span's earlier record — its route decision — off
 * the wire. Past the cap, rows are counted and summarized once.
 */
const MAX_STANDING_EVENTS_PER_TURN = 32;
/** The default ceiling on one exported CONTENT value, in characters. */
const DEFAULT_MAX_CONTENT_CHARS = 65_536;
const isPlainObject = (value) => value !== null && typeof value === 'object' && !Array.isArray(value);
/** Text that is a serialized plain object — the "serialized string" the spec
 *  says to take as the object it encodes. Only text opening with `{` is
 *  parsed; the caller has already refused anything over the ceiling. */
function isSerializedObject(text) {
    if (!text.trimStart().startsWith('{'))
        return false;
    try {
        return isPlainObject(JSON.parse(text));
    }
    catch {
        return false;
    }
}
/**
 * A tool call's arguments or result, as this adapter may export it:
 *
 *   - a plain object, or text that parses to one → the spec's
 *     `gen_ai.tool.call.arguments` / `.result`, as a JSON string (the spec's
 *     span form: its schema is `{"type": "object"}`, and OTel JS span
 *     attributes hold no objects);
 *   - anything else — plain text, a number, an array, the string `"123"` —
 *     → `agentfootprint.tool.<args|result>.content`: text verbatim, other
 *     values as JSON. Never the spec attribute, so a string is never
 *     exported as the number it spells;
 *   - over `maxChars` → neither: `agentfootprint.tool.<…>.omitted_chars`
 *     states the size. Never a cut value, which reads as complete data;
 *   - nothing serializable (undefined, a function, a cycle, a BigInt) → {}.
 */
function toolContentAttrs(kind, value, maxChars) {
    const specKey = kind === 'args' ? 'gen_ai.tool.call.arguments' : 'gen_ai.tool.call.result';
    if (value === undefined)
        return {};
    let text;
    if (typeof value === 'string')
        text = value;
    else {
        try {
            text = (0, wireJson_js_1.toWireJson)(value);
        }
        catch {
            return {};
        }
    }
    if (text === undefined)
        return {};
    if (text.length > maxChars)
        return { [`agentfootprint.tool.${kind}.omitted_chars`]: text.length };
    const object = typeof value === 'string' ? isSerializedObject(value) : isPlainObject(value);
    return { [object ? specKey : `agentfootprint.tool.${kind}.content`]: text };
}
const countOf = (list) => (Array.isArray(list) ? list.length : 0);
/**
 * `tools.absent` / `tools.coverage_declared` — a COUNT per declared list, and
 * the suggested tool's NAME only when `knownTool` recognizes it as a tool this
 * strategy has seen RUN (a call that returned without error — it is held, by
 * construction). Never a sentence: `looked_for`, `what`, `why`, `tryInstead` —
 * and an unrecognized `tryInsteadTool.tool`, which is as much author text as
 * the rest — can each interpolate an argument (coverage README, "What that
 * does not close"); an unrecognized suggestion says only that it exists. A
 * registered tool the agent has not run yet is not recognized: the event
 * carries no roster (that would change every `absent()` event's bytes), so
 * this may omit a real name — never export text that is not one.
 */
function coverageAttrs(p, knownTool) {
    const suggested = typeof p.tryInsteadTool?.tool === 'string';
    return {
        ...(typeof p.toolName === 'string' && { 'gen_ai.tool.name': p.toolName }),
        ...(typeof p.toolCallId === 'string' && { 'gen_ai.tool.call.id': p.toolCallId }),
        ...(typeof p.iteration === 'number' && { 'agentfootprint.iteration.index': p.iteration }),
        'agentfootprint.coverage.checked.count': countOf(p.checked),
        'agentfootprint.coverage.not_checked.count': countOf(p.notChecked),
        'agentfootprint.coverage.cannot_cover.count': countOf(p.cannotCover),
        ...(suggested &&
            (knownTool(String(p.tryInsteadTool?.tool))
                ? { 'agentfootprint.coverage.try_instead.tool': bound(p.tryInsteadTool?.tool) }
                : { 'agentfootprint.coverage.try_instead.unregistered': true })),
    };
}
/** `agent.evidence_checked` — the verdict and a COUNT; the unsupported
 *  values themselves only under `captureContent`. */
function evidenceVerdictAttrs(p, content) {
    const unsupported = Array.isArray(p.unsupported) ? p.unsupported : [];
    return {
        ...(typeof p.action === 'string' && { 'agentfootprint.evidence.action': p.action }),
        ...(typeof p.posture === 'string' && { 'agentfootprint.evidence.posture': p.posture }),
        ...(typeof p.candidates === 'number' && { 'agentfootprint.evidence.candidates': p.candidates }),
        // How many of `candidates` were actually looked up (exempt values are
        // not) — absent on an event recorded before the field existed.
        ...(typeof p.lookedUp === 'number' && { 'agentfootprint.evidence.looked_up': p.lookedUp }),
        'agentfootprint.evidence.unsupported.count': unsupported.length,
        ...(typeof p.afterRevision === 'boolean' && {
            'agentfootprint.evidence.after_revision': p.afterRevision,
        }),
        ...(p.evidenceTruncated === true && { 'agentfootprint.evidence.truncated': true }),
        ...(typeof p.iteration === 'number' && { 'agentfootprint.iteration.index': p.iteration }),
        ...(content &&
            unsupported.length > 0 && {
            'agentfootprint.evidence.unsupported.values': boundList(unsupported.map((u) => String(u.value))),
        }),
    };
}
/**
 * `findings.standing` — ids, the enum and counts. The assertions, `line` and
 * every other word the model wrote stay in the committed ledger. An
 * `unknownId` row exports the FLAG only: its `toolCallId` is a string the
 * MODEL wrote that names no result the run returned — model text, and
 * nothing any reader could join on.
 */
function standingAttrs(p) {
    const knownId = p.unknownId !== true && typeof p.toolCallId === 'string';
    return {
        ...(typeof p.standing === 'string' && { 'agentfootprint.findings.standing': p.standing }),
        ...(knownId && { 'gen_ai.tool.call.id': bound(p.toolCallId) }),
        ...(typeof p.toolName === 'string' && { 'gen_ai.tool.name': p.toolName }),
        ...(typeof p.declaredOn === 'string' && {
            'agentfootprint.findings.declared_on': p.declaredOn,
        }),
        ...(typeof p.assertionCount === 'number' && {
            'agentfootprint.findings.assertion_count': p.assertionCount,
        }),
        ...(Array.isArray(p.conflictKeys) && {
            'agentfootprint.findings.conflict_count': p.conflictKeys.length,
        }),
        ...(p.unknownId === true && { 'agentfootprint.findings.unknown_id': true }),
        ...(typeof p.agrees === 'boolean' && { 'agentfootprint.findings.agrees': p.agrees }),
        ...(typeof p.iteration === 'number' && { 'agentfootprint.iteration.index': p.iteration }),
    };
}
/** One count per standing in the closed vocabulary — zeros included, since
 *  the adapter saw every standing row the traced turn filed. */
function standingCountAttrs(counts) {
    const attrs = {};
    for (const s of STANDINGS)
        attrs[`agentfootprint.findings.${s.replace('-', '_')}.count`] = counts.get(s) ?? 0;
    return attrs;
}
/** `integrity.disposition` rows summed — the "did any check fire?" a reader
 *  filters on. Synthetic (canary) counts stay out, as they do on each row. */
function dispositionSpanAttrs(p) {
    const rows = Array.isArray(p.rows) ? p.rows : [];
    const sum = (key) => rows.reduce((n, row) => n + (typeof row[key] === 'number' ? row[key] : 0), 0);
    return {
        ...(typeof p.posture === 'string' && { 'agentfootprint.integrity.posture': p.posture }),
        ...(typeof p.workExisted === 'boolean' && {
            'agentfootprint.integrity.work_existed': p.workExisted,
        }),
        'agentfootprint.integrity.checked': sum('checked'),
        'agentfootprint.integrity.findings': sum('findings'),
        'agentfootprint.integrity.not_applicable': sum('notApplicable'),
        'agentfootprint.integrity.unreachable': sum('unreachable'),
    };
}
/** One registered (check, seam) row — the check's name and its outcomes. */
function dispositionRowAttrs(row) {
    const n = (v) => (typeof v === 'number' ? v : 0);
    return {
        'agentfootprint.integrity.check.name': bound(row.check),
        'agentfootprint.integrity.check.seam': bound(row.seam),
        'agentfootprint.integrity.check.checked': n(row.checked),
        'agentfootprint.integrity.check.findings': n(row.findings),
        'agentfootprint.integrity.check.not_applicable': n(row.notApplicable),
        'agentfootprint.integrity.check.unreachable': n(row.unreachable),
        'agentfootprint.integrity.check.synthetic': n(row.synthetic),
    };
}
/**
 * `eval.score` → the GenAI semconv `gen_ai.evaluation.result` attributes
 * (github.com/open-telemetry/semantic-conventions-genai,
 * docs/gen-ai/gen-ai-events.md). The spec has no field for the target or the
 * evaluator kind, so those ride this adapter's own names — and only as words
 * of the payload's own closed vocabularies (a TypeScript union is not a
 * runtime check; a hand-fed sentence in `target` is not exported). The
 * explanation is content (a judge's reason can quote what it graded):
 * `captureContent` only, and omitted — its size stated — over `maxChars`.
 * `evidence` is a free-form record and is never exported.
 */
function evaluationAttrs(p, content, maxChars) {
    const finite = (v) => typeof v === 'number' && Number.isFinite(v);
    const explanation = content && typeof p.explanation === 'string' ? p.explanation : undefined;
    return {
        'gen_ai.evaluation.name': bound(p.metricId),
        ...(finite(p.value) && { 'gen_ai.evaluation.score.value': p.value }),
        ...(typeof p.label === 'string' && { 'gen_ai.evaluation.score.label': bound(p.label) }),
        ...(explanation !== undefined &&
            (explanation.length > maxChars
                ? { 'agentfootprint.eval.explanation.omitted_chars': explanation.length }
                : { 'gen_ai.evaluation.explanation': explanation })),
        ...(EVAL_TARGETS.has(p.target) && {
            'agentfootprint.eval.target': p.target,
        }),
        ...(typeof p.targetRef === 'string' && {
            'agentfootprint.eval.target_ref': bound(p.targetRef),
        }),
        ...(finite(p.threshold) && { 'agentfootprint.eval.threshold': p.threshold }),
        ...(EVALUATORS.has(p.evaluator) && {
            'agentfootprint.eval.evaluator': p.evaluator,
        }),
    };
}
/** `EvalScorePayload`'s closed vocabularies, checked at run time. */
const EVAL_TARGETS = new Set(['iteration', 'turn', 'run', 'toolCall']);
const EVALUATORS = new Set(['llm', 'fn', 'heuristic']);
// ─── Strategy factory ────────────────────────────────────────────────
function otelObservability(opts) {
    if (!opts.serviceName) {
        throw new TypeError(`[otelObservability] \`serviceName\` is required. ` +
            `Pass an identifier visible in your OTel backend's service map, e.g. 'my-agent-prod'.`);
    }
    const sampleRate = opts.sampleRate ?? 1;
    const genAiNames = opts.genAiSpanNames === true;
    const explainability = opts.explainability !== false;
    const scopeName = opts.scopeName ?? 'agentfootprint';
    const captureContent = opts.captureContent === true;
    const captureToolContent = opts.captureToolContent === true;
    const maxContentChars = opts.maxContentChars ?? DEFAULT_MAX_CONTENT_CHARS;
    const rawConversationId = opts.conversationId === 'raw';
    const allCheckRows = opts.checkAccounting === 'all';
    // Lazy-resolve tracer if not injected. Defer the API import until
    // first event so consumers who don't actually fire events (no agent
    // run yet) don't even hit the OTel API surface.
    let tracer = opts.tracer;
    let otelApi = opts._otelApi;
    function ensureTracer() {
        if (tracer)
            return tracer;
        if (!otelApi) {
            try {
                otelApi = (0, lazyRequire_js_1.lazyRequire)('@opentelemetry/api');
            }
            catch {
                throw new Error('otelObservability requires the `@opentelemetry/api` peer dependency.\n' +
                    '  Install:  npm install @opentelemetry/api\n' +
                    '  Plus an OTel SDK + exporter for your backend (e.g.,\n' +
                    '  `@opentelemetry/sdk-trace-node` + `@opentelemetry/exporter-trace-otlp-http`).\n' +
                    '  Or pass `tracer` for test injection.');
            }
        }
        if (!otelApi.trace?.getTracer) {
            throw new Error('otelObservability: `@opentelemetry/api` is installed but `trace.getTracer` not found. Update the package.');
        }
        tracer = otelApi.trace.getTracer(scopeName);
        return tracer;
    }
    const activeTurns = new Map();
    /**
     * Tool names this strategy has seen RUN — a `tool_end` without `error` or
     * `notExecuted` — so a held tool, by construction. What a `tryInsteadTool`
     * suggestion is recognized against. Bounded; names only.
     */
    const ranTools = new Set();
    /**
     * Tool names that filed a `tools.code_run` — a code runner. Its arguments
     * ARE the generated program, which "must not reach an exporter"
     * (`events/payloads.ts` · `ToolsCodeRunPayload`): under
     * `captureToolContent` its calls export no arguments. `code_run` fires after
     * the tool ran and BEFORE its `tool_end`, so the first call is covered too;
     * a call that failed exports no content at all.
     */
    const codeRunners = new Set();
    const MAX_TOOL_NAMES = 1024;
    function remember(set, name) {
        if (set.size >= MAX_TOOL_NAMES && !set.has(name))
            return;
        set.add(name);
    }
    /**
     * Turns that have CLOSED (turn_end / error.fatal), by run id — so a late
     * event can still find its run. Two events arrive late by construction: the
     * checker accounting (`integrity.disposition`, filed in the run door's
     * `finally`, after turn_end) and an evaluation scored after the run. A real
     * SDK drops span events on an ENDED span, so a late event gets its own
     * short child span. An unsampled turn is remembered too (no span), so its
     * late events are dropped with it rather than exported loose.
     *
     * Bounded — the last {@link MAX_CLOSED_TURNS} — and cleared by `stop()`.
     * The window is per strategy, and one strategy usually serves the whole
     * process: in a busy server a late event for a run 32 turns back finds it
     * forgotten. A forgotten run's id is still remembered (a string, in
     * {@link forgottenRuns}), so "forgotten" is told apart from "never traced"
     * and reported, never dropped in silence. Each held root keeps its span
     * object (and, under `captureContent`, the task text on it) on the heap
     * until it leaves the window.
     */
    const MAX_CLOSED_TURNS = 32;
    const MAX_FORGOTTEN_RUNS = 1024;
    const closedTurns = new Map();
    /** run id → whether it was sampled, for runs that left the window. */
    const forgottenRuns = new Map();
    function rememberClosed(runId, t) {
        closedTurns.delete(runId);
        closedTurns.set(runId, {
            ...(t.root !== undefined && { root: t.root }),
            ...(t.sessionDigest !== undefined && { sessionDigest: t.sessionDigest }),
        });
        while (closedTurns.size > MAX_CLOSED_TURNS) {
            const [oldest] = closedTurns.entries();
            if (oldest === undefined)
                break;
            closedTurns.delete(oldest[0]);
            forgottenRuns.set(oldest[0], oldest[1].root !== undefined);
        }
        while (forgottenRuns.size > MAX_FORGOTTEN_RUNS) {
            const [oldest] = forgottenRuns.keys();
            if (oldest === undefined)
                break;
            forgottenRuns.delete(oldest);
        }
    }
    /** `gen_ai.conversation.id` for a span of this turn — or nothing. */
    function conversationAttr(t) {
        return t.conversationId !== undefined ? { 'gen_ai.conversation.id': t.conversationId } : {};
    }
    /** `gen_ai.task.<input|output>`, or its size when over `maxContentChars`. */
    function taskContent(kind, text) {
        return text.length > maxContentChars
            ? { [`agentfootprint.task.${kind}.omitted_chars`]: text.length }
            : { [`gen_ai.task.${kind}`]: text };
    }
    /** A non-empty `meta.sessionId`, or undefined. */
    function sessionOf(event) {
        const sessionId = event.meta?.sessionId;
        return typeof sessionId === 'string' && sessionId !== '' ? sessionId : undefined;
    }
    let stopped = false;
    // The fallback when the consumer wires nothing. Rate-limited; a
    // consumer-supplied sink is not. Armed at CONSTRUCTION — before 8.11.0 it
    // was installed lazily inside `_onError` itself, which meant any caller
    // reading the hook rather than calling the method found `undefined`.
    const consoleSink = (0, deliveryErrors_js_1.rateLimitedConsoleSink)('otel');
    /** A failure this adapter must not swallow — through the strategy's own
     *  `_onError`, so a hook assigned after construction is the one called. */
    function report(error, event) {
        try {
            strategy._onError?.(error, event);
        }
        catch {
            /* a failing sink must never break the agent loop */
        }
    }
    /**
     * Resolve the run anchor for an event.
     *
     * Real runtime events are dispatcher envelopes — the run id lives on
     * `event.meta.runId` (built by `bridge/eventMeta.ts`). The legacy
     * `payload.runId` read is kept as a fallback for consumers feeding
     * hand-built events (the pre-6.17 shape this adapter's own tests
     * used). Without the meta read, NO span ever opened on a real agent
     * run — the bug the fabricated test shapes masked.
     */
    function anchorRunId(event) {
        const meta = event.meta;
        return meta?.runId ?? event.payload?.runId;
    }
    /**
     * Start a span under `parent` (a root when `parent` is undefined) — the ONE
     * place spans are started. OTel parent-context wiring: we capture the
     * parent in a context and start the new span under it. (For BYO SDK
     * setups, the `trace.setSpan` + `context.with` pattern is canonical. For
     * the test-injected tracer path, we just pass the parent as implicit
     * context.) The parent may already have ENDED: OTel links a child by span
     * context, so a span started after its parent closed is still its child.
     */
    function startSpanUnder(parent, name, attrs, root = false) {
        let ctx;
        if (parent && otelApi?.trace?.setSpan && otelApi?.context?.active) {
            ctx = otelApi.trace.setSpan(otelApi.context.active(), parent);
        }
        const options = attrs || root
            ? { ...(attrs && { attributes: capAttrs(attrs) }), ...(root && { root: true }) }
            : undefined;
        return ensureTracer().startSpan(name, options, ctx);
    }
    function pushSpan(turnState, name, attrs) {
        const span = startSpanUnder(turnState.stack[turnState.stack.length - 1]?.span, name, attrs);
        turnState.stack.push({ name, span });
        return span;
    }
    /** A short span under `parent` carrying `events`, ended at once — how an
     *  event lands under a turn it can no longer be written onto. */
    function recordOnChildSpan(parent, name, attrs, events) {
        const span = startSpanUnder(parent, name, attrs);
        for (const e of events)
            recordSpanEvent(span, e.name, e.attrs);
        endSpan(span);
    }
    /** The root of a traced turn, open or recently closed — `undefined` for an
     *  unsampled turn or a run this adapter never traced (or has forgotten). */
    function rootOf(runId) {
        const live = activeTurns.get(runId);
        if (live !== undefined)
            return live.root;
        return closedTurns.get(runId)?.root;
    }
    function popSpan(turnState, match) {
        let idx = turnState.stack.length - 1;
        if (match !== undefined) {
            const matches = typeof match === 'string' ? (name) => name === match : match;
            // idx >= 0 guard guarantees stack[idx] exists.
            // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
            while (idx >= 0 && !matches(turnState.stack[idx].name))
                idx--;
        }
        if (idx < 0)
            return undefined;
        // splice(idx, 1) returns a 1-element array; idx < 0 guarded above.
        // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
        return turnState.stack.splice(idx, 1)[0].span;
    }
    function endSpan(span, endOpts) {
        if (endOpts?.error) {
            const code = otelApi?.SpanStatusCode?.ERROR ?? 2;
            try {
                span.setStatus({ code });
            }
            catch {
                /* mock tracers may not implement setStatus — ignore */
            }
        }
        span.end();
    }
    function setAttrs(span, attrs) {
        writeAttrs(span, capAttrs(attrs));
    }
    /** The raw write, for attributes already through {@link capAttrs}. */
    function writeAttrs(span, attrs) {
        for (const [key, value] of Object.entries(attrs)) {
            try {
                span.setAttribute(key, value);
            }
            catch {
                /* ignore — never break the agent loop on a sink error */
            }
        }
    }
    /** Emit a span event (preferred) or flattened-attribute fallback —
     *  see "Decisions = SPAN EVENTS" in the module docs. */
    function recordSpanEvent(span, name, attrs) {
        // Capped BEFORE flattening: the fallback prefixes every key, and a content
        // key must still be recognized as one.
        const capped = capAttrs(attrs);
        if (typeof span.addEvent === 'function') {
            try {
                span.addEvent(name, capped);
                return;
            }
            catch {
                /* fall through to attribute fallback */
            }
        }
        const flattened = {};
        for (const [key, value] of Object.entries(capped))
            flattened[`${name}.${key}`] = value;
        writeAttrs(span, flattened);
    }
    function topSpan(t) {
        return t?.stack[t.stack.length - 1]?.span;
    }
    /** Single-active-turn resolution for FlowRecorder evidence (which has
     *  no dispatcher runId to join on). One agent = one turn in flight is
     *  the norm; with >1 concurrent turn we can't attribute the decision
     *  safely, so we skip rather than risk cross-run contamination. */
    function soleActiveTurn() {
        if (activeTurns.size !== 1)
            return undefined;
        const [t] = activeTurns.values();
        return t;
    }
    // ─── Explainability span events (typed-event side) ─────────────────
    function handleExplainability(event, t) {
        const top = topSpan(t);
        if (!top)
            return;
        const p = event.payload;
        switch (event.type) {
            // The ReAct loop's own decision: tool-calls vs final.
            case 'agentfootprint.agent.route_decided': {
                recordSpanEvent(top, 'agentfootprint.agent.route_decided', {
                    'agentfootprint.decision.stage': 'react-route',
                    'agentfootprint.decision.chosen': bound(p.chosen),
                    ...(typeof p.rationale === 'string' && {
                        'agentfootprint.decision.rationale': bound(p.rationale),
                    }),
                    ...(typeof p.iterIndex === 'number' && {
                        'agentfootprint.iteration.index': p.iterIndex,
                    }),
                });
                break;
            }
            // Conditional core-flow routing. `evidence` (when an emitter
            // populates it with decide() output) renders at operator level.
            case 'agentfootprint.composition.route_decided': {
                const attrs = {
                    'agentfootprint.decision.stage': bound(p.conditionalId),
                    'agentfootprint.decision.chosen': bound(p.chosen),
                    ...(typeof p.rationale === 'string' && {
                        'agentfootprint.decision.rationale': bound(p.rationale),
                    }),
                };
                if (looksLikeDecideEvidence(p.evidence))
                    Object.assign(attrs, renderEvidenceAttrs(p.evidence));
                recordSpanEvent(top, 'agentfootprint.composition.route_decided', attrs);
                break;
            }
            // Skill-graph routing provenance — one span event per routed
            // injection: the decision path (predicate labels + branch taken),
            // the route edge, and the tools the route unlocked.
            case 'agentfootprint.context.evaluated': {
                const routing = p.routing;
                if (!Array.isArray(routing))
                    break; // no skill routing this iteration — no event
                for (const r of routing) {
                    recordSpanEvent(top, 'agentfootprint.skill.routing', {
                        'agentfootprint.skill.injection_id': bound(r.injectionId),
                        ...(r.via !== undefined && { 'agentfootprint.skill.via': bound(r.via) }),
                        ...(r.label !== undefined && { 'agentfootprint.skill.label': bound(r.label) }),
                        ...(r.from !== undefined && { 'agentfootprint.skill.from': bound(r.from) }),
                        ...(Array.isArray(r.path) && {
                            'agentfootprint.skill.path': boundList(r.path.map((step) => `${step.label} → ${step.branch}`)),
                        }),
                        ...(Array.isArray(r.tools) && {
                            'agentfootprint.skill.tools': boundList(r.tools.map(String)),
                        }),
                    });
                }
                break;
            }
            case 'agentfootprint.skill.activated': {
                recordSpanEvent(top, 'agentfootprint.skill.activated', {
                    'agentfootprint.skill.id': bound(p.skillId),
                    'agentfootprint.skill.reason': bound(p.reason),
                    ...(Array.isArray(p.injectedTools) && {
                        'agentfootprint.skill.tools': boundList(p.injectedTools.map(String)),
                    }),
                });
                break;
            }
            // #9 tool-arg validation rejections. The span event renders paths /
            // expectations / received TYPES only. A string-shape issue also carries
            // `value` (a capped excerpt) and `hint` for the MODEL's correction —
            // deliberately NOT projected here: third-party telemetry stays
            // value-free (PII contract).
            case 'agentfootprint.validation.args_invalid': {
                const issues = (p.issues ?? []);
                recordSpanEvent(top, 'agentfootprint.validation.args_invalid', {
                    'agentfootprint.validation.tool_name': bound(p.toolName),
                    'agentfootprint.validation.tool_call_id': bound(p.toolCallId),
                    'agentfootprint.validation.enforced': p.enforced === true,
                    'agentfootprint.validation.issue_count': issues.length,
                    'agentfootprint.validation.issues': boundList(issues.map((i) => `${i.path}: expected ${i.expected}, got ${i.got}`)),
                });
                break;
            }
            case 'agentfootprint.permission.check': {
                recordSpanEvent(top, 'agentfootprint.permission.check', {
                    'agentfootprint.permission.capability': bound(p.capability),
                    'agentfootprint.permission.actor': bound(p.actor),
                    ...(p.target !== undefined && { 'agentfootprint.permission.target': bound(p.target) }),
                    'agentfootprint.permission.result': bound(p.result),
                    ...(p.policyRuleId !== undefined && {
                        'agentfootprint.permission.policy_rule_id': bound(p.policyRuleId),
                    }),
                    ...(typeof p.rationale === 'string' && {
                        'agentfootprint.permission.rationale': bound(p.rationale),
                    }),
                    ...(typeof p.reason === 'string' && {
                        'agentfootprint.permission.reason': bound(p.reason),
                    }),
                });
                break;
            }
            case 'agentfootprint.permission.halt': {
                recordSpanEvent(top, 'agentfootprint.permission.halt', {
                    'agentfootprint.permission.target': bound(p.target),
                    'agentfootprint.permission.reason': bound(p.reason),
                    ...(typeof p.iteration === 'number' && {
                        'agentfootprint.iteration.index': p.iteration,
                    }),
                });
                break;
            }
            // Credential lifecycle — payloads carry kind / service / session
            // identifiers ONLY (the registry contract: never the secret).
            case 'agentfootprint.credential.requested':
            case 'agentfootprint.credential.acquired':
            case 'agentfootprint.credential.authorization_required':
            case 'agentfootprint.credential.failed': {
                recordSpanEvent(top, event.type, {
                    'agentfootprint.credential.service': bound(p.service),
                    ...(p.kind !== undefined && { 'agentfootprint.credential.kind': bound(p.kind) }),
                    ...(p.mode !== undefined && { 'agentfootprint.credential.mode': bound(p.mode) }),
                    ...(p.sessionId !== undefined && {
                        'agentfootprint.credential.session_id': bound(p.sessionId),
                    }),
                    ...(p.reason !== undefined && { 'agentfootprint.credential.reason': bound(p.reason) }),
                });
                break;
            }
            // A tool's declared absence / coverage — on the call's OWN span while it
            // is open (parallel calls interleave, so the stack top may be another
            // call's), else the active span. Counts per list, never a sentence.
            case 'agentfootprint.tools.absent':
            case 'agentfootprint.tools.coverage_declared': {
                const callId = typeof p.toolCallId === 'string' ? p.toolCallId : undefined;
                const span = (callId !== undefined ? t.toolSpans.get(callId) : undefined) ?? top;
                recordSpanEvent(span, event.type, coverageAttrs(p, (name) => ranTools.has(name)));
                break;
            }
            // The evidence gate's verdict on one would-be-final answer.
            case 'agentfootprint.agent.evidence_checked': {
                recordSpanEvent(top, event.type, evidenceVerdictAttrs(p, captureContent));
                break;
            }
            // One model standing on one earlier result; counted for turn_end. The
            // MODEL decides how many rows it files, and a real SDK keeps 128
            // events per span by evicting the OLDEST — so past a per-turn cap the
            // rows are counted, not written, and one summary event says how many
            // at the turn's close. The per-standing counts stay complete.
            case 'agentfootprint.findings.standing': {
                const row = p;
                if (t.standingEvents < MAX_STANDING_EVENTS_PER_TURN) {
                    t.standingEvents += 1;
                    recordSpanEvent(top, event.type, standingAttrs(row));
                }
                else
                    t.standingOmitted += 1;
                if (row.standing !== undefined && STANDINGS.includes(row.standing)) {
                    t.standingCounts ??= new Map();
                    t.standingCounts.set(row.standing, (t.standingCounts.get(row.standing) ?? 0) + 1);
                }
                break;
            }
            default:
                break;
        }
    }
    /**
     * The turn's findings summary on the agent span, at whichever close the
     * turn gets (turn_end, error.fatal, or `stop()` for a paused one): a count
     * per standing when any row was filed, and the one overflow event when the
     * per-turn cap held rows back.
     */
    function flushStandings(t) {
        if (t.root === undefined || !explainability)
            return;
        if (t.standingCounts !== undefined)
            setAttrs(t.root, standingCountAttrs(t.standingCounts));
        if (t.standingOmitted > 0) {
            recordSpanEvent(t.root, 'agentfootprint.findings.standing_overflow', {
                'agentfootprint.findings.standing_events_omitted': t.standingOmitted,
                'agentfootprint.findings.standing_events_written': t.standingEvents,
            });
        }
    }
    /** The proposal as `tool_start` carried it, serialized THEN — a snapshot, so
     *  nothing written into the object later can reach the export. */
    function snapshotArgs(toolName, args) {
        let text;
        try {
            text = (0, wireJson_js_1.toWireJson)(args);
        }
        catch {
            text = undefined;
        }
        if (text === undefined)
            return { toolName, chars: 0 };
        return text.length > maxContentChars
            ? { toolName, chars: text.length }
            : { toolName, text, chars: text.length };
    }
    /**
     * `captureToolContent`, for a call that RAN and RETURNED (no `error`, no
     * `notExecuted`): its arguments — the proposal snapshot, with every key a
     * rule changed or the tool's redaction hides (`changedArgKeys`) shown as
     * `'REDACTED'`: withheld, never replaced by a value the event does not
     * carry — and what the model READ (`modelResult` when a rule changed it,
     * else `result`). A code runner's arguments are its generated program and
     * are withheld. A call that failed or never ran exports no content. Shape
     * and size by {@link toolContentAttrs}.
     */
    function toolContent(t, p, errored) {
        const proposal = p.toolCallId !== undefined ? t.proposedArgs.get(p.toolCallId) : undefined;
        if (p.toolCallId !== undefined)
            t.proposedArgs.delete(p.toolCallId);
        if (errored || p.notExecuted === true)
            return {};
        const read = Object.prototype.hasOwnProperty.call(p, 'modelResult') ? p.modelResult : p.result;
        return {
            ...argsContent(proposal, p.changedArgKeys ?? []),
            ...toolContentAttrs('result', read, maxContentChars),
        };
    }
    function argsContent(proposal, changed) {
        if (proposal === undefined)
            return {};
        if (codeRunners.has(proposal.toolName))
            return { 'agentfootprint.tool.args.withheld': 'code-runner' };
        if (proposal.text === undefined)
            return proposal.chars > 0 ? { 'agentfootprint.tool.args.omitted_chars': proposal.chars } : {};
        if (changed.length === 0)
            return toolContentAttrs('args', proposal.text, maxContentChars);
        let parsed;
        try {
            parsed = JSON.parse(proposal.text);
        }
        catch {
            return {};
        }
        if (!isPlainObject(parsed))
            return {};
        const shown = { ...parsed };
        for (const key of changed)
            shown[key] = 'REDACTED';
        return toolContentAttrs('args', shown, maxContentChars);
    }
    // ─── Late-capable events (they may arrive after the turn closed) ───
    /**
     * The checker accounting, filed ONCE per run from the run door's `finally`
     * — after turn_end on a finished run, after error.fatal on a failed one,
     * with the turn still open on a paused one. When it is exported it gets
     * its own short span under the turn's root, so a reader finds it in one
     * place whichever exit the run took.
     *
     * By default only when a row is ACTIONABLE — a check filed findings, or a
     * registered check filed nothing while the run did work (the wiring rot
     * `integrity/disposition/ledger.ts` · `filedNothing` names) — and only those
     * rows ride as events. A healthy run exports nothing. `checkAccounting:
     * 'all'` exports every row on every run.
     */
    function handleDisposition(event, runId) {
        const root = rootOf(runId);
        if (root === undefined)
            return;
        const p = event.payload;
        const rows = Array.isArray(p.rows) ? p.rows : [];
        const n = (v) => (typeof v === 'number' ? v : 0);
        const actionable = (row) => n(row.findings) > 0 ||
            (p.workExisted === true &&
                (0, ledger_js_1.filedNothing)({
                    checked: n(row.checked),
                    notApplicable: n(row.notApplicable),
                    unreachable: n(row.unreachable),
                }));
        const shown = allCheckRows ? rows : rows.filter(actionable);
        if (!allCheckRows && shown.length === 0)
            return;
        recordOnChildSpan(root, 'agentfootprint.integrity.disposition', dispositionSpanAttrs(p), shown.map((row) => ({
            name: 'agentfootprint.integrity.check',
            attrs: dispositionRowAttrs(row),
        })));
    }
    /** A score about a whole run can name it: `target` 'run' / 'turn' with the
     *  run id as `targetRef` — the only join a score filed after the run has
     *  (`agent.emit` stamps `runId: 'consumer-scope'`). A tool-call or
     *  iteration ref names something else and never joins a run. */
    function runNamedBy(p) {
        return (p.target === 'run' || p.target === 'turn') && typeof p.targetRef === 'string'
            ? p.targetRef
            : undefined;
    }
    function heldRun(runId) {
        const live = activeTurns.get(runId);
        if (live !== undefined)
            return {
                runId,
                live,
                ...(live.root !== undefined && { root: live.root }),
                ...(live.sessionDigest !== undefined && { sessionDigest: live.sessionDigest }),
            };
        const closed = closedTurns.get(runId);
        return closed === undefined ? undefined : { runId, ...closed };
    }
    /**
     * Where a verified score lands on a run whose turn is still OPEN: on the
     * span it names while that span is open — the tool call (`targetRef` = its
     * call id), the iteration (`targetRef` = its index) — else on a short span
     * of its own under the turn's root, never on a sibling operation it does
     * not name. A score about the run or the turn lands on the agent span.
     */
    function placeOnOpenTurn(t, root, p, attrs) {
        const ref = typeof p.targetRef === 'string' ? p.targetRef : undefined;
        let span;
        if (p.target === 'toolCall')
            span = ref !== undefined ? t.toolSpans.get(ref) : undefined;
        else if (p.target === 'iteration')
            span = t.stack.find((entry) => entry.name === `iteration:${ref ?? ''}`)?.span;
        else
            span = root;
        if (span !== undefined)
            recordSpanEvent(span, 'gen_ai.evaluation.result', attrs);
        else
            recordOnChildSpan(root, 'agentfootprint.evaluation', undefined, [
                { name: 'gen_ai.evaluation.result', attrs },
            ]);
    }
    /** A score this adapter may not (or cannot) place under a run: its own
     *  root span, the ref it named as an attribute, and why it stands alone. */
    function recordUnparented(attrs, reason) {
        // `root`: a NEW trace, never a child of whatever span is active in the
        // caller's context (an unrelated request being served) — unparented means
        // unparented.
        const span = startSpanUnder(undefined, 'agentfootprint.evaluation', { 'service.name': opts.serviceName, 'agentfootprint.eval.unparented': reason }, true);
        recordSpanEvent(span, 'gen_ai.evaluation.result', attrs);
        endSpan(span);
    }
    /**
     * `eval.score` → `gen_ai.evaluation.result` (the spec's evaluation event,
     * carried here as a SPAN event: this adapter speaks only the Tracer API).
     *
     * WHICH run: the one the score NAMES (`target` 'run' / 'turn', `targetRef`
     * = a run id) when this adapter holds it; else the run the score was
     * emitted from. A ref that names no run held here ('this-run', a label)
     * falls back to the emitting run.
     *
     * WHETHER it may be parented there: only when the library can place the
     * score in that run's session — it was emitted from inside that very run,
     * or its own `meta.sessionId` is that run's session. Run ids are minted in
     * sequence and anyone can write one, so a score that cannot show it
     * belongs (every `agent.emit` after the run: no session on its meta) is
     * exported on a span of its OWN, unparented, naming the ref — never under
     * someone else's trace. Placement while the turn is open follows
     * {@link placeOnOpenTurn}; after it closed, a short `agentfootprint.evaluation`
     * span under the root (a finished span takes no events).
     *
     * Never silently lost: a run sampling dropped takes its scores with it; a
     * run that left the closed-turn window is exported unparented AND reported
     * through `onError`; a score with no `metricId` (the spec's one Required
     * attribute) is reported. This path is best effort — the durable home for
     * scores filed after the run is a score record, not a trace span.
     */
    function handleEvalScore(event, runId) {
        const p = event.payload;
        if (typeof p.metricId !== 'string' || p.metricId === '') {
            report(new Error('otelObservability: an agentfootprint.eval.score without a metricId was not exported — ' +
                'gen_ai.evaluation.name is the one attribute the spec requires.'), event);
            return;
        }
        const attrs = evaluationAttrs(p, captureContent, maxContentChars);
        const named = runNamedBy(p);
        const namedRun = named !== undefined ? heldRun(named) : undefined;
        // A ref naming a run this strategy FORGOT is still that run's score: it
        // never falls back to the run it was emitted from.
        const forgottenRef = namedRun === undefined && named !== undefined && forgottenRuns.has(named) ? named : undefined;
        const target = forgottenRef !== undefined ? undefined : namedRun ?? heldRun(runId);
        if (target === undefined) {
            const ref = forgottenRef ?? named ?? runId;
            const sampled = forgottenRuns.get(ref);
            if (sampled === false)
                return; // sampling dropped it; its scores go with it
            if (sampled === true) {
                recordUnparented(attrs, 'run-forgotten');
                report(new Error(`otelObservability: an evaluation score names run '${bound(ref)}', which closed more ` +
                    `than ${String(MAX_CLOSED_TURNS)} turns ago on this strategy — exported on its own ` +
                    `span, unparented.`), event);
                return;
            }
            recordUnparented(attrs, 'run-not-held');
            return;
        }
        if (target.root === undefined)
            return; // sampling dropped it
        const scoreSession = sessionOf(event);
        const verified = target.runId === runId ||
            (scoreSession !== undefined &&
                target.sessionDigest !== undefined &&
                (0, sha256_js_1.sha256Hex)(scoreSession) === target.sessionDigest);
        if (!verified) {
            recordUnparented(attrs, 'owner-unverified');
            return;
        }
        if (target.live !== undefined)
            placeOnOpenTurn(target.live, target.root, p, attrs);
        else
            recordOnChildSpan(target.root, 'agentfootprint.evaluation', undefined, [
                { name: 'gen_ai.evaluation.result', attrs },
            ]);
    }
    // ─── Event-to-span dispatch ────────────────────────────────────────
    function handleEvent(event) {
        if (stopped)
            return;
        const runId = anchorRunId(event);
        if (!runId)
            return; // Events without a turn anchor — skip.
        switch (event.type) {
            case 'agentfootprint.agent.turn_start': {
                const sampled = sampleRate >= 1 || Math.random() < sampleRate;
                // The CONVERSATION this run belongs to: `meta.sessionId` (9.4.0),
                // stamped by `run(input, { sessionId })` and by `standingAgent` from
                // the request's own session — the one a `SessionLifecycle` store
                // (9.26.0) persists. It is the spec's `gen_ai.conversation.id` exactly:
                // "the unique identifier for a conversation (session, thread)", and
                // the spec rules out a stand-in ("a new UUID, a trace identifier …
                // SHOULD NOT be used"), so a run that is not session-bound gets none —
                // never the runId, which names ONE turn and would merge nothing and
                // split everything in a backend's session view.
                //
                // WRITTEN AS A DIGEST by default (`conversationId`): at a door with no
                // verifier the session id is the handle to the conversation, and a
                // trace backend is read by many more people than the browser that
                // minted it. The digest groups exactly as the id would; the raw id is
                // an opt-in for doors that verify who may open a conversation.
                const sessionId = sessionOf(event);
                const sessionDigest = sessionId !== undefined ? (0, sha256_js_1.sha256Hex)(sessionId) : undefined;
                const turnState = {
                    stack: [],
                    sampled,
                    toolSpans: new Map(),
                    toolNames: new Map(),
                    standingEvents: 0,
                    standingOmitted: 0,
                    proposedArgs: new Map(),
                    ...(sessionId !== undefined && {
                        conversationId: rawConversationId ? sessionId : sessionDigest,
                        sessionDigest,
                    }),
                };
                activeTurns.set(runId, turnState);
                closedTurns.delete(runId);
                forgottenRuns.delete(runId);
                if (sampled) {
                    const turnIndex = event.payload.turnIndex;
                    // `invoke_agent` span per the GenAI agent-span conventions.
                    // `gen_ai.provider.name` / `gen_ai.request.model` (conditionally
                    // required) are back-filled on the first llm_start — unknown here.
                    // `userPrompt` rides the span ONLY when the consumer asked for it
                    // (`captureContent`) — see that option for what enabling it exports.
                    // `agentfootprint.run.id` names the run (one turn) beside the
                    // conversation id above — one session, many runs.
                    // WHO the run is for (9.11.0). This adapter maps selected signals
                    // onto spans rather than serializing the envelope — unlike the
                    // file / CloudWatch / AgentCore sinks, which write
                    // `JSON.stringify(event)` and inherit every meta field for free — so
                    // the actor has to be placed deliberately or it would not appear at
                    // all. Own namespace, not `enduser.id`: the semconv name for this has
                    // moved once already, and quietly claiming a convention we do not
                    // track is worse than a name that says whose field it is.
                    const actor = event.meta;
                    turnState.root = pushSpan(turnState, genAiNames ? `invoke_agent ${opts.serviceName}` : opts.serviceName, {
                        'service.name': opts.serviceName,
                        'gen_ai.operation.name': 'invoke_agent',
                        'gen_ai.agent.name': opts.serviceName,
                        ...conversationAttr(turnState),
                        'agentfootprint.run.id': runId,
                        ...(typeof turnIndex === 'number' && { 'agentfootprint.turn.index': turnIndex }),
                        // Absent when nobody named one — an anonymous run gets no
                        // attribute rather than an empty string.
                        ...(actor?.principal !== undefined && {
                            'agentfootprint.principal.id': actor.principal,
                        }),
                        ...(actor?.tenant !== undefined && { 'agentfootprint.tenant.id': actor.tenant }),
                        ...(captureContent &&
                            typeof event.payload.userPrompt === 'string' && {
                            ...taskContent('input', event.payload.userPrompt),
                        }),
                    });
                }
                break;
            }
            case 'agentfootprint.agent.turn_end': {
                const t = activeTurns.get(runId);
                if (!t)
                    break;
                if (t.root) {
                    // Turn-total usage on the invoke_agent span (semconv allows
                    // usage attrs on agent spans) + the iteration count.
                    const p = event.payload;
                    setAttrs(t.root, {
                        ...(captureContent &&
                            typeof p.finalContent === 'string' &&
                            taskContent('output', p.finalContent)),
                        ...(typeof p.totalInputTokens === 'number' && {
                            'gen_ai.usage.input_tokens': p.totalInputTokens,
                        }),
                        ...(typeof p.totalOutputTokens === 'number' && {
                            'gen_ai.usage.output_tokens': p.totalOutputTokens,
                        }),
                        ...(typeof p.iterationCount === 'number' && {
                            'agentfootprint.iteration.count': p.iterationCount,
                        }),
                    });
                }
                // A count per findings standing (only when the turn filed any) and the
                // overflow summary (only when the per-turn cap held rows back).
                flushStandings(t);
                // Defensive: end everything still on the stack.
                while (t.stack.length > 0) {
                    const span = popSpan(t);
                    if (span)
                        endSpan(span);
                }
                activeTurns.delete(runId);
                rememberClosed(runId, t);
                break;
            }
            case 'agentfootprint.agent.iteration_start': {
                const t = activeTurns.get(runId);
                if (t?.sampled) {
                    const iteration = event.payload.iterIndex ??
                        event.payload.iteration;
                    pushSpan(t, `iteration:${iteration ?? '?'}`, {
                        ...(typeof iteration === 'number' && { 'iteration.number': iteration }),
                    });
                }
                break;
            }
            case 'agentfootprint.agent.iteration_end': {
                const t = activeTurns.get(runId);
                if (t?.sampled) {
                    const span = popSpan(t, (name) => name.startsWith('iteration:'));
                    if (span) {
                        const toolCallCount = event.payload.toolCallCount;
                        if (typeof toolCallCount === 'number')
                            setAttrs(span, { 'agentfootprint.tool_call.count': toolCallCount });
                        endSpan(span);
                    }
                }
                break;
            }
            case 'agentfootprint.stream.llm_start': {
                const t = activeTurns.get(runId);
                if (!t?.sampled)
                    break;
                const p = event.payload;
                // Inference span per GenAI semconv: operation `chat`.
                // `gen_ai.provider.name` passes the adapter's provider id through
                // unchanged — 'anthropic' / 'openai' / 'cohere' are already
                // well-known semconv values; others ride as custom values (the
                // spec permits them).
                pushSpan(t, genAiNames && p.model ? `chat ${spanNamePart(p.model)}` : 'llm', {
                    'gen_ai.operation.name': 'chat',
                    ...(p.model !== undefined && { 'gen_ai.request.model': p.model }),
                    ...(p.provider !== undefined && { 'gen_ai.provider.name': p.provider }),
                    ...(typeof p.temperature === 'number' && {
                        'gen_ai.request.temperature': p.temperature,
                    }),
                    // Conditionally Required on inference spans "if available" (spec).
                    ...conversationAttr(t),
                });
                // Back-fill the conditionally-required agent-span attrs now that
                // the first inference call reveals provider + model.
                if (t.root && t.rootEnriched !== true) {
                    t.rootEnriched = true;
                    setAttrs(t.root, {
                        ...(p.provider !== undefined && { 'gen_ai.provider.name': p.provider }),
                        ...(p.model !== undefined && { 'gen_ai.request.model': p.model }),
                    });
                }
                break;
            }
            case 'agentfootprint.stream.llm_end': {
                const t = activeTurns.get(runId);
                if (!t?.sampled)
                    break;
                const span = popSpan(t, (name) => name === 'llm' || name.startsWith('chat'));
                if (!span)
                    break;
                const p = event.payload;
                // Response-side semconv attrs. `content` is deliberately NOT
                // emitted (PII) — the snapshot/audit-log channel carries it
                // under the consumer's redaction policy.
                setAttrs(span, {
                    ...(typeof p.usage?.input === 'number' && {
                        'gen_ai.usage.input_tokens': p.usage.input,
                    }),
                    ...(typeof p.usage?.output === 'number' && {
                        'gen_ai.usage.output_tokens': p.usage.output,
                    }),
                    ...(typeof p.usage?.cacheRead === 'number' && {
                        'gen_ai.usage.cache_read.input_tokens': p.usage.cacheRead,
                    }),
                    ...(typeof p.usage?.cacheWrite === 'number' && {
                        'gen_ai.usage.cache_creation.input_tokens': p.usage.cacheWrite,
                    }),
                    ...(typeof p.stopReason === 'string' && {
                        'gen_ai.response.finish_reasons': [p.stopReason],
                    }),
                    ...(typeof p.providerResponseRef === 'string' && {
                        'gen_ai.response.id': p.providerResponseRef,
                    }),
                });
                endSpan(span);
                break;
            }
            case 'agentfootprint.stream.tool_start': {
                const t = activeTurns.get(runId);
                if (!t?.sampled)
                    break;
                const p = event.payload;
                const toolName = p.toolName ?? 'tool';
                // A call the batch settlement answered (9.113.0) never executed, so it
                // gets no `execute_tool` span — one would claim an execution that
                // never happened. The fact is a library decision, recorded like every
                // other one here: a span event on the active span (SYNTHESIZED name).
                // Only on a leg this adapter traces — see the header's KNOWN LIMIT.
                if (p.notDispatched !== undefined) {
                    const top = topSpan(t);
                    if (top) {
                        const paused = p.notDispatched.pausedCall;
                        recordSpanEvent(top, 'agentfootprint.tool.not_dispatched', {
                            'gen_ai.tool.name': toolName,
                            ...(p.toolCallId !== undefined && { 'gen_ai.tool.call.id': p.toolCallId }),
                            ...(paused?.toolCallId !== undefined && {
                                'agentfootprint.tool.paused_call.id': paused.toolCallId,
                            }),
                            ...(paused?.toolName !== undefined && {
                                'agentfootprint.tool.paused_call.name': paused.toolName,
                            }),
                        });
                    }
                    break;
                }
                // Tool-execution span per GenAI semconv (`execute_tool`).
                // Args: top-level key NAMES by default. The values ride
                // `gen_ai.tool.call.arguments` — Opt-In in the spec, because they are
                // raw (PII / prompt-injection echo) — ONLY under `captureToolContent`,
                // SNAPSHOTTED here (serialized now — the tool receives this same
                // object and may write into it) and exported at `tool_end`, once the
                // rules have named the keys they changed (`changedArgKeys`).
                const argKeys = p.args !== undefined && typeof p.args === 'object' ? Object.keys(p.args) : [];
                const nameInSpan = spanNamePart(toolName);
                const span = pushSpan(t, genAiNames ? `execute_tool ${nameInSpan}` : `tool:${nameInSpan}`, {
                    'tool.name': toolName,
                    'gen_ai.operation.name': 'execute_tool',
                    'gen_ai.tool.name': toolName,
                    ...(p.toolCallId !== undefined && { 'gen_ai.tool.call.id': p.toolCallId }),
                    ...(p.protocol !== undefined && { 'agentfootprint.tool.protocol': p.protocol }),
                    ...(argKeys.length > 0 && { 'agentfootprint.tool.args.keys': boundList(argKeys) }),
                    // Conditionally Required on execute_tool spans "if available" (spec).
                    ...conversationAttr(t),
                });
                if (p.toolCallId !== undefined) {
                    t.toolSpans.set(p.toolCallId, span);
                    t.toolNames.set(p.toolCallId, toolName);
                    if (captureToolContent)
                        t.proposedArgs.set(p.toolCallId, snapshotArgs(toolName, p.args));
                }
                break;
            }
            case 'agentfootprint.stream.tool_end': {
                const t = activeTurns.get(runId);
                if (!t?.sampled)
                    break;
                const p = event.payload;
                // A settled call's tool_start opened no span (see tool_start), so its
                // tool_end closes nothing — and must never reach the name-less
                // fallback below, which would pop whatever tool span is on top.
                if (p.notDispatched !== undefined)
                    break;
                const errored = p.error !== undefined && p.error !== false;
                const ranName = p.toolCallId !== undefined ? t.toolNames.get(p.toolCallId) : undefined;
                if (p.toolCallId !== undefined)
                    t.toolNames.delete(p.toolCallId);
                // A call that returned without error and was not refused ran a tool
                // this agent holds — what a `tryInsteadTool` name is recognized by.
                if (ranName !== undefined && !errored && p.notExecuted !== true)
                    remember(ranTools, ranName);
                // Correlate by toolCallId (the only identity ToolEndPayload
                // carries) — parallel tool calls end out of LIFO order, so name
                // matching alone would close the wrong span. Fallback chain
                // keeps legacy hand-fed events (toolName) working.
                let span;
                if (p.toolCallId !== undefined && t.toolSpans.has(p.toolCallId)) {
                    span = t.toolSpans.get(p.toolCallId);
                    t.toolSpans.delete(p.toolCallId);
                    // Remove from the stack by identity so the LIFO unwind stays clean.
                    const idx = t.stack.findIndex((entry) => entry.span === span);
                    if (idx >= 0)
                        t.stack.splice(idx, 1);
                }
                else {
                    span = popSpan(t, p.toolName !== undefined
                        ? (name) => name === `tool:${spanNamePart(p.toolName ?? '')}` ||
                            name === `execute_tool ${spanNamePart(p.toolName ?? '')}`
                        : (name) => name.startsWith('tool:') || name.startsWith('execute_tool '));
                }
                if (!span)
                    break;
                // Result: TYPE by default — never the value (PII discipline; mirrors
                // the #9 contract). Content only under `captureToolContent`
                // (`toolContent`): the proposal with rule-changed keys withheld, and
                // what the model read.
                setAttrs(span, {
                    'agentfootprint.tool.result.type': p.result === null ? 'null' : typeof p.result,
                    ...(errored && { 'error.type': '_OTHER' }), // boolean error flag — no class info
                    ...(captureToolContent && toolContent(t, p, errored)),
                });
                endSpan(span, { error: errored });
                break;
            }
            // A fatal run error: the turn will never see turn_end, so close
            // the span tree here (ERROR on root) instead of leaking it until
            // stop(). Stage + scope only — error MESSAGES can echo PII.
            case 'agentfootprint.error.fatal': {
                const t = activeTurns.get(runId);
                if (!t)
                    break;
                const p = event.payload;
                if (t.root) {
                    recordSpanEvent(t.root, 'agentfootprint.error.fatal', {
                        ...(p.stage !== undefined && { 'agentfootprint.error.stage': bound(p.stage) }),
                        ...(p.scope !== undefined && { 'agentfootprint.error.scope': bound(p.scope) }),
                    });
                }
                flushStandings(t);
                while (t.stack.length > 1) {
                    const span = popSpan(t);
                    if (span)
                        endSpan(span);
                }
                const root = popSpan(t);
                if (root)
                    endSpan(root, { error: true });
                activeTurns.delete(runId);
                // The run door's `finally` still files its checker accounting.
                rememberClosed(runId, t);
                break;
            }
            // A code runner ran: its NAME marks its calls' arguments (the program)
            // as never exported. Names only; the event carries no code.
            case 'agentfootprint.tools.code_run': {
                const tool = event.payload.tool;
                if (typeof tool === 'string')
                    remember(codeRunners, tool);
                break;
            }
            // Late-capable events: they may name a turn that has already closed.
            case 'agentfootprint.eval.score': {
                handleEvalScore(event, runId);
                break;
            }
            case 'agentfootprint.integrity.disposition': {
                if (explainability)
                    handleDisposition(event, runId);
                break;
            }
            // Other events — annotate / record on the topmost active span.
            default: {
                const t = activeTurns.get(runId);
                if (!t?.sampled)
                    break;
                // Cost ticks are particularly valuable as attributes.
                if (event.type === 'agentfootprint.cost.tick') {
                    const top = topSpan(t);
                    if (!top)
                        break;
                    // Runtime shape: `cumulative.estimatedUsd` (CostTickPayload).
                    // Legacy fallback `cumulativeCostUsd` keeps hand-fed events
                    // working (the pre-6.17 fabricated test shape).
                    const p = event.payload;
                    const usd = p.cumulative?.estimatedUsd ?? p.cumulativeCostUsd;
                    if (typeof usd === 'number')
                        setAttrs(top, { 'cost.cumulative_usd': usd });
                    break;
                }
                if (explainability)
                    handleExplainability(event, t);
                break;
            }
        }
    }
    const strategy = {
        name: 'otel',
        capabilities: { events: true, traces: true },
        exportEvent: handleEvent,
        flush() {
            // OTel SDKs handle their own flushing (the consumer-configured
            // SpanProcessor's `forceFlush()`). We don't cross that boundary
            // here — calling `provider.forceFlush()` is the consumer's
            // responsibility on shutdown. Documented in the README.
        },
        stop() {
            stopped = true;
            // Defensive: end any spans the agent loop didn't close.
            for (const [, t] of activeTurns) {
                flushStandings(t);
                while (t.stack.length > 0) {
                    const span = popSpan(t);
                    if (span)
                        endSpan(span);
                }
                t.toolSpans.clear();
            }
            activeTurns.clear();
            closedTurns.clear();
            forgottenRuns.clear();
        },
        /**
         * Where errors go. Overriding it works — assign `_onError`, or pass
         * `onError` in the factory options.
         */
        _onError(err, event) {
            (opts.onError ?? consoleSink)(err, event);
        },
        decisionEvidenceRecorder() {
            // One purpose (Convention 1): forward decide()/select() evidence
            // from footprintjs's FlowRecorder channel into this strategy's
            // span machinery. Plumbing filters mirror the #5 causal-evidence
            // bridge (sf-cache gate deciders, the agent's Context slot-fork).
            const forward = (stageId, chosen, evidence) => {
                if (stopped || !explainability)
                    return;
                // No structured evidence → already reported via the typed
                // route_decided events; skip to avoid double-reporting.
                if (evidence === undefined)
                    return;
                const t = soleActiveTurn();
                if (!t?.sampled)
                    return;
                const top = topSpan(t);
                if (!top)
                    return;
                recordSpanEvent(top, 'agentfootprint.decision.evidence', {
                    'agentfootprint.decision.stage': bound(stageId),
                    'agentfootprint.decision.chosen': bound(chosen),
                    ...renderEvidenceAttrs(evidence),
                });
            };
            return {
                id: 'otel-decision-evidence',
                onDecision(event) {
                    const stageId = event.traversalContext?.stageId ?? event.decider;
                    // Internal agent plumbing (the cache-gate decider) is not
                    // domain decision evidence. `includes` (not startsWith): in
                    // reactMode 'dynamic-grouped' names are double-prefixed.
                    if (String(event.chosen ?? '').includes('sf-cache/') ||
                        String(stageId).includes('sf-cache'))
                        return;
                    forward(String(stageId), String(event.chosen ?? 'unknown'), event.evidence);
                },
                onSelected(event) {
                    const stageId = event.traversalContext?.stageId ?? event.parent;
                    if (String(stageId).includes('sf-cache'))
                        return;
                    // The agent's own Context slot-fork is a selector — plumbing.
                    if (String(stageId).includes('context') &&
                        event.selected.every((s) => s.startsWith('sf-')))
                        return;
                    forward(String(stageId), event.selected.join(', '), event.evidence);
                },
            };
        },
    };
    return strategy;
}
exports.otelObservability = otelObservability;
//# sourceMappingURL=otel.js.map