Composition over inheritance: every piece sits behind an interface that you
satisfy with a plain object/function, then optionally wrap with a decorator
(the with* helpers) — never subclassed. The four interfaces you implement or
supply:
ModelClient — the LLM boundary (implement { stream }).
The streaming test double (MockModelClient) lives in ./mocks/mock-model;
it is a testing utility imported directly by tests, not part of this published
surface.
import { runAgent, SessionMemoryStore, defineTool } from "@open-agent-loops/core";const result = await runAgent({ model, // your ModelClient memory: new SessionMemoryStore(), sessionId: "demo", tools: [searchTool(backend)], prompt: "Find the TODOs in this repo.",});console.log(result.newMessages); // messages produced by this run
A FIFO queue of Messages for the loop's steering / follow-up seams — the unbounded specialization of BoundedBuffer (capacity: Infinity), so the overflow policy never engages and it behaves as a plain FIFO.
The outcome of a push, item-by-item, so a caller can react to backpressure (notify, retry, or slow its producer). The four arrays are disjoint and together account for every pushed item — except under { coalesce }, where a folded arrival is counted as accepted.
How many queued items a drain releases: "one-at-a-time" (the oldest single item) or "all" (every queued item). Mirrors pi's steeringMode / followUpMode.
What happens to an item pushed into a BoundedBuffer that is already at capacity. The four cases are genuinely distinct — they differ on whether the arrival is kept and whether the producer is asked to slow down:
Prepare working history for sending: drop both reasoning representations (reasoning and reasoning_details) from assistant turns that did NOT call tools.
Construct an AssistantMessage — pins the role discriminant and stamps timestamp with the construction time; you supply the rest. Everything but content is optional (reasoning, reasoning_details, finishReason, tool_calls, isError, and an overriding timestamp).
Construct a SystemMessage — pins the role discriminant and stamps timestamp with the construction time; you supply the rest (content, and a timestamp of your own to override the default).
Construct a ToolMessage — pins the role discriminant and stamps timestamp with the construction time; you supply the rest. tool_call_id stays required (the message is meaningless without the call it answers); toolName, isError, and an overriding timestamp are optional.
Construct a UserMessage — pins the role discriminant and stamps timestamp with the construction time; you supply the rest (content, and a timestamp of your own to override the default).
Merge the derived chat_template_kwargs into an OpenAI chat-completions request body, keyed by its own model field. The seam a proxy uses to inject thinking control without the client knowing the per-family dialect.
Resolve the chat_template_kwargs to send for a model's thinking state, or undefined to inject nothing (unknown / non-reasoning model, or an always-on model with no toggle).
The per-run configuration shared across every session the dispatcher drives — everything runAgent needs except the per-call sessionId, prompt, and signal, which the dispatcher supplies itself.
The default thread → sessionId mapping: one conversation per thread (falling back to the channel when a provider doesn't thread). Override via ChannelBridgeOptions.sessionIdFor for per-channel or per-user grain.
The per-round run configuration shared across every round — everything runAgent needs except the prompt (which runGoal re-prompts each round) and the signal (which runGoal forwards). The sessionId lives here and is reused for every round, so each round loads the prior rounds' history.
One turn of the progressive-disclosure timeline: what was disclosed to the model this turn, and what changed since the previous turn. Built by diffing consecutive RequestSnapshots.
The fully assembled request body captured off the wire (request side) — one per model turn, the exact JSON POSTed to the endpoint. Carries the whole conversation as sent: messages (system folded in, every assistant tool_calls block and tool result), the available tools, sampling params, and chat_template_kwargs. With TraceMeta.baseURL it's enough to reconstruct a reproducible curl for the call. Captured by the onRawRequest tap; the request-side twin of RawSSE.
A snapshot of what was disclosed to the model on one request (one turn) — captured by observe(), which sees every ModelRequest. Tracing these across a run is how you watch progressive disclosure: the tool surface, the system prompt, and the size of the context window as they change turn to turn.
Run-level configuration captured alongside the timeline — the context you need to reproduce or compare a run. Filled in from whatever taps are wired: sessionId from the agent's agent_start, system/tools from the model request (via observe), and model/params/system from a provider's onRequest tap (e.g. OpenAICompatibleModel).
One turn of the agent's trajectory: the model's action paired with the observations it produced. assistant is the action (text and/or tool calls); tools are the observations (each call's result).
Compact serialization of a TraceEntry — the on-disk / wire shape. The data payload is flattened up, and the two redundant fields are dropped: label (always equals data.type) and the absolute t (reconstruct it from the document's startedAt + this entry's dt). The in-memory TraceEntry keeps the richer shape for programmatic access; only JSON output is compacted.