@juno-ai/bind
v16.0.0
Published
Agent harness: the tool-calling turn kernel, deterministic LLM provider routing with transport-error classification, the streaming-completion watchdog, run mechanics, sub-agent lineage and admission, transcript healing, tool-schema sanitization, the plugi
Maintainers
Readme
bind
@juno-ai/bind — an agent harness.
The agent loop is a bind chain: each turn sequences a model completion into
tool effects into the next turn's context. bind is the harness that runs that
chain — the runtime-agnostic core of a production agent loop, extracted from
Monad.
This document is organised on the Diátaxis axes. Tutorial and How-to are practical; Reference and Explanation are theoretical. If you are a coding agent working against this package, read the exported types for signatures — they are the specification — and use Reference for the contracts those types cannot express, Usage scenarios for porting shapes, and Rules for automated contributors for the constraints that will fail CI if you break them.
| I want to… | Go to | |---|---| | Understand what this is and whether I need it | Explanation | | Get something running end to end | Tutorial | | Solve one specific problem | How-to guides | | Know which entry point to use, and what holds between calls | Reference | | Know how versions work before installing | Versioning | | Port an existing agent runtime onto this | Usage scenarios | | Know what is deliberately not here yet | Roadmap | | Change this package safely | Rules for automated contributors |
Changelog
Unreleased
Added
@juno-ai/bind/session— versioned settled checkpoints over the existing loop, prepared history, explicit reply/wake input, independent eval forks, and required host restore/capture/commit ports. See "Checkpointed scenarios".Three loop ports for a host whose transcript is durable —
ToolLoopTurn.acceptMessage,ToolLoopParams.beforeToolMessageAcceptedandToolLoopParams.onStepSettled(plusStepSettlement,DiscardedTurnWithToolCallsErrorandInputBlockedBySuspendError). Together they let a host record each accepted message as the loop accepts it, in the order it accepts it, and hand back messages it has committed. All three are optional and unwired behaviour is unchanged. See "How to commit a transcript as the loop builds it".@juno-ai/bind/skills— progressive disclosure for instructions, the mirror of@juno-ai/bind/plugins.createSkillRegistryfor what your source ships,partitionSkillCatalogfor the Tier-1 catalog and its token budget,resolveActiveSkillInstructionsfor the bodies (live-head or pinned to a completed run's hashes, over a batchedExternalSkillSourcefor skills your users author),createSkillActivationto drive the loop'sactivateSkillsport,admitSkillLoadfor the active-set bounds,parseSkillMarkdown/serializeSkillMarkdownfor theSKILL.mdinterchange format over your own YAML, andbuildAgentSkillsDiscoveryIndexfor the Agent Skills Discovery RFC v0.2.0 document. See "How to give an agent loadable skills".Two notes for a host that already has something like this. The catalog returns data, not prose — the wording is yours, for the same prompt-cache reason
partitionPluginCataloggives. And the content digest is the package's, not a port like the onetoolCallArgsHashtakes: a skill's hash is computed at registration, which is synchronous, and it identifies build content rather than being persisted across versions.Sha256Hexis still a parameter if you would rather inject a native one.
Breaking
sanitizeToolSchemanow emits a union for a nullable property instead of collapsing it.{type:["string","null"], minLength:1}comes out as{anyOf:[{type:"string",minLength:1},{type:"null"}]}, with the node's type-bearing keywords on the typed branch and its annotations left outside; the old output was{type:"string", minLength:1}plus an "Accepts string or null." note on the description. Nothing in the API changed, but the emitted schema did — a consumer asserting on sanitizer output, or reading.typeoff a sanitized node, needs updating.The collapse was lossy in a way that broke callers. A model handed a type-satisfying schema and a description that says "or send null" cannot express "none" in the half of the declaration it treats as binding, so it invents a value: one tool parameter authored
type:["string","null"], minLength:1received"/",". "and".000001"for thousands of calls, each refused by the downstream API and each retried. The union shape is measured accepted on grok-4.3/4.5/4.6, gemini-3.5/3.6/3.7-flash and gpt-5.6-terra/sol/luna. A genuine multi-type union (["string","number","boolean"]) still collapses — no single shape is accepted by every provider — and so does a nullable type at the parameters root, in a branch of a rootanyOf/oneOf, or in a composition branch whoserequiredresolves against the enclosing node.runToolLoopnow returnsToolLoopResult({ stopReason, stats }) instead ofvoid. A caller that ignores the return value is unchanged, but a wrapper annotatedPromise<void>no longer typechecks — widen it toPromise<ToolLoopResult>.RunStatsgainedcachedInputTokens.emptyRunStats()and the folds set it; code that hand-builds aRunStatsliteral must add the field.ToolLoopTurnandCompactionAppliedgained a matching optionalcachedInputTokens, so the loop can actually populate it — return it fromcallModelif your transport reports one.MissingActivationPortError— a tool outcome that asks the loop to activate a plugin or skill while the matching port is unwired is now reported throughonToolCallRejectedinstead of being dropped in silence. The run still completes; the wiring bug is no longer invisible.defineToolno longer appliesnormalizeArgsinsideexecute. Normalization is the dispatcher's step (it runs before the idempotency hash), and applying it in both places applied it twice.StopReasonno longer hassuspended. It split intowaiting_for_reply(a tool asked a human a question; nothing happens until someone answers) andresuming_later(a tool scheduled its own resume). Removing a union member breaks any exhaustiveswitch, so map both new values wherever you handledsuspended— and note that the old single value could not tell them apart at all, which is why it was split.@juno-ai/bind/pluginsnow loadszodat runtime. The barrel re-exportsdefineTool/pluginFromTools, andtoolWireDefinitionneedsz.toJSONSchema. Every other module in the subpath was previously runtime-zod-free, so a consumer importing onlycreateToolRegistrywhile ignoring the peer-dependency warning now fails to resolve.zodis a required (non-optional) peer, so a correctly-installed consumer is unaffected.@juno-ai/bind/loopis deliberately still zod-free — which is whytoolResultMessagelives in its own type-only module.
Added
ToolLoopResult.stopReason—done/waiting_for_reply/resuming_later/iteration_limit/aborted, one per exit, replacing the two or three loop-state flags every host was combining differently. The two pause reasons are separate values on purpose: one needs a person to act and the other does not, and that is the distinction a host most needs to surface.deadlinestays in theStopReasonunion for a host classifying a thrownRunTimeoutError; the loop cannot return it, and the type's doc comment says why.ToolLoopResult.stats— aRunStatsthe loop folds itself: turns, dispatched tool calls, tokens, cost, and the model-time/tool-time split with a per-tool breakdown. A compaction contributes its spend but not a turn (accumulateAuxiliarySpend, also new), sostats.turnsstays comparable withmaxIterations.ToolLoopParams.nowinjects the clock.@juno-ai/bind/testing— the scripted-model fixtures this package's own cross-module suites use:loopHarness,scriptedModel,toolCallTurn,finalAnswer,toolCall,freshState,recordingSink,steppingClock. Credential-free multi-turn, multi-tool tests without mocking a chat client.defineTool/pluginFromTools(@juno-ai/bind/plugins) — author a tool as{ name, description, schema, execute }and get argument parsing, avalidationfailure the model can act on, and typedargsinexecute. PlustoolWireDefinition(zod → sanitized JSON Schema) andtoolResultMessage(therole:"tool"encoding the loop itself uses).ToolLoopParams.activePlugins,activatePluginsandactivateSkillsare now optional. A host with a fixed tool surface had been required to supply empty functions;activePluginswas never read by the loop at all.runToolCallsPooledByToolaccepts an optionalsignal, andAbortedToolCallError/ToolBatchOptionsare exported from@juno-ai/bind/run. Additive — a caller that passes nothing is unchanged, and a test pins that. A batch that is given one stops claiming queued calls once it aborts, and those come backrejectedwith anAbortedToolCallError(kind: "not_run"once the loop synthesizes them), so a caller relying on every call always executing simply does not pass a signal.createTurnTextStream(@juno-ai/bind/completion) — streams a turn's assistant text to a live surface and repairs it across routing retries. A retractable surface keeps the whole fallback chain; a permanent one clamps as before. See "How to stream tokens to a user without breaking fallback".ToolLoopParams.signal— bounds the tool batch. Without it the deadline and cancellation ports are only consulted between iterations, so a budget that expired during the model call still let the batch run its side effects.
Explanation
Understanding-oriented. Read this to know why the package is shaped the way it is; you do not need it to use the package.
What a harness is, and what it is not
A harness owns the parts of an agent loop that are the same for everyone: the iteration itself — call the model, run what it asked for, repeat — plus deciding which provider to call and what to do when it fails, bounding a run in wall-clock time, keeping a transcript in a shape providers accept, rewriting tool schemas that strict validators reject, and tracking which tools are currently loaded.
A runtime owns the parts that are yours: identity, authorization, persistence,
transports, prompt voice, and product behaviour. bind deliberately contains
none of that. It never reads the environment, never touches a filesystem, and
holds no secrets — which is what lets the same code run on Bun, Node, and edge
runtimes such as Cloudflare workerd.
The fences, and why they exist
Four constraints, enforced by lint:
- No
@/*application imports. Anything the harness needs from the host arrives through an injected function or value — a port — never a direct import. - No Node builtins, no
process. No filesystem, no environment reads. Configuration is explicit input. Clocks are injectable, with aDate.nowdefault as the one sanctioned exception. - No framework imports.
- Peer dependencies only (
zod, plusopenaias an optional type-only peer). The host supplies the instances, so schemas never split across duplicate copies — a failure that stays invisible until two zod instances disagree about the same schema at runtime.
The fences are not stylistic. They are the reason a Cloudflare Worker and a long-lived Node server can share this code unmodified.
Why the plugin types are generic
ToolPlugin is generic over the invocation context (TCtx) rather than
shipping a concrete one. Almost nothing in a real tool context is common: the
abort signal is, and the rest is the host's own identity model, its
authorization, and its product features. Two applications comparing notes here
will typically find they share one field. A concrete context would therefore be
either a lowest-common-denominator or the union of several products' identity
models — so the context is a type parameter you supply, and you add your own
plugin fields by ordinary interface extension.
ToolResult is separately generic over the content-part type for a smaller
reason: the wire shape is the provider's, but turning bytes into a multimodal
part needs runtime-specific APIs that differ between Node and the edge. The
harness carries parts through without interpreting them, which keeps those APIs
— and the dependencies they imply — out of a package that must run on workerd.
Why the registry is a factory
createToolRegistry returns an instance rather than exposing a module-level
map. Module state is per-isolate on edge runtimes and its lifetime is not the
host's; it also makes tests share state implicitly. A host that wants singleton
ergonomics wraps one instance in a module — the choice belongs to the host.
Why a restored activation set is a hint
Progressive tool disclosure persists plugin names; implementations resolve
at load time. Between two runs a plugin can be renamed, gated off, or (if it is
dynamically connected) fail to reconnect. rehydrateActivation therefore
re-validates every persisted entry against current reality and drops what no
longer resolves, returning the drops with a reason rather than failing the run.
Reporting a plugin as active when its tools cannot be called is worse than
losing it.
What stays with your application
Transports' request construction and your own error classes, credentials and environment parsing, your routing policy configuration, billing accounting (persistence and charging), inference logging, authorization, prompt rendering, and run orchestration — the queue a run is scheduled on, and the enqueuing and storage behind any child runs it spawns. The harness decides whether a child is allowed; putting it on a queue is yours (see Spawning child runs).
The loop is here, but the driver around it is not: starting a run, recording
what it did, delivering its output, and deciding when to run it again. That is
where a runtime's identity, storage, and product behaviour live, and it is why
runToolLoop takes a dozen observers instead of doing any of it.
Tutorial: route one completion
Learning-oriented. Follow these steps in order on a scratch file; the goal is a working mental model, not production code.
You will plan a route across two providers, execute it against a fake transport, and watch the failure policy fall over to the second provider.
1. Install. zod is a peer dependency — bring your own v4 instance. Add
openai too if you use /run, /transcript, /contracts, or the package
root: they reference its message types (type-only, erased at runtime).
bun add @juno-ai/bind zod openai2. Describe your providers. A PlannerTransport answers two questions:
are you available, and can you serve this model? It returns a candidate, a
recorded skip, or unserved.
import {
canonicalModelIdSchema,
providerIdSchema,
type PlannerTransport,
} from "@juno-ai/bind/routing";
const model = canonicalModelIdSchema.parse("openai/gpt-example");
const primaryId = providerIdSchema.parse("primary");
const backupId = providerIdSchema.parse("backup");
function fakeTransport(id: typeof primaryId): PlannerTransport {
return {
id,
getAvailability: () => ({ available: true }),
resolveCandidate: () => ({
kind: "candidate",
candidate: {
providerId: id,
canonicalModelId: model,
providerInvocationModel: "gpt-example",
credentialSource: "platform",
creditEligible: true,
capabilities: new Set(["chat_completions"]),
maxCompletionTokens: null,
pricingBasis: { kind: "provider_reported" },
bindingFingerprint: `${id}:gpt-example`,
},
}),
};
}3. Build the plan. Policy order is the only ordering input. The result is frozen, secret-free, and safe to log or snapshot in a test.
import { buildRoutePlan } from "@juno-ai/bind/routing";
const { plan, skips } = buildRoutePlan({
primaryModel: model,
fallbackModel: null,
requirements: {
capabilities: new Set(["chat_completions"]),
requestedMaxCompletionTokens: null,
},
policyFor: () => ({ mode: "ordered", providers: [primaryId, backupId] }),
transports: new Map([
[primaryId, fakeTransport(primaryId)],
[backupId, fakeTransport(backupId)],
]),
});
console.log(plan.stages[0].candidates.map((c) => c.providerId)); // primary, backup
console.log(skips); // [] — nothing was filtered out4. Execute it. Your attempt function performs the real call and
classifies any failure into facts. It never decides route order — that is the
executor's job.
import { executeRoutePlan, createCircuitBreaker } from "@juno-ai/bind/routing";
const result = await executeRoutePlan({
plan,
breaker: createCircuitBreaker(),
attempt: async (candidate, cursor) => {
if (candidate.providerId === primaryId) {
return {
kind: "failure",
error: {
kind: "http",
category: "server_error",
statusCode: 503,
retryAfterMs: null,
target: {
cursor,
providerId: candidate.providerId,
canonicalModelId: candidate.canonicalModelId,
providerInvocationModel: candidate.providerInvocationModel,
durationMs: 12,
},
cause: new Error("upstream unavailable"),
},
};
}
return { kind: "success", value: "hello from backup" };
},
});
if (result.ok) {
console.log(result.value); // "hello from backup"
console.log(result.served.providerId); // backup
console.log(result.fallbackKind); // "provider"
}What you just saw. A 503 classifies as a retriable transport failure, so the disposition table allows traversal to the next provider and records a breaker failure against the first endpoint. You did not write that logic, and you cannot accidentally reorder it from inside a transport.
5. Next. Add a wall-clock budget with
createRunDeadline, or add
progressive tool disclosure with
createToolRegistry.
How-to guides
Goal-oriented. Each answers one question and assumes you know roughly what you are doing.
How to run the loop
runToolLoop is the engine: it calls the model, runs the tools the model asks
for, and repeats until the model stops asking, a tool suspends the run, a caller
stops it, or maxIterations is reached. Everything that happens as a result is
a callback you supply.
import { runToolLoop, type ToolLoopState } from "@juno-ai/bind/loop";
const state: ToolLoopState = {
messages: [systemMessage, userMessage],
inputTokens: 0,
outputTokens: 0,
costCents: 0,
lastPromptTokens: 0,
lastOutputTokens: 0,
hasFreshTokenCount: false,
toolCalls: 0,
};
const { stopReason, stats } = await runToolLoop({
state,
maxIterations: 30,
callModel: (messages, tools) => llm.complete({ messages, tools }),
buildTools: () => registry.toolDefinitions([...activePlugins]),
runToolCall: (call) => dispatch(call),
// Only if your tools can change the tool surface mid-run:
activatePlugins: (names) => names.forEach((n) => activePlugins.add(n)),
activateSkills: (refs) => loadInstructions(refs),
});
// `state` is mutated in place — read totals off it mid-run from a heartbeat.
console.log(state.inputTokens, state.outputTokens, state.toolCalls);
// The result is the run's conclusion, which only exists once it is over.
await persistRun({ stopReason, ...stats });The two halves are deliberate. state is mutated rather than returned so a
heartbeat can read live totals while the run is still going; ToolLoopResult
is what could not exist until the run ended.
stopReason is the whole outcome, in one word:
| Reason | The model | What a host should say |
|---|---|---|
| done | Stopped calling tools | It answered |
| waiting_for_reply | A tool asked a human a question | It needs you to answer — nothing happens until you do |
| resuming_later | A tool scheduled its own resume | It paused on purpose and will come back |
| iteration_limit | Still calling tools at the ceiling | It ran out of room, mid-task |
| aborted | Cut off by shouldStop or the batch signal | It was stopped |
The two pause reasons are separate values because their consequences differ
more than any other pair: one needs a person to act, the other needs nobody to.
Collapsed into one suspended, a host that wanted to say which had to go back
and read state.suspended — the loop-state-flag reconstruction this return
value exists to replace.
deadline is in the StopReason union but never returned: the wall-clock port
(throwIfTimedOut) is throw-based, so an expired budget leaves the loop as a
RunTimeoutError your catch block maps — classifyRunFailure recognises the
same condition. The loop returns aborted for a fired signal because once a
deadline and a cancellation are combined into one AbortSignal it genuinely
cannot tell which one fired.
stats is a RunStats: turns, dispatched tool calls, tokens (including
provider-reported cached input, when your callModel returns a
cachedInputTokens), cost, and model time vs tool time with a per-tool
breakdown. Three things are worth reading carefully:
statsis this invocation's contribution;stateis whatever you seeded plus that. They match only if you seeded zeros. A host resuming a run seedsstatefrom the stored totals, and thenstate.costCentsis the run's lifetime cost whilestats.costCentsis this leg's — bill from whichever you mean, and don't substitute one for the other.stats.toolCallscounts calls that ran;state.toolCallscounts calls the model requested. They differ by exactly the work an aborted batch prevented, which is why they are two numbers.A compaction contributes tokens, cost and model time but not a turn, so
stats.turnsstays comparable withmaxIterations.
Both are lost if the loop throws: a deadline, a cancellation, or a fatal tool
error leaves no return value, so state — mutated in place — is the only
accounting that survives those exits.
Pass now to make either measurement deterministic in a test; it defaults to
Date.now.
How to test an agent without a provider
@juno-ai/bind/testing ships the fixtures this package's own cross-module
suites use. A scripted model is a queue of prepared turns, so a multi-turn,
multi-tool test needs no credential, no network, and no mocked chat client.
import { runToolLoop } from "@juno-ai/bind/loop";
import {
loopHarness, toolCall, toolCallTurn, finalAnswer,
} from "@juno-ai/bind/testing";
const h = loopHarness([
toolCallTurn([toolCall("search", { q: "bind" }), toolCall("read_file")]),
toolCallTurn([toolCall("write_file", { path: "out.md" })]),
finalAnswer("Done."),
]);
const { stopReason, stats } = await runToolLoop(h.params);
expect(stopReason).toBe("done");
expect(stats.turns).toBe(3);
expect(h.ran).toEqual(["search", "read_file", "write_file"]);Every field of h.params is overridable, which is how you reach the
interesting states — { runToolCall } to make one tool fail, { signal } to
abort mid-batch, { now } to make the timing figures deterministic
(steppingClock() for a fixed tick, or your own closure advanced inside
runToolCall when you want to choose each interval).
h.ran records what the loop dispatched and h.sideEffects what actually
completed; the gap between them is the answer to every cancellation question.
Two failure modes are deliberate. A script that runs out throws rather than
returning an empty turn — otherwise maxIterations absorbs the mistake and the
test passes while describing a run that never happened. And a duplicate
tool-call id throws at construction, rather than producing a transcript the
provider rejects ten frames deep in the loop.
How to author a tool without writing dispatch by hand
A ToolPlugin dispatches by tool name, which is right for a bundle with shared
setup and pure ceremony for a flat list of independent tools. defineTool does
the mechanical half — parse the arguments, turn a parse failure into something
the model can act on — and pluginFromTools bundles the result.
import { defineTool, pluginFromTools } from "@juno-ai/bind/plugins";
const search = defineTool({
name: "search",
description: "Search the corpus.",
schema: z.object({ query: z.string().min(1), limit: z.number().default(10) }),
// `args` is typed from the schema; `limit` has already defaulted.
execute: async (args, ctx: Ctx) => ({
success: true,
data: await corpus.search(args.query, args.limit, ctx.tenantId),
}),
});
const plugin = pluginFromTools<Ctx>({
name: "corpus",
description: "Corpus tools.",
tools: [search],
});The schema is the single source of truth: it is converted to JSON Schema for the model and used to validate what comes back, so the two cannot drift.
Bad arguments come back as { success: false, kind: "validation", error }
naming the offending path — returned, not thrown. That distinction is the
bug this replaces: a thrown parse error turns a recoverable "you passed the
wrong field" into a dead run. An unknown tool name is likewise a returned
not_found, because a resumed session's history can reference a tool you have
since retired.
The validation lives on the tool, not on the bundle, so a host with its own
dispatcher can call search.execute(rawArgs, ctx) directly and get the same
guarantee — pluginFromTools only resolves names.
Two encoders come with it. toolWireDefinition(tool, wireName?) converts a
tool to what the provider is shown, through sanitizeToolSchema — wireName
because tool naming is host policy (Monad encodes plugin__tool to route a
call back to its plugin). toolResultMessage(callId, result) produces the
role:"tool" message, using the same encoding the loop synthesizes for a
failed or refused call — so a model never has to learn two error formats in one
transcript.
How to make a tool take effect mid-batch
A model can request several tools at once, and one of them may change what the others can do — activating a plugin, loading an instruction module. Those must run first, alone, or a dependent call in the same batch executes against the old tool surface.
runsSerially: (call) =>
call.type === "function" && ACTIVATION_TOOLS.has(call.function.name),The loop runs those one at a time, applies each outcome immediately, then fans
the rest out concurrently (pooled per tool name). Outcomes are reassembled in the
model's original order either way — every tool_call_id gets its answer in the
sequence the provider expects.
Unwired, nothing is serial. That is correct for a host whose tools do not reshape the tool surface, and wrong the moment one does.
How to decide which tool failures kill the run
By default a thrown tool becomes a tool error the model can read and recover from, which is what you want for an isolated failure. Two kinds are not that:
isFatalToolError: (error) =>
error instanceof RunCancelledError || // must abort, not be answered
error instanceof PersistenceError, // we could not RECORD the outcomeCancellation has to propagate even when no ensureNotCancelled observer is
wired. A persistence failure matters for a subtler reason: synthesizing "the tool
failed" over a write you could not record tells the model a lie about work that
may well have happened.
Everything not fatal is answered and observed:
onToolCallRejected: (toolCallId, error) => log.warn("tool rejected", { toolCallId, error }),Wire it. The model sees these failures either way; without the observer, nothing else does.
How to pin a model to one provider
Use a hard only fence. Plan-time filtering and runtime traversal both respect
it — a pinned provider that fails at request time is never retried elsewhere.
policyFor: () => ({ mode: "only", provider: complianceProviderId });Useful for evals (reproducible plans) and for compliance routing.
How to bound a run in wall-clock time
Create the deadline where you classify the outcome, and dispose it in a
finally.
import { createRunDeadline, classifyRunFailure } from "@juno-ai/bind/run";
const deadline = createRunDeadline({ timeoutMs: 60 * 60 * 1000, label: "agent run" });
try {
for (;;) {
deadline.throwIfTimedOut();
await callModel({ signal: deadline.withExternal(cancellationSignal) });
}
} catch (error) {
const status = classifyRunFailure(deadline, error); // "timed_out" | "failed"
} finally {
deadline.dispose();
}Ownership rule: whoever needs to read deadline.timedOut must create and
own the deadline. A loop handed one must not dispose it; a loop given none
should mint its own, so a turn is never unbounded. Handle caller-driven
cancellation before calling classifyRunFailure — a cancellation is not a
timeout, and timedOut stays false when only a combined external signal fires.
How to stop a stalled stream from hanging forever
Your SDK's timeout bounds establishing the request, not the gap between
streamed chunks. Hand the watchdog's signal to the transport, tell it what each
chunk carried, and ask it afterwards whether it was the one that tore the stream
down.
import { createStreamWatchdog } from "@juno-ai/bind/completion";
const watchdog = createStreamWatchdog({ external: cancellationSignal });
try {
const stream = await client.chat.completions.create(body, { signal: watchdog.signal });
watchdog.open(); // arms the first-token budget and the absolute cap
for await (const chunk of stream) {
const delta = chunk.choices?.[0]?.delta;
// Only ANSWER output arms the tight inter-chunk budget. Reasoning deltas —
// and the pause between reasoning and the first answer token — must stay on
// the generous budget, or a reasoning model's normal think-then-answer gap
// fails healthy turns.
// Providers disagree on the reasoning field's name, and it is off-spec for
// the OpenAI types either way — read both spellings.
const reasoning = delta?.reasoning_content ?? delta?.reasoning;
watchdog.observedChunk(
delta?.content || delta?.refusal || delta?.tool_calls
? "answer"
: reasoning
? "reasoning"
: "none",
);
// …accumulate…
}
} catch (error) {
const stall = watchdog.stall();
if (stall !== null) throw new MyRetriableError(describe(stall)); // your wording
throw error; // a caller abort, or a real transport failure
} finally {
watchdog.dispose();
}stall() returns null when your own external signal aborted, so a
cancellation is never reported as a retriable upstream stall. Call it in the
catch, before dispose(). Budgets are validated at construction: a NaN or
Infinity budget throws rather than silently tearing down every healthy stream
(setTimeout coerces a non-finite delay to ~1ms — it does not disable the
timer).
How to read the arguments of a tool call the model asked for
JSON.parse(toolCall.function.arguments) is the obvious implementation and it
is wrong for the commonest tool there is. Providers send "" for a
zero-argument call as readily as "{}", so the obvious version kills a
perfectly good call as a JSON syntax error and burns a recovery turn on a turn
that was never broken.
import { parseToolCallArguments } from "@juno-ai/bind/completion";
const read = parseToolCallArguments(toolCall);
switch (read.kind) {
case "parsed":
return dispatch(toolCall.function.name, read.arguments);
case "unsupported_type":
return toolMessage(toolCall.id, `Unsupported tool call type: ${read.type}`);
case "unparseable":
// Put `detail` in front of the MODEL, not only in a log — its next turn is
// the only thing that can correct the arguments.
return toolMessage(toolCall.id, `Invalid tool arguments: ${read.detail}`);
}Every outcome is a value, not a throw, because every outcome has to end with a
tool message carrying this call's id — a transcript where an assistant asked
for a tool and nothing answered it is rejected by the provider on the next
request, so "give up on this call" was never an option.
Valid JSON that is not an object — null, [], 42 — is refused rather than
dispatched. Tool arguments are a named parameter bag by definition, and handing
a tool an array where it expects fields turns a clear failure here into a
confusing one inside the tool, after any side effect it performs before its own
validation.
How to assemble streamed tool calls
Providers send tool calls as indexed deltas, and the obvious accumulation loop is wrong in four ways that all fail silently.
import { createToolCallAccumulator } from "@juno-ai/bind/completion";
const toolCalls = createToolCallAccumulator();
for await (const chunk of stream) {
toolCalls.observe(chunk.choices?.[0]?.delta?.tool_calls);
}
const assembled = toolCalls.isEmpty ? undefined : toolCalls.assembled();Key by the provider's index, not arrival order — deltas for index 1 can
precede index 0, and pushing onto an array transposes the calls while leaving
both parseable. Read id on every delta, not just the first for a slot; a
late one is legal and a call without an id cannot be answered. Concatenate
name as well as arguments — providers split it, and assigning keeps only
the last fragment (_file from read_file), which surfaces as an unknown-tool
error naming a tool the model never asked for. Order by index at the end, since
Map iterates in insertion order.
How to keep a retry from repeating a tool's side effect
A durable runtime retries: a queue redelivers, a workflow step re-runs, a reclaimed job starts the turn again. If the turn sent an email, the retry sends a second one. A receipt is the row that lets the second attempt find out.
import {
toolCallArgsHash, decideToolCallReceipt, type DigestFn,
} from "@juno-ai/bind/run";
const sha256: DigestFn = async (s) => {
const d = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(s));
return [...new Uint8Array(d)].map((b) => b.toString(16).padStart(2, "0")).join("");
};
const key = { tenantId, runId, toolName, argsHash: await toolCallArgsHash(args, sha256) };
const decision = decideToolCallReceipt({
state: await store.read(key), // yours — see the shape below
effect: tool.resumable ? "resumable" : "opaque",
});
switch (decision.kind) {
case "replay": return (await store.read(key)).result;
case "wait": return retryLater(decision.reason);
case "ambiguous": return surfaceToAHuman(decision.reason);
case "execute": {
await store.claim(key, decision.attempt); // BEFORE executing
const result = await tool.execute(args);
await store.complete(key, decision.attempt, result);
return result;
}
}Identity is content, not position. The key is
(tenant, run, tool, arguments). Keying by position — run + turn + index in the
batch — looks equivalent and is not: a retried turn is a fresh completion, so
above temperature zero the model may reorder the batch or ask for a different
tool at the same index. Position-keyed receipts then match calls that are not
the same call, replay one tool's result as another's, and skip the tool actually
requested. Content keying fails the other way, which is the safe way: a genuinely
new call finds no receipt and runs.
The tenant is part of the key. Without it the key cannot be partitioned or relocated by tenant, and two tenants' runs are not guaranteed to share a database.
Record before executing, not after. A receipt claimed but never completed is the evidence that a write may have landed. Record afterwards and that window is invisible.
"Claimed but not completed" is not automatically ambiguous — this is the part
worth getting right. On any at-least-once substrate a lease expires whenever a
worker dies or merely stalls, which is routine. decideToolCallReceipt needs a
lease and an attempt counter to tell the three cases apart:
| Receipt state | Decision |
|---|---|
| absent | execute, attempt 1 |
| completed | replay |
| failed | execute, attempt n+1 |
| running, lease live | wait — another attempt owns it and is alive |
| running, lease expired, effect resumable | execute, attempt n+1 — reclaim |
| running, lease expired, effect opaque | ambiguous — refuse, surface it |
resumable means the host records each sub-operation as it completes, so a
reclaimed attempt skips what already happened. opaque is one indivisible
effect with no trail, where reclaiming cannot tell "never sent" from "sent, then
crashed".
Two obligations the package cannot enforce: lease times must come from the
store's clock, because application clocks drift enough to steal a live lease;
and every write must gate on the attempt fence (status = 'running' AND
attempts = <mine>), inside the same transaction as the side effect where the
store allows it — fencing only the completion write leaves a window where two
attempts both believe they own the call.
Hash the raw parsed arguments, not the output of a schema parse that coerced
types. canonicalJson throws rather than serializing a value it cannot
represent faithfully — a Map, a Set, a class instance, NaN, a cycle —
because a silent collision here reads as "same call" and skips a write that
never happened.
Checkpointed scenarios
Use createLoopCheckpoint({ executionId, messages, maxIterations, bindings })
to prepare history without inference. runFromCheckpoint({ checkpoint, restore,
commit }) executes the existing loop; restore returns its executable ports and
captureBindings, and commit persists each settled checkpoint before returning
"continue" or "checkpoint". bindings contains JSON configuration pins and
an environment descriptor; the host verifies/restores them before returning tools.
parseLoopCheckpoint(JSON.parse(saved)) validates storage. Save with
serializeLoopCheckpoint. Waiting/finished checkpoints do no inference;
applyCheckpointInput accepts explicit reply, wake, or new user message.
Resumes preserve consumed iterations. forkLoopCheckpoint(source, { executionId,
maxIterations, bindings }) copies history and position into a new identity with
fresh spend/budget and parent lineage. Supply isolated environment bindings for
independent eval trials. Finished forks need new input; waiting forks need their
reply or wake.
The optional runToolLoop(params, control) overload exposes the same awaited
boundary hook for custom drivers. Unlike onStepSettled, boundaries follow
nudge, compaction and suspension decisions. Existing one-argument calls keep
their return type and behavior. The controlled overload adds "checkpointed".
The session driver returns invocation statistics alongside cumulative checkpoint
statistics. Hosts enforce single-writer/revision ordering in storage.
These are settled checkpoints, not recovery of an in-flight tool call. Restore external state or reconcile execution receipts before replaying an older snapshot. Bind does not serialize tool implementations or provision workspaces.
How to commit a transcript as the loop builds it
state.messages is an in-memory array. If your host persists each message as it
lands — a Durable Object writing transitions, an event-sourced run that replays
after eviction — you need three things the array alone cannot give you: the
exact message at the moment it is accepted, its position among the results the
batch actually produced, and a point where you can hand back input your users
committed while the agent was working.
await runToolLoop({
// …
callModel: async (messages, tools) => {
const completion = await transport(messages, tools);
const empty = !completion.content.trim() && completion.toolCalls.length === 0;
if (empty) await settleReservation(turnId, "failed");
else await commit({ kind: "assistant_accepted", turnId, message: completion.message });
return { ...completion, acceptMessage: !empty };
},
beforeToolMessageAccepted: async (message, resultOrdinal) => {
// Throws → the message is NOT appended and the run fails.
await commit({ kind: "tool_result_accepted", message, resultOrdinal });
},
onStepSettled: async ({ wouldEnd }) => {
const pending = await claimCommittedInput({ wouldEnd });
return { messages: pending.messages, stop: pending.runWasCancelled };
},
});acceptMessage: false keeps a turn out of the transcript without hiding what
it cost. The case is a tolerated empty completion: the provider returned
nothing, your host has already settled the slot it reserved, and appending an
empty assistant message would persist a turn that said nothing and re-send it on
every later call. Tokens and cost are still folded into stats and state. The
loop refuses the discard for a turn that requested tools — those results are
about to be appended and would have no request to pair with — and reports the
misuse through onToolCallRejected as a DiscardedTurnWithToolCallsError.
resultOrdinal is dense over accepted results. It is not the call's index
in tool_calls: an answer-suspend withholds its result, so the calls after it
close the gap, and the answer takes the ordinal it is accepted at when it
arrives. Reconstructing that order from state.messages afterwards is the one
thing that goes wrong for exactly the batch that suspended.
beforeToolMessageAccepted covers results this loop produced, not messages
you hand back. A tool message you return from onStepSettled skips the
port and takes no ordinal — you committed it before handing it over, so
reporting it back would ask you to record the same thing twice.
A settled step is where input can join. onStepSettled fires at most
once per iteration, and wouldEnd says which of the two shapes that
iteration took: true when the model asked for no tools (the run is about to
end), false when the batch's results are all in the transcript. Anywhere
else, an appended message lands between an assistant message and the results
it is waiting on, which is a transcript providers reject. Returned messages
are appended in order and the run continues; stop ends it as aborted with
them already appended, and a throw propagates out of runToolLoop
unconverted.
Two behaviours worth knowing before you wire it:
- At
wouldEnd: true,onStepSettledruns beforeonTurnWouldEnd. Real input outranks a nudge — a turn with more to answer was never stalled. - A returned
role:"tool"message carrying an open suspend'stool_call_idclears the suspension, so a host that already has the answer keeps going instead of pausing for a reply it has been handed. Awake-suspend from another call in the same batch still ends the run: the answer says nothing about it. - While a suspend is open, only
toolmessages are accepted. Ausermessage delivered ahead of the answer is refused and reported as anInputBlockedBySuspendError: appending it is how a resumed run ends up sendingassistant(tool_calls) → user → tool, which providers reject. Put the answering result first in the array and the rest is accepted after it. shouldStopoutranks delivered input. The messages are kept — your host committed them — and the run ends asabortedrather than making another model call.
How to stream tokens to a user without breaking fallback
Three granularities reach you, and the third is the one with a retry problem.
| You want | Use |
|---|---|
| Each assistant message as it lands | onAssistantMessage(content) on the loop — fires per message, including text emitted alongside tool calls, rather than batching at turn end |
| A live "N tokens so far" indicator | onOutputProgress into callModel → onProgressUpdate(outputTokens, toolCalls) — a count, never content |
| Individual tokens in a UI | createTurnTextStream from @juno-ai/bind/completion, driven from inside your AttemptFn |
The problem the third one has: routing's answer to a mid-stream failure is to
try again — same endpoint, next provider, fallback model — and each of those
re-renders a turn your user is already reading. createTurnTextStream solves it
by asking one question about your surface. Can it be told to discard what it
rendered?
const stream = createTurnTextStream({
turnId: messageId,
sink: { retractable: true, emit: (event) => socket.send(JSON.stringify(event)) },
});
const attempt = async (candidate, cursor) => {
stream.beginAttempt(); // once per attempt, including the first
try {
const value = await callProvider(candidate, {
onDelta: (text) => stream.observe(text), // content only — see below
});
return { kind: "success", value };
} catch (error) {
return {
kind: "failure",
error: classify(error, candidate, cursor),
producedOutput: stream.producedOutput, // never hand-rolled again
};
}
};
const result = await executeRoutePlan({ plan, attempt, breaker });
stream.finish(result.ok ? "succeeded" : "failed");
return result;Two lifetime rules, and neither is enforceable from inside the package:
Construct it once per turn — outside the executor and outside your
structured-output retry loop. One per attempt never sees a second attempt, so
it never resets and producedOutput is never true; the single-attempt path is
indistinguishable from correct, and the bug shows up only under fallback, as two
partial answers glued together.
Always call finish(), and tell it how the turn ended. A reset still armed
at the finish line means an earlier attempt's text is on screen, and the two
outcomes want opposite things. finish("succeeded") flushes it — the retry
succeeded with content: null plus tool calls, so nothing triggered the lazy
reset and that narration belongs to a turn that never said it.
finish("failed") drops it — every attempt failed, so the partial text is the
best thing the reader is going to get, and wiping it hands them a blank space
plus an error instead.
With retractable: true, producedOutput stays false, so the plan keeps
every stage. On the retry's first byte the sink receives
{ kind: "reset", epoch, seq, reason } and re-renders from scratch. With
retractable: false — a message already posted through a third-party API, an
email, a webhook, an append-only row — it latches on the first byte and routing
clamps to propagate-only, which is the old behaviour and the right one.
Your client needs three lines to be correct under a transport that can reorder or duplicate, because an in-flight delta from attempt 1 can arrive after attempt 2's reset and text alone cannot be told apart from stale text:
// Per turn: { lastSeq: -1, newestEpoch: -1, rendered: "", held: new Map() }
// `lastSeq` starts at -1 — `seq` starts at 0, so seeding it to 0 silently drops
// the first delta of every turn, on the single-attempt path that is almost all
// traffic.
const turn = state.getOrCreate(event.turnId); // scope everything to the turn
if (event.seq <= turn.lastSeq) return; // replay or duplicate, drop
if (event.seq > turn.lastSeq + 1) return buffer(turn, event); // arrived early
turn.lastSeq = event.seq;
if (event.epoch < turn.newestEpoch) return; // stale attempt, drop
turn.newestEpoch = event.epoch;
if (event.kind === "reset") turn.rendered = "";
else turn.rendered += event.text;
drainBuffered(turn); // apply anything that was earlyBoth keys are load-bearing and they do different jobs. seq is monotonic
within the turn and never restarts, so it is what makes the stream tolerant of a
transport that duplicates or reorders — drop anything at or below the last seq
applied, hold anything that arrives ahead of it. epoch identifies the
attempt, so it is what tells a fresh delta from a stale one after a reset.
Neither substitutes for the other: without seq a duplicated text event appends
twice and same-epoch chunks concatenate in arrival order; without epoch a
delta from the wiped attempt is indistinguishable from the retry's.
Bound the hold buffer, and define when a turn ends. The rule above holds an
early event until its gap fills — and on a reconnect the gap never fills, because
the events that would have closed it were dropped. Left alone, the surface then
freezes on whatever the retracted attempt rendered, which is the exact outcome
this module exists to prevent, and the buffer grows one entry per token. So: cap
the buffer (a count or a timeout), and on overflow resync from the persisted
message rather than continuing to hold. For the same reason the client needs a
turn-final signal — onAssistantMessage, or the persisted row landing — at which
it drops the turn's state entirely. Nothing on this wire tells it; that is the
host's to define.
If your transport already guarantees ordered exactly-once delivery to the client
(a single WebSocket with no replay window, say), the seq half collapses to a
no-op and the two epoch lines are enough — but say so deliberately rather than
discovering it under load.
In React, the recipe above mutates in place. Dropped into a store as written,
getSnapshot returns an identity-stable object and useSyncExternalStore never
re-renders — the stream looks dead. Publish a fresh snapshot per applied event.
And key the rendered element by turnId, never by epoch: keying by epoch turns
every reset into an unmount, discarding focus, selection and scroll position when
the contract only ever needed a content update.
turnId is not decoration either. epoch and seq both restart each turn,
so a client that carried "newest epoch" across turns drops every event after the
first turn that retried, and a later turn's reset tells it to wipe an earlier,
committed message.
Three things stay yours:
Reasoning deltas. The watchdog gates its tight budget on the first answer token, so reasoning can flow well before one. Whether "thinking…" counts as output the user has seen is a product call — express it by choosing what you pass to
observe.Tool-call deltas are not output and should not go through
observe. A tool call is not a side effect until it is dispatched, which happens after the turn — so a turn that dies having streamed only tool-call bytes changed nothing anyone can see, and clamping it forfeits a fallback for free. If you have a genuine mid-attempt effect, callstream.markProducedOutput().How much flicker is acceptable. A wipe is not only a flicker — it collapses the message's height mid-stream, so an auto-scrolled transcript lurches, and it destroys any text selection inside that message. If the body is an
aria-liveregion, every wipe re-announces the whole answer from the top; keep itaria-busywhile streaming and announce once at the end instead.maxResetsis opt-in with no default: a plan with three stages, three candidates and two defect retries can legally wipe the screen more than twenty times. Spending the budget latchesproducedOutputso routing stops traversing — it does not stop emission, because an attempt already in flight may be the one that succeeds and its answer still has to reach the reader. Note the budget therefore also shapes how many endpoints record a failure against the circuit breaker for one turn.Which surface gets which callback.
onAssistantMessageon the loop fires once per completed assistant message; these events stream one attempt of one message. Wiring both to the same UI element delivers the text twice. Deltas drive the live view;onAssistantMessagedrives the permanent record.
Structured-output retries run in your loop, outside the executor, and get the
same treatment — open them with stream.beginAttempt("structured_output_retry")
so the user sees the re-ask replace the malformed JSON rather than follow it.
How to map your transport errors onto the routing taxonomy
failureDisposition decides what the router does with a classified failure, but
something has to produce the classification. Give classifyAttemptError two
ports — one that recognizes your error class, one that overrides the neutral
HTTP mapping where your provider disagrees with it — and it does the rest.
import { classifyAttemptError, type AttemptClassification } from "@juno-ai/bind/routing";
const CLASSIFICATION: AttemptClassification = {
asTransportFailure: (error) =>
error instanceof MyLLMError
? { kind: error.kind, statusCode: error.statusCode, retryAfterMs: error.retryAfterMs }
: null,
// This gateway answers 403 for moderation-flagged INPUT. The neutral mapping
// reads 403 as a credential failure and opens the circuit immediately —
// degrading a healthy shared endpoint for every tenant over one prompt.
categorizeStatus: (status, providerId) =>
status === 403 && providerId === MY_GATEWAY ? "provider_bad_request" : null,
};
// `target` is the `AttemptTarget` you assemble in your `attempt` callback from
// the candidate and cursor it was handed — see the tutorial's step 4.
const attemptError = classifyAttemptError(error, target, CLASSIFICATION);An AbortError is classified first, whatever else is true of it. Anything
asTransportFailure does not recognize becomes a propagating client_error: an
error that escaped your transport without becoming one of its own is a bug, and
retrying it against every provider and your fallback model arrives at the same
exception having spent a whole plan. retryAfterMsFromHeaders lives alongside
it and feeds the retryAfterMs the breaker uses to extend a cooldown — clamp
it before using it as a delay anywhere else, since it is a value the upstream
chose and RFC 9110 puts no ceiling on it.
How to stop a run's progress writes from stampeding
import { createCoalescedHeartbeat } from "@juno-ai/bind/run";
const heartbeat = createCoalescedHeartbeat({
coalesceMs: 10_000,
flush: () => db.bumpRunRow(runId),
onError: (error) => log.warn("heartbeat flush failed", { error }),
});
await heartbeat.beat(); // coalesced
await heartbeat.beat({ force: true }); // always flushes, resolves after the writeFlush errors go to onError and are swallowed, so a transient database hiccup
never aborts a run. Flushes drain one at a time, so two overlapping writes can
never land out of order.
How to keep a provider from rejecting your whole tool list
Strict validators reject the entire request — every tool — on the first schema violation. Run each tool's JSON Schema through the sanitizer before it reaches the model.
import { sanitizeToolSchema } from "@juno-ai/bind/tools";
const wireTools = tools.map((tool) => ({
type: "function",
function: {
name: tool.name,
description: tool.description,
parameters: sanitizeToolSchema(tool.inputSchema),
},
}));Every transform is correctness-preserving and was bisected against live
inference. It fixes, among others: a type array where a provider requires a
scalar; enum on a non-string type; required entries with no matching
property; a boolean additionalProperties: false on a nested object; and a
parameter literally named properties. Run it on third-party (e.g. MCP) tool
schemas too — those are where the violations usually come from.
One transform is worth knowing about even if no provider forced it. A nullable
property — {type:["string","null"], minLength:1} — is rewritten to
{anyOf:[{type:"string",minLength:1},{type:"null"}]} rather than reduced to its
non-null type with a note on the description. Reducing it is what a strict
provider needs, but it leaves the model unable to say "none" in the part of the
declaration it treats as binding, so it invents a value that satisfies the type:
"/", " ", "null", "undefined". If you author nullable parameters, you do
not need to hand-write the union — the sanitizer produces it.
How to repair a transcript before sending it
import { validateAndHealMessages } from "@juno-ai/bind/transcript";
const { messages, issues } = validateAndHealMessages(transcript);
if (issues.length > 0) log.warn("healed transcript", { issues });Detects and repairs orphan tool results, dangling unanswered tool calls, empty assistant messages mid-conversation, and a trailing assistant turn — which is prefill on one provider's native API and a hard rejection through another.
How to add progressive tool disclosure
Model tool-selection accuracy degrades past a few dozen tools, and every tool's schema is resent on every turn. Load a small core set, announce the rest as a catalog, and activate on demand.
import {
createToolRegistry,
initialActivePlugins,
partitionPluginCatalog,
} from "@juno-ai/bind/plugins";
const registry = createToolRegistry<MyPlugin>({
corePlugins: ["messaging", "memory"],
aliases: { "old-name": "new-name" },
onRegister: (plugin) => indexPluginSkills(plugin),
});
registry.register(myPlugin);
const active = new Set(initialActivePlugins(registry.corePlugins()));
const { active: shown, loadable } = partitionPluginCatalog(active, registry.summaries());aliases are permanent: persisted activation state stores names, so an alias is
how a rename avoids silently stripping capabilities from live sessions without a
data migration.
partitionPluginCatalog returns data, not prose — catalog wording is your
system prompt's business, and re-rendering it byte-identically for unchanged
inputs is what preserves a provider's prompt-cache prefix.
How to give an agent loadable skills
A skill is a markdown procedure or reference module the model can pull into its own instructions: a catalog line it always sees, a body injected into the system prompt once loaded, and resources it may read after that. It is the same progressive disclosure as tool activation, applied to knowledge — and it exists because the accumulated know-how of a real workspace does not fit in a context window, while a catalog line costs about fifty tokens.
Register what your own source ships:
import { createSkillRegistry, partitionSkillCatalog } from "@juno-ai/bind/skills";
const skills = createSkillRegistry({ onWarn: (message, fields) => log.warn(message, fields) });
skills.registerPlatform({
name: "triaging-inbound-work",
description: "How this team triages inbound requests.",
whenToUse: "When asked to sort, rank, or route a queue of incoming work.",
render: () => TRIAGE_BODY,
});
// A skill contributed by a plugin is offered only to an agent that can load
// that plugin — a recipe for tools it cannot call is noise.
skills.registerPlugin("documents", DRAFTING_SKILL);Render the catalog from a partition, in your own words:
const active = new Set<string>(session.activeSkills);
const available = skills.summaries({ availablePlugins: agent.plugins });
const { active: loaded, loadable, truncated } = partitionSkillCatalog(active, available);partitionSkillCatalog returns data, not prose, exactly as
partitionPluginCatalog does. It also enforces a token budget by marking the
overflow name_only rather than dropping it — a name is still enough for the
model to call load_skill and read the real description, whereas a skill it
cannot see is one it can never ask for. Pass your own cost if your line format
differs from - name: description — whenToUse; a budget is only as honest as
its measurement.
Then wire activation into the loop, and bound how much can be loaded:
import {
admitSkillLoad,
createSkillActivation,
estimateSkillBodyTokens,
resolveActiveSkillInstructions,
} from "@juno-ai/bind/skills";
const loadedSkillShas: Record<string, string> = {};
const resolveActiveInstructions = (activeRefs: string[]) =>
resolveActiveSkillInstructions({ activeRefs, available, registry: skills });
const activation = createSkillActivation({
availableSkills: available,
activeSkills: active, // yours: seeded before the first turn, persisted after the last
loadedSkillShas, // yours: hoist it so a failed run still records what it read
store: { resolveActiveInstructions },
applyInstructions: (instructions) => renderSystemPrompt({ instructions }),
});
await runToolLoop({ ...params, activateSkills: (refs) => activation.activateSkills(refs) });Two details are worth knowing before you wire your own load_skill tool. The
resolver renders in a total order (origin, then name) rather than the order
skills were loaded, because these bodies sit high in the system prompt and a
resumed session's persisted order would otherwise byte-shift the cacheable
prefix. And admitSkillLoad is what keeps a looping model from loading its way
into a context-limit error — you measure, it judges:
// Cheapest first. The count is a `Set.size`; the token bound needs the
// resolver, which for a host-stored skill is a query plus the wrapping of every
// active body. A model that has hit the cap keeps calling `load_skill`, so
// folding these into one pass pays that cost on every call purely to refuse.
const byCount = admitSkillLoad({ activeCount: active.size });
if (!byCount.admitted) return { success: false, kind: "validation", error: byCount.reason };
const { instructions } = await resolveActiveInstructions([...active, ref]);
const byTokens = admitSkillLoad({ projectedBodyTokens: estimateSkillBodyTokens(instructions) });
if (!byTokens.admitted) return { success: false, kind: "validation", error: byTokens.reason };Omitting a measurement omits its bound, which is what makes the two-pass shape
expressible — and a measurement that arrives broken (NaN, negative) refuses
rather than admits, because a nonsense count is not evidence of room.
Skills your users author live in your database, not the registry. Hand the
resolver an externalSource and it routes any ref that is not platform:<name>
to you — batched, so one activation stays one query:
resolveActiveSkillInstructions({
activeRefs,
available,
registry: skills,
externalSource: async (refs, pinnedShas) => loadWorkspaceSkills(workspaceId, refs, pinnedShas),
});pinnedShas is how a replay stays honest. Each resolution records the
contentSha it actually rendered; feed a completed run's map back in and every
skill resolves to the body that run saw, so an eval is not silently grading
against instructions that were edited afterwards.
To read or write the interchange format, pass your own YAML implementation — the package takes peer dependencies only:
import { parseSkillMarkdown } from "@juno-ai/bind/skills";
import yaml from "js-yaml";
const parsed = parseSkillMarkdown(raw, { parse: yaml.load, stringify: (v) => yaml.dump(v, { lineWidth: -1 }) });Import is deliberately lenient — it repairs the unquoted-colon frontmatter
mistake and warns — and fails only on frontmatter that is not YAML and on a
missing description, the one field with no sensible default.
How to restore a persisted activation set
import { rehydrateActivation } from "@juno-ai/bind/plugins";
const { active, dropped } = await rehydrateActivation(persistedNames, {
canonicalizeName: (name) => registry.canonicalizeName(name),
resolve: async (name) => {
if (isGatedOffThisRun(name)) return "unavailable";
if (!isDynamic(name)) return registry.get(name) ? true : "unknown";
return (await connect(name)) ? true : "unreachable";
},
});
for (const drop of dropped) log.warn("activation dropped", drop);The walk canonicalizes, de-duplicates (two legacy names collapsing to one plugin
resolve once, so a reconnect is not paid twice), and drops rather than
throws. resolve is async precisely so a reconnect can happen inside it.
How to scope the circuit breaker per tenant
Pass a non-secret credentialScope. Without it, one tenant's revoked
bring-your-own key opens the circuit for every tenant sharing the same
credentialSource.
breaker.recordFailure({
providerId,
invocationModel,
credentialSource: "tenant",
credentialScope: tenantTag, // opaque, non-secret — it lands in state keys
});If a half-open probe ends without a recordable outcome (an abort, a propagated
client error), call releaseProbe so the slot cannot stick.
Reference
Information-oriented. The exported types are the specification — read them in your editor. This section covers what the types cannot say: which entry point to reach for, and the contracts that hold between calls.
Every export is re-exported from the package root **except @juno-ai/bind/testing
