@adia-ai/agent
v0.8.50
Published
Composable chat-agent harness — assemble an agent from four declared parts (prompt layers, tools, workflows, resources) on top of @adia-ai/llm. Owns the tool-call loop, the session model, and the one shared event reducer. Works in browser and Node.
Readme
@adia-ai/agent
Composable chat-agent harness. Assemble an agent from four declared parts —
prompt layers, tools, workflows, resources — on top of
@adia-ai/llm. The package owns the tool-call loop, the
serializable Session, and the one shared event reducer; it renders nothing
(pair it with web-modules/chat or any UI).
The contracts are validated by a real consumer: the A2UI generation pipeline
runs on them (gh#664)
— its engine escalation ladder is a workflow
(packages/gen-ui/a2ui/compose/strategies/cascade.js), its corpus reads are
tool-mode resources, and its refiner is a defined tool. What that migration
could not express became this package's hardening pass
(gh#665): workflow
verdicts, exit gates, same-step retry, a services channel, guardrails,
tracing, session memory, and partial tool-input deltas.
Deferred, with reason: @genui/adia-producer's bridge keeps its own loop.
The retry seam covers per-tier round budgets, but the bridge also owns a
turn-wide maxProviderCallsPerTurn budget spanning steps, a genui TurnTrace,
and OrchestrationExhaustedError semantics that come from @genui/producer
— none of which this package models, and modelling them here would import
another package's orchestration vocabulary into a general harness.
Install
npm install @adia-ai/agentUsage
import {
createAgent, promptLayer, defineTool, defineWorkflow,
defineResource, reduce,
} from '@adia-ai/agent';
const agent = createAgent({
llm: { model: 'claude-sonnet-4-6', proxyUrl: '/api/chat' },
prompt: [
promptLayer('identity', 'You are the AdiaUI copilot.', { cache: true }),
promptLayer('context', () => new Date().toISOString()), // per-turn, never cached
],
tools: [
defineTool({
name: 'search_patients',
description: 'Find a patient by name or MRN',
inputSchema: { type: 'object', properties: { q: { type: 'string' } }, required: ['q'] },
execute: async ({ q }) => lookup(q), // or endpoint: { url, method }
}),
],
workflows: [
defineWorkflow('generate', [
{ name: 'zettel', run: zettel, until: r => r.strategy === 'composition-match' },
{ name: 'free-form', run: freeForm, until: async r => r.ok && await passesGate(r) },
{ name: 'monolithic', run: monolithic }, // terminal — always accepted
]),
],
resources: [
defineResource({ name: 'visit-summary', mode: 'attach', get: fetchSummary }), // app-controlled
defineResource({ // model-controlled
name: 'care-plan', mode: 'tool',
inputSchema: { type: 'object', properties: { section: { type: 'string' } } },
get: ({ section }) => readPlan(section), // non-string returns are JSON-serialized
}),
],
maxToolRounds: 8,
});
let session = agent.createSession(); // plain JSON-safe object — you own persistence
for await (const event of agent.send(session, 'weather in Oslo?')) {
session = reduce(session, event); // the one reducer — the single session writer
render(event); // text | thinking | tool_use | tool_result | done | error
}
await agent.run(session, 'generate', { intent });The four parts
| Part | Declared with | What the harness does |
|---|---|---|
| Prompt | promptLayer(name, text, {cache}) | Static layers first, cache_control on the last cached one (Anthropic block array; joined string elsewhere) |
| Tools | defineTool({name, inputSchema, execute\|endpoint}) | JSON-schema check at the call boundary, then the execute-and-feed-back loop (bounded by maxToolRounds) |
| Workflows | defineWorkflow(name, steps, {exitGate}) | Sequential cascade — until accepts, absent until is terminal, when skips, retry re-runs the same step |
| Resources | defineResource({name, mode, inputSchema, get}) | attach → dynamic prompt layer each turn; tool → a read-only read_<name> tool taking inputSchema's arguments |
The attach envelope contains what it wraps. An attach-mode resource
inlines somebody else's bytes into the system prompt, so a body containing
</resource> would otherwise close its own envelope and everything after it
would read as prompt — a file, a database row, or an MCP server could write
instructions into the system prompt that way. attachLayers escapes the two
structural sequences (<resource and </resource) in the body and the
attribute specials in name / description. Only those: attached bodies here
are catalog JSON, component markup and docs, and mangling every < in them to
close one hole would degrade the data the model reads on every turn for a
containment property those two sequences already give. Everything else arrives
byte-for-byte. This applies to every attach resource, hand-declared or MCP.
A resource's get receives (input, ctx) — ctx.signal is the turn's
cancellation. Existing one-argument gets are unaffected.
Three cross-cutting layers ride on top: guardrails (veto/rewrite), tracing (structured records of what the harness did), and session memory (a JSON-safe slot on the Session). Tools and resources may also arrive from an MCP server instead of being hand-declared — see MCP-client mode.
Workflows in detail
until and when may both be async, and until also receives the context
(until(result, ctx)) — the acceptance gate for a generated artifact
usually has to read something. The result carries the whole run:
const { result, step, accepted, attempts } = await agent.run(session, 'generate', { intent });
// attempts: [{ step:'zettel', accepted:false, reason:'no-emission', round:1, result:… },
// { step:'free-form', accepted:true, round:1, result:… }]attempts lists every step round that RAN (a when-skipped step doesn't
appear), in order, with its verdict — that list is the escalation trace a UI
renders. accepted is false only when the last step also rejected; give the
ladder a terminal step (no until) if you want a guaranteed answer.
Verdicts. until may return a verdict object —
{ accepted, reason, detail, data } — or any other value, read for its
truthiness, which is what a pre-verdict predicate (r => r.messages.length)
already did. Only an object with a boolean accepted is treated as a verdict.
The reason lands on the attempt, so a consumer renders why the ladder climbed
instead of recomputing it:
until: (r) => r.messages.length
? { accepted: true }
: { accepted: false, reason: 'no-emission', detail: 'engine emitted nothing' },Exit gate. The check every step's result must pass is declared once on
the workflow instead of restated in every until:
defineWorkflow('generate', steps, {
exitGate: async (result, ctx) => await validatesAgainstCatalog(result, ctx.input),
});It runs after a step's own until accepted, and a rejection falls the ladder
through exactly like an until rejection (the attempt carries
gateRejected: true and reason: 'gate-rejected'). A terminal step — one
with no until — is not exit-gated: the floor of a ladder is the answer of
last resort. Give the last step until: () => true if you want it gated too.
Same-step retry. A step may retry against its own rejection before falling through:
{ name: 'repair', retry: { maxRounds: 3 },
run: (ctx) => generate(ctx.input, ctx.feedback), // ctx.round is 1-based
until: (r) => r.valid ? { accepted: true } : { accepted: false, reason: 'invalid', data: r.errors } }ctx.feedback is the previous round's verdict; each round is its own entry
in attempts. maxRounds counts total runs (2 = one retry). A workflow
exit-gate rejection consumes a round exactly like an until rejection,
and reaches the next round as ctx.feedback carrying gateRejected: true —
so a repair round can tell the gate's objection from the step's own.
Services. ctx.input is what the run is ABOUT; ctx.services is what it
may USE. Declare them on the agent, per run, or both (per-run wins):
createAgent({ …, services: { llmAdapter, store } });
await agent.run(session, 'generate', { intent }, { services: { llmAdapter: turnAdapter } });Services used to ride input, and still may — nothing breaks — but a new
consumer should keep input the subject and put dependencies here.
Events
agent.send() yields the AgentEvent union; reduce(session, event) folds it.
Every failure inside a tool call becomes an isError tool_result the model
can react to — the loop throws for nothing tool-shaped.
{ type:'message', role:'user', text }
{ type:'text', text, snapshot } · { type:'thinking', text }
{ type:'tool_input_delta', id, name, partial, snapshot }
{ type:'tool_use', id, name, input } · { type:'tool_result', id, name, output, isError? }
{ type:'step', workflow, step, data? }
{ type:'progress', stage } · { type:'surface', surfaceId, line }
{ type:'guardrail', name, target, action, reason?, toolName?, toolUseId? }
{ type:'trace', record } · { type:'memory', patch }
{ type:'done', text, usage, stopReason } · { type:'error', error }stopReason passes through raw from the provider; the loop adds exactly two
synthetic values — max_tool_rounds when the bound is hit, and
guardrail_denied when an output guardrail vetoed the turn.
thinking / step / error / progress / surface / tool_input_delta /
guardrail / trace are render-only — reduce() folds them into no session
change. memory is the one new event that DOES change the session.
Partial tool input
While a provider streams a tool call's arguments, each fragment surfaces as
tool_input_delta (Anthropic input_json_delta, OpenAI partial argument
strings; Gemini streams complete calls and emits none). snapshot is the raw
JSON so far — incomplete and unparseable until the matching tool_use
arrives. Render progress from it; execute only on tool_use.
Progress events (CHAT-HARNESS law 3)
progress carries a CLOSED, code-owned stage: one of PROGRESS_STAGES
('sent' | 'started' | 'reasoning' | 'content' | 'validating' | 'retry' |
'tool' | 'done'), exported alongside the isProgressStage() guard so any
reader — a deserialized wire message, a stored transcript — can drop an
out-of-vocabulary value instead of rendering it. Never render stage as
label text directly; a UI's label table is code-owned and keyed on these
strings, never on model output (the honesty guard).
The loop today emits only the stages it genuinely reaches — sent before
every provider call, retry before a call that repeats a round after tool
feedback, tool before each tool executes, done right before the
terminal done event. started / reasoning / content / validating
are declared in the vocabulary for future producers (the L1 loop doesn't
independently observe those signals today, so it doesn't invent an
emission point for them).
Surface events
{ type:'surface', surfaceId, line } carries one generative-UI wire line
for an envelope-keyed surface. This package only carries the event kind on
the union — turning it into a rendered surface is L3's job
(web-modules/chat, WCH-4), never this package's.
Framed client-message turns (CHAT-HARNESS law 6)
A surface action, a function/tool result, or a validation rejection re-enters the agent as a turn — never a raw data blob — through one framing function:
import { frameClientMessage, shouldRunTurn } from '@adia-ai/agent';
const msg = { kind: 'surfaceAction', surfaceId: 'sf_1', action: 'submit', context: { formId: 'f1' } };
if (shouldRunTurn(msg)) {
for await (const event of agent.sendClientMessage(session, msg)) { /* … */ }
}ClientMessage arms and their framed wording:
| Kind | Framed turn |
|---|---|
| surfaceAction | The user triggered the ${action} action on surface ${surfaceId} with context ${JSON(context)}. |
| functionResult | The function ${call} returned: ${JSON(value)}. |
| validationRejection | The previous surface was rejected (${code}): ${message}. Emit a corrected surface. |
| dataModelUpdate, ack | silent-apply — shouldRunTurn returns false; frameClientMessage throws if called on one directly |
agent.sendClientMessage(session, msg, opts?) is the wired entry: it gates
with shouldRunTurn, frames with frameClientMessage, then runs the same
loop as agent.send(). A silent-apply message yields nothing and never
touches the provider — the caller already applied it directly (e.g. to a
surface's data-model store).
MCP-client mode
Point the agent at an MCP server and its
tools and resources join the declared ones. Nothing downstream knows the
difference: the calls go through the guardrail chain, the tracer records the
executions, checkInput runs at the boundary, a failure comes back as an
isError tool_result. MCP is a source of tools, not a second kind of tool.
import { createAgent, mcpServer } from '@adia-ai/agent';
const agent = createAgent({
llm: { model: 'claude-sonnet-4-6', proxyUrl: '/api/chat' },
integrations: [
mcpServer({
url: 'https://mcp.example.com/mcp',
headers: { authorization: `Bearer ${token}` }, // v1 auth: headers only
prefix: 'wiki_', // namespace its tool names
tools: ['search', 'fetch'], // allowlist; absent = everything it lists
maxTools: 24, // loud cap, default MAX_INTEGRATIONS (16)
attach: ['style-guide'], // resources to inline every turn
timeoutMs: 60_000, // per request; 0 disables (default 60s)
}),
],
});
const report = await agent.connect(); // optional — otherwise the first turn does it
// [{ url, serverInfo, protocolVersion, tools: [...], resources: [{name, uri, mode}] }]
await agent.close(); // ends the MCP session(s)Connection is lazy and memoized. createAgent is synchronous and a
constructor that reaches the network is a constructor that can fail, so the
handshake happens on the first turn — or eagerly on agent.connect(), which
returns what each server offered. agent.tools lists the declared set until
the sources connect, and everything afterwards. A failed connect is not
cached as a verdict: the next turn tries again.
Failure is loud where it must be and model-visible where it can be.
| When | What happens |
|---|---|
| Server unreachable, non-2xx, unspeakable protocol version, tools/list fails | McpConnectionError thrown out of connect() / send(). An infrastructure fault the model cannot work around — the same class as a missing @adia-ai/llm, never a silent empty tool list |
| Two sources (or a source and a declared tool) contribute the same tool name | McpConnectionError at connect — set a prefix |
| More tools listed than maxTools | McpConnectionError naming the count. resolveIntegrations' cap silently drops the overflow; this one refuses to serve two thirds of a server without saying so |
| A tools/call fails, times out, is aborted, or the server goes away mid-session | isError tool_result (McpCallError's message). The model sees it and the loop continues |
| The server answers isError: true | Same — the error text reaches the model as an error, never as prose that reads like an answer |
| An attach-mode resource's resources/read fails mid-session | McpCallError thrown out of send() during prompt assembly. There is no turn to degrade into: the prompt the model was going to see is the thing that could not be built |
Nothing waits forever. Every round trip carries timeoutMs (default
DEFAULT_MCP_TIMEOUT_MS, 60 s), covering both the fetch and the wait for the
answer to arrive on an SSE body — a server that accepts a POST and then says
nothing is a real failure mode and is otherwise indistinguishable from a hang.
The default has to clear the slowest legitimate answer, and for MCP that is a
tool that thinks (the in-repo a2ui server runs an LLM inside tools/call), so
it is generous rather than handshake-sized; raise it per source for a slower
server, or set 0 if you own the clock through signal. A timeout during
connect throws; a timeout during a call reaches the model like any other call
failure.
Abort is plumbed end to end. agent.send(session, text, { signal }) puts
that signal on the turn's ctx, and an MCP tools/call or resources/read
carries it to the wire — a user who cancels mid-answer cancels the server call
instead of leaving it running and ignored. Aborting the turn that happens to
be doing the connect cancels the handshake too; the signal does not stay
attached to the resulting connection, so a later turn is unaffected.
Failures leave nothing open. If a source fails after its handshake
succeeded, its session is closed before the error propagates; if the second of
two sources fails, the first one's session is closed too. close() during an
in-flight connect wins — the late arrival closes what it opened and rejects
with "the agent was closed while connecting" rather than quietly reinstalling
itself. close() ends the sessions but does not spend the agent: a later
send() reconnects.
Schemas pass through verbatim. A server's JSON Schema reaches the model
unmodified, including keywords checkInput cannot enforce. The server is the
authoritative validator (its rejection returns as an error result), so a
faithful copy of a contract someone else owns beats a locally-enforceable
subset that describes the tool less accurately. The manifest states this with
the new IntegrationManifest.validation: 'remote' rather than leaving it
implied; 'local' (the default, and every hand-declared integration) keeps
the strict registration allowlist, because there the only validator is us.
Transport is streamable-HTTP, hand-rolled, no new dependency.
@modelcontextprotocol/sdk brings a server framework, stdio/websocket
transports, an auth stack and zod for a client subset that is six JSON-RPC
methods over one POST endpoint; this package stays dependency-free apart from
@adia-ai/llm. The SDK still belongs in packages/gen-ui/a2ui/mcp, which serves.
stdio is not supported — it needs a child process, so it is Node-only by
construction and no in-repo consumer wants it; a mcpServerStdio() Node entry
point is the shape to add if one does.
In a browser, the server must be reachable by fetch — meaning it sends
CORS headers for your origin, or you put it behind the same proxy you already
route @adia-ai/llm through (proxyUrl) and point url at that path. This
package deliberately ships no proxy: a server-side pass-through is the host
app's, and forwarding mcp-session-id and mcp-protocol-version both ways is
all it has to do. fetchImpl is the injection seam (tests use it for the
in-process server; a host can use it to route through its own client). The
in-repo packages/gen-ui/a2ui/mcp server sends no CORS headers today, so reaching
it from a page means proxying it; from Node it works directly.
MCP resources, and the gh#671 ruling
A server's resources map onto the two existing modes and no new one:
tool(default) — the harness cannot know which of a remote server's resources belong in every prompt, and attaching all of them grows the system prompt without bound. So each becomes aread_<name>tool.attach— the APP promotes specific resources by name or URI (attach: ['style-guide']), and those are fetched and inlined every turn.
That is exactly MCP's own application-controlled vs. model-controlled distinction, expressed with the modes the harness already has.
gh#671 asked whether
engine-internal retrieval (zettel and free-form reading corpus content on
their own schedule inside a workflow step's run) needs a third mode —
'internal'. Ruled no, and W3 did not need one either. A resource mode
is a declaration of who invokes it and when, and both existing modes name a
party the harness mediates: attach = the harness fetches, tool = the model
asks. 'internal' would name "engine code, whenever it likes", which is not
a mediation — it is a function call. Declaring it would put into the resource
contract a thing the model can never call and the harness never fetches,
buying nothing but a second way to spell await. The two things the issue
actually wanted are already contracts: the dependency is declared through
services (W2), and the invocation is visible through ctx.trace in
the workflow phase. Server-pushed MCP resources do not need it either —
every read is either app-scheduled or model-asked.
Everything a server returns is DATA. A tool description, a resource body,
an error string — a description that reads like an instruction is still a
description. It lands in the tool spec or inside a <resource> envelope and
decides nothing about what the harness does. Guardrails are how you constrain
what a remote tool may actually do.
Guardrails
The deterministic layer between the model and the world — code that runs
outside the model's context and cannot be talked past. Declared once at
createAgent, two attach points, both optional:
import { defineGuardrail } from '@adia-ai/agent';
createAgent({
llm: { … },
guardrails: [
defineGuardrail({
name: 'scope-to-tenant',
// Before the call executes: allow (or return nothing), rewrite, deny.
toolCall: (call, ctx) => ({ action: 'rewrite', input: { ...call.input, tenantId } }),
}),
defineGuardrail({
name: 'redact',
// On the model's completed text for the round.
output: (text) => ({ action: 'rewrite', text: text.replace(SSN, '[redacted]') }),
}),
],
});Guardrails run in declaration order and chain: a rewrite feeds the next one,
the first deny short-circuits.
- Tool-call deny → the model gets an
isErrortool_result naming the guardrail and its reason, and the loop continues. Nothing executes. - Output deny → the turn ends with no assistant text and
stopReason: 'guardrail_denied'; the reason rides theguardrailevent. - Output rewrite → the rewritten text is what
donecarries and what the transcript keeps. A final{ text: '', snapshot: <rewritten> }event follows, and a denial sends{ text: '', snapshot: '' }. A UI that foldstextevents by APPENDING the delta must treat an empty delta as a replace fromsnapshot, or it will keep showing the original while the transcript says otherwise —web-modules/chat'swireAgentEventsdoes exactly this viachatShell.setStreamedText().
Two scope limits worth knowing before you rely on the output chain:
- Output guardrails see the text of the round that ENDS the turn. Text
from a round that also called tools is intermediate reasoning the model
supersedes, and denying it has no coherent answer for the tool calls
emitted beside it — so it passes through unguarded and reaches the
transcript. Put the check on
toolCallif what matters is what the model is about to DO. - A guardrail may
ctx.remember(patch)— it receives the same context a tool does. The write is drained as amemoryevent beforedone, never dropped.
onToolCall is now exactly one guardrail of the toolCall kind: it still
works, still denies with its original wording, and runs ahead of the declared
chain.
A guardrail decides from the call and the harness's own context. Text produced by a model, a tool, or a resource is DATA — a guardrail that lets such text talk it into allowing something is not a guardrail.
Tracing
Off by default. trace: true uses the console sink; a function IS the sink.
Every record is also a trace event on the stream:
const agent = createAgent({ llm: { … }, trace: (record) => myLogger.debug(record) });
// { phase, name, at, sessionId?, data? }phase is the closed vocabulary (TRACE_PHASES, guarded by
isTracePhase()): prompt (assembly: layer count, cached blocks, chars),
request / response (per provider call: round, model, tool count, stop
reason, ms), tool (per execution: name, id, ms, isError), workflow
(step:start / step:verdict with the verdict reason), guardrail (each
non-allow decision), usage (the turn's token totals), mcp (connect /
connect:failed per source — a tool a server contributed then executes
under tool like any other). A sink that throws
warns and the turn continues. There is no exporter protocol and no
dependency — an OpenTelemetry bridge is a caller-side function.
Session memory
Per-session, JSON-safe, and written ONLY through reduce() — the same
single-writer rule the messages obey:
import { remember } from '@adia-ai/agent';
session = reduce(session, remember({ patientId: 'p_7', tone: 'brief' }));
session.memory; // { patientId: 'p_7', tone: 'brief' }
session = reduce(session, remember({ tone: null })); // null deletes the keyA tool writes by ASKING: ctx.remember(patch) queues the patch, and the loop
yields it as a memory event right after that tool's result — and drains
anything still queued before done, so a final-round tool's write (or a
guardrail's, which shares the context) is never dropped. The asker never
touches state.
A patch that would not survive JSON.stringify → JSON.parse unchanged (a
Date, a function, undefined, a class instance) throws at the write, naming
the path. Memory rides the Session through toJSON/fromJSON; where that
JSON is stored stays the caller's — cross-session persistence is a seam
this package deliberately does not implement.
Byte-identity baseline (CHAT-HARNESS law 2)
Every axis on AgentConfig is optional, and absence must produce a
byte-identical request to before the axis existed. baseline/prompt-
equivalence.baseline.json pins the exact ChatOpts a minimal config (one
static prompt layer, nothing else) produces; baseline.test.js re-derives
it every run and diffs byte-for-byte, including asserting no optional-axis
key (tools, cache, signal, …) appears at all — not even as an empty
array.
Changing a default that legitimately changes this output breaks the test until the baseline is deliberately regenerated:
npm run build -w @adia-ai/agent
node scripts/build/regen-agent-baseline.mjsNever hand-edit the JSON file — regenerate it, then diff-review the change like any other pinned contract.
Testing
scriptClient(turns) is a deterministic LLMClient: script [{text, toolUse}]
turns and drive the whole loop keylessly. It also records every ChatOpts it
was called with (client.calls) so tests can assert what reached the wire.
mcp-fixture.mjs's createTestMcpServer({ tools, resources, … }) is the
same idea for MCP: an in-process server behind a fetch-shaped function
(fetchImpl), speaking real JSON-RPC with session headers, cursor
pagination, isError results, and either a JSON or an SSE response body —
no network, no ports, no SDK.
