Extracted from three servers that had each written it separately — kanban_server,
task_server and — after the copies drifted far enough that a fix in one was a bug
still live in another.
The endpoint-agnostic half of an OpenAI-compatible agent loop: tool-schema compatibility, on-demand tool loading, one-shot side tasks, run events, and a pooled client.
2026-11-14
ดาว
—
เครื่องมือ
—
อัปไทม์
—
min-agent
Install
npm install @cubicecho/agent-core openai
openai is a peer dependency (>=6) so the client this hands back is the same one your code
already imports — one SDK version in the tree, one instanceof that means what it says. ESM
only, Node >=22.
What is here
Module
What it does
schema-compat
Makes an MCP tool schema something a strict or grammar-constrained server will accept. sanitizeTools, relaxTools, isGrammarError, and sanitizeSchema and relaxSchema for a schema that is not a tool's.
tool-loading
On-demand tool discovery: a name-only catalogue plus a load_tools meta-tool, so a run pays for the schemas it asks for instead of all of them. Plus the proxied form of the same thing — PROXY_TOOLS, proxyLoadResult, proxiedCall — for a server whose prompt cache a growing tool array throws away.
stream
Reads one streamed turn back into a message: token callbacks, tool-call reassembly, fenced reasoning taken out of the answer, and the idle watchdog that turns a silent endpoint into EndpointSilent.
capabilities
What an endpoint turned out not to support — and, under it, what one model on that endpoint did not — plus the loop that answers either when it says so. capabilitiesFor, modelCapabilitiesFor, negotiate.
thinking
Tells a scratchpad fenced inside content from the answer: FenceSplitter for a stream, stripThinking for a whole reply, and the fence tables both read.
side-task
One-shot calls that support a run without being one — small prompt, short answer, no tools, never worth failing the run over. askJson holds the answer to a schema where the server can, and taskCall turns the settings an agent spec gives a task into the call that honours them.
hooks
The host's side of lifecycle hooks: gather before a request and notify after, the shared context budget, withContext to put what they add on the turn's question, untrusted to fence text nobody vouched for, and turnMessages to hand them a transcript. Running a hook is a runner the caller passes.
events
The in-memory bus a watcher reads while a run happens: emit, watch, history, fold, and runMetrics for what a run cost. A watcher's backlog is capped and reports its own gaps.
client
A pooled OpenAI client per endpoint, plus the context window: the served one where a local server says, the listed one otherwise, and their caches. And resolveApiKey, for a caller deciding which key an endpoint gets.
retry
What to do when a request is lost, refused or too big: isTransient, isModelLoading, backoffMs, ContextOverflow, EndpointSilent.
calibration
How many characters a token is worth on one model, learned from the prompt counts its endpoint reports: charsPerTokenFor, calibrate.
continuation
continueTurn: carries on an answer the token ceiling cut off, by prefilling it as a trailing assistant message.
config
The structural interfaces every function here asks for.
run-turn
runTurn: one turn with the retry loop around the negotiation around the stream. The whole loop, for a caller that wants it rather than its parts. Sizes the request against an opt-in contextLimit.
agent-loop
runAgentLoop: the loop above a turn — runTurn per step, the tools between, load_tools and preselection handled, until the model stops asking.
request-body
buildBody: the one place a streamed request's body is decided from a config and what the endpoint and the model have refused.
run-calls
The tools between two turns: a step's calls read, run and written into the transcript, with every call answered whatever happens. preview cuts a long argument or result for a watcher.
preselect
Choosing a run's tools before it starts: preselect asks a small model, preselectByKeywords scores the request's own words against the tool names without one, and preselectSystem, preselectInput and preselection are the prompt and the reading of the reply for a host that makes the call itself.
tool-calls
Reading what a model meant by a tool call it did not write cleanly: parseToolArguments repairs almost-JSON arguments and says when they were cut off, recoverToolCalls finds calls written into the reply as text. Plus the shapes a call is handed round in: ToolCallRequest, ToolCallResult, ToolCallOutcome.
compaction
Keeping a long run inside its window: pruneToolResults clears stale tool results, planCompaction and compactTranscript fold the oldest stretch into a summary.
ledger
What stretches of a transcript cost, read off the prompt counts the server reported rather than estimated: recordRequest, tokensBetween, estimateFrom, rebaseLedger.
snapshot
exportCapabilities and importCapabilities: the latched refusals as a JSON blob a consumer stores, so a restart need not learn them again.
spec
parseSpec, resolveAgentSpec and exportSpec: an agent as a versioned JSON document, layered into the flat config the loop takes. Imports nothing but types, and is published separately at @cubicecho/agent-core/spec.
reset
resetAll: drops every cache and latch in one call, so a teardown cannot forget one.
runtime
createRuntime: a second set of those caches, with the functions that use them as methods — for a host whose tenants must not share a client pool, a latch or an event bus. defaultRuntime is the one the top-level functions use.
tokens
How big a request is, estimated: estimateTokens — characters over four, deliberately low — and what is built on it for everything here that has to guess at a window: messageTokens, requestTokens, contextTokens.
errors
errorMessage: a caught unknown turned into something a run row can hold. And what runAgentLoop throws, with the run as it stood: AgentLoopError, ToolIterationLimit, AgentLoopOverflow, and failedRun to read it off any of them.
catalog
CatalogServer: the name-only shape tool-loading reads a connected server as.
What is not here is the work: orchestration, prompts, and whatever the run is about. That
is the caller's, and it is the part that actually differs between one server and the next.
llms.txt is the same surface as a flat index — every export with the first line of its own doc
comment. It is generated from src/index.ts by npm run llms, which the build runs, so it is
the exports rather than a second description of them; CI fails if the committed copy has drifted.
A turn
negotiate wrapping streamTurn is the whole of one turn against an endpoint: the request is
re-sent for as long as the answer is this server refusing something the request can do without,
and nothing is re-sent once the model has started answering.
import {
capabilitiesFor, emit, getClient, negotiate, relaxTools, sanitizeTools, streamTurn, timeoutMs,
} from "@cubicecho/agent-core";
const declared = sanitizeTools(tools);
const supports = capabilitiesFor(config.baseUrl, config.apiKey);
const turn = await negotiate(supports, (supports, produced) =>
streamTurn(
getClient(config),
{
model, messages, stream: true,
// Rebuilt per attempt: what the endpoint has refused is latched off by the line above.
...(supports.usageInStream ? { stream_options: { include_usage: true } } : {}),
tools: supports.strictSchemas ? declared : relaxTools(declared),
},
{ produced, signal, idleMs: timeoutMs(config), onOutput: (text) => emit(runId, { kind: "output", text }) },
),
);
The key is passed because it is part of what an endpoint is here, not only how it is paid
for: a router is free to send two keys to two different backends, and then what one of them
refused is not a fact about the other. Absent and empty read the same, so a local server with no
key is one entry however its caller spells it.
Turn is content, toolCalls, usage, finishReason and reasoning. The fourth is worth
reading: a turn cut off at the token ceiling comes back looking exactly like a finished one, with
truncated prose or — the case that bites — a tool call whose arguments stop mid-JSON, so the
caller meets a parse failure with nothing to attribute it to. finishReason is "length" there,
"" where the endpoint never said.
usage is prompt, completion, total and cached, always there and zero where the server
sent nothing. Everything else on it is there only when something measured it, and absent rather
than zero otherwise:
Field
From
uncached
the prompt less cached, only where a cache count was reported, so a cold cache and no report read differently
reasoningTokens
completion_tokens_details.reasoning_tokens; never estimated from the thinking stream
reasoning is the scratchpad onThinking was told, kept because two common families want it
back. gpt-oss and DeepSeek in thinking mode read the analysis behind a tool call off the assistant
message on the next request: store it as reasoning_content on that message while it ends in a
tool call, and drop it once the model has answered. Any other model is better off without it,
since it is context paid for on every turn. requestTokens counts it either way.
A server without a reasoning parser leaves the scratchpad in content, fenced, and then it is shown
as output, stored and sent back. streamTurn routes text inside a fence to onThinking and
reasoning instead, holding back the tail of a chunk that could be half a tag. DEFAULT_FENCES is
<think>, gpt-oss harmony's analysis channel served raw, and Kimi's ◁think▷, none of which a
model writes as an answer; ALL_FENCES adds <thinking> and <reasoning>, which it can be
quoting, and is what the side tasks use. Pass fences: [] to read content as all answer. A reply
cut off inside a fence has an empty content, not the deliberation promoted to one. A template that
opens <think> in the prompt leaves only the closing tag, so everything before it is moved to
reasoning when it arrives; startInReasoning: true says so up front, so onOutput is never told
it at all.
What the model refuses, rather than the server
strictSchemas and usageInStream are facts about a server. Three more arrive through the same
channel — an error string on a chat completion — and are facts about a model: a
reasoning_effort it does not take, a ceiling it spells max_completion_tokens, a temperature
that is not ours to pick. They cannot latch on the endpoint, because one API key reaches every
model a provider offers: the first turn on gpt-4o would stop gpt-5 ever being asked to reason
again, with the setting still reading high and nothing anywhere saying it had stopped.
So they hang off the endpoint under the name the endpoint knows the model by. Pass model and
the same loop answers both levels; leave it out and nothing changes.
A refusal of the value is not one of these, however alike the two read: a max_tokens larger
than the model's ceiling, a temperature out of range. Dropping the field answers those too — at
the model's own default, latched for the rest of the process, with the settings row still reading
what was typed and nothing saying it had stopped meaning it. They are passed to the caller
instead, where whoever typed the number can see it.
The effort is the exception, because there the model has said what it would take:
Unsupported value: 'reasoning_effort' does not support 'none' with this model. Supported values are: 'minimal', 'low', 'medium', and 'high'.negotiate latches the rung that was refused and
re-sends at the cheapest one above it, which is a request rather than a failure. Where the refusal
lists nothing it walks EFFORT_LADDER — none, minimal, low, medium, high — a rung per
refusal. Both latch per (endpoint, model) and ride in the snapshot, so a restart does not walk
the ladder again.
Only ever upward, and only from a value on the ladder. Answering a refused xhigh with high
would quietly reason less than whoever typed it asked for; stepping up from none only costs
tokens, and says so in a notice. When the ladder runs out, or the value is not on it, the refusal
goes to the caller as it did before.
effortFor(model, asked) is what a body builder calls to get the value to send — buildBody and
the side tasks both do — and it answers the empty string for a model that takes no effort at all,
for "off" and for an absent setting.
const turn = await negotiate(
supports,
(supports, produced, model) => {
// Each rebuilt per attempt from what this model has already refused.
const asked = effortFor(model, effort);
return streamTurn(
getClient(config),
{
model: name, messages, stream: true,
...(asked ? { reasoning_effort: asked } : {}),
...(model?.legacyTokenLimit === false
? { max_completion_tokens: limit }
: { max_tokens: limit }),
...(model?.chosenTemperature ? { temperature } : {}),
tools: supports.strictSchemas ? declared : relaxTools(declared),
},
{ produced, signal },
);
},
{ model: name },
);
The ceiling is the one of the three guarded on === false rather than on truthiness, and it is
the only one that has to be. The other two omit a field where the flag is absent, so a caller
that leaves model out sends a smaller request and nothing else; both branches of this one are a
field, so truthiness picks the newer spelling for a caller who was told nothing would change —
and the newer spelling is exactly the one an older model or a llama.cpp-shaped endpoint rejects.
modelCapabilitiesFor starts a model at legacyTokenLimit: true, and an absent model has to read
the same way it does.
One more model flag is never answered by negotiate: assistantPrefill, whether the model
carries on a trailing assistant message rather than answering afresh after it. Only continueTurn
sends one, so only it latches the flag, and it is persisted in a snapshot like the rest.
runTurn takes the same option and hands request the same second argument. Keying on
(endpoint, model) rather than the model name alone is the part worth keeping: gpt-4o at
OpenAI and gpt-4o behind a proxy need not be the same weights, and one that refused a reasoning
effort must not speak for the other.
send takes a callback rather than a body because the body has to be rebuilt from the latched
flags. produced is one box per attempt — streamTurn sets it on the first chunk that carries
text, reasoning or a piece of a tool call, and the re-send reads it — so a caller with its own
retry budget passes one in ({ produced }) and reads it afterwards to decide whether the failure
is worth another attempt. The content-free {"role":"assistant"} most servers open a stream with
does not set it: nothing has been shown to anybody yet, so an endpoint that primes the stream and
then wedges is retried like one that never answered at all.
idleMs is silence, not a deadline: the timer is rearmed on every chunk, so a model that is still
talking is never cut off however long it takes, and one that has stopped answering raises
EndpointSilent rather than hanging the run. timeoutMs(config) returns undefined for a
requestTimeoutSeconds of zero or absent, which waits forever — what a local model answering slowly
needs.
The first chunk gets its own allowance, firstChunkMs, because the first wait is prefill: tens of
seconds for a long prompt on a local GPU, minutes on a CPU, and longer again when the server is
loading the model on demand. It holds until a chunk carries something, so an empty
{"role":"assistant"} sent before the prompt is read does not start the idle clock.
firstTokenMs(config) reads firstTokenSeconds off the endpoint, and five times
requestTimeoutSeconds where that is absent. With a watchdog armed, the SDK's own timer is switched
off for the stream; it runs until the headers arrive, which is the end of prefill, and used to
abandon one at the idle number. requestTimeoutSeconds still bounds calls that do not stream, side
tasks and model listings, the same way.
Structured side tasks
askJson is ask for an answer with a shape. It sends the schema as response_format of type
json_schema, which llama.cpp compiles into a grammar and vLLM, LM Studio, Ollama and OpenAI each
hold the reply to, so a small model that wraps JSON in prose on its own cannot do so here. The
schema is normalised the way a tool's parameters are, and relaxed where the endpoint could not
build a grammar, because llama.cpp reads both with the same converter. It also rides on the system
prompt, and the reply goes through parseJson either way — which finds the JSON inside prose or a
fence and repairs what a local model gets nearly right: single quotes, True and None, bare keys,
a trailing comma.
const picked = await askJson<{ tools: string[] }>(config, small, system, request, PRESELECT_SCHEMA, {
name: "preselection",
onNotice,
}); // undefined when no JSON came back
A model that refuses the field latches structuredOutput off, per (endpoint, model) like the
other refusals here, and is asked in words from then on. A server that finds the schema invalid
is not latched: that error is the caller's to see. strict defaults to true, which OpenAI takes to
mean every property required and additionalProperties: false; a looser schema wants it off there.
preselect is its first user, answering { tools: [...] }, and preselection still takes the
bare array an older prompt produced.
Showing a side task an image
ask and askJson take the user turn as a string or as content parts, because an image reaches an
OpenAI-compatible server only as an image_url part beside the text. The parts are sent as given
and nothing here checks that the model can see: whether a text-only model rejects the image or
answers without it is up to the server, so pick a vision model.
const page: SideTaskInput = [
{ type: "text", text: "Transcribe this page." },
{ type: "image_url", image_url: { url: `data:image/png;base64,${png}` } },
];
const text = await ask(config, visionModel, "You are an OCR engine.", page);
Telling a finished reply from a cut-off one
ask returns the text alone, so a reply that ran into maxTokens reads like one that finished.
Where the reply is the product, askReply takes the same arguments and also returns the choice's
finish_reason as the server sent it, or undefined where it sent none. A model looping on a page
writes until the ceiling stops it, and a transcript saved from that is worse than none:
const { text, finishReason } = await askReply(config, visionModel, "You are an OCR engine.", page);
if (finishReason === FinishReason.Length) {
// cut off: retry with a higher ceiling, or keep the OCR text already there
}
Nothing is thrown for a cut-off reply, since whether one is still worth keeping is the caller's
call. askJson needs no such variant: a reply cut off before its JSON closed does not parse, so it
comes back undefined, as a reply with no JSON in it does.
Sizing a request before sending it
runTurn will refuse a request that cannot fit rather than spending a round trip finding out:
import { contextLimitFor, emit, runTurn } from "@cubicecho/agent-core";
const turn = await runTurn(client, supports, build, {
maxRetries: 3,
// Opt-in: the number is the caller's to find, because neither of these is network I/O a
// turn should be doing. The second argument is the operator's own number and it wins
// outright when set, so there is no need to check it yourself first.
contextLimit: await contextLimitFor({ ...settings, model }, settings.contextLength),
onNotice: (message) => emit(runId, { kind: "notice", text: message }),
});
contextLimitFor answers the operator's number when there is one. Otherwise it reads the
/v1/models listing first. A window at the top of the model's entry (context_length,
max_context_window, max_model_len, context_window or n_ctx: vLLM's max_model_len,
OpenRouter's context_length) is taken as the one being served, and nothing else is asked.
Where the listing does not settle it, the server's own API is asked (servedWindow): llama.cpp's
/props (default_generation_settings.n_ctx), then LM Studio's /api/v0/models
(loaded_context_length). That covers a model listed with no window, which is LM Studio, and one
listed with only meta.n_ctx_train, which is llama.cpp. The trained window differs from the served
one in the case the guard exists for, a 256k model started at -c 16384, so it is used only when
neither native route gives a number. A server with neither route is latched and not asked again.
/api/v0/models is also dropped on its own: for the life of the process once it answers 404, 405
or 501, and for listingMissMs after any other error.
A host that sizes the window before each run passes the run's signal as the third argument, so a
stopped run does not wait on the lookup:
const contextLimit = await contextLimitFor({ ...settings, model }, settings.contextLength, { signal });
It rejects with the signal's reason, closes the request it had open, and remembers nothing of the
lookup. Nothing else makes it reject. listModels and servedWindow take the same option.
The lookup is also bounded on its own, at ten seconds across the listing and the native routes
together, or at the endpoint's request timeout where that is shorter. requestTimeoutSeconds is
how long to wait for a model to speak, and an endpoint that accepts the connection and then says
nothing would otherwise hold the lookup for that long, or for ever where there is none. Past the
bound contextLimitFor answers zero, as it does for a server it could not reach, and
listModels rejects with a TimeoutError.
A top-level listing key is trusted without a second opinion, so on a server that puts a trained
window there, pass contextLength. Ollama reports no window on any route this reads, and truncates
an over-long prompt rather than refusing it, so on Ollama pass contextLength or there is no guard
at all.
What is weighed is the prompt plus the reply ceiling the body carries, under whichever spelling
was chosen, because that is what the endpoint weighs: a 30k prompt into a 32k window with
max_tokens: 4096 is refused there, so it is refused here. A body with no ceiling reserves
nothing, and the server gives the reply whatever the prompt leaves. A consumer that subtracted
maxTokens from the limit itself before calling in no longer needs to, and doing both reserves
the ceiling twice.
How many tokens the body is, is a guess: characters over a divisor. Four is right for English prose
and wrong for what a tool-using run is made of — JSON schemas and tool results pack closer to two or
three characters a token — so a request the guard let through was refused anyway. The divisor is
learned instead. Every turn runTurn answers comes back with the endpoint's exact prompt count for a
body whose characters were already counted, and calibrate keeps that ratio per endpoint and model;
the next request to the same model is sized by charsPerTokenFor, which is four
until a turn has reported one. It is the highest of the model's last four readings, which is the
lowest token count: a count that comes out high refuses a run that would have fit, and one that
comes out low only costs the round trip the guard was saving. A reading from a request carrying an
image, or outside one to eight characters a token, is not taken.
requestTokens and messageTokens take { charsPerToken }, and so does planCompaction, so a
caller sizing its own work can use the same number:
The ratio is a measurement rather than a refusal, so it is not a capability latch and is not in
exportCapabilities: it moves every turn, and a restarted process learns it again from its first
one. llama.cpp's /tokenize would count exactly, but only after /apply-template renders the
prompt — two round trips before every guarded request, on one server — so it is not used.
The body is sized once, not per attempt: a downgraded request is strictly smaller than the one
before it and the transcript does not change between retries. A ContextOverflow from this is
neither a capability negotiate can answer nor something isTransient accepts, so it leaves
both loops on the first attempt.
contextLimit decides how early the caller hears, not what it hears. Without one, the endpoint
refuses instead — one round trip later — and runTurn reads that refusal back through
isOverflow and raises the same ContextOverflow, carrying the endpoint's own wording and the
original error as cause. A rate limit borrows those words and means the opposite ("Request too
large for gpt-4o ... on tokens per min"); that is ruled out and waited through as the 429 it is.
A server still loading the model is waited for on its own clock. llama.cpp answers 503 Loading model (type unavailable_error) until the weights are mapped, thirty to ninety seconds for a large
model from a cold cache, and a router build says the same while it swaps models; backoffMs would
give up inside fifteen. isModelLoading recognises it, and runTurn polls every three seconds
for up to loadingTimeoutMs (two minutes by default, zero to turn it off) without spending
maxRetries, with one notice at the start. runAgentLoop reads it as loadingTimeoutSeconds off
the config. A 503 that says nothing about loading stays on the ordinary backoff.
What is filling the window
A total tells an operator a run is close to the edge and nothing about what to do next, so
contextTokens cuts the same body four ways, along the four levers there are: system is the
system and developer messages, which means shortening the prompt; tools is the declared schemas,
which means loading them on demand instead of declaring them whole; toolResults is exactly the
tool messages, which is precisely what pruneToolResults shrinks; and history is everything
else, which is what compaction folds, the arguments of the calls in it included.
const { system, tools, history, toolResults, total } = contextTokens(body, {
charsPerToken: charsPerTokenFor(supports, config.model),
// Optional: what the endpoint said the prompt cost, once a turn has come back.
promptTokens: turn.usage?.prompt_tokens,
});
The parts are shares of one total rather than four separate estimates, because a readout whose
parts do not add up to the number beside them is one nobody trusts; the largest part absorbs the
rounding, so they sum exactly. Nothing in the round trip reports anything finer than a prompt
count — a completion says how many tokens it read and not a word about where they came from — so
the proportions are a guess whatever the total is, and contextChars is there for a caller that
wants the exact characters underneath them.
Given a promptTokens the total is what was charged and every part is a share of it. Without one
the total is requestTokens, and the tool block is counted the way requestTokens and
TurnMetrics.toolSchemaTokens count it rather than shared out, so the breakdown and the metrics
line cannot disagree about the same tool list.
A host on runAgentLoop has no body to hand it, since the loop builds the request. It does not
need one: each usage event's turn.context is this breakdown for that step, and onRequest is
handed the request itself for a host that cuts it some other way. Both are under the loop, below.
The loop
runAgentLoop is the part of an agent that three servers had each written, and that had drifted
the way the turn had before runTurn: one noticed a turn cut off at the ceiling and two did not,
one tested the ceiling's spelling the other way round, one sent a reasoning effort and two never
did. What it does not know is what the run is for — the prompt, the tools, and what a tool call
does are the caller's.
import { runAgentLoop, emit } from "@cubicecho/agent-core";
const { turn, messages, usage, loaded } = await runAgentLoop({
config, // Endpoint & ModelParams & { maxToolIterations, toolDiscovery?, maxRetries?, loadingTimeoutSeconds?, contextLength? }
system, // sent as the first message; on-demand and proxied modes append the catalogue
messages: history, // ending in the question; not written to
tools, // every tool the run may reach
catalog, // the same, name-only, for on-demand and proxied loading
preselected, // from `preselect`, if a small model chose
dispatch: ({ name, args }, signal) => pool.call(name, args, signal),
hooks: { run, context: { session: { id } } },
signal,
onEvent: (event) => emit(runId, event),
});
Each step is one runTurn with the body from buildBody, so everything negotiate answers is
answered here too, and a request that is too big throws ContextOverflow whichever side found
out. Between steps the loop runs the calls: sequentially by default, or together with parallel: true.
Either way an identical call — the same name and arguments, byte for byte — is made once and its
answer handed to the repeat, and two still in flight share the request. A call that threw is
forgotten rather than cached, so asking again is a real retry. What a tool throws is what the model
reads, and so are arguments that did not parse.
The scope is the step, not the run: between steps other tools have run, and the file the model read
may be the file it has since written. dedupeToolCalls: false dispatches everything, and a
predicate is asked per call — which is how send_email opts out, since twice is two emails and
nothing in an OpenAI tool definition says which tools those are. A pool that reads the MCP
readOnlyHint and idempotentHint annotations can answer it; this package cannot.
Three kinds of call never reach dispatch: load_tools, which the loop answers itself, a call
whose arguments could not be read, and that identical repeat. A host that draws each call as a
card and fills it in when the result lands hears of those through onToolCall and onToolResult,
which are told of every call the model made — the request as dispatch would have been handed it
(with empty args where they could not be read), then { id, name, ok, content } with the whole
text the model reads. A repeat gets a result of its own under its own id, carrying the answer it
shares. Neither is awaited. The tool-call and tool-result events carry the same id, with
text still cut by preview, and so does each entry of the result's toolCalls — which is what
tells two calls to one tool apart when parallel lands their results out of order. An id is
distinct within a step, and a server is free to use it again in a later one.
Arguments go through parseToolArguments, which is lenient where the model's meaning is plain:
JSON held in a string is opened, and the almost-JSON local models write — single quotes, Python's
True and None, bare keys, a trailing comma — is repaired, without touching what is inside a
string. What still is not an object throws a ToolArgumentsError whose kind is truncated when
the turn stopped at the ceiling, with a message telling the model so, and malformed otherwise.
The repaired JSON is what the transcript keeps, and an unreadable call is replayed as {}, because
a server that parses replayed arguments refuses the originals on every later request. dispatch
is still handed the model's own text as raw, and the dedupe compares repaired arguments,
so {'a': 1} and {"a": 1} are one call.
A server whose tool-call parser was written for another template streams the model's call as
plain text, and the run ends on a reply that is nothing but a call nobody made. Unless
recoverToolCalls: false, a turn with no calls, some text, and tools to call is passed through
recoverToolCalls, which finds <tool_call> blocks (Hermes, Qwen, Qwen3-Coder's markup),
[TOOL_CALLS] (Mistral, both spellings) and <|python_tag|> (Llama 3) after the last </think>,
and — naming only tools that exist — a reply that is only a JSON call or holds one fenced one.
Found calls are run as call_recovered_0 onward, the text is what is left, onTurn and the
result see the turn that way, and a notice says so, since the real fix is the server's parser.
Every request declares its tools in name order, whichever way the caller assembled the array. A
chat template renders the tool block ahead of the system prompt, so the tool array is the first
thing a prompt cache has to match, and an array built from a map, from database rows, or from the
order servers happened to connect in is a different array on the next boot — the same tools, the
same run, and the cache for the whole transcript thrown away. Ordering by name makes it a property
of the set instead. toolOrder: false sends the caller's order, for a host that means it — a model
reads the array top to bottom — and a comparator orders it another way. orderTools is the same
thing for a caller with its own loop, and buildBody takes the order as its last argument.
With toolDiscovery: "ondemand" and a catalogue, the request declares load_tools and what has
been loaded, and the catalogue rides on the system prompt
unmarked, the same text on every step. Marking loads there rewrote the head of the prompt and lost
the prompt cache for the whole transcript on each one; a model that loads a tool twice is told in
the load_tools result that it already has it. A model that calls
a catalogued tool without loading it first is right about what it wants, and gets it loaded and
run. A preselection shapes the first step alone: those tools, no catalogue, no load_tools —
a model with the menu still in front of it shops, reloading what it has or picking a sibling —
and everything is back from the second step on.
That first step has a request head of its own — a different system prompt and a different tool
array from the last turn's final request, and from this turn's second step — so a prompt cache
misses the whole transcript twice on a question that has a preselection. preselectRouting: "append" drops the separate head: the shortlist is loaded as a load_tools call would have loaded
it, after whatever loaded carried and without declaring a carried name twice, and the first step
is sent like every other, catalogue and load_tools included. The trade is the menu back in front
of the model on the step the shortlist was meant to settle. Neither cost has been measured against
the other, so "exclusive" stays the default.
Appended is where the tools go before toolOrder orders them. With toolOrder: false a carried
tool array stays a strict prefix of the new one. With name order, the default, a preselected tool
is sorted in and moves every definition after it — the first step still matches the second, but
the last turn's cache holds only as far as the first new name.
toolDiscovery: "proxy" is the same catalogue behind a tool array that never changes. A chat
template renders the tool array in the system turn, so on a server that caches by prefix —
llama.cpp, and anything built on it — a load_tools that appends a definition changes the head of
the prompt and the whole transcript is prefilled again, on the very step that was meant to save
tokens. Proxied, every step declares PROXY_TOOLS and nothing else: load_tools, and call_tool,
which takes { name, arguments }. A load answers with each new tool's name, description and
parameters as a line of JSON, since no request will ever declare them, and with a pointer back for
one whose definition is already in the conversation. A call_tool is refused for a name outside
the catalogue, takes arguments as an object or as JSON in a string, and reaches dispatch as the
tool it names — the inner name and arguments, under the call's own id — so a dispatcher, a watcher
and used see the same thing in every mode. The transcript keeps the call_tool the model wrote.
const { messages, used } = await runAgentLoop({
config: { ...config, toolDiscovery: "proxy" },
messages: history,
tools, // never declared; a load answers with them
catalog,
preselected, // written into the transcript, not into the tool array
dispatch: ({ name, args }, signal) => pool.call(name, args, signal),
});
A preselection has no tool array to go into there, and no step of its own. It is written into the
transcript after the question as a load_tools call the model did not make, and the result that
call would have had:
The pair is in the messages the loop hands back, to be stored like any other, and is reported as
a tool-call and a tool-result event, told to onToolCall and onToolResult, and counted in
toolCalls and in the load_tools metrics. The
id is numbered by where the call lands in the transcript, so two questions do not share one. A name
with no definition in tools, or outside the catalogue, is left out, and none left is no exchange.
What is loaded there is what the history holds, so loaded comes back empty and the loaded
option is not read: there is nothing to carry, and a definition a compaction folded away has to be
loadable again. For the same reason a beforeStep that returns a different array makes the loop
forget what it loaded, which costs a definition sent twice at worst. call_tool is honoured in
ondemand mode as well — a session switched out of proxied mode still has it in its history, and
a model copies what it has seen work — unless the host has a tool of that name, which is then the
host's. Eager mode does not know it.
That preselection costs a round trip to a model, which on a local box is a few seconds before the
run has started, spent on a model doing term matching. preselect(..., { keywords: true }) does
the matching directly and spends the model only on what the words cannot settle:
const preselected = await preselect(config, config.toolSelectModel, catalog, prompt, {
keywords: true, // or { minScore, dropoff } to move the bar
onNotice,
});
preselectByKeywords is the matcher on its own, ranking every tool against the request by BM25
over its name, its server's label and its one-line description. BM25 rather than counting shared
words, because a catalogue is full of words every tool uses — "list", "get", "file" — and an
overlap count hands the top of the ranking to whichever tool has the longest description. English
function words are dropped outright: the inverse document frequency is meant to handle them, and
over a real corpus it would, but twenty one-line descriptions are few enough that "for" lands in
one of them and scores as the most distinctive word in the request.
What a caller acts on is confident, which is deliberately hard to earn: something more
distinctive than a word the catalogue shares has to have matched, and the tools the cap left out
have to score well below the ones it kept — a hit just under the line scoring nearly as much as
one just over it means the ranking chose arbitrarily, which is the case a model is worth spending
on. Matching nothing is not confident either, since the words cannot tell a request that needs no
tools from one whose words are not in the catalogue. An empty toolSelectModel still means no
preselection at all, words included. preselect also takes a temperature and a
reasoningEffort and an extraBody, read as ask reads them, and runAgentLoop({ preselect: true }) makes the call
itself — see "Its side tasks" below.
A turn cut off at maxTokens is said so as a notice, and with maxContinuations above zero it is
continued first. continueTurn is the same thing for a caller with its own loop:
It sends the transcript again with the answer so far as a trailing assistant message, which
llama.cpp renders as a prefill: the model carries on from the last token, and the cache holds all
of the prompt and most of the reply. vLLM only does so given continue_final_message: true and
add_generation_prompt: false on that request, which nothing here sends yet. The pieces come
back as one turn — content and reasoning joined, token counts summed, continuations counting the
extra requests, timings summed or weighted, a field only one piece reported dropped. Only an answer
begun and cut off is continued. A turn cut off in its scratchpad is left alone, since llama.cpp
refuses a prefill outright on a template with thinking on, and so is one ending in a tool call,
whose truncated arguments parseToolArguments already reports.
Whether it works is latched per model as assistantPrefill. A 400 or 422 for the request latches it
off, and so does a continuation that starts the answer over word for word — how hosted OpenAI, which
takes a trailing assistant message and ignores it, shows itself. Either way, and on any other
failure short of a stop, the cut-off answer is kept with a notice. The cap is one continuation for
continueTurn and none for the loop, since it spends a request, and on a server that does not
continue, one to find out.
Every turn ends in a usage event, whether or not the endpoint reported tokens. Its totals are the
run's so far; its turn is that turn's own TurnUsage and finishReason. The loop adds what only
it can know: toolsDeclared and toolSchemaTokens for the tool block it sent, and, from the second
request on, cacheExpected (the previous prompt plus its reply) and — where a cache count was
reported — cacheBroken, a hit short of 90% of the previous prompt. A broken cache is given a
cacheBreakReason read off the request against the one before: tools-changed, system-changed,
history-rewritten (a compaction or a prune in beforeStep), or none-known where the new request
only appended, which points at the server — a slot evicted, a template that re-renders the tail.
The same turn carries context, the request cut by contextTokens into system, tools,
history and toolResults: shares of the prompt the endpoint reported for it, adding up to it, or
an estimate where it reported none. A host that wants a different cut — the catalogue apart from the
prompt it rides on, this turn's messages apart from the ones before — takes the request from
onRequest and measures it itself:
It is called once a step, before the request goes out, with the messages and tools as the client
is about to be sent them. It is for reading: beforeStep is where the transcript is changed. What
it reports is the request as first built. A retry sends that again, but a refusal is answered with
a lesser body and a continuation with the reply so far on the end, and neither is reported — and
context is measured on the same first request, against the first prompt reported for the turn.
runMetrics(events) adds a run up from those events — tokens, cache hit ratio and breaks by
reason, prefill, decode and tool time, the slowest turn, mean time to first token, draft
acceptance, the largest prompt against a contextLength, turns cut off, tool errors by name, and
an outcome. The loop returns it as metrics, with the load_tools counts only it can see
(toolsLoaded, redundantLoads, unknownToolNames). It is a sibling of fold rather than part
of it, since fold's blocks are for display and a summary is not one:
Counts are always present; every other field is absent where no turn reported what it is made of.
What it cannot say: whether a failed run was stopped, errored or ran out of tool iterations, and
whether the host compacted — neither is in the events.
beforeStep is handed the transcript before each request and may return a replacement, which is
where compaction goes (below). Its third argument is { used, limit, ledger } — the prompt the
run's last request reported, the window, and the token ledger so far — so a host can compact
mid-run without tracking usage itself; used is absent before the first step and where the
endpoint reported nothing. Hooks are gathered once, onto the question, and never written into
the transcript that comes back; afterTurn is told the reply without the run waiting on it.
That leaves an earlier question's context to the host, because the loop only ever touches the
current one. A question sent with <context> blocks one turn and bare the next is a different
prompt from that message on, and the server's prefix cache is lost behind it — on every turn. So
the result hands back what it said, context and the preface above it, for the host to keep
beside the question and put back with withContext on every later call:
import { runAgentLoop, withContext } from "@cubicecho/agent-core";
// Stored: what the user typed, and what the hooks added to it the turn it was asked.
const history = session.messages.reduce(
(sent, message, at) => withContext(sent, at, message.hookContext ?? "", message.hookPreface),
session.messages.map(({ hookContext, hookPreface, ...message }) => message),
);
const result = await runAgentLoop({
config,
messages: [...history, { role: "user", content: prompt }],
dispatch,
hooks: { run, context: { session: { id }, prompt } },
});
session.messages.push(
{ role: "user", content: prompt, hookContext: result.context, hookPreface: result.preface },
...result.messages.slice(history.length + 1),
);
await result.afterTurn; // only if its notes are wanted, or the process is about to exit
withContext with those two arguments builds the question exactly as the loop sent it, whatever
configureHooks has since been told, so the next request is the last one with its tail added.
result.messages is the transcript the loop was given plus what the run added — earlier questions
as the host passed them, this one as typed — which is why the snippet stores only the new tail.
afterTurn on the result is the afterTurn hooks' notes as a promise. The loop still returns
without waiting; a host that stores those notes with the turn, or would otherwise exit before the
turn is remembered, awaits it once the answer is out.
The loop gathers before its first request, and nothing of the host's can run beside that. A host
with pre-turn work to overlap with the hooks — compaction, a preselection — calls gather itself,
applies withContext to the current question as well, calls notify after, and passes no hooks
(see Hooks).
A host that stores its transcript — a row per message, so a crash or a stop mid-run still leaves
readable history — gets each message as the loop appends it, and the run as it stood when the loop
throws:
try {
await runAgentLoop({
...options,
// Awaited: the row is written before the tools run, and before the next request is sent.
onMessage: (message, step, turn) =>
store.add(sessionId, turn?.reasoning ? { ...message, reasoning_content: turn.reasoning } : message),
});
} catch (error) {
const run = failedRun(error); // messages, usage, toolCalls, loaded, used, preselected — or undefined
if (run) await store.keep(sessionId, { usage: run.usage, loaded: run.loaded });
if (signal.aborted) return; // a stop, told by the signal as it always was
if (error instanceof ContextOverflow) return compactAndRetry(run?.messages);
if (error instanceof ToolIterationLimit) return outOfSteps();
throw error; // an AgentLoopError: the message is the cause's, and the cause is on `cause`
}
onMessage hears the assistant message as it will be replayed — recovered calls folded in, and
arguments repaired, which the turn onTurn is handed does not show — together with that Turn,
and then each tool result, in transcript order. onTurn is not awaited and never sees a tool
result; this is the one to store from. What it returns is ignored, since beforeStep is the one way to rewrite
the transcript, and what it throws ends the run.
Everything the loop throws carries the run. A spent maxToolIterations is a ToolIterationLimit,
with the message it always had. A request too big for the window is an AgentLoopOverflow, which
is a ContextOverflow, so a catch written for one still catches it. Anything else — a stop, a
refused request, a dropped stream — is an AgentLoopError whose cause is what was caught and
whose message is the cause's own. It is a wrapper and not fields on the error that was caught
because a stop throws the signal's reason, one object for every run under that signal. A stop is
still recognised by the signal the host aborted.
A failure while a step's tools are running leaves a transcript that can be sent again. The results
that came back are kept, a call the run was stopped during is answered Stopped before this call finished., and one the loop never reached Not run: the run stopped first. — a call with no
result is a transcript an endpoint refuses. Those are announced through onMessage like any other
result. The reply of a request that was in flight when the run stopped is not kept: the host has
the deltas from its output and thinking events, and whether half an answer belongs in the
history is its call.
resolveApiKey is exported and not applied, because which key an endpoint gets is a rule a
consumer states and a library guessing it could send one where it was not meant to go. The rule
it encodes is the conservative one: an endpoint's own key wins; one that names a base URL of its
own, different from the settings it inherits from, gets NO_KEY rather than the operator's key or
$OPENAI_API_KEY; anything else inherits.
Fields this interface cannot spell
ModelParams has temperature, maxTokens and reasoningEffort. Everything else a model card or
a server asks for — top_k, min_p, repeat_penalty, llama.cpp's id_slot, cache_prompt and
reasoning_budget — goes in extraBody, which buildBody merges in last. It can override
temperature but not model, messages, stream or tools, which are the loop's.
A local server ignores a field it does not know. OpenAI refuses it — Unrecognized request argument supplied: min_p — and so do some proxies, as Unknown parameter: 'min_p'. Given the
names it may drop as droppable (runTurn takes the same option, and runAgentLoop passes the
extraBody keys),
negotiate reads either wording, latches the name off for that model on that endpoint, and sends
again without it, with a notice naming it. A nested name is dropped at its top-level field. A
refused field nobody said was droppable is passed on, since dropping it would change the request
behind the caller's back.
On a llama.cpp server started with --parallel, pin each session to a slot with
extraBody: { id_slot: n }. The slot keeps that session's KV cache warm, which is the difference
between a cached prefill on every turn and a full one — but only while the prefix stays the same
from turn to turn; see #63, and the caution on compaction below.
Ollama's options object is not read on its OpenAI-compatible /v1 route, so sampling set there
does nothing; send the fields at the top level.
Keeping a long run inside its window
Two ways to make a transcript smaller, cheap first.
pruneToolResults(messages, { keepLast: 5, maxChars: 256 }) replaces every tool result but the
latest five with a stub — [result cleared, 10,412 chars]. A 40k-character read_file is 10k
tokens on every turn after it, and by then the model has usually taken what it wanted; the stub
keeps the call answered and says how much was there. A proxied load_tools result that carries
definitions is never cleared, however old: it is the only copy of the schema the model has, and
holdsDefinitions is the test for a host with a prune of its own.
planCompaction(messages, { limit, used }) says where to fold the oldest stretch into a summary,
once used (the last turn's prompt tokens, or the estimate) is past three quarters of the window.
The kept tail fills at most 35% of it and starts on a user message, since a transcript resuming
mid-exchange is one servers refuse; leading system prompts are never folded, and an earlier
summary is continued rather than summarised. compactTranscript writes the summary and tells
beforeCompact hooks what is going while it does. Because the cut lands on
a user message, one long tool run under a single question has nothing to fold — pruning is what
keeps that one going.
A hook can ask for a compaction not to happen — its runner sets veto on the outcome — and by default
nobody listens: the hooks run beside the summary, so a slow one costs the run nothing. Pass
honourVeto: true with the hooks and they run first — the summary waits on them — and a veto from
any hook that ran leaves the transcript as it was, with a note naming the hook. One that failed
vetoes nothing. A compaction passed forced: true, because a request was already refused as too
big, goes ahead regardless: a veto there only trades the summary for a ContextOverflow.
consult is the same wait for a host that compacts its own way.
const compacted = await compactTranscript(messages, plan, summarise, {
hooks: { run, context, onNote, honourVeto: true },
forced: retryingAfterOverflow, // the last request came back as a ContextOverflow
});
Both rewrite the prefix. A prompt cache matches from the first token, so a transcript whose
early messages change is re-processed whole — on a local server that is the entire prefill, every
time. Run them rarely and together, at the point planCompaction says the window is filling, so
the cache is lost once rather than a little on every turn. Pruning on every step is the expensive
way to save tokens.
pruneToolResults keeps the transcript's indexes, so a plan made before pruning still applies to
what it returns, as above.
What the messages cost, measured
planCompaction decides where to cut by estimating each message, and a calibrated divisor fixes
only the average: a transcript is tool results, JSON arguments, code and prose, each at its own
ratio, plus whatever the chat template adds per message. The server has already said what they
cost. A request that only appended to the one before it differs from it by exactly the cost of what
was appended, so two reported prompts and a subtraction give a number with no tokenizer in it — the
template's tokens and a tool result's real density included, and reasoning, which is never
replayed, left out.
runAgentLoop keeps that as a ledger — one { through, prompt, epoch } per request: the index of
the request's last message, the prompt it reported, and an epoch that changes whenever the prefix
did. It comes back as result.ledger, rides on every usage event, and is handed to beforeStep;
pass it back as ledger on the next run and it is continued. It is a plain array of three numbers
an entry, so it stores as two columns on the message row through names or as JSON beside the
session.
tokensBetween(ledger, from, to, messages) is the cost of messages.slice(from, to). Between two
boundaries recorded in one epoch it is the subtraction, and exact. A boundary falls after a question
and after each step's tool results, never after an assistant message, so a stretch that starts or
ends inside a group gets that group's total by character share, the way contextTokens divides a
reported prompt. Whatever no pair of entries covers is the calibrated estimate: the messages before
the first entry, the group either side of an epoch's end, and what has not been sent yet.
estimateFrom(ledger, messages) is the same thing one message at a time, which is the function
planCompaction already takes as estimate.
target is the share of the window the whole request should come down to. keepRatio bounds the
kept tail alone, with the system prompt, the tool schemas and the summary sitting on top of it
uncounted; given a target, the cut is the first user message at which used, less what is
folded, plus the summary's ceiling (summaryTokens, 1024 unless given) is at or under it, and the
plan's after says what that leaves. Where no cut reaches it the plan folds as far as the last
user message and after says how far short it fell. Without target nothing changes.
A new epoch starts whenever a request did more than append: a fold or a prune in beforeStep, a
system prompt that moved, and a tool array that grew — a load_tools in on-demand mode adds a
schema to the head, so the group beside it is left unmeasured rather than charged for it. When
beforeStep returns a different transcript the loop moves the entries that still stand to their new
indexes with rebaseLedger(ledger, before, after), which a host that folds between runs calls
itself. Differences inside an old epoch stay good, since the prefix they shared cancels.
What is not measured: the first request of every run starts an epoch, because the loop cannot see
whether the run before it had the same system prompt and tools, so a run's question and the answer
before it are estimated; a step whose prompt was not reported has no entry, and the next one that
does is measured across it; and the prompt recorded is the first request's, before any continuation.
A difference of zero or less is not trusted, and neither is a prompt reported smaller than its own
cache count — both are how a server reporting prompt_tokens net of its cache would show up.
Whether any endpoint does that has not been checked, and one whose net counts kept rising would not
be caught. A host with its own loop keeps a ledger with recordRequest(ledger, previous, next).
A fold you store, instead of a transcript you rewrite
compactTranscript hands back a new array, which is the whole answer for a host whose transcript
is that array. A host that keeps its messages append-only — rows in a database, every one still
shown in the chat — wants the other half: what the fold was, as something to store on the session.
runCompaction is compactTranscript without the rewrite. It returns { summary, through, at },
or undefined when a hook vetoed or the summary came back empty, and compactTranscript is built
out of it, so there is one summariser and one cut rather than two that drift.
const from = session.fold?.through ?? 0;
const plan = planCompaction(session.messages, {
limit,
used,
from, // where the last fold ended, rather than scanning for it
previous: session.fold?.summary,
});
if (!plan) return;
const fold = await runCompaction(session.messages, plan, summarise, { hooks: { run, context } });
if (fold) await save(session.id, fold); // the messages themselves are never touched
planCompaction takes from and previous because its defaults are a recovery: it skips the
leading system messages, and reads an earlier summary back out of a SUMMARY_LEAD message among
them. A host whose system prompt is a separate argument and whose summary is a column has neither
in the array, and knows both exactly. Given them, nothing is scanned.
applyCompaction(messages, fold) is the way back — the summary as a system message, then
everything from through — and it writes the same SUMMARY_LEADplanCompaction looks for, so
the next fold continues those notes rather than summarising them a second time. No fold yet hands
back the messages themselves.
Two numberings. A stored transcript keeps its indexes and a folded request does not, so
anything naming a position has to say which one it means. requestIndex(index, fold) maps the
stored index onto the request — for withContext's index, or a range being shown to a hook.
turnMessages takes an offset, the stored index of the array's first message, so a message keeps
the uuid it had before the fold and a memory server deduping on it files that turn once rather than
twice; turnIndex takes one too, for the turns a fold took out of the array it is counting.
Planning over the stored transcript, as above, sidesteps both: the plan's indexes are the host's
already, and so are the ones runCompaction hands the beforeCompact hooks.
Watching a run
watch replays what the run has already emitted, then yields what happens next until done.
emit assigns seq, a per-run counter from 1, and that is what a client orders and
de-duplicates on.
import { watch } from "@cubicecho/agent-core";
// The signal is how a watcher leaves early — a disconnected client, a page navigated away from.
for await (const event of watch(runId, request.signal)) {
render(event);
}
Pass one for anything that can go away before the run ends. watch otherwise finishes only on
done, and returning the generator is not a substitute: waiting for the next event it is
suspended at an await rather than at a yield, so a return() queues behind a promise only
that event can settle. A watcher stuck there also holds its run's backlog past every deadline,
because the sweep skips any stream a listener is on.
A watcher that stops keeping up is capped rather than left to grow. Its queue holds the most
recent 1000 events by default — trimmed in batches, so it runs a little over that before cutting back —
the oldest go, and what is dropped is released where it is dropped, not held until a consumer
that has already stalled next reads.
The gap is reported once, as a single notice, not once per lost event. It carries the seq
immediately before the event that follows it: inside the gap, where no real event will ever
appear. Sharing a seq with the event behind it would make the pair indistinguishable from a
repeat, and a client doing what seq is documented for would throw away one of the two.
history reads what a run has emitted without subscribing, fold collapses a token stream into
blocks for display, and endRun drops a finished run's buffer.
The four numbers behind that — the backlog cap, the slack it is trimmed in batches of, and the two
deadlines the sweep reaps on — are defaults rather than decisions. configureEvents moves them,
and returns the whole set as it now stands:
import { configureEvents } from "@cubicecho/agent-core";
// A long-lived server with many concurrent runs, whose tool calls are minutes rather than hours.
configureEvents({ maxEvents: 200, retainUnendedMs: 5 * 60_000 });
It is module-level because the bus is: there is one of each per process, and a run does not carry
its own. A field left out keeps what it had, and so does one given something that is not a
positive number — nothing here has a meaningful zero, and a 0 standing in for "no opinion" must
not turn the backlog off. resetEvents (and resetAll) puts the defaults back, so one test's cap
is not the next one's.
expandNames, carryOver, preselection, preselectInput and preselectSystem take their caps
the same way — as a last argument, which is 12 tools a load and 16 carried unless given. A resolution
carries the cap it was held to, so loadResult tells the model the number it was actually
measured against rather than the module's own.
Hooks
A hook is something a host runs at a point in a session — sessionStart and beforeTurn, whose
output can reach the request, then afterTurn, beforeCompact, sessionEnd and sessionDelete,
which can only read what happened. What runs is not decided here. gather and notify take a
HookRunner, and @cubicecho/agent-mcp-pool's runHooks is one as it stands: its types are the
shapes restated here, so its outcomes pass straight through without either package importing the
other.
The context goes on this turn's question and never into the system prompt — a prompt that changed
every turn would miss the prompt cache every turn — and withContext returns a new array, so a
host that stores what the user typed never stores the context as something they said. It is stored
beside the question instead, and put back on every later request: a question that loses its blocks
on the next turn rewrites the prompt from there on, and the server's prefix cache with it.
runAgentLoop hands back context and preface for the same purpose. Every
injecting hook shares HOOK_CONTEXT_TOKENS (2000) by default, each held to its own maxTokens
inside that, so a generous hook cannot crowd out the conversation it was meant to inform.
The budget is the caller's to move, at either level. configureHooks sets it for the process, and
maxTokens on gather (or the second argument to assembleContext) sets it for one request and
wins over it — the one to reach for when the budget follows the model, since a 128k window can
afford more recall than an 8k one:
Either one given something that is not a number above zero keeps what was there, as
configureEvents does, so a 0 threaded through for "no opinion" does not switch recall off.
resetHooks (and resetAll) puts the default back.
The preface said above the blocks moves the same way. HOOK_PREFACE names no host, so a host that
wants its own name says so once with configureHooks({ preface }), and a preface passed to
withContext (or on runAgentLoop's hooks) wins over it for one request. An empty string is a
preface of nothing — the blocks lead the question on their own, with no blank line above them —
and anything that is not a string keeps what was there:
configureHooks({ preface: "Added by my-host's hooks — background, not the user's words:" });
const request = withContext(messages, messages.length - 1, gathered.context); // says it
withContext(messages, messages.length - 1, gathered.context, ""); // says nothing
Neither function rejects. A hook failing is an outcome, and a runner that throws outright is
noted once for its event and costs only that event's context. notify takes no signal: a reader
who leaves once the turn is answered has not asked for it not to be remembered.
Untrusted text
Hook context is not the only text in a prompt that nobody vouched for. A fetched page, an email, a
submitted card and a tool result all reach the model in the same words as the operator's own, and
untrusted gives the model a fence it can see around them. Put UNTRUSTED_PREFACE in the system
prompt once, where it costs the prompt cache nothing, and wrap each piece where it is pasted in:
import { UNTRUSTED_PREFACE, untrusted } from "@cubicecho/agent-core";
const system = `${instructions}\n\n${UNTRUSTED_PREFACE}`;
const content = `Summarise this page.\n\n${untrusted(page, { source: url })}`;
Any untrusted tag inside the text, opening or closing and in any case, has its < escaped, so a
page that writes </untrusted> followed by an instruction leaves that instruction inside the
block. This is one layer and not a defence on its own. A model can still be talked out of a fence,
and the tool policy is what decides what the text can make the agent do. withContext does not
fence hook blocks this way, because they have to stay identical to the MCP pool's contextBlocks.
The config seam
Nothing here imports a config type from a consumer, and no function asks for a whole
configuration. Each takes the narrowest shape it reads — Endpoint, ModelParams,
ToolPolicy, RetryPolicy — and a caller satisfies it structurally:
import { getClient, type Endpoint } from "@cubicecho/agent-core";
// A Drizzle settings row, a resolved agent, or a zod-inferred config: all three already are one.
const client = getClient(settings satisfies Endpoint);
This matters because the three consumers do not agree on the fields. task_server's settings
row has no contextLength; min-agent spells it contextLimit and carries no retry budget.
A single god interface would have forced two of them to grow columns they have no use for.
The seam is not finished. timeoutMs narrows to the one field it reads, but getClient still
asks for the whole of Endpoint. requestTimeoutSeconds became optional in v2, so a consumer
with no timeout to give now leaves it out rather than inventing a 0.
An agent as a document
The seam above says what a running agent is. @cubicecho/agent-core/spec says what a stored
one is: a versioned JSON document that any host on this package can read, layer and hand to the
loop. It exists because the applications here spell "an agent" three incompatible ways — maxTokens: 0
means no ceiling in one, inherit in the next and a real ceiling of zero in the third, and an
empty server list means no tools in one and every tool in another. docs/agent-spec.md argues
the whole thing out.
import { parseSpec, resolveAgentSpec } from "@cubicecho/agent-core/spec";
const { spec, errors, warnings } = parseSpec(JSON.parse(text));
if (!spec) throw new Error(errors.join("\n"));
const config = resolveAgentSpec([settingsSpec, spec]); // weakest layer first
const result = await runAgentLoop({ config, ... }); // already the shape the loop takes
Absent inherits, and nothing else does. There is no inherit sentinel at any depth: 0, -1,
"" and [] are values that mean what they say, and null reads as absent because a nullable
column round-trips through JSON that way. tools.servers keeps its tri-state — absent is every
server the host offers, [] is none, a list is exactly those — and layering replaces it whole
rather than unioning, since a union of "these two" and "none" is "these two", which silently widens
a scoped agent. prompt, hooks and tasks merge by id, which is one rule expressing two
behaviours: different ids stack, the same id replaces.
parseSpec reports every problem at once and refuses only four things, all of them about the
document rather than a value: it is not an object, the spec token is missing or of another major,
a requires entry this host does not understand, or a container is of the wrong kind. Everything
else is dropped with a warning — including an out-of-range number, which is dropped rather than
clamped, because a clamp invents a value the author did not write while dropping falls back to one
somebody did.
The document carries no credential at any depth, and a ${VAR}-shaped string is kept as the
literal it is rather than expanded — a format with no interpolation needs no credential denylist.
Resolution yields apiKey: "", which getClient turns into NO_KEY; a host that wants inheritance
applies resolveApiKey afterwards, so a document naming somebody else's endpoint cannot make this
host send its own provider key there. exportSpec(spec) strips a bundled server's env and
headers, and a redacted document is still a valid one.
Those two fields are where the format puts a bundled server's credentials, and not the only place a
pasted .mcp.json has them: an --api-key among the args, a ?token= or a user:pass@ in the
url. exportSpec keeps those, since a server with its query string cut off is a different
server, and findSpecSecrets says where they may be so a host can warn before it calls a file safe
to share:
const out = exportSpec(spec);
for (const { path, server, sign } of findSpecSecrets(out)) {
warn(`${server}: ${path} may hold a credential (${sign})`); // bundle.mcpServers[0].args[2] … (flag)
}
A hint is a place and a reason — SecretSign.Userinfo, Query, Flag, Value or Field — and
never the value. It is a guess in both directions: a credential in a positional argument with no
telling prefix is missed, and a flag called --keyboard is named. A ${VAR} is a reference, and is
not reported.
bundle.mcpServers is parsed only under parseSpec(document, { bundle: true }). A bundled server
is a command line and an environment map, so importing one is equivalent to running a program, and
that is a decision a host makes rather than inherits from a default.
The subpath is separate from the main entry for one reason: client.ts and hooks.ts import
node:crypto, and spec.ts imports nothing but types, so a browser validating a pasted document in
a form can load it. Nothing in src/ imports it — the same direction the config seam runs, which is
what lets a document travel between hosts without the loop growing an opinion about where an agent
comes from.
Its side tasks
tasks.<key> configures a side task apart from the main model — its own model, endpoint, ceiling,
temperature, reasoning effort and extraBody — and resolution hands each back as a ResolvedTask holding only
what a layer stated. taskCall turns one into the arguments ask, askJson, summariser and
preselect already take, so a setting added to a task later arrives without the host's call
changing:
const title = taskCall(config.tasks.title, { signal, onNotice }, config);
const name = await ask(title.endpoint, title.model, "Name this chat.", firstMessage, title.options);
An absent setting is the side task's own default, never the agent's. A titler under an agent
running at temperature 1.0 is still sent 0.3, and its ceiling is still ask's 512, summariser's
1024 or preselect's 256. What the task does state wins over the same field in the options beside
it, so the options are where a host puts its own default for a task that names none. maxTokens: 0
sends no ceiling, as it does on the main model.
reasoningEffort is the one that reads differently from the main model's. A side task is sent the
no-thinking hints by default, and absent or "off" keeps them; a level — "low", "high" — is
sent as reasoning_effort in their place, for a compaction model an operator pointed at a model
that should deliberate.
extraBody carries the sampler fields a model card asks for, as it does on the main model: ask,
askJson, summariser and preselect all take one in their options, merge it in after the
temperature, and offer its keys to negotiate as droppable, so a field the endpoint refuses by
name is latched off for that model and the request is sent again without it. The agent's own
model.extraBody is never sent to a side task, and taskCall lays a task's over the host's key by
key.
The third argument is the endpoint the main turn uses. Given it, a task on the same endpoint is
sent the agent's spelling of the URL and, having no key of its own, the agent's key, so the two
share one entry in capabilitiesFor and a refusal learned by either is known to both; a task on
another endpoint is sent only a key of its own, as resolveApiKey has it.
The loop reads two of them when asked to. preselect: true has runAgentLoop make the
preselection itself with tasks.toolSelect — or with toolSelectModel on the agent's endpoint,
which stays the flattened spelling for a host that is not on the spec — and compact: true has it
fold the transcript before a step, once planCompaction says a contextLength window is filling,
with tasks.compaction writing the summary and no falling back to the main model without it. Both
are off by default: a host that already calls preselect or compacts in beforeStep is not sent
a second request.
What the loop picked it says twice. A notice event names the tools before the first turn event
— PRESELECTED_NOTICE and then the names, comma-separated — and the result's preselected is the
shortlist the run opened with, the host's own where it gave one and empty where nothing was picked.
A run that fails carries it too, beside loaded and used.
An empty toolSelectModel means no preselection, and says nothing abo
The endpoint-agnostic half of an OpenAI-compatible agent loop: tool-schema compatibility, on-demand tool loading, one-shot side tasks, run events, and a pooled client.
คัดลอกคอนฟิก stdio สำหรับ Cursor, Claude Desktop, Claude Code หรือ VS Code ได้ในหน้านี้ คอนฟิกนี้ใช้การติดตั้งของผู้เผยแพร่ ไม่ใช่พร็อกซีของ PlugMCP
@cubicecho/agent Core ต้องใช้ API key ไหม?
ระเบียนนี้ไม่ได้ระบุตัวแปรสภาพแวดล้อมที่จำเป็น
@cubicecho/agent Core ใช้ทรานสปอร์ตแบบใด?
@cubicecho/agent Core ทำงานแบบโลคัลผ่าน stdio
@cubicecho/agent Core ถูกตรวจสอบล่าสุดเมื่อใด?
ตรวจสอบล่าสุดเมื่อ 2026-11-14 The endpoint-agnostic half of an OpenAI-compatible agent loop: tool-schema compatibility, on-demand tool loading, one-shot side tasks, run events, and a pooled client.