@alma-harness/loop
v0.12.0
Published
Alma governed turn engine: steps, tool dispatch, delegation and cache hygiene.
Readme
@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 any deferred tool is in the active set, every step advertisessearch_tools, whose description names them; a search loads the matches for the rest of the turn, logs the load on the access trail by name, and tells the model what it can call now. Privileged core: never in a profile, never advertised to a delegate,tool:preapplies. - 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. - 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. - 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.
- 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. 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.
