UNPKG

@sema-agent/core

Version:

Stateless, task-oriented AI agent core

315 lines (314 loc) • 23.1 kB
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;