Skip to content

Run the agent loop on its own

The loop that streams a reply and runs its tools, as a package of its own — run it headless in Node or a worker, wrap the one AparteClient runs, own its history, or pause it on a call and resume later.

On this page
bash
npm install @aparte/engine
ts
import { runStreamAgent, type StreamChatEvent, type StreamChatRequest } from '@aparte/engine';

// A stand-in for the model: it calls the clock, then answers with what the clock said.
async function* reply(request: StreamChatRequest): AsyncIterable<StreamChatEvent> {
  const last = request.messages[request.messages.length - 1];
  if (last?.role === 'tool') yield { type: 'text', delta: `It is ${String(last.content)}.` };
  else yield { type: 'tool_use', id: 'c1', name: 'clock', input: {} };
  yield { type: 'done' };
}

await runStreamAgent({
  messageId: 'a1',
  baseRequest: { messages: [{ role: 'user', content: 'What time is it?' }], modelId: 'my-model' },
  transportCall: async (request) => reply(request),
  toolLookup: (name) => (name === 'clock' ? async () => ({ content: new Date().toISOString() }) : undefined),
  emitter: (event) => { if (event.type === 'text-delta') process.stdout.write(event.delta); },
  signal: new AbortController().signal,
});

runStreamAgent is the loop AparteClient runs: it calls the model, reads the stream, runs the tools the model calls, gives their results back and calls again, until the model answers or a cap is reached. It has no dependency and no DOM, so it runs in the browser, Node, a worker or an Electron process, and installs alone; it is ESM only, and @aparte/core depends on it. It resolves to the run’s usage, and throws when the stream reports an error or a tool fails other than by an abort. transportCall returns the model’s events — text, thinking, tool_use, error and done — or a plain string from a model that does not stream. Each turn calls it again with the conversation so far, the tool messages answering the last calls included, as reply reads above.

OptionWhat it is
messageIdthe id of the reply this run writes; the loop does not read it, a runner that forwards the run does
baseRequestthe first request: its messages and modelId, the tools, toolChoice
transportCall(request)calls the model with the request of each turn
toolLookup(name)the handler of a tool, (call, signal) => Promise<{ content, structuredContent? }>
toolConfigLookup(name)a tool’s maxTurns, needsApproval and external
approvalResolver(call, signal)answers a call that needs approval: { approved, payload?, instruction?, reason? }
emitter(event)receives every event of the run
signalstops the run
maxTurns, toolTimeoutMsthe turns of one run, ten by default, and a tool’s time, five minutes by default
onHistoryAppend(message)each message the loop adds to the history, for a host that keeps its own
deferralpause on a call rather than wait for it
idGen, providerIdthe id of the call the loop makes for a forced toolChoice, and your provider’s id, passed untouched to a runner that sends the run elsewhere

A tool’s needsApproval may be a function of the call, answering true, false, 'ask' or 'deny'. A gated call goes to approvalResolver, a denied one included, which is then refused without a wait being announced, its reason being what the model reads; without a resolver, a gated or denied call is aborted and the run ends. A tool marked external runs elsewhere: with deferral the run pauses for its result, and without it the call is aborted and the run ends. A toolChoice of { name, input } runs that tool’s handler before the model is called, and the run goes on with no tools; { name } alone makes the model call it on the first turn, and the later turns are back to 'auto'.

EventWhen
run-start, turn-startthe run begins; each streamed reply from the model begins
text-delta, thinking-deltathe reply’s text and reasoning arrive
text-flusha turn’s text is complete
tool-startthe model calls a tool: { toolCallId, name, input }
tool-awaiting-approval, tool-approved, tool-rejecteda call waits for a decision, and gets it; a rejection carries reason (the sentence the model reads), by and the person’s instruction
tool-resolved, tool-failed, tool-aborteda call’s result, its error, or its end without either; an abort carries a cause
turn-limit-exceededa cap was reached: { scope: 'global' | 'tool', limit }
run-abortedthe run was stopped; run-done follows
run-donethe run ends, with its usage
run-suspendedthe run ends paused, with its pending calls, in place of run-done

The fields the row speaks from: tool-rejected has by, 'person' | 'policy' | 'earlier-refusal' (the last is a call the loop did not run because another call of the same step was refused), and instruction, what the person wrote instead of choosing. tool-aborted has cause: 'stopped', 'timeout', 'no-handler' or 'no-resolver', and none for a call the run ended before reaching. A runner of your own may leave them out. An approvalResolver can return by beside a reason; without it a reason is a policy’s and an instruction a person’s.

toolRefusal(decision) writes the refusal for you, and a runner that answers an agent elsewhere should use it so the model reads the same sentence:

ts
import { toolRefusal } from '@aparte/engine';

toolRefusal({ instruction: 'use the staging bucket' });
// { reason: 'The user rejected this tool call and said: use the staging bucket',
//   by: 'person', instruction: 'use the staging bucket' }
toolRefusal({});                                  // reason: 'Tool execution was rejected by the user.'
toolRefusal({ reason: 'Read-only mode.' });       // by: 'policy', reason as given
toolRefusal({ by: 'policy' });                    // reason: 'Tool execution was refused by the approval policy.'

The default sentence follows by. A handler that rejects with its own AbortError, with no Stop and no timeout behind it, aborts the call with no cause.

@aparte/core re-exports it. reason is the one sentence the model reads, whoever refused; by and instruction are for the row, which tells the person in their own language.

A run that fails emits neither of the last two: runStreamAgent throws. StreamRunEvent types them all, and the engine reference lists every field.

ts
import { AparteClient } from '@aparte/core';
import { runStreamAgent } from '@aparte/engine';

new AparteClient({
  streamRunner: (opts) => runStreamAgent({
    ...opts,
    emitter: (event) => {
      if (event.type === 'tool-resolved') console.info('tool answered', event.toolCallId);
      opts.emitter(event);
    },
  }),
}).start();

AparteClient runs runStreamAgent by itself; streamRunner is the place to change that. Wrap it to watch or change a run, as above — a wrapped loop still calls the model from the page, so a provider has to be selected — or give the client another function with the same signature, an AparteStreamRunner: a loop of your own that sends the same events, which the chat draws as it draws its own. createAgentStreamRunner (server loop) and createAgUiRunner (AG-UI) are two.

ts
const log = new PromptLog();   // your append-only transcript

await runStreamAgent({
  // …messageId, emitter, signal, toolLookup
  baseRequest: { messages: [], modelId: 'my-model' },
  onHistoryAppend: (message) => log.append(message),   // the calls, each result, each reply
  transportCall: () => myCompletion(log.render()),     // your bytes, extended, never rebuilt
});

For a server with a prefix cache — llama.cpp slots, vLLM — keep the transcript yourself: transportCall may ignore request.messages, and onHistoryAppend reports each message the loop adds, in order, before the call that carries it. It never reports the messages you passed in baseRequest: write the person’s turn into your log yourself before the run. The assistant’s message is reported once, on its first call, and its content and toolCalls are completed later in the same turn: keep the reference and read it when you write, rather than a copy taken on arrival. Through the client, add it at the seam: streamRunner: (opts) => runStreamAgent({ ...opts, onHistoryAppend }). Writing tools, calls and results into a raw prompt stays yours.

ts
import { runStreamAgent, unansweredTail } from '@aparte/engine';

// First run: it pauses on the call that needs a person.
const history = [...run.baseRequest.messages];
await runStreamAgent({
  ...run,
  deferral: {},
  onHistoryAppend: (message) => history.push(message),
  emitter: (event) => {
    if (event.type === 'run-suspended') savePending(history, event.pending);   // [{ toolCallId, name, input, reason }]
  },
});

// Later, with the answer: the history ends on the paused call, which runs first.
await runStreamAgent({
  ...run,
  baseRequest: { ...run.baseRequest, messages: history },
  deferral: { outcomes: { [callId]: { kind: 'decision', approved: true } } },
});

unansweredTail(history);   // the calls a history leaves open

With deferral, a call that needs a person, or an external one, is not awaited: the run reads the reply to its end, leaves its calls pending, and ends on run-suspended, whose pending says why each one waits — 'approval', 'external', or 'queued' behind them. Keep the history as it grows, through onHistoryAppend: it ends on the assistant message that declares the paused calls, and the run that resumes needs it. A later run whose history ends on those calls, with their answers in deferral.outcomes, picks up where it stopped: a decision, a tool’s result, or a failure. A denied call is not paused: approvalResolver refuses it, as above. The server loop is built on this.

The loop passes on the usage the model reports, and decides nothing about a long conversation: Long conversations shows the gauge and the compaction that answer it.