@alma-harness/loop
The internal governed turn engine of Alma — a
TypeScript runtime for multi-tenant agents operating on sensitive data. It runs
against @alma-harness/core. Hosts use
@alma-harness/conversation or the composed
@alma-harness/runtime; this package has no standalone agent factory.
Status: pre-1.0. The API is still moving; see the roadmap for where it stands.
What it owns
- The loop — steps, tool dispatch, delegation and lifecycle hooks. The matching-version internal API requires governed execution and a main routing snapshot validated before admission. Conversation owns identity, caps and stores.
- The volatile suffix, per turn (spec: volatile-per-turn) —
volatileis a function called once per turn with the turn's scope, session, intent, trigger and clock; its text joins the static volatile blocks ofsystemas one block appended to the input, markedorigin: "harness"so a screen can hide it, persisted and replayed byte-identical so the cache reads through it. The summary transcript omits it. A source failure after admission retainsreconciliation_requiredownership. - Deferred tools and the built-in search (spec: deferred-tools) — a tool
registered
deferred: trueis not advertised until the model asks: while the turn's grant defers any tool, loaded or not, every step advertisessearch_tools, whose description names them all; a search loads the matches, records them inloadson its result block, logs the load on the access trail by name, and tells the model what it can call now. A load lasts while the conversation's view shows that result, within each turn's own grant (spec: stable-tool-advertisement). Each request carriesdeferredTools, the grant's whole deferred set in registration order, so an adapter can load natively and keep the cached prefix. Privileged core: never in a profile, never advertised to a delegate,tool:preapplies.names: [...]loads exactly the tools named, by full name or a unique short name, never ranked and not bounded bylimit; it reportsalreadyAvailable,ambiguousandunknown(spec: search-tools-names). Inquery, tools the query names load first (spec: named-tool-search): a full name, or the part after the last__when exactly one of the turn's tools has it. The words the names did not consume (spec: named-search-words) rank the rest with BM25 over each tool's name and description, weighted by how few of the turn's granted tools share a word (spec: bm25-tool-ranking); a tool loads only at half the best score or more, solimitis a cap, never a quota, and a search that matches nothing well loads nothing. The result addsalreadyAvailablefor named tools already callable andambiguousfor a short name several granted tools share. Only the turn's granted tools are consulted: a name outside the grant matches nothing and is never listed. A deterministic benchmark (test/tool-search-bench, spec: tool-search-benchmark) scores the search on three catalogs and whole sequences against a committed baseline that may only tighten. - Expected tool failures (spec: tool-error) — a handler returns
toolError(payload)for a failure that completed as expected ("not found"). The call's result is the payload withisError: true,tool:postsees the flag, and the turn continues. Recognized by identity only: a domain object with anisErrorfield stays ordinary output. A throw still makes the turn uncertain, and a returnedtoolErrornever clears an uncertain turn. - Provider-executed web search (spec: provider-tools) —
providerToolsregisters the kinds; a profile grants by kind, and cannot name an unregistered one. Each is advertised only to a turn at or below itsmaxSensitivity(internalby default) and never to a delegate; the call and its results are persisted in the neutral format in provider arrival order and replayed on the next step, each result is one access event (tool: "web_search",resource: "provider:<id>:<n> results", never the query), the stream narrates them, and apausestop re-sends the turn as another step againstmaxStepsand the cap. Priced per request from the table, fail-closed.maxUsesis a per-turn cap the loop enforces on every provider (spec: what-the-wire-cuts): at the cap, the tool is withheld from the turn's later steps. - What the wire cuts (spec: what-the-wire-cuts) — a tool call whose
arguments did not parse is answered with an invalid-input result on
tool_usewhilemax_tokenswithholds the step output; the raw text is never persisted. A media block in the input is egress and lands on the access trail by provider and kind, never by URI: by URL, the adapter hands the URI to the provider to fetch. - Output schemas at the hook boundary (spec: provider-request-controls) —
step:precannot add or changeModelRequest.output. The attempt is refused and recorded as metadata, with no schema content in the trail. Public agent calls do not select structured output yet. - The cache's duration (spec: cache-ttl) — a policy choice may carry
cache: { ttl: "1h" }; the loop copies it onto every step, and a delegate's, records it on the routing trail and pins it afterstep:prelike the tier; a one-hour write is priced at its own row, fail-closed. - Media by bytes (spec: media-by-bytes) — with
mediaconfigured, the bytes a block carries, or the product'smedia.sourceasked once per ref and turn, go to the provider as base64; the session keeps the ref alone; the trail adds the size; the content type is normalised before the adapter sees it. Withoutmedia, a block carrying content is refused (spec: close-066-069-findings). History media on later turns is a placeholder text block by default (the bytes go once) or, withreplay: "bytes", the bytes again.maxBytes(32 MiB) prevents oversized media from reaching the provider. A source failure after admission retains uncertainty; it does not authorize a retry. - The call limit (spec: call-limit-final-answer) — when the execution
reports its calls (
TurnExecution.calls), the loop reads the calls left before each main step and each tool call. The last call gets a harness notice when it advertises tools, and no function tool it asks for runs (a provider search runs inside that call); with none left, the turn ends, and a delegate's tool with no call left to read it never runs. The main loop'smaxStepsends a turn the same way, asTurnResult.stepLimit(spec: step-limit-final-answer); a delegate's own step ceiling still throws.TurnResult.callLimit: { maxCalls, answered }says so, and an unanswered turn's reply is empty. Absentcalls, onlymaxStepsbounds the plan. When the execution reports the time short (lastByTime), a main step after the first is the last the same way, asTurnResult.timeLimit: { answered }, with its own notice;finalAnswerNoticereplaces the text of every notice (spec: call-retry). - Terminal tools (spec: terminal-tools) —
terminalToolsnames registered tools whose success ends the turn. When every call of a main step returned a result that is not an error and one of them named a terminal tool, the turn endscompletedon that step'stool_usewith no further model call: a tool that speaks to the person needs no closing call. Every call of the step runs; an error result on any call of the step never ends the turn, so the model reads it (spec: terminal-sibling-error); on the last call, or when a child used it and later calls were not run, the limit ending stands. Delegates ignore it. At most 64 distinct registered names, neversearch_tools. - Two faces on one turn —
runTurnreturns the result when it is done;streamTurnobserves settled main-step output and carries the identical result on.result, alongside them. One implementation, so enforcement cannot differ. - One-call work lives in
@alma-harness/batchand@alma-harness/single-call. The legacy job factories have been removed; batch ownership, original financial receipts and explicit uncertainty now govern those paths (spec: legacy-job-runner-removal). - Routines run through this loop but live with the clock:
Governed routine adapters live in
@alma-harness/schedulesince spec: routines-with-the-clock. - The long-context policy (
longContext) — session rotation with a summary at the cold boundary (§6.6, spec: long-context). A cold start (the last message older thancoldAfterMs, default five minutes) overrotateAtTokenssummarizes the older history onsummaryTierthrough the delegate path — routed, charged, on the trails — keeps the lastkeepLastMessagesverbatim from a user message, and continues from[summary, tail, input]; the summary is persisted with a marker and every later step's view starts there. Only an attested, settled zero-usage context rejection permits one reactive rotation and retry; an arbitrary provider exception never grants that authority. Never mid-history pruning; absent, the whole log is sent as before.rotateAtTokensis measured on the history's content (roles, text, tool calls and results; nevermeta, JSON keys, opaque payloads or media), byestimateTokenswhen the host sets one (spec: long-context-measure).speechToolsnames tools that speak to the person and their argument, so the summary reads that speech whole;summaryFramesets the lines around a summary. - Financial enforcement — the execution composition reads authoritative spend and applies normalized caps. Main calls, delegates, direct calls and summaries settle through original governed receipts; the loop never writes legacy costs.
- Recorded events (spec: conversation-events) — each request's view goes
through
renderEvents: ahumanorsystemmessage reaches the model as the person's side with its label (eventLabels, English defaults), never as its own speech. Every message a turn records carriesmeta.turn, the turn's id, stamped at record time so the reply stays the persisted object. The rotation summary labels the same lines and marks an undelivered reply. - Coordination and observation live in conversation. Durable admission replaces expiring lease takeover. The host retains a busy request's input and key to retry after completion. WhatsApp burst aggregation is also a host responsibility.
- The cache-hygiene observer (
cacheHygieneGuard) — warns when a session keeps cold-writing its prefix, which is a misconfiguration the bill would otherwise report months later.
Where things are
engine.ts defines internal construction and shared result/event types;
turn.ts assembles a turn and runs its steps; step.ts applies the hook boundary;
dispatch.ts owns tools and nested calls; recall.ts assembles recall. Shared
internals, hooks, state, cache hygiene and context helpers remain separate files.
There is no legacy stream controller, lease coordinator or charging implementation.
The matching-version /internal subpath is not a stable standalone host API.
What it must never do
- Widen what
corepermits. Sensitivity narrows through a delegate and never escalates; a hook may not rewrite routing or the advertised tool set, and may lower the per-step output ceiling but never raise it (spec 029). What astep:prehook touched — honored or refused — lands on thecontexttrail. - Reach the filesystem, spawn a subprocess, or ship a shell.
- Depend on a provider SDK. The neutral message format is the boundary.
- Narrate the model's reasoning. It is persisted with the assistant message
and replayed to the provider;
replyand the stream never carry it (spec: reasoning-blocks).
Usage and streaming
Start with the canonical quickstart. Once a canonical
runner is configured, both entrypoints require the same explicit identity:
const request = { scope, sessionId, idempotencyKey, input, intent };
const view = await runner.runTurn(request);
// Or observe a new request; a completed retry replays the original result.
const turn = runner.streamTurn(request);
for await (const event of turn) {
if (event.type === "text") render(event.text);
}
const finalView = await turn.result;
break, iterator return/throw and socket closure detach only the observer. They
never abort execution. Explicit execution cancellation or deadline expiry retains
uncertainty for reconciliation. A slow reader has a bounded queue and resynchronizes
through read; it cannot block the turn.
Text is released per settled main step, after governed output checks and a fresh
retention/erasure read. Earlier visible steps may precede a later tool failure or
budget stop. No token deltas, delegate text or reasoning are exposed. The final
result event and .result use the same canonical view. Invalid input rejects;
non-durable routing refusal is not_admitted, overlapping work is busy, and
admitted failures are reconciliation_required, not a retryable error envelope.
Testing helpers
@alma-harness/loop/testing ships the scripted ModelClient the loop's own
tests run against — deterministic event sequences, no network, no timing.
Nothing in it imports vitest.
import { FakeModelClient, scriptedText } from "@alma-harness/loop/testing";
const client = new FakeModelClient([scriptedText("Hello!")]);
Documentation
Docs index · Architecture · Invariants · Quickstart
Apache-2.0
Temperature and output controls remain pinned at step:pre; attempted changes
are recorded without schema content. Canonical public calls do not select these
controls. Pure context helpers come from @alma-harness/memory/context and retain
identity through loop reexports; memory is a matching-version peer dependency.
The execution seam requires the snapshotted main choice. Summaries and children resolve their own routes and retain lineage, usage and root limits. Settled main steps enqueue references without waiting for observers or controlling cancellation.
Explicit delegation refusal
ctx.models.tryDelegate(request) returns either { status: "completed", text, usage } or an immutable { status: "refused", reason }, where reason is depth,
sensitivity or tool_grant. A tool
may handle that data or return the exact refusal object to emit a bounded
isError tool result. Clones and foreign objects are ordinary output. The
existing delegate keeps throwing; neither method waives handler/child failures,
cancellation or uncertain persistence. Only a live handler can use tryDelegate.
Requests are snapshotted before child routing: plain data properties, a valid tier/sensitivity, well-formed prompt up to 1,000,000 UTF-16 units, and at most 256 tool names of 1–200 units. Accessors, sparse arrays and unknown fields are invalid. Structural refusals precede child policy, hooks and reservation. A normally returned refusal still passes execution health checks and the existing tool:post, output cap and advisory handling. See spec governed-delegation-refusal.
The governed main loop reacts only to conversation's trusted settled rejection
marker, before recording/observing rejected output. A pre-stream refusal marker
(spec: pre-stream-rejections) on the turn's only call, with nothing but the
input recorded, ends the turn error and records nothing, the input included.
turn:end still runs, and its failure poisons the execution. Anywhere else the
marker is thrown, as any failure. It rotates at most once;
summary and retry use the same root limits. There is no exception-based legacy fallback (spec: safe-context-rejection-rotation).
The optional request toolChoice is also frozen as a copy and pinned, including
absence, at step:pre. Hook injection, removal or substitution is refused and
recorded as metadata. Canonical conversations do not offer a global forced-tool
setting; use the official governed step runner for one-call structured extraction.