@sema-agent/core
Version:
Stateless, task-oriented AI agent core
315 lines (314 loc) • 23.1 kB
TypeScript
import type { AssistantMessage } from "../../internal/llm.js";
import type { HumanReviewGateRow } from "../task-result.js";
import type { NestedUsage, PausedCause, TaskResult, TaskSpec, TerminalCause } from "../types.js";
import type { TerminalBlockedDetail } from "../terminal-cause.js";
import type { HaltConsumption } from "../../internal/harness.js";
/** Per-task usage/cost accumulator (filled across the run loop, surfaced as `TaskResult.stats`). */
export interface Stats {
turns: number;
tokens: number;
/** Total tool calls executed (one per `tool_start`). Spread into `TaskResult.stats`. */
toolCalls: number;
/** Prompt tokens that MISSED the cache (Anthropic-protocol `input_tokens` shape). */
promptTokens: number;
cachedTokens: number;
cacheWriteTokens: number;
cacheWriteTokensLong: number;
outputTokens: number;
cacheHitRate?: number;
/** Cache-INCLUSIVE prompt total (`promptTokens + cachedTokens + cacheWriteTokens*`) — the
* billing/window/hit-rate denominator. Required (not derived) so a new accumulation site that forgets
* it fails the type check instead of silently reporting a total short by its own leg. */
totalInputTokens: number;
costMicroUsd: number;
/** Present (`true`) when at least one model round of this leg reported NO usage frame (the brain's
* own `usageMissing` mark on the assistant message: an aborted / errored / usage-less provider reply). The
* numeric fields stay required and stay what was COUNTED — with this key present they are a LOWER BOUND,
* not a measurement ("0 is unknown, not free"). Absent ⇔ every round reported usage. */
usageMissing?: true;
nested?: NestedUsage;
/** Post-task memory-consolidation usage; set after the result is assembled. Kept out of
* the budget gate (`overBudget` reads `costMicroUsd`/`tokens`, never this). */
memory?: {
tokens: number;
costMicroUsd: number;
applied: number;
};
/** Human-review burden. Wall-clock approval-gate wait + count, bucketed by
* `gate.kind`, accumulated across resume legs. Set by the run loop from the per-task human-review
* accumulator (synchronous `resolveAsk` timing + durable resume latency). **NOT an LLM cost** — never
* folded into `costMicroUsd`/`costBreakdown`/the budget gate (same budget-excluded treatment as `memory`).
* Flows straight through to `TaskResult.stats.humanReview` via the public-stats spread; absent ⇒ no gate. */
humanReview?: {
count: number;
totalWaitMs: number;
gates: HumanReviewGateRow[];
};
/** TB telemetry B2: engine-mechanism engagement counters. Set by the run loop
* only when ≥1 engaged; flows straight through to `TaskResult.stats.mechanisms` via the
* public-stats spread. */
mechanisms?: {
finalVerifyInjected?: true;
finalVerifyInjections?: number;
attachmentsInjected?: number;
repetitionCuts?: number;
repetitionSpared?: number;
repetitionEvents?: Array<{
turn: number;
action: "cut" | "spared";
rule: "char-run" | "unit-loop";
period: number;
reps: number;
segment: string;
}>;
};
/** Within-task compaction LLM cost (micro-USD), accumulated by
* `recordCompactionUsage` as a SEPARATE running total because that cost IS folded into `costMicroUsd`
* (so it can't be re-derived from `costMicroUsd` alone). The {@link costBreakdown.compactionMicroUsd}
* source. Run-loop internal — not a public `TaskResult.stats` field. */
compactionMicroUsd?: number;
/**
* A thin FINANCE TAXONOMY of the LLM-derived costs the engine actually
* prices, decomposing the task's spend into report categories (no new persisted structure — derived from
* the cost sources that already exist). CORE = LLM-token-derived ONLY; the SERVICE adds the infra axes it
* owns (tool-call / sandbox-walltime / egress — it has the k8s cost data) and composes them with this.
* Each category is ≥ 0 and the parts reconcile to the independently-summed LLM total
* (`llmRoot + nestedSubagent + compaction` — `memory` is a separate, budget-excluded line, see its doc).
* Filled in assembleResult for `llmRoot`/`nestedSubagent`/`compaction`; the `memoryConsolidation` line is
* permanently 0 since B19 retired the runner-integrated consolidation pass (field kept — it is
* public stats contract; an engine-plane re-mount would fill it again).
*/
costBreakdown?: {
/** Root-agent LLM cost (micro-USD): `costMicroUsd` MINUS the sub-categories already folded into it
* (compaction). NOT minus nested — nested subagent cost is tracked separately and is NEVER folded into
* `costMicroUsd` (it lands only in `stats.nested`), so subtracting it would under-report the root. */
llmRootMicroUsd: number;
/** Delegated sub-agent (nested) LLM cost (micro-USD) = `stats.nested?.costMicroUsd ?? 0`. */
nestedSubagentMicroUsd: number;
/** Post-task memory-consolidation LLM cost (micro-USD) = `stats.memory?.costMicroUsd ?? 0`. Permanently
* 0: the runner-integrated consolidation pass is retired (the public `TaskStats` doc is the one home
* for what would fill it again). */
memoryConsolidationMicroUsd: number;
/** Within-task compaction LLM cost (micro-USD) = the running `compactionMicroUsd` accumulator (the
* category Framing-4 missed). This IS part of `costMicroUsd`, so it is subtracted from `llmRoot`. */
compactionMicroUsd: number;
};
}
/** The two cumulative budget ceilings a task can trip. Tokens outrank cost when both are
* over at the same boundary. */
export type BudgetAxis = "tokens" | "cost";
/**
* The terminal-state flags the run loop hands {@link assembleResult} — the typed contract between the
* loop and result assembly (exported so it's grep-able and tests can construct it).
*/
export interface ResultFlags {
threw: unknown;
/** The run's ENGINE-minted identity (`Prepared.runId`), echoed verbatim on
* `TaskResult.runId`. Pure pass-through on every terminal: assembly neither mints nor derives it
* (a caller reaching assembly at all has completed prepare, so the value exists), and a
* hand-constructed flags bag without one simply produces a result without one. */
runId?: string;
/** The EFFECTIVE (resolved) model id that served the task — echoed on `TaskResult.model`. */
model?: string;
/** Some spend was accounted while the serving model had NO configured price table
* (no `RunnerDeps.pricing` entry, no `Model.cost`). The numeric total is then a fabricated
* undercount — `stats.costMicroUsd`/`costBreakdown` are OMITTED from the public result instead of
* reporting a 0 indistinguishable from "declared free". The internal accumulator (budget gate's
* coordinate) is untouched. */
unpricedSpend?: boolean;
/** Prepare-time rewind disclosures to echo on `TaskResult.rewindNotes`.
* Pure pass-through — assembly neither adds nor filters (a rewind that FAILED never reaches here; it
* throws at prepare and lands in the `threw` slot as a terminal errorCode). */
rewindNotes?: TaskResult["rewindNotes"];
/** The run's OWN edited-file ledger, echoed on `TaskResult.editedFiles`. Pure pass-through on
* EVERY terminal — what this run's hands changed is a fact about the leg that ran, whatever
* terminal it reached (a failed/aborted run that edited files is exactly the case a "restore the
* code this message changed" affordance is for). Undefined ⇒ the key is omitted. */
editedFiles?: TaskResult["editedFiles"];
/** The run's final turn was halted by a person's BARE rejection of a tool call (the parent-thread
* control-flow boundary) — echoed on `TaskResult.haltedOnUserRejection`. Pure pass-through on
* every terminal: the fact is about the leg that ran, whatever terminal it reached (on the normal
* path the terminal is `completed`, and this is what tells that completion apart from a natural
* one — the model did not finish; the person stopped it and the run awaits their direction). */
haltedOnUserRejection?: boolean;
/** the harness's STATEMENT of what the run's halt did (see `HaltConsumption`):
* the loop's report that the halt's cut settled the final turn, and/or the boundary consult that
* stopped the loop for the halt's request. This is the ONLY input to `TaskResult.haltedByUser`
* (`haltConsumed`, halt-attribution.ts) and to slot 9.7 — the assembly never infers causation from the
* final's shape or from the verb's acceptance. A halt that was accepted but consumed nothing (the run
* ended for its own reason first) leaves this empty: the original terminal stands and the seat is
* NOT signed (the settle lane mints `task.halt_unconsumed` for it). */
haltConsumption?: HaltConsumption;
/** Call ids of answered-but-never-collected questions, echoed on
* `TaskResult.strandedHumanAnswers`. Pure pass-through; empty/absent ⇒ the field is omitted. The
* optional `onError` alert is NOT the disclosure — this mandatory result face is. */
strandedHumanAnswers?: TaskResult["strandedHumanAnswers"];
/** The run's remote-workspace lifecycle failures, echoed on `TaskResult.remoteEnvFailures`.
* Pure pass-through (assembly neither adds nor filters) and INDEPENDENT of the status/errorCode chain:
* a refused `suspendVM` still ends the task exactly the way it did before — this only stops the eleven
* distinct codes from arriving as one anonymous `limits.max_turns_exceeded`. Empty/absent ⇒ the field is omitted. */
remoteEnvFailures?: TaskResult["remoteEnvFailures"];
/** The run's effective read posture (resolved face + normalized deny
* additions), echoed on `TaskResult.effectiveReadFace` / `.effectiveWriteFace` / `.effectiveReadDenyPatterns`. Pure
* pass-through (assembly neither adds nor filters) and INDEPENDENT of the status/errorCode chain:
* the posture is a fact about the leg that ran, whatever terminal it reached — a post-completion
* spawner folds it stricter-wins into follow-on legs; every other consumer may ignore it. */
effectiveReadFace?: TaskResult["effectiveReadFace"];
effectiveWriteFace?: TaskResult["effectiveWriteFace"];
effectiveReadDenyPatterns?: TaskResult["effectiveReadDenyPatterns"];
/** The memory-visibility observation, echoed on
* `TaskResult.effectiveMemoryScopes`. Pure pass-through on every terminal, same law as the
* read-posture seats above: present iff prepare completed (the memory-less states are their own
* values — absence means only "prepare never ran to completion"). */
effectiveMemoryScopes?: TaskResult["effectiveMemoryScopes"];
/** The leg's effective reasoning resolution, echoed on `TaskResult.effectiveReasoning`.
* Pure pass-through on every terminal (same law as the read-posture seats): the SAME object the
* `reasoning.resolved` trace frame carried (runtask resolves once per leg — two faces, one mint);
* absent when thinking was off/unset for the leg, so the key is omitted. */
effectiveReasoning?: TaskResult["effectiveReasoning"];
/** The run's compaction record (see `TaskResult.compaction`); assembly omits the key when absent. */
compaction?: TaskResult["compaction"];
/** ruled 2026-08-04 — the usage-governance wait hint carried by the platform terminal the run adopted
* (`undefined` for every other cause: an expiring environment has no return time to give, and a store
* failure is not a window). Passed as data rather than read back off `threw` so the seat has a typed
* producer. The assembler still gates it on the SETTLED errorCode, so a hint that arrived alongside a
* higher-ranked terminal is dropped instead of being filed under a code that does not name it —
* see `TaskResult.retryAfterMs` for the in-presence condition this preserves. */
retryAfterMs?: number;
abortedForTimeout: boolean;
abortedForTurns: boolean;
/** audit A-1: the abort signal fired DURING the run (user interrupt / timeout / max-turns / budget),
* captured before the run loop's unconditional release-abort. Since the loop now exits CLEANLY on an
* abort (no synthetic stopReason:"aborted" assistant is appended), `final` is the last REAL assistant
* message — this flag is what keeps an interrupted run from being mistaken for a completed one. */
abortedLive?: boolean;
/** The leg's ONE evaluation of {@link answerSettled} (a clean `"stop"` final with no queued continuation
* lost): a live abort that found this true landed on the run's tail, not on work — slot 9 stands down
* and the run takes the completed floor. Absent/false = the abort's ordinary reading. */
answerSettled?: boolean;
budgetHit?: "exceeded" | "precall";
/** Which budget ceiling the hit belongs to. Set together with {@link budgetHit} at every
* site that records one; it picks the terminal code (`limits.max_tokens_exceeded` vs
* `limits.max_cost_exceeded`), so a hit without an axis would be an unattributable terminal. */
budgetAxis?: BudgetAxis;
blockedReason?: string;
blockedDetail?: TerminalBlockedDetail;
conflict?: boolean;
/** env-tail migration F5: the request-build guard threw the irreducible-core terminal this run —
* the loop folded it into a text-only failure message, so the typed code rides this flag. */
gitCoreOverBudget?: boolean;
outputInvalid?: boolean;
/** A re-suspend was refused because the task already suspended `maxSuspends` times
* (a resume/restart loop). It aborted the run (no `threw`) but must read as `failed`/`suspend.loop`,
* NOT `suspended` — slotted ABOVE `suspendRef` and the aborted branches (this suspend never minted a
* checkpoint, so `suspendRef` is unset anyway; the ordering makes the intent explicit). */
suspendLoop?: boolean;
/** The run's ONE committed durable pause (the holder's `pausedRef.current.cause`): the run stopped at a
* gate — an approval or content ask, a resource slice, a platform pause, a plan or dry-run review —
* and a checkpoint is durably committed. It used an abort to stop the loop (`final.stopReason ===
* "aborted"`) but set no `threw`/timeout/turns, so it is slotted ABOVE the `!final`/aborted arms (a
* clean pause, not a failure) and BELOW the real budget/threw failures (a limit that coincided still
* owns the ending). WHICH terminal status it reads (`suspended` / `needs_review`) is the pause's own
* `gate.kind` registry row, not a second flag: the two same-shaped refs that used to split the approval
* family from the review family — and the slot order between them that only ever mattered if both were
* somehow set — are gone with the commit-side routing that fed them. */
paused?: PausedCause;
}
/**
* Extract a machine-readable code from a thrown error (e.g. `SessionError.code`, a Node error code).
* Walks the `cause` chain because the harness wraps lower-level errors (e.g. a `SessionError("conflict")`
* append failure surfaces as an `AgentHarnessError("unknown")` whose cause is the real one) — so we
* prefer the first specific code over a generic `"unknown"` wrapper.
*/
export declare function errorCodeOf(err: unknown): string | undefined;
/**
* WHY the run ended — the ONE terminal arbitration ({@link TerminalCause}). First match wins, in the
* slot order documented on {@link assembleResult} (a public contract: do not reorder). Every plane
* field a result shows (`status`, `errorCode`, `errorMessage`, `blockedReason`, the checkpoint seats)
* is projected from the value this returns by {@link terminalProjection}, so a terminal that carries a
* token cannot be reported under a status that does not, and a review pause cannot be reported as an
* approval pause: the pairing is the cause's shape, not the arbitration's discipline.
*
* `apiFailure` rides beside the cause rather than inside it on purpose: it is the message's own
* provider-failure disclosure, filled only when a provider failure IS the terminal (the same
* in-presence rule the usage-window wait hint states for itself), and it is not part of the cause's
* public projection.
*/
/**
* Has the run's ANSWER settled — is `final` the model's own clean end of its work? A run whose last turn
* ended `stopReason:"stop"` has said everything it was going to say; whatever ran after that turn (the
* final boundary's compaction, a stop hook, the teardown) is the run's tail, not its work. A hard stop
* that lands in that tail — `TaskStream.interrupt()`, the task's `signal`, the walltime or turns limit —
* cuts nothing the person or the caller wanted, so the run is NOT cancellable there: it ends `completed`
* with the answer as `result` (slot 9 yields to the completed floor), no interruption marker is written
* for it, and the end-of-task hook seats keep their full deadline. One predicate, read by every site that
* used to decide the question for itself; there is no second spelling.
*
* What does NOT count as settled — each is "the model was still working" and stays cancellable:
* - `stopReason:"length"` — the output was truncated; the auto-continue chain owns what follows;
* - `stopReason:"toolUse"` — a batch was in flight (the orphan reconcile owns the abort);
* - `stopReason:"aborted"` / `"error"` — the turn itself was cut or failed;
* - a `partialFinalized` `"stop"` — a mid-stream loss promoted to a final; the loop's bounded
* continue nudge owns it, so the stop the caller sees is a salvage, not the model's own end;
* - no `final` at all;
* - `continuationCut` — the run was cut with a continuation still owed: a steer / follow-up the person
* had already queued was still pending (queued, or drained at the final boundary and never served —
* the harness's agent_end account, which also mints `task.user_steer_undrained` /
* `task.user_followup_undrained`), or the loop itself had decided to run another turn (a drained
* frame, a truncated-output / thinking-only / degenerate-output nudge) and the abort caught it before
* the dispatch (`Prepared.workCutRef.continuation`, from the loop's own trace steps) — in both, a
* stop landing between the decision and the next stream returns the PREVIOUS clean answer as
* `final` (the loop exits at its pre-dispatch guard), so `final` alone cannot tell, and a
* thinking-only "stop" the loop was about to nudge into a visible answer is an empty result, not
* an answer; or the abort was already in force when the last turn ENDED
* (`Prepared.workCutRef.abortedBeforeTurnEnd`) — a brain that ignored its signal and delivered a
* full "stop" through the abort still answered a run somebody had stopped mid-work.
* Three axes, one law: the `abortedLive` flag alone decides that a stop landed; this decides whether it
* landed on work or on the tail. Evaluated ONCE per leg (the terminal-adoption lane, where both facts
* are in hand) and handed on as a value — {@link ResultFlags.answerSettled} — so the arbitration, the
* marker gate, the hook-seat grace, the teardown attribution and the notice read the same verdict.
* (A limit that is NOT a stop — a budget ceiling crossed by the last turn's own spend — is arbitrated
* above slot 9 and is unaffected.)
*/
export declare function answerSettled(final: AssistantMessage | undefined, continuationCut?: boolean): boolean;
export declare function terminalCauseOf(final: AssistantMessage | undefined, stats: Pick<Stats, "humanReview">, flags: ResultFlags): {
terminal: TerminalCause;
apiFailure: TaskResult["apiFailure"];
};
/**
* Build the final {@link TaskResult} from the run's terminal state. **The errorCode priority chain is a
* public contract** (callers `switch` on these strings) — first match wins, in this exact order
* (red-line #3, do not reorder):
* 1. `outputInvalid` → `"output.invalid"`
* 2. degenerate → `"output.degenerate"` + `salvagedOutput` (wins over budget/timeout)
* 3. `budgetHit` → `"limits.max_tokens_exceeded"` | `"limits.max_cost_exceeded"` (by `budgetAxis`;
* the pre-call estimate rejection and the after-spend crossing share the code — the distinction
* lives in `errorMessage`, not in a second code)
* 3.5 `suspendLoop` → `"suspend.loop"` (the re-suspend cap; deterministic root
* cause, above `threw` because it owns the abort)
* 4. `threw` + walltime → `"limits.max_walltime_exceeded"`
* 5. `threw` + turns → `"limits.max_turns_exceeded"`
* 6. `threw` + conflict→ `"conflict"`
* 7. `threw` (other) → `errorCodeOf(threw)`
* 8. `blockedReason` → status `"blocked"` (no errorCode)
* 8.5 `paused` → status by the pause's registry row (`suspended` for an approval / resource
* slice / platform pause, `needs_review` + `"review.pending"` for a plan or dry-run review) +
* checkpointToken/gate (a deliberate durable pause beats the abort it used, but
* yields to a real budget/threw failure above)
* 9. `abortedLive` | stopReason aborted → `"limits.max_walltime_exceeded"` | `"limits.max_turns_exceeded"` | undefined
* (audit A-1: hoisted ABOVE no-final — the loop exits cleanly on abort, so `final` may be a
* normal message or absent; a plain user interrupt carries NO errorCode — switch on `status`),
* EXCEPT when the aborted `final` is the bare halt's OWN cut artifact (`haltOwnsAbortedFinal`: the
* loop reported the halt's cut settled the final turn — never shape inference),
* and EXCEPT when the answer had already settled (`answerSettled`: a clean `"stop"` final, not
* partial) — a stop landing on the run's tail cuts no work, so the run falls through to 12
* (`completed`, the answer as `result`, no `limits.*` code for the axis; three axes, one law)
* 9.7 halt CONSUMED (`haltConsumed(flags.haltConsumption)`) + (no `final` | the halt's own aborted
* `final`) → status `"completed"` (a bare user halt — the verb working, not a defect; below the
* aborts on purpose, a real abort/limit that also fired owns the terminal and the halt rides as
* the pass-through seat; an accepted-but-unconsumed halt never reaches this slot)
* 10. no `final` → status `"failed"`
* 11. stopReason error → brain `[code]` prefix (or `"conflict"` from the storage-layer flag)
* 12. else → `"completed"`
*/
export declare function assembleResult(spec: TaskSpec, sessionId: string, final: AssistantMessage | undefined, stats: Stats, flags: ResultFlags): TaskResult;