@alma-harness/loop

中文文档
0.41.0发布于 2026/11/04Apache-2.0TypeScriptES Module零依赖近 30 天下载 2543

Alma governed turn engine: steps, tool dispatch, delegation and cache hygiene.

原始使用文档
文档目录7 节
  1. What it owns
  2. Where things are
  3. What it must never do
  4. Usage and streaming
  5. Testing helpers
  6. Documentation
  7. Explicit delegation refusal

@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) — volatile is a function called once per turn with the turn's scope, session, intent, trigger and clock; its text joins the static volatile blocks of system as one block appended to the input, marked origin: "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 retains reconciliation_required ownership.
  • Deferred tools and the built-in search (spec: deferred-tools) — a tool registered deferred: true is not advertised until the model asks: while the turn's grant defers any tool, loaded or not, every step advertises search_tools, whose description names them all; a search loads the matches, records them in loads on 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 carries deferredTools, 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:pre applies. names: [...] loads exactly the tools named, by full name or a unique short name, never ranked and not bounded by limit; it reports alreadyAvailable, ambiguous and unknown (spec: search-tools-names). In query, 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, so limit is a cap, never a quota, and a search that matches nothing well loads nothing. The result adds alreadyAvailable for named tools already callable and ambiguous for 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 with isError: true, tool:post sees the flag, and the turn continues. Recognized by identity only: a domain object with an isError field stays ordinary output. A throw still makes the turn uncertain, and a returned toolError never clears an uncertain turn.
  • Provider-executed web search (spec: provider-tools) — providerTools registers the kinds; a profile grants by kind, and cannot name an unregistered one. Each is advertised only to a turn at or below its maxSensitivity (internal by 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 a pause stop re-sends the turn as another step against maxSteps and the cap. Priced per request from the table, fail-closed. maxUses is 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_use while max_tokens withholds 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:pre cannot add or change ModelRequest.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 after step:pre like the tier; a one-hour write is priced at its own row, fail-closed.
  • Media by bytes (spec: media-by-bytes) — with media configured, the bytes a block carries, or the product's media.source asked 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. Without media, 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, with replay: "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's maxSteps ends a turn the same way, as TurnResult.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. Absent calls, only maxSteps bounds the plan. When the execution reports the time short (lastByTime), a main step after the first is the last the same way, as TurnResult.timeLimit: { answered }, with its own notice; finalAnswerNotice replaces the text of every notice (spec: call-retry).
  • Terminal tools (spec: terminal-tools) — terminalTools names 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 ends completed on that step's tool_use with 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, never search_tools.
  • Two faces on one turn — runTurn returns the result when it is done; streamTurn observes 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/batch and @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/schedule since 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 than coldAfterMs, default five minutes) over rotateAtTokens summarizes the older history on summaryTier through the delegate path — routed, charged, on the trails — keeps the last keepLastMessages verbatim 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. rotateAtTokens is measured on the history's content (roles, text, tool calls and results; never meta, JSON keys, opaque payloads or media), by estimateTokens when the host sets one (spec: long-context-measure). speechTools names tools that speak to the person and their argument, so the summary reads that speech whole; summaryFrame sets 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: a human or system message reaches the model as the person's side with its label (eventLabels, English defaults), never as its own speech. Every message a turn records carries meta.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 core permits. 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 a step:pre hook touched — honored or refused — lands on the context trail.
  • 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; reply and 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.

版本与发布记录

@alma-harness/loop 共发布 52 个版本,首次发布于 2026/09/01;过去 12 个月发布了 52 个版本。

版本发布日期距今
0.41.0latest2026/11/043 天前
0.40.02026/10/308 天前
0.39.22026/10/309 天前
0.39.12026/10/299 天前
0.39.02026/10/299 天前
0.38.02026/10/2910 天前

关于 @alma-harness/loop 的常见问题

@alma-harness/loop 是什么?

Alma governed turn engine: steps, tool dispatch, delegation and cache hygiene.

如何安装 @alma-harness/loop?

在项目目录运行 npm install @alma-harness/loop,或使用 pnpm add / yarn add / bun add。它还需要宿主项目提供 peer 依赖:@alma-harness/core(^0.41.0)、@alma-harness/memory(^0.41.0)。npm 7 及以上与 pnpm 8 及以上默认会自动安装必需的 peer 依赖,Yarn 需要手动安装。

@alma-harness/loop 支持 TypeScript 吗?

支持。包内自带 TypeScript 类型声明(package.json 声明了 types 入口),安装后即可获得类型提示,无需再安装 @types 包。

@alma-harness/loop 支持 ESM 还是 CommonJS?

以 ES Module 形式发布,推荐使用 import 引入。在 CommonJS 项目中,Node.js 20.19+ / 22.12+ 可以直接 require() 不含顶层 await 的 ES Module,更早的版本需要改用动态 import()。

@alma-harness/loop 需要什么版本的 Node.js?

package.json 的 engines 字段要求 Node.js >=22。版本不符时 npm 默认只给出警告,开启 engine-strict 后会拒绝安装。

@alma-harness/loop 可以商用吗?

@alma-harness/loop 使用 Apache-2.0 许可。宽松许可:允许商业使用、修改和再分发,并附带明确的专利授权;分发时需保留许可与 NOTICE 文件,修改过的文件要注明改动。以上为概括,不构成法律意见,请以许可原文为准。

@alma-harness/loop 的下载量有多少?

根据 npm 官方下载统计,@alma-harness/loop 在 2026/10/06 – 2026/11/04 这 30 天内被下载 2,543 次,日均约 85 次。下载量会把 CI 构建、镜像同步和作为其他包依赖的安装一并计入,适合横向比较热度,不能直接等同于使用人数。

@alma-harness/loop 还在维护吗?

最新版本 0.41.0 发布于 2026/11/04(3 天前);至今共发布 52 个版本,其中过去 12 个月发布了 52 个;首次发布于 2026/09/01。发布频率只是参考信号:功能稳定的工具可能很少需要更新,建议结合源码仓库的 issue 和提交记录判断。