All ports are versioned and released together; entries here apply to every port unless a port
is named. All seven ports are at tier full (js / python / golang / java / csharp / elixir /
clojure) — see conformance/options_manifest.json, which is where a
port's tier is declared, never by the port about itself. Releases are cut as
GitHub Releases vX.Y.Z via release.yml (see PUBLISHING.md).
Classifier's backoff timer was created and then unref'd. An unref'd timer does not keep
Node's event loop alive, so when a retry backoff was the only pending work, the loop could
resolve before the retry ever happened — the process exiting mid-retry rather than completing it.
The timer is awaited, so it is real pending work and must hold the loop open; the §8 Client's
own delay has never unref'd, for exactly this reason. The request timeout watchdog still
unrefs, which is correct: a watchdog should never be the reason a process stays alive.
JavaScript only — the other six ports use blocking sleeps with no equivalent notion.
Found by CI rather than by reading: Node 22 cancelled every test after the first one to exercise
a classifier retry ("Promise resolution is still pending but the event loop has already
resolved"), reporting 237 passed, 0 failed, 8 cancelled and exiting 1. Node 24, which the work
was written on, hid it completely. The suite is now 245/245 on both.
Each of these produced a green build and a valid-looking request, which is why they went unnoticed. Two are contract that was never written down; two are Clojure behaving unlike the other six.
decisions is now gated. The static backend's recorded corpus is what CI runs on — no
network, no credential, and the only backend a test may assert a number against. Every port had
it; the options manifest had no row for it and SPEC.md §8B named the "static" style without
naming the field that feeds it. The single option the whole test strategy rests on was the one
option parity could not see. It is now a core-tier row (classifier options: 16 → 17), so a
port that drops it fails the check rather than losing the ability to run the shared fixtures
quietly. No port code changed.
An absent calibrated means true, and now says so. All seven ports already decoded a
missing or null calibrated as true, treating only the literal false as false — seven
ports agreeing by inspection rather than by contract. SPEC.md §8B states it and each port pins
all four cases in a test. Nothing changes today; what changes is that a port cannot later default
it to false and silently invert every threshold you tuned, on a field your backend never sent.
Clojure: choice-over no longer ships the colon. A keyword key reached the wire as
":billing" rather than billing, because the port stringified ids with str instead of their
name. That is schema-valid, returns HTTP 200 and a well-formed distribution — with option ids
that differ from every other port (Elixir's atom :billing has always sent billing) and from
the string you then compare (:choice answer) against. Keys may now be strings, keywords or
symbols, and each travels as its plain name; a qualifier is kept (:desk/billing ⇒
desk/billing) so two distinct options cannot collide into one id. If you keyed a downstream
branch on the ":billing" form, drop the colon. If you passed strings — which the port's own
docs told you to — nothing changes.
Clojure: the client's retry backoff matches the other six. :retry-base-ms defaulted to
250 where every other port defaults to 500, and the backoff omitted the + jitter(0–99 ms)
term the others add. The jitter is not cosmetic: without it, a fleet of hosts that failed against
the same upstream at the same moment retries against it at the same moment, so the retry turns one
spike into several. Clojure now waits base * 2^attempt + jitter(0–99 ms) from a default of
500, and a usable Retry-After still wins outright. A Clojure host that never set
:retry-base-ms now waits ~500 ms before its first retry instead of ~250 ms.
Tracked in openspec/changes/close-typed-decision-parity-gaps.
Clojure's Classifier example now runs on both hosts. examples.judge was in the JVM
runner and nowhere in the cljgo one — no run_judge.cljc entry, no ex-judge build target — so
the port's whole claim, one source tree behaving identically on two hosts, went unchecked for its
newest subsystem. It is now the seventh example in both runners, AOT and interpreted, and CI runs
both. All seven pass on both hosts.
Classifier retried a transient failure on a backoff base nobody could change: a hardcoded
500 ms, so two retries cost 1.5 s of real waiting. Only JavaScript exposed a
retryBaseMs for it — one port with an option the other six lacked, on a seam SPEC.md §8B
says mirrors ClientOptions field-for-field.
ClassifierOptions now carries it everywhere, spelled natively: retryBaseMs (JavaScript, Java),
RetryBaseMs (Go, C#), retry_base_ms (Python, Elixir), :retry-base-ms (Clojure). The default
is 500, the delay is still base * 2^attempt with no jitter, and a Retry-After header still
wins over it — so if you do not set the option, nothing about your timing changes. Set it to 1
and a test that exercises the retry path stops sleeping: our own classifier retry tests dropped
from 1.51 s to 0.01 s (Go) and 2.56 s to 0.05 s (Java), and the C#, Elixir and Clojure suites no
longer fake a Retry-After: 0 header to stay off the clock.
The option is registered in conformance/options_manifest.json (classifier options: 15 → 16), so
a port that forgets it now fails the parity check instead of drifting quietly.
Follow-up, now shipped: this entry originally reported two unfixed differences in the client's backoff on Clojure. Both are closed below, under "Four quiet disagreements".
Classifier — and two defects it exposedstyle: "systemone" already reached TypeSafe's first-party API by default
(https://api.typesafe.ai/v1, model jev-latest, TYPESAFE_API_KEY), but everything written down
pointed at OpenRouter's gateway. Both are now documented side by side on
Backends & configuration, and every
port's examples/judge.* picks its backend from the environment: TYPESAFE_API_KEY first,
OPENROUTER_API_KEY second, and with no key at all the existing offline static replay, exactly
as before. No default changed, and no new API was needed — baseUrl, model and apiKeyEnv were
already enough.
They are equivalent in latency (339 ms / 449 ms p50 / p95 against 351 ms / 400 ms, warm and
interleaved — a tie). The reason to use the first-party key is one fewer party in the path, not
speed. The one functional difference: TypeSafe returns no usage.cost, so a cost-based budget
only works through the gateway.
Pointing at it turned up two real defects, fixed in all seven ports:
529 Overloaded was not retryable, anywhere. The default retryable set was
429/500/502/503/504, so TypeSafe's documented "retry with backoff" status failed hard on the
first attempt. 529 is now in the set — on the client path and the classifier path alike — and
that is the whole behaviour change: no status other than 529 changed classification. The
set stays an exhaustive enumeration rather than "any 5xx", because sweeping in permanently-broken
statuses like 501 Not Implemented would change the retry behaviour of every existing host
without asking.
retryableStatuses, a new option on both ClientOptions and ClassifierOptions (named per
port: RetryableStatuses, retryable_statuses, :retryable-statuses). It is how a backend with
its own transient status opts in — the case it exists for is a Cloudflare-fronted origin
answering 520–527:
createClient({ retryableStatuses: [520, 521, 522, 523, 524, 525, 526, 527] })
It is additive and cannot subtract: listing statuses never removes 429 from the set, so you
cannot accidentally lose Retry-After handling by using it. It decides the default
classification only — onError still runs on every failed attempt and has the final say, so
onError returning "fail" overrides a status you listed yourself. Registered in
conformance/options_manifest.json, so the parity check covers it in all seven ports.
The docs were wrong about the retry set, and had been all along. SPEC.md §8, §8B's option
table and every port's retries doc comment said "429/5xx/network" while the code enumerated
five statuses. The prose now states the real set (429/500/502/503/504/529, plus 408
on the classifier path) and points at retryableStatuses for anything beyond it. No behaviour
changed to make that true — the documentation was corrected to the code, not the reverse.
"cost absent" was not representable in Go or C#. ClassifierUsage.Cost was a bare float64
/ double, so against a backend that reports no cost both said $0.00, as though the call were
free, when the truth was "unknown". Go now exposes *float64 and C# double?, matching
js/python/java/elixir/clojure, and a real 0 is still a real 0. This is a source-breaking
change for Go and C# hosts that read Usage.Cost — dereference or null-check it.
Classifier, in all seven portsHalf the decisions in an agent are not actions, they are judgments: is this command risky, does
this turn need the billing skill, how urgent is this ticket. Until now you asked a chat model and
parsed prose back, with a round self-reported confidence and no distribution. Tool is the
contract for an action; Classifier is the contract for a judgment.
You declare typed questions once and get calibrated numbers back — no free text, no tool calling, no loop:
noul — one probability in 0..1. It reports no confidence; the number is the answer.choice — one of your named options (up to 255), with a probability for every offered
option and a confidence.score — a rating against an ordered rubric of 2–10 levels, and 1.21 is a real answer.The keys you address the questions by are never transmitted, so a key may be a tool, skill or agent
name verbatim. Limits are enforced client-side before the request, and the error names the question
key rather than making you read the backend's 400. Four backends: systemone (the wire),
llm (the same questions as one structured-output call on the §8 client you already have — the
exit for a host with no System One credential), custom, and static, which is what CI runs: no
network, no credential. ClassifierOptions mirrors ClientOptions field-for-field, reusing the §8
onError/Retry-After policy verbatim rather than growing a second one — with two deliberate
differences: apiKeyEnv takes the name of an environment variable rather than a value, and
timeout bounds one request rather than a run.
Two things in it exist because they were measured, not designed. Describing your options is not
style advice: options described by consequence scored 17, 17, 17 apples, and the identical state
with each option replaced by its own id scored 0, 1, 0 — the floor of a shuffle control. That shape
is schema-valid and returns HTTP 200, so nothing would have told you. Now something does: a choice
whose criteria are all empty, all equal to their own keys, or all identical emits one warning
per question key through your existing metric sink, naming the key, and sends the request
byte-unchanged — detection, never repair. That warning arrives as a classifier.warning event
carrying its text in a warning field, never in error: it is advisory, not a failure, so a
consumer already counting failures off the same sink does not start counting warnings as outages. And every choice answer carries a derived nearUniform
(max|p − 1/n| ≤ 0.05), the one encoding health check that needs no ground truth and can run on
live traffic.
Both are advisory, and neither is a correctness signal. nearUniform cannot separate a good
encoding from a subtly wrong one, and confidence is no help either — the worst working encoding
measured carried the highest median confidence. More bluntly: a classifier interprets, it never
authorises. "Cannot hallucinate" means only that the value is inside the declared schema. Numeric
limits, permission checks and allowlists stay in your code, and a threshold tuned against one
backend does not transfer to another — which is what Decision.calibrated is for, and why you read
it before you compare a number to a threshold.
Same behaviour in js, python, golang, java, csharp, elixir and clojure, pinned by seven shared
fixtures in examples/judge/ that every port asserts byte-for-byte. A host that constructs no
Classifier behaves byte-identically to a build without any of this, proven by a test rather than
asserted. New docs: Cookbook → Typed decisions (judge) and Harness → Judge, measured live.
Contract: SPEC.md §8B; encoding decisions: docs/adr/0021.
What is NOT done, and where it is tracked:
SkillRelevance, ToolGuard or Verified built on
this seam yet, and no model routing — you write the questions yourself. Tracked in
openspec/changes/add-judge as the follow-up add-judge-adapters.judge-live harness runner wired into the suite the way harness/live has one, so re-running
them is a manual step. The page says so, per table, with its source named.llm backend is a compatibility exit, not an equivalent. It reports
calibrated: false, returns no real distribution, and on one measured fixture disagreed outright
with the calibrated backend.landing flag — which let conformance/check_options_parity.py
report a not-yet-written port file as something other than a failure while these seven ports were
being written — is deleted with this change, along with the code path that honoured it. A
missing options file is a failure again, for every group.beforeTool hook can raise a suspension, and now the contract says soSPEC.md §10 defined a suspension by the result — a ToolResult whose metadata.pending is a
Request — but only ever described a tool producing one. A beforeTool hook short-circuiting
with that same result suspends the run identically, and the guarded tool never executes. Five
ports already implemented and tested it; it was simply never written down, so nothing said it was
guaranteed rather than incidental.
§10 now names both paths (A: the tool returns it, B: a hook short-circuits with it) and requires
every port to resolve a hook-raised suspension through waitFor exactly as it resolves a
tool-raised one, and to halt with the hook's own Request when no waitFor is set. This is what
a three-state policy gate needs: a guardrail returns a string and has only allow and deny, so
"ask a human first" belongs on beforeTool. Two coverage gaps closed with it — Go lacked the
hook-raised-plus-no-waitFor corner (the one a durable approval queue actually ships on), and
Elixir had no path-B test at all.
A Guardrail returns a verdict string synchronously: "" or "allow" to permit, any other
string to deny. Handing it something else — most naturally an async function — did not report
an error anywhere. It silently did the wrong thing, and it did three different wrong things
depending on the port:
Promise/coroutine is truthy and is not
"allow", so an async guardrail denied every tool call in the run, with the reason rendered
as denied: [object Promise] / denied: <coroutine object …> (Python additionally leaked an
un-awaited coroutine). An agent wired this way could not call a single tool, and nothing said why.is_binary / string?
test and fell through as an allow, so a policy check silently widened — the one direction a
guardrail must never fail.Guardrail type returns a string.All four dynamic ports now raise immediately, naming what came back and where asynchronous work
belongs (a beforeTool hook, which is awaited). The contract is unchanged and stays synchronous
in all seven ports, so every guardrail that was already correct keeps its exact behaviour; only
the previously-silent mistake is now loud. If you need a judgement that takes I/O — a policy
service, a classifier — put it in beforeTool rather than in a guardrail.
Run/Ask accept a nil toolkit as "no tools" (golang)client.Run(ctx, prompt, nil) panicked with a nil-pointer dereference, although §8 already
defines what an empty tool list does (the tools key, and tool_choice on the openai style, are
omitted from the request entirely). Passing nil is the obvious way to say "this call needs no
tools", so it now means exactly that: Tools, ToOpenAI, ToAnthropic, ToGemini,
SkillsPrompt, Get, Execute and McpStatus are all nil-safe on the receiver. Building an
empty Toolkit still works and is unchanged.
The page claimed the runtime "does not surface" beforeTool/afterTool, and listed that under
its honest limits. That stopped being true when the harness and loop shipped: an agent spec takes
hooks, which the runtime forwards verbatim, and guardrails, which compile into one
beforeTool. The page now explains when to wrap a tool and when to use a hook — wrap when the
rule belongs to one tool and should travel with it, hook when the policy spans many — and the
limits list carries the guardrail's synchronous contract instead.
SPEC.md §7A pins the contract: a ctx abort means "A2A task <id> canceled" with
metadata.state == "canceled". That held when the abort landed between polls. When it landed
while a SendMessage/GetTask request was in flight, four of seven ports reported whatever the
transport happened to raise instead — Java surfaced the literal string
"java.lang.InterruptedException", Go returned context canceled with the state still
submitted, and Elixir had no abort semantics at all: Context.signal existed in the struct and
nothing in the port ever read it.
Every error and exit path of an agent tool's execute now re-checks the abort signal before
reporting a transport error, so an abort observed anywhere — between polls, mid-SendMessage,
mid-GetTask — produces the same result. Each port gained tests for both in-flight cases.
Worth naming because it explains a symptom you may have seen: in the JS port this defect class had already shown up as a "flaky" test that failed roughly twice in twelve runs. It was not flaky. It was intermittently catching a real defect, which is what prompted the cross-port audit.
Closes #64. Tracked in openspec/changes/audit-a2a-cancel-mid-request.
Every port filtered an MCP CallToolResult's content[] down to text parts and silently
discarded the rest. Point toolnexus at a screenshot tool, a chart tool, or Playwright MCP and
you got an empty string — no error, no log line, no way to distinguish "the tool returned
nothing" from "your picture was thrown away". It was specified that way (SPEC.md:218), so it
was a contract bug, not a slip, and it was identical in js, python, golang, java, csharp, elixir
and clojure.
Non-text content now becomes ToolResult.parts: image, audio, embedded resource, and
resource_link — a fifth block type that every SDK exposes and that we were dropping too.
Parts are collected on every branch, including the structuredContent and isError
short-circuits that returned before the content list was ever read; a server sending structured
content and an image kept neither before, and keeps both now. A text-only tool result is
byte-identical to before, down to the absent parts key.
This is a behaviour change. If you relied on non-text MCP content vanishing, it no longer does — see the unsupported-part rule below, which is designed so that this cannot break a run that works today.
You could not hand the loop an image, and a tool could not return one. Both now work.
await client.run(["What is broken in this screenshot?", await attach("./shot.png")], { toolkit })
Parts go in the first argument, alongside your text, because the order of text and image is
semantic to a model and an {attachments} option throws it away. Passing a plain string is
unchanged and byte-identical — no existing call site moves.
A ContentPart is text, image, file or audio, carrying base64 data or a url plus a
mimeType. It never holds a filesystem path: a path does not survive a transcript being
persisted and replayed, or crossing into a subagent, a served toolkit, or an A2A peer. The
convenience lives at the edge instead — the constructors take a path, native bytes, or a data:
URL and normalise immediately, so the disk touch is one legible call and the part that reaches
your transcript is portable. Mime type comes from a fixed extension table shared with read;
it is never sniffed and never resolved through the platform mime database, whose contents differ
per machine and would quietly break cross-port parity.
The edge takes what you already have. A caller holding an InputStream, a FileInfo, a
Blob, an io.Reader or an open file handle should not have to convert it by hand — that tax is
paid in every calling program instead of once in the library, and it is the same tax as making
you base64 things yourself. So each port accepts its own native sources: File/Blob/
ArrayBuffer in js, os.PathLike and any binary file-like object in python, io.Reader/
fs.File/fs.FS (so an embed.FS works) in golang, java.io.File and InputStream in java,
FileInfo and Stream in csharp, iodata and File.Stream in elixir. Clojure takes a path or
bytes and says so — java.io.File is JVM-only and cannot appear in dual-host .cljc, so
rather than fake it that port gives you a named error telling you what to pass.
The rule is accept broadly, store narrowly: whatever goes in, what lands in your transcript is bytes and a mime type. Streams are read eagerly at construction — a part holding a half-read stream would survive a replay no better than a path does — and a handle you supplied is read, not closed; disposing it stays yours.
Tools can return parts too — ToolResult gains an optional parts alongside its still-required
output, so the transcript, compaction and any text-only provider keep seeing text.
read now returns an image part for a recognised media file. golang only: File(path)
carries its read error until RunParts surfaces it, so attaching a file adds no second err to
handle. python only: reading a binary file used to raise an unwrapped UnicodeDecodeError
straight into the client loop rather than returning an error result; it now returns one.
The two provider styles disagree, and the disagreement is load-bearing. Anthropic accepts image
blocks inside tool_result.content. OpenAI rejects them outright — Image URLs are only allowed for messages with role 'user', but this message with role 'tool' contains an image URL, a hard
400, verified live rather than assumed.
So parts ride natively where the style has a shape for them, and only for openai are they
relocated into a single synthetic user message after the last tool message, in tool-call order,
each labelled with the tool it came from. Relocating on every style would have been simpler to
describe and worse to use: it discards the tool_use_id association, breaks cache breakpoints,
and makes the model read tool output as user input. The synthetic message is an adapter artifact
and is never written to RunResult.messages or the ConversationStore, so switching provider
mid-conversation leaves no OpenAI-shaped residue.
Silence is what this whole change exists to remove, so nothing is discarded quietly. But
erroring on everything would have been its own regression: an MCP server that volunteers an audio
clip would start failing runs that succeed today. The rule therefore follows intent. A part
you attached that the provider cannot represent is a typed error before any HTTP call — you
asked for something specific and silently changing it is the betrayal. A part that merely arrived
from a tool degrades to a named placeholder with a warn-once. onUnsupportedPart: "error" | "text" overrides both.
The guard is a positive allowlist over the encoded block, not a mapping that hopes for the best. That is not caution for its own sake: sending an unrecognised block type upstream returns HTTP 200 with the content silently discarded — the same failure this release fixes, one layer up.
anthropic names audio as a refusal, because the provider defines no audio block.
clojure only: that "typed error" is a value, not a throw — the port's rule is that nothing
crosses a source boundary as an exception. An unsendable part (attached-and-unsupported, both
data and url, an unknown extension, over :max-part-bytes) comes back as a RunResult with
:status "incomplete", :limit "contentPart" and an :error {:code :message}, and no HTTP
request is made. The edge constructors follow the same rule: they return an error part carrying
the reason rather than raising, the way the Go port's File(path) defers its read error.
SPEC.md §11)The spec said an array content is flattened to text. Six of seven ports actually passed a
text-empty array through raw and undocumented — one of them with a comment explaining it. Both
the spec and the code now carry one rule: text parts concatenate, non-text parts translate. The
seven ports agree by specification rather than by coincidence.
Verified live, and worth knowing before it wastes your afternoon: an image sent to
openrouter.ai for an Anthropic model arrives as HTTP 200 with the image silently
discarded. Measured with the 82-byte examples/media/fixture.png, as a prompt-token delta:
| model, via OpenRouter | delta | |
|---|---|---|
anthropic/claude-haiku-4.5 | +4 | dropped (5/5 trials) |
anthropic/claude-sonnet-4.5 | +4 | dropped |
openai/gpt-4o-mini | +8500 | delivered |
google/gemini-2.5-flash-lite | +258 | delivered |
Switching to style: "anthropic" does not help — OpenRouter's Anthropic-compatible
/v1/messages drops it the same way (14 → 18 input tokens for a hand-written native
source{} block with no toolnexus in the path). The route is the problem, not the block
shape. To send images to an Anthropic model, point baseUrl at api.anthropic.com
directly.
Nothing in toolnexus can fix this; the block we emit is the documented one, and the loop
completes correctly. The danger is that the model then answers anyway — ours replied
"Blue, Red, Yellow, Green" about an image it never received. That is why
scripts/live-multimodal-check.py and every port's examples/multimodal.* prove arrival by
prompt-token delta and never by reading the reply.
Tracked in openspec/changes/add-multimodal-content:
ClientStyle is
openai | anthropic, and toGemini only emits tool declarations for a caller's own client.
Attachments work on both implemented styles; Gemini needs a client first.fileId — the answer to sending the same 5 MB PDF on twenty turns. Deferred.countTokens does not exist in that port, so the compaction mitigation that
charges for part bytes has nothing to hook into there yet.createInProcessClient advertised "no apiKey" and then failed without one: the client resolves a
key from the environment and errors when it finds none, so an in-process model — which has no
endpoint to authenticate to — still demanded one. Every local run passed because a developer shell
has OPENROUTER_API_KEY set; CI, which has none, failed the in-process tests in all seven ports
at once. The constructor now supplies its own sentinel so that resolution never runs, and js/python
pin it with a test that strips the variables from the environment first.
createInProcessClient: a model in your process, with no wire to configure (all ports)Running a model inside your own process was possible, and it made you lie three times: a baseUrl
that is never dialled, an apiKey for an endpoint with no auth, and a style for a wire that does
not exist. Two of those were already optional and were only ever noise in our own docs. baseUrl
was genuinely required — and omitting it did not error, it crashed with
Cannot read properties of undefined (reading 'replace').
The bigger tax was the envelope. The seam is HTTP-shaped, so a host that only wanted to answer a
question had to build an HTTP response first — a Response in JS, an *http.Response in Go, an
HttpResponseMessage in C#, and in Java a 94-line HttpClient subclass. Every example in our
own cookbook opened by defining the same LocalModel.generate wrapper to hide it. When every
example needs the same adapter, the adapter belongs in the library.
createInProcessClient({ model: "my-local", generate: (req) => ({ content: "…" }) })
generate returns one assistant message — {content} to finish, {toolCalls} to call tools —
plus optional usage. The library derives finish_reason, builds the choices envelope, and
encodes tool arguments unless they are already a string. Tool calls are flat
({id, name, arguments}): the nested function:{} wrapper is a wire detail, not something a model
author should have to type.
It is an ordinary client. MCP servers, agent skills, sub-agents, hooks, metrics, conversation memory and the completion gate all behave exactly as against a hosted model, because this is a second constructor built on the shipped transport — not a second seam. That transport is unchanged and remains the answer for proxy, mTLS, credential injection and record-replay.
Shaped against the ecosystem rather than invented: Vercel's LanguageModelV4
(doGenerate → content/finishReason/usage), Microsoft.Extensions.AI's IChatClient and Pydantic
AI's Model all take messages in and a response out, and none requires a base URL or key — that
is an SDK-level pattern, not a framework one. All three also ship a built-in fake model, treating
this case as first-class.
Failures are final, not retried. A network client rides out transient failures; an in-process
one has no wire, so there is nothing transient — whatever generate throws will throw again, and
retrying only buys backoff before you see your own bug. Measured: the streaming refusal took 3.7s
to surface before this default and 1ms after. A genuinely flaky model can opt back in with
retries — or, in golang, with OnError, because that port documents Retries: 0 ⇒ 2 and zero
cannot mean zero there without changing shipped semantics for every network client.
Streaming is refused, not faked. A generate returns a whole answer, and a single-chunk stream
is indistinguishable from a real one by content or delta count, so the streaming path raises with a
message naming the limitation. The exception is clojure, which has no streaming entry point at all,
so there is nothing there to refuse — stated rather than papered over.
docs/adr/0019 rejected a semantic callback as a replacement for the transport seam; layering
one on top is a different claim, and the ADR is amended rather than quietly contradicted. Tracked in
openspec/changes/add-in-process-client.
An agent framework has two things you must be able to name: the harness (everything the agent
may do — tools, identity, team, ceilings, policy) and the loop (a live execution of it).
toolnexus already shipped both and named neither, so every user invented their own vocabulary.
harness() now puts the word in the API without adding a type — a spec built through it and one
written inline are indistinguishable, so there is nothing to migrate. loop() opens a live
execution that reports status, turns and an Outcome whose stop reason is always named.
The loop deliberately takes no configuration: capability belongs to the harness, per-call
choices to the run options, and the loop only reports what happened. That is why model sits on
the run options — one conversation may change model between turns.
The part that is genuinely new is the gate. An agent could report done while its own declared
plan was still unfinished. You could bolt a retry loop on the outside, but a host-side loop
cannot follow a delegation — when agent A hands work to B via task, B runs to completion
inside the runtime and A's caller never sees it. Putting completion = {verify, maxAttempts} on
the harness is what makes the check travel with the agent. A built-in verifier, allTodosDone,
reads the shipped todowrite builtin and requires every declared item to be checked; it is
structural, never learns what a todo means, and passes when no plan was declared.
Six rules, each found by prototyping rather than design, and each tested in all seven ports:
done run is never re-judged, so the gate cannot turn a pending into an incompletemaxAttempts is required — an unbounded verify loop is a DoS on your own billincomplete plus a structured limit: "completion"Guardrails ship alongside: policy-only checks on tool calls composed into one beforeTool with
first-deny-wins, so a later guardrail can never widen an earlier denial.
Absent ⇒ byte-identical. No guardrails and no completion is the existing path, unchanged. No new
status strings were minted — SPEC.md pins TaskStatus across ports, so the gate reuses
incomplete and distinguishes itself via limit.
todowrite did not return its {todos} metadata (clojure)Six ports attach the todo list as result metadata; clojure returned only the rendered text. Nothing had ever read the plan back, so nothing caught it — until the completion gate, which uses exactly that metadata to see which items are still open. In clojure the gate therefore passed an unfinished plan silently. Fixed, with the parity note recorded in the builtin itself.
Tracked in openspec/changes/add-harness-and-loop.
Retry-After meant seven different things in seven ports (all ports)SPEC.md says the LLM request retries "honoring Retry-After". Every port implemented that
sentence, and no two of them agreed on what a header value means. Handed the same 429, the ports
waited for different lengths of time — and three of them could be made to misbehave outright:
Retry-After: 5.9 waited 5.9s in python and js, 5s in
elixir (Integer.parse stops at the first non-digit, so it silently truncated), and fell back
to backoff in golang, java, csharp and clojure.NumberFormatException thrown from inside the retry
path) and in clojure (parse-long answers nil past a long, and (* 1000 nil) throws), and
parked python, js and elixir on a sleep measured in millennia.Retry-After: 0 — the server saying "retry now" — was discarded as "no opinion" by python,
js and elixir, which then applied backoff anyway.All seven ports now implement one rule: honour Retry-After only in its delay-seconds form,
a run of ASCII digits (RFC 9110 §10.2.3) in 0 … 2147483647, waited as exactly that many whole
seconds, including 0. Everything else — fractional, signed, the HTTP-date form, out of range,
empty, unparseable — falls back to exponential backoff, and can no longer raise, wait a negative
duration, or retry without delay. The HTTP-date form stays deliberately unsupported, uniformly:
it buys one avoided backoff in exchange for portable date parsing in seven languages, and a server
that sends it now gets our backoff rather than a wrong answer in some ports and a crash in others.
What you may notice: if a provider sends a fractional Retry-After, python and js now back off
instead of waiting the fraction, and elixir no longer truncates it. Every port gains a regression
test pinning the same table of inputs — there was previously no test for this in any port, which
is why the drift survived seven ports and a conformance suite. Tracked in
openspec/changes/fix-retry-after-parity.
Not done: an honoured delay is interruptible by the whole-run deadline in five ports (golang
sleep(ctx, …), python _sleep(…, deadline), js delay(ms, signal), java and csharp
sleep(ms, deadline)) but not in elixir (Process.sleep) or clojure (ktime/sleep!), which
sleep the full delay and only notice the deadline on the next attempt. With the range now capped at
~68 years that is a long-tail annoyance rather than a hang, but it is a real remaining divergence.
It is a bounding question rather than a parsing one, so it is deliberately left out of this change
and not yet tracked by its own proposal.
canceled reliably (js)Aborting an in-flight A2A tool call reported one of three different states depending on when the
signal landed. Between polls it correctly returned canceled; if the signal fired while a
SendMessage/GetTask request was in flight, the aborted fetch threw and the result carried the
last known state (submitted or working) plus a raw transport error message instead. SPEC.md
§7A pins abort ⇒ "A2A task <id> canceled", and the golang port already honored it on that path —
this was js drifting from the shared contract, so a host could not treat the metadata state as
meaningful for a cancelled call.
Fixed in js only. The other five ports are not audited for the same hole; golang is known
correct. If you rely on this, check your port.
A long-running agent on style:"anthropic" could, the first time compaction fired, produce a
transcript the Anthropic API rejects. Compaction keeps the most recent tail and requires that tail
to begin at a user turn, which guarantees tool-pair safety in the OpenAI dialect — a tool result
there is a tool message. Under Anthropic a tool result is a user message carrying
tool_result blocks, so the tail could begin on the tool result itself while the assistant
message holding the matching tool_use was summarized away. The provider rejects a tool_result
with no tool_use, so the agent broke at exactly the point its context filled up.
The tail boundary is now dialect-neutral: a user turn carrying tool results is not a boundary, and
the tail extends back to a genuine one (the same "safety over size" fallback the rule already used).
OpenAI-style transcripts are unaffected — every user message remains a boundary and output is
byte-identical. Fixed in all seven ports.
Not done: the underlying reason the rule could be dialect-bound at all — conversation history is
stored in whichever provider dialect produced it — is tracked in
openspec/changes/add-canonical-transcript, which makes a tool result a first-class message kind so
"a user turn" can no longer mean "a tool result". Regression tests currently ship in golang, js and
python; java, csharp, elixir and clojure carry the fix and pass their existing suites, with the
shared fixture tracked in openspec/changes/fix-compaction-tool-pair-dialect (task 1.3).
The whole benchmark suite was re-measured in one sitting on 2 August 2026 — 39 framework
configurations across seven languages — so no cell on the results page is a splice of two
different days or two different toolchains. benchmarks/results.json,
docs/performance-benchmarks.md and the site's Performance
page all carry the same run. Nothing was skipped; results.json now records a skipped list
so that a framework that fails to stand up in a future run is named rather than quietly missing.
benchmarks/run_toolnexus_clojure/) and measures both hosts
from one .cljc file — toolnexus-clojure-jvm via clojure -M, toolnexus-clojure-cljgo via
a cljgo build AOT binary. The performance page had deliberately stayed six-language because
no seventh runner existed; it is seven-language now for exactly that reason and no other.run_all.py now registers every runner that exists (JS, Elixir, the Go competitors, Clojure)
instead of a subset, accepts both runner output shapes, and can run a runner in its own working
directory. benchmarks/README.md documents the full set, plus two install traps found on the
way: LangGraph and Google ADK need mcp<2 pinned (2.0.0 removed symbols their adapters
import), and CrewAI needs the crewai-tools[mcp] extra or its MCP adapter aborts on a prompt.js/package-lock.json had been left at 0.10.0 while package.json moved to 0.13.0; running
npm install re-syncs it. Nothing user-visible changed, but a lockfile that disagrees with its
manifest is the kind of drift a release should not carry.full, not yet published)One .cljc source tree on two runtimes: Clojure on the JVM and
cljgo (Clojure hosted on Go). Not two implementations
that agree — one implementation, byte-identical on both, with zero reader conditionals;
every host difference lives behind koine, which is the
port's only third-party dependency.
Implemented: SPEC §0.1–0.2 (Tool/ToolResult, sanitize), §0.3+§2 (MCP over stdio and
streamable-HTTP, per-source isolation), §0.5/§0.6/§3 (agent skills, byte-exact skill output),
§0.7 (OpenAI/Anthropic/Gemini adapters), §0.8/§0.9 (native + HTTP tools), §0.10/§8 (the client
loop, both provider styles, parallel tool calls), §0.11/§4A (builtins and MCP precedence),
§0.12/§10 (suspension), §7A/§7B/§7C (A2A out, serve in, MCP server in).
Also landed from the shared capability specs: :request-params, :body-transform,
:http-client, :retries/:retry-base-ms, :timeout-ms, :on-error (retry|fail — no
failure-originated suspend tier), :on-metric, :store + conversation memory.
The port is at tier full — every logical client and toolkit option present, zero
permitted absences, the same bar as the six shipped ports.
Since landed: client hooks (:before-llm / :after-llm / :before-tool / :after-tool),
§11 single-turn translation, the MCP elicitation bridge (§2/§10, on both
transports — see below), and all twelve toolkit options (:skill-provider, :skills-filter,
:skill-sample-limit, data skills, :agents, toolkit :wait-for, :disable-tools /
:disable-skills).
Still absent, and the option gate structurally cannot see any of it (it compares option
NAMES in two files, so a missing subsystem has no names to compare): the agent runtime (§7D) and
sub-agents, plus the two §7E entry points that need them (from-dir, start-agent). Context
compaction (§7F) and the rest of agent home shipped — see the next entry. Those remaining gaps
are what hold this port back from Clojars: not a tier downgrade, a subsystem not yet written.
Note :agents is the A2A option (remote agents behind an Agent Card),
which is a different capability from openspec/specs/subagents — that one remains unshipped
here and is not satisfied by it. clojure.core/agent exists on both hosts, so a future
subagents entry point cannot be named agent.
MCP elicitation now works on streamable-HTTP too, not just stdio — the gap reported here
previously is closed. It was koine's, not §2's: koine.http/request buffers the whole body (so a
server→client reverse request arriving mid-tools/call can never be seen in time) while
koine.stream/sse-post streamed but exposed no response headers — and MCP carries session
identity in the Mcp-Session-Id RESPONSE header that the reply must echo, so a consumer had to
choose between streaming and the session id. koine 0.10.0 added {:on-open f} to sse-post,
applied once to {:status :headers} while the stream is still open. The HTTP transport now
switches to the streaming leg as soon as a server answers in text/event-stream, maps an
elicitation/create onto the same one §10 waitFor as stdio (form ⇒ kind:"input" with
requestedSchema in data.schema; URL ⇒ kind:"authorization"), and posts the Answer back on
its own request carrying the session id — inline, so the in-flight tools/call resumes and
the tool is not re-executed. A JSON-only peer keeps the buffered leg unchanged.
A silent cross-host bug fixed with it: response header CASING. The two runtimes' HTTP clients
disagreed about the case of the names they hand back — java.net.http lowercases, Go's
http.Header canonicalises — so (get (:headers res) "Mcp-Session-Id") found the value on cljgo
and nil on the JVM, and the lowercase spelling did the exact reverse. No portable spelling
existed, and it failed silently, because a missing header and a mis-cased one are both nil: the
client would simply stop echoing the session id and the server would start a new session per
request. koine 0.10.0 lowercases response header names on every host and adds
koine.http/header for a case-insensitive read; every response-header read in the port (MCP
session id, MCP content type, the client loop's Retry-After) now goes through it, and the
port's own private copy of that normalisation is gone. Regression-tested against a real
loopback peer that issues the SAME session id under two different spellings of the header name —
either spelling alone is a state where a correct and a broken client coincide on one of the two
hosts.
§11 divergence, recorded not resolved. SPEC §11 says any tool call ⇒ finishReason
"tool_calls". js, go, python and elixir all prefer the provider's own finish_reason when
present, so an OpenAI-style provider returning "stop" alongside tool calls yields "stop" —
the prose is violated in four shipped ports. The Clojure port matches the five ports, not the
prose. Correcting it is a cross-port change.
Verified in five execution modes, not two: jvm-main, jvm-repl, cljgo-aot, cljgo-run,
cljgo-repl — a REPL is where a human meets a library, and cljgo's own ADR 0007 calls a
REPL-vs-binary divergence unforgivable.
toolnexus.agents.compaction/compactor returns a :before-llm hook that summarises the older
transcript and keeps a recent tail, so a long-lived agent stays inside the model's context
window. Below :max-tokens it is a no-op and the run is byte-identical to one with no
compactor. toolnexus.agents.home/compose-soul composes a persona's bootstrap files into one
system prompt — the directory is the agent — and home/memory-tool gives it durable notes it
edits itself, with a write landing on disk immediately but loading only at the start of the next
session.
from-dir and start-agent (the heartbeat) are the two §7E entry points still missing here:
both compose an agent definition, so both need the §7D runtime this port does not ship yet.
Two upstream defects were found by writing these:
cljgo: a descending range was an inconsistent seq — (range 6 1 -1) counted 5 and
mapped to five elements while seq/vec/doall/some/filter traversed it as (6), and
the 2-arity (reduce + coll) returned 6, the first element. Nothing threw, so any code
walking a collection backwards was silently wrong on cljgo and right on the JVM. Root-caused to
three ascending-only comparisons in LongRange, fixed in cljgo v0.9.0 (PR #194).
One correction, since the first version of this entry got it wrong and koine caught it: the
seeded 3-arity (reduce f init coll) was CORRECT at the Clojure level — clojure.core
does not route it through the broken method for this type. The broken Go method
(LongRange.ReduceInit) is real, and our Go-level test measured it returning init; the
Clojure surface simply never reached it. Both measurements were right about different layers,
and only the Clojure one is what a user could hit — so an audit grepping for a seed-returning
reduce would clear code that is actually broken and miss (reduce f coll), the form that
failed.
The documented Clojure examples did not all run. They do now: site/tests/runners/clojure.sh
executes every one of them four ways — JVM main, JVM REPL, cljgo interpreted, cljgo AOT —
and caught a call to a function that does not exist, an invented build.cljgo verb, and a
broken success contract, all in freshly written documentation.
clojure/examples/clj/ and clojure/examples/cljgo/ are two projects over one symlinked source
tree with five runnable examples each (MCP + skills + native, native/HTTP tools, progressive
disclosure, persona memory, compaction), verified in CI on both hosts.
The agent layer is now whole. from-dir (the directory is the agent) and start-agent
(the heartbeat, on the runtime's injectable clock — deterministic under a virtual clock) landed
on the §7D runtime, plus :on-budget — the §7D host budget callback
(stop | extend | suspend, "suspend" parking on a §10 approval). js and golang already ship
onBudget; python, java, csharp and elixir do not — a pre-existing gap now named here so it
cannot go quiet. The runtime's previously-untested edges (maxWallMs, the tool-call pool, forced
close, wake-on-closed, model inherit, def-level on-metric) are each covered by a test that was
watched to fail.
An adversarial audit found three shipped defects, each proven by mutation before fixing:
tool/execute only, and both hooks ran outside it, so deliver never fired and the deref
never returned. Now every exit delivers, and a hook's throw is rethrown on the calling thread,
matching the shipped ports.{:tools {"bash" false}}
and any JSON-read config left all ten builtins armed. Keys are normalised now, the same rule
the §3 skills filter always had.write reported a different byte count on each host, and both were wrong — utf8-count
folded code units, so one file was "8 bytes" on the JVM, "5" on cljgo, and 6 in truth.A whole class fell with them: nine host-dependent sorts. sort orders by UTF-16 unit on
the JVM and UTF-8 byte on cljgo, so every sorted output surface — the §0.6 <skill_files>
block, the §3 catalog and not-found list, glob, the §7B Agent Card skills[], and mcp.json
server order, where which server wins a name collision could depend on the host — now goes
through a code-point comparator. Three sorts were deliberately left: their inputs are
ASCII-by-construction, and a change that cannot be made to fail is not a fix.
The long-standing "cljgo-only flake" was ours. A fixture pinned at a fixed relative path let concurrent suite runs trample each other — one run's delete mid-rebuild while another read, which also produced our historical short-count aborts. Process-unique temp dirs; proven at 5-concurrent red before, 6-concurrent green after, on the JVM. The load-sensitivity hypothesis this had fed upstream was withdrawn the same day.
And the gates that let all of this ship green got teeth: the suite registry is counted and
cross-checked (a dropped suite now fails by name, not by a floor 3× too loose), the five
execution modes must agree with each other to the assertion, the §0.11 test that asserted an
unreachable collision now drives a real MCP peer, and a new env-chain-check.sh proves the
API-key fallback chain from outside with fake keys — the one §8 behaviour no in-process test
can reach. Verified live end-to-end against a real provider on both hosts (2 turns, 1 tool
call, identical output) — the port's first live-LLM run.
393 tests / 1608 assertions, five execution modes, both hosts in exact agreement.
conformance/check_options_parity.py now tokenizes kebab-case. It previously split on
-, so a Lisp port could never match an option name and reported all 23 as missing when only
20 were. Applied by file extension: widening it for C-family languages would glue unrelated
tokens together and manufacture false PASSES, which is the worse direction for a gate.conformance/options_manifest.json —
full (every option) or core (the §0 conformance contract). A full-tier option missing from
a core-tier port is debt, printed by name on every run, never a pass: a permitted absence
that stops being reported is indistinguishable from one that was implemented. The tier lives
in the shared manifest so lowering the bar is a visible diff the other ports review.<skill_files> block, §3 catalogs, adapter order) diverges
between the two camps for a non-BMP tool or skill name. Harmless for ASCII names, which is
every name in the shared fixtures. Fixing it means SPEC.md pinning one order and three ports
moving — a cross-port change, tracked here until an OpenSpec change picks it up. (cljgo
aligning its compare with the JVM, requested upstream, would not close this: it would only
move clojure between camps.)Adds single-turn translation (SPEC.md §11, ADR-0011) — the inbound half of the format
adapters. Additive: nothing existing changes, and no port behaves differently unless you call
the new entry point.
SPEC.md §0 item 7 pinned the adapters as schema only: toOpenAI/toAnthropic/toGemini
translate tool declarations outbound, and nothing read a provider's tool calls back
inbound. Every user of those public functions hit the same wall — they could tell a
provider about their tools but not receive the calls it made. So the library served one
posture well ("the library executes tools in a loop") and the majority posture — "I want
provider-portable tool calling, but I execute the tools", the premise of the entire
OpenAI function-calling protocol — not at all.
New: translate (idiomatic naming per port). Exactly one provider call, returned in
OpenAI shape. No agent loop, no tool execution, no conversation state — every call is
self-contained, so it can be run statelessly and scaled horizontally.
messages, tools and tool_choice verbatim, so a caller
never builds provider-native payloads. It also accepts an ordinary toolkit — MCP tools,
skills, native functions, A2A agents, builtins — which is declared and never executed.
The two tool sources compose.tool_calls become native tool-use blocks with arguments re-parsed from
its JSON string into an object; a tool-role result becomes a tool-result block keyed by
tool_call_id, merged into one user turn when consecutive; system/developer messages
are hoisted into the provider's separate field; content-parts arrays are flattened. Both
arguments wire forms (JSON string and object) are accepted.text, toolCalls with arguments as a JSON string (the wire
form, echoable byte-for-byte), a mapped finishReason — any tool call wins, giving
"tool_calls" — plus usage, model and the raw response. No tool call is dropped or
truncated.llm
observability event. beforeLLM/afterLLM fire once; tool hooks never fire, because no
tool runs.Parity verified by byte-diff, not assumed (spike 0003). One adversarial fixture hitting
every §11 rule at once was run through all six ports and diffed: js, python, java,
csharp and elixir are byte-identical to golang, first diff, no corrections needed.
The agreed output is committed at docs/spikes/0003-translation-parity-fixture.json so a
future port or refactor can be checked against it directly.
Test counts: js 13 · python 20 · golang 12 · java 13 · csharp 20 · elixir 27. Every port's full suite green; Elixir coverage 96.9% (gate 95).
Also adds golang/examples/translator — a stateless OpenAI-compatible proxy in ~60 lines.
golang ONLY, a previewgolang/ also gains relay (declaration-only) tools and an answer-carrying durable resume
(RelayTool, RunWithAnswer/AskWithAnswer) built on the §10 suspension primitive — ADR-0010,
issue #37. The other five ports do not implement this yet, so it is deliberately not part of
the SPEC.md §0 conformance contract; the §10 subsections carry a status banner and the remaining
ports are tracked as unchecked tasks in openspec/changes/add-tool-relay-mode/tasks.md. Saying so
out loud rather than letting parity drift silently is the point.
If you want cross-port behaviour today, use §11 translation. ADR-0011 explains the split: translation is the right mechanism when the caller owns the conversation (the pass-through posture, ~95% of proxy traffic), and relay is for proxy-managed memory, where toolnexus owns the conversation and the caller sends only the new message. Two postures, two mechanisms.
golang relay is green — 24 tests, -race clean, and the pre-existing hardened §10 concurrency
tests pass unmodified.
Fixed: python pinned mcp>=1.0.0,<2.0.0. mcp 2.x renamed streamablehttp_client to
streamable_http_client, which broke mcp_source.py at import time — the whole package failed
to load for anyone resolving a fresh 2.x. Pinned until the rename is adopted.
Makes §7F compaction actually reachable from a §7E persona agent. SPEC.md §7F defined
compaction as a use of the §8 beforeLLM hook, but the §7D agent runtime built each
handle's client internally and forwarded no hooks — so the spec promised a capability its
own runtime could not deliver. All six ports; nothing changes unless you opt in.
The §8 seams on a §7D agent run (SPEC.md §7D "The §8 seams on an agent run",
OpenSpec change expose-agent-runtime-hooks, driven by docs/adr/0008). hooks and
onMetric are now optional on both the agent runtime and an individual agent
definition — spelled as each port already spells them (hooks/onMetric in js,
hooks/on_metric in python and elixir, Hooks/OnMetric in golang and csharp,
hooks(...)/onMetric(...) on the java builder). Four rules hold identically everywhere:
resolved def-over-runtime, replace never merge, each field independently (so an agent
may override hooks and still inherit the runtime's onMetric); forwarded verbatim,
never composed, wrapped, reordered, defaulted or read; not a route to alter the
handle's composed soul, its §10 escalating waitFor, its turn-gated HTTP seam or the
runtime-wide store (which is why it is two typed fields and not a configureClient escape
hatch); and unset ⇒ byte-identical to a runtime without the fields.
Per-agent is the point: two agents in one runtime can now carry different compaction
budgets, and a metric sink can attribute events to the agent that produced them. Ships a
shared examples/agent-hooks/fixture.json conformance fixture (scenarios H1–H6 plus four
invariants) cited by every port's test file.
golang: Runtime.ConversationStore(). The other five ports already exposed the
runtime-wide conversation store (store in js, conversation_store in python and elixir,
conversationStore() in java, ConversationStore in csharp); Go had no accessor, so a
caller had to inject its own Options.Store just to read a handle's transcript. Returns the
injected store itself when one was supplied. Read handle only — the store is still chosen at
construction. The obligation is now stated in SPEC.md §7D for all six.
before_llm hook was silently ignored. The client guarded on
is_function(f, 1) and otherwise fell through to the no-op branch — the hook simply never
ran, with no error. It now raises ArgumentError. Much easier to hit now that hooks can
arrive from two places.withBudget + copyWithRegistry; CloneWith +
CloneWithRegistry), all on the spawn / Level-1 path. Both now carry the new fields, pinned
by a clone test in csharp. Not a risk in the other four (golang copies by value, js spreads,
python uses dataclasses.replace, elixir uses Map.put).elapsed < 280 over two 150ms sleeps) and hit 524ms on a loaded CI runner. Now asserts the
peak number of tools in flight simultaneously, which serialized execution can never
reach; mutation-verified by forcing max_concurrency: 1.Both deferrals from docs/adr/0008 stand: a preCompact hook able to abort a compaction
(new control flow across six ports, awaiting downstream evidence) and cache_control
breakpoints (a provider-payload change). Each wants its own ADR.
The agent release: both agent archetypes — coding (sub-agents) and persona (agent home) —
ship on a shared actor-model runtime, with compaction to keep either alive over long
sessions. All six ports, byte-parity against the shared examples/ fixtures.
Context compaction in all six ports (SPEC.md §7F, OpenSpec change add-compaction).
An opt-in beforeLLM helper that keeps a long-lived or high-tool-volume agent under its
context window — additive, no core loop change. In the agents surface:
compactor({ maxTokens, keepTail, summarize, countTokens, flushToMemory }) returns a
beforeLLM hook that, once the transcript estimate exceeds maxTokens, replaces the older
body with one summary system message and keeps a recent tail; below budget it is a no-op,
byte-identical to no compactor. Two invariants: the retained tail begins at a user turn
(tool-pair safety — a tool result is never orphaned from its tool_call_id), and a
leading system prompt is preserved verbatim. summarize(older) is pluggable and MAY call an
LLM; countTokens defaults to ceil(chars/4) (estimateTokens, an estimator not a
tokenizer); flushToMemory injects a pre-compact reminder to persist durable facts via the
§7E memory tool before summarizing. Ships a shared examples/compaction/ conformance
fixture and a "keep a persona alive for weeks" recipe (compactor + flushToMemory + the
memory builtin) on the persona-agents docs page.
Persona agents (agent home) in all six ports (SPEC.md §7E, OpenSpec change
add-agent-home). The persona archetype over the §7D runtime — additive and opt-in, no
runtime change. In the agents namespace: fromDir(dir) (Python agent_from_dir, Java
agentFromDir) composes the bootstrap files
AGENTS/SOUL/IDENTITY/USER/TOOLS/HEARTBEAT/MEMORY.md (in that order, 2 MB/file cap) into a
frozen soul snapshot at session start; a file-backed memory builtin (memoryTool(dir),
actions add/replace/remove over MEMORY.md/USER.md) that writes to disk and loads
at the START of the next session — never mutating the live prompt, keeping a long-lived persona
cache-stable (a missing substring is a loud isError; opt out with memory: false); and
startAgent(agent, …, { everyMs }) — a heartbeat that posts a coalescing tick to the agent's
own inbox and wakes it to read HEARTBEAT.md, where a HEARTBEAT_OK reply stays silent.
Channels stay the host's job (wire inbound to post/wake). Ships with a runnable
examples/persona-agent/ ("Ava") + JS/Python/Go entrypoints, a "when to use which surface"
guide, and dream/consolidation + channel-assistant recipes (composition, no new API).
Agent runtime + sub-agents in all six ports (SPEC.md §7D, OpenSpec change
add-subagents). A new agents namespace per port (never colliding with the A2A
Agent): agent(name, { does, uses, soul/soulFile, team, budget, model, waitFor, onSpawn, onClose }) with .run(prompt) and .asTool() — an Agent IS a Tool. Delegation
runs through a built-in task { agent, prompt } tool (team-scoped, opt-in per
definition): isolated child transcript, one tool message back, usage roll-up, parallel
task calls. Underneath: a Handle state machine with six host verbs
(spawn/post/wake/wait/interrupt/close), two delivery rails, three loud backpressure
gates, hierarchical live-enforced budgets, §10 suspension escalation with durable resume
by task-key reattachment, and a per-port cancellation contract.
RunResult.status gains "incomplete" (QG5). A maxTurns stop that still had tool
calls in flight — on the plain client run/ask/stream loops as well as agent runs —
now returns status: "incomplete" plus limit: "maxTurns" (idiomatic casing per port)
instead of a silent "done". Any limit stop (turns, tokens, tool calls, wall clock) is
loud and names its limit; partial work and the transcript are preserved. Code that
matches status === "done" after hitting maxTurns must update to handle
"incomplete". The full closed status vocabulary is now
"done" | "pending" | "incomplete" | "interrupted" | "closed" | "timeout" | "error",
identical strings in all six ports.Can you improve this documentation?Edit on GitHub
cljdoc builds & hosts documentation for Clojure/Script libraries
| Ctrl+k | Jump to recent docs |
| ← | Move to previous article |
| → | Move to next article |
| Ctrl+/ | Jump to the search field |