Skip to content

The agent engine

@aparte/core runs a full agent loop when you drive a chat with AparteClient: it streams the model, splits the reply into segments (text, thinking, tool calls), runs the tool-calling loop, and reports usage. That loop is this package’s — core depends on it.

@aparte/engine is that loop as a headless, framework-agnostic package: zero runtime dependencies, no DOM, runs in the browser or Node. Its headline export, runStreamAgent, is the loop AparteClient runs — and the same function a backend runs server-side, so the two behave identically by construction rather than by parity.

It is deliberately just the loop core drives. Opt-in tools (ask-user, RAG, skills, code execution) belong in plugins/*; product behaviour (memory, intent orchestration) and the not-yet-wired text agent loop live elsewhere. None of that ships here.

Terminal window
npm install @aparte/engine

@aparte/core is not a dependency of the engine — it is the other way round, core depends on it — and runStreamAgent and the parsers need nothing from it, so you can install @aparte/engine alone. If you wire it into core’s client (below) you already have @aparte/core; otherwise npm install @aparte/core @aparte/engine. ESM-only (like the rest of @aparte/*); CJS consumers use await import().

AreaExportsStatus
Structured-stream looprunStreamAgent, StreamRunEventReady — the seam below

Everything is a plain function or class — no globals, no side effects (sideEffects: false), fully tree-shakeable, so you pull in only what you use.

The context gauge, and where compaction lives

Section titled “The context gauge, and where compaction lives”

<aparte-context> in core is a gauge of the model’s window: it reads the usage each turn reports and the window the current model declares (a provider’s /models fetch fills it in, or set the window attribute), turns warn and danger at 75 % and 90 % (the warn / danger attributes), fires aparte-context-threshold when the level changes — and with auto-compact dispatches aparte-compact for its chat on reaching danger, once, until the level drops. Before the first turn, or without a window, it shows nothing. variant="ring" draws it as a ring with the percentage, for a toolbar with no width to give.

What answers that command is not the engine: the loop reports usage and lets the caller decide, and compaction — the budget, the selector, the summariser — is @aparte/plugin-compaction. The budget and selector lived here until 0.16.0; they moved with their one consumer, same names, same signatures:

import { setupCompaction } from '@aparte/plugin-compaction';
setupCompaction(); // the gauge's auto-compact is answered; the recent turns stay verbatim
<aparte-composer-toolbar>
<aparte-context auto-compact style="flex: 1"></aparte-context>
</aparte-composer-toolbar>

The gauge, the selector and the model speak the same numbers: the window is the model’s, the budget is the plugin’s, and the reading is what the provider reported — nothing is estimated twice.

AparteClient runs runStreamAgent by itself — there is nothing to inject to get the engine’s loop. streamRunner is the seam for the two things that go beyond that: wrapping the loop’s options (the prefix-cache case below) or replacing the loop with one of your own that emits the same events, which core renders exactly the same way — same messages, same events, same DOM output:

import { AparteClient } from '@aparte/core';
import { runStreamAgent } from '@aparte/engine';
const client = new AparteClient({
// …your transport / config…
streamRunner: (opts) => runStreamAgent({ ...opts, maxTurns: 4 }), // the engine's loop, your options
});

Owning the history yourself (prefix-cache hosts)

Section titled “Owning the history yourself (prefix-cache hosts)”

By default the loop holds the message list and re-sends it each turn, enriched with the tool_call / tool_result turns it produced. That is what a stateless message API wants, and the opposite of what a prefix cache wants — llama.cpp slots, vLLM — where turn N+1 has to extend turn N byte for byte or the cache is thrown away.

Such a host owns its own transcript. Half of that already worked: transportCall receives the request the loop built and may ignore its messages entirely. The other half is onHistoryAppend — it reports each turn the loop appends, in order, before the call that would carry it, so you don’t reimplement the loop’s tool bookkeeping:

const log = new PromptLog(); // your append-only transcript
await runStreamAgent({
// …messageId, emitter, signal, toolLookup
baseRequest: { messages: [], modelId: 'my-model' }, // your transport may ignore both
onHistoryAppend: (turn) => log.append(turn), // tool_call · tool_result · phase reply
transportCall: () => myCompletion(log.render()), // your own bytes, extended not rebuilt
});

Through the streamRunner seam it needs no change in core — augment the options at injection:

new AparteClient({ streamRunner: (opts) => runStreamAgent({ ...opts, onHistoryAppend }) });

Serializing a tool inventory, its calls and their results into a raw prompt is still yours to write: the providers that do it target message-based APIs.

The loop’s behaviour isn’t “meant” to be stable — it’s recorded. Core’s stream-parity suite was born in this package as a parity test between core’s former inline loop and runStreamAgent through the real createStreamAdapter. Its scenarios in nine groups — the happy paths (plain text, thinking, human-in-the-loop approve and reject, a streamed tagged block, forced tool calls), and then the ones that matter more: the paths that STOP (a provider error, a tool with no handler, both turn ceilings, a tool that never resolves), walking away from a live stream mid-token, a withheld prefix never reaching content, events and tags neither loop used to handle, a non-streaming response, and three block-framing edge cases — were snapshotted while both loops ran and were equal, so the snapshots are the inline loop’s behaviour, pinned. The suite now lives in core, where it also holds AparteClient’s own wiring to a direct engine run; a change that alters a sequence fails there, with the diff.