Repository navigation
Conversation
A spike showing what adopting the lanok protocol kit looks like in mira, scoped to the one piece that is cleanly separable: `MAJOR.MINOR` negotiation. It fixes a real bug on the way. `MIN_PROTOCOL_VERSION` has always been published in `meta.json` and never checked. `version_compatible` compared majors only, so a study announcing a version this build had dropped support for connected anyway and failed later at whichever method it could not satisfy. Today the constant happens to equal the major floor, so nothing is observably broken; the moment mira raises it, the constant is silently ignored. A malformed version string is also refused now instead of parsing as major `0`, which made `"not-a-version"` merely incompatible rather than invalid. lanok-core is the same wire contract the yolop extension protocol follows, so the rule is implemented once rather than per protocol. The dependency is git and pinned while lanok is unpublished; it must become a version dependency before mira can be released again. Nothing else moves. Request/Response/Notification, RpcError, and the payload types stay exactly as they are, because swapping them is wire-visible and needs a decision rather than a refactor: lanok carries `retryable` inside `data` for strict JSON-RPC conformance, while mira carries it at the top level and both SDKs read it there.
…ification Deletes mira's hand-rolled copies of three things the yolop extension protocol also maintains, and keeps the wire identical. **`RpcError` and `codes`** (83 lines) are now re-exported from lanok-core, which implements exactly the shape mira already used: `code`, `message`, a top-level `retryable` hint omitted when false, and optional `data`. Adopting it required fixing lanok, not mira: lanok had buried `retryable` inside `data` on a strict reading of JSON-RPC that the spec does not require, which would have broken every existing study. It also needed `with_code`, and better field documentation, since the schema artifacts a protocol publishes carry those doc comments and lanok's said less than mira's did. **Message classification** (47 lines) is now `lanok_core::Message`. The rule it encodes is the one mira's comments call out as the safety property: a line bearing `method` is a request or a notification, and only a `method`-less line is a response, because a reverse request's id lives in the study's own id space and both sides number from 1. Misreading one completes an unrelated in-flight request with an empty response. That is worth having one implementation of. **Version negotiation** now enforces `MIN_PROTOCOL_VERSION`, which mira has always published in `meta.json` and never checked, and refuses a malformed version instead of reading it as major `0`. Two behaviour deltas, both narrow and both tested. A missing `code` reads as `INTERNAL_ERROR` rather than `0`, which is how an error that declines to classify itself should be interpreted, and `0` is not a JSON-RPC code; retry behaviour keys on `retryable` and the message, not on this value. A response with neither `result` nor `error` now surfaces as a deserialization failure at the typed call site rather than the string "empty response". Net 204 lines removed, 99 added. The schema diff is five lines: the same fields with better descriptions, and `int32` becoming `int64`.
The adoption claimed the wire was unchanged. That was asserted, not measured,
so this measures it: the serialized bytes for every envelope and error shape,
compared against what main produces.
Six of seven are byte-identical. One is not. `retryable` used to be written
even when false, and lanok omits it:
before {"code":-32603,"message":"boom","retryable":false}
after {"code":-32603,"message":"boom"}
That is inside mira's own forward-compatibility contract rather than a break.
Every implementation already defaults the field (`retryable: bool = False` in
the Python SDK, `retryable?: boolean` in the TypeScript one, `#[serde(default)]`
here), and the published schema lists only `message` as required. Both SDKs
drive the adopted host green without modification, and the schema again
declares `default: false`, so an implementor is told what absence means rather
than left to guess.
The tests pin both directions: the exact bytes written, and that an error
omitting the field parses the same as one sending it explicitly false. A future
change to either is now a deliberate act rather than a silent wire change.
|
CI is red, and I am not fixing it in this PR. The cause is one thing, and it is Blocker 1, not the diff. All four failing jobs (Lint, Test, Audit, Examples) die in the same place, within ~8 seconds, before compiling anything:
Nothing in the change is failing. Locally, with access to lanok, the full gate is green: Two ways to make CI green, both decisions rather than patches:
I have not done either: option 1 is a release decision and option 2 widens what mira's CI holds. The PR stays a draft until one is chosen. Generated by Claude Code |
The protocol was described twice and checked nowhere. Method names were
string literals at the host's call sites (host.rs:64, 90, 107, 148, 169,
187, 215) and again as match arms in the study's dispatch (study.rs:237
onward). Nothing made the two lists agree, and a method's direction,
whether it expects a reply, and which capability it needs lived only in
prose.
One declaration now says all of it, and both sides read from it:
- `method::*` replaces every literal on both sides, so a rename is a
compile error rather than a silent "unknown method".
- `capabilities::*` is generated from the `capabilities` block. The
tokens keep their documentation, which is the part that matters: a
token is a promise about behaviour, and the promise is what an
implementor on the other side needs. Generating a one-liner over the
top of mira's prose would have been the same downgrade the error-type
adoption already caught once, so lanok grew documented capability
tokens instead.
- `negotiation()` is the declaration's `NEGOTIATION`. PROTOCOL_VERSION
and MIN_PROTOCOL_VERSION stay strings, because meta.json and both
SDKs publish them that way; a test pins them to the declaration.
- `InitializeParams` types the handshake request, which was an inline
`json!` object. Both fields default, so a study SDK calling
`handle("initialize", {})` still parses.
The declaration also states the reverse-channel seam as structure
rather than a comment: everything the host calls is `initiator`, the
two notifications are `responder`, and a study to host request would be
an additive `responder fn`.
The wire is unchanged. 296 tests pass, the schema artifacts are
unchanged (`mira-schema-gen --check`), and the Python and TypeScript
studies drive the host green unmodified.
The generated stubs, handlers and dispatchers are not wired in yet:
that is the host.rs and study.rs migration, and it belongs in its own
diff.
… SDKs the method table `meta.json` is the artifact the Python and TypeScript SDK generators read. Its method list and capability tokens were typed out again in `mira-schema-gen`, a third copy of the names with nothing keeping them in step: adding a method to the protocol and forgetting this list published a `meta.json` describing the previous protocol, and both SDKs generated from it inherited the omission without a word. It is now `serde_json::to_value(protocol::META)`, the declaration as data. The two mira-specific keys stay, since neither is protocol vocabulary: `schema` names the sibling artifact, and `event_kinds` is the `event` payload's own token set. That makes the artifact say more than a name list. Each method carries its direction, whether it expects a response, the capability it needs, and its documentation, which is enough for a generator to emit a typed method rather than a string constant. Both SDKs take the first half of that now. Each had a hand-written tuple of the methods its serve loop dispatches, with a test asserting it covered `METHODS`. The list is the declaration's instead (`SERVED_METHODS`), so a new method arrives by regenerating. The test that guarded it was also wrong in a way the flat list could not express: it asserted that a study answers every method in the protocol, including `event` and `log`, which are the notifications a study *sends*. Direction splits them. Generating the typed method stubs themselves needs the params/result type per method, which meta.json does not carry yet. 296 Rust tests, 114 pytest, 112 node; both `codegen --check` guards and `mira-schema-gen --check` clean; the Rust, Python and TypeScript greet studies all drive the host green.
The declaration landed with no consumer: the stubs, handlers and
dispatchers it generates were compiled and unused, so it read as 200 lines
added for nothing. This is the host half of using them.
`host.rs` had its own JSON-RPC client underneath the eval-specific part: a
reader task owning the study's stdout, a pending map keyed by request id, an
id allocator, a drop guard freeing the slot, a hand-rolled `cancel` line
writer, and the EOF sweep that fails every waiter. None of that is about
evals, and all of it is what lanok exists to hold.
The peer holds it now. The calls are generated stubs, so `run` is
`peer.run(params)` rather than `request("run", to_value(params))` followed
by a `from_value` on the way back, and a capability-gated method answers
locally instead of making a round trip that ends in `method not found`.
Notifications arrive through the generated `InitiatorHandler` with their
params typed, and a malformed one is dropped before it reaches the
callback. `supports_cancel` is no longer a second `AtomicBool` beside the
peer's own capability record.
Cancel-on-drop stays mira's, which is the point of lanok making it a hook:
mira cancels with an acknowledged *request*, not the usual fire-and-forget
notification, and only for `run`/`execute`/`score` against a study that
said it can.
Two behaviour changes come from the transport. A spawned study's stderr is
now drained and forwarded rather than inherited, which is what stops a
chatty study wedging at a full pipe buffer and what puts a crashing study's
final lines in front of whoever is debugging it. And `shutdown` returns
once the child is actually reaped, via the `Peer::shutdown` this migration
needed lanok to grow.
165 insertions against 335 deletions, and much of what is added is prose
about what the file still owns.
295 tests pass (the one dropped test asserted that a reverse request cannot
complete an unrelated pending request, which is now structurally impossible
here and covered in lanok). Both cancel tests pass, and the Rust, Python
and TypeScript greet studies all drive the host green.
The SDKs generated their wire types but not their dispatch. Each carried a
hand-written chain — `if method == "run"` in Python, `case "run":` in
TypeScript — doing its own decode, call and encode per branch, with the
payload types present only as casts: `params["eval"]` on one side,
`params.eval as string` on the other. Adding a protocol method meant
editing that chain in every language and hoping.
It could not be generated before because `meta.json` said which methods
exist but not what any of them carries. It now names each method's params
and result types, so `codegen` emits:
- `StudyHandler`, one typed method per method a host sends, each
defaulting to method-not-found so a study implements only what it
answers; and
- `dispatch`, the decode/call/encode, exhaustive over the declaration.
`Study` implements that surface in both languages. The methods are
`run(params: RunParams) -> RunResult`, so an eval name is `params.eval`
with a type behind it, and the `toWire("RunResult", …)` literal that used
to be repeated per branch comes from the declaration. `handle()` stays
dict-in/dict-out, so no authoring code moves.
Two things fall out. A method missing from a serve loop is now a refusal
the declaration produces rather than a branch nobody wrote. And
method-not-found is classified by exception type instead of by testing
whether the error message starts with "unknown method".
Generating this exposed that `schema.json`'s payload registration was
itself a hand-kept list, and that it had drifted: `InitializeParams` was
missing, so `meta.json` named a type the schema did not define. The
registration now comes from the declaration's own `schema_document()`,
which produces the identical `$defs` plus the one that was missing, and
adds a `messages` map of the payload shapes per method. A new test asserts
every type named in `meta.json` resolves in `schema.json` — the guard whose
absence let that drift sit there.
296 Rust tests, 114 pytest, 112 node; all three drift guards clean; the
Rust, Python and TypeScript greet studies each drive the host green.
What changed
Deletes mira's hand-rolled copies of three things the yolop extension protocol also maintains.
204 lines removed, 99 added. Draft, because it cannot merge yet (see Blockers).
RpcError+codeslanok_core::RpcError, re-exportedenum Inbound+fn classifylanok_core::Messageversion_major/version_compatiblelanok_core::Negotiationprotocol.rsloses 84 lines,host.rsloses 100.Is it drop-in? Measured, not asserted
The question worth answering is not "does it still work" but "are the bytes the
same". Measured by building the same
wire_probeexample againstorigin/mainin a worktree and against this branch, and diffing the emitted lines:
main{"jsonrpc":"2.0","id":1,"method":"run","params":{...}}{"jsonrpc":"2.0","method":"progress","params":{...}}{"jsonrpc":"2.0","id":1,"result":{...}}{"jsonrpc":"2.0","id":1,"error":{...}}{"code":-32603,"message":"busy","retryable":true}{"code":-32603,"message":"boom","retryable":false}{"code":-32603,"message":"boom"}{"code":-32602,"message":"bad","retryable":false}{"code":-32602,"message":"bad"}Six of seven byte-identical. One difference:
retryableis omitted whenfalse instead of written as
false.That difference sits inside mira's own forward-compatibility contract, and the
proof is that the readers already existed before this PR:
messageas required;retryablecarries"default": false, restored explicitly so the artifact still says so.retryable: bool = False.retryable?: boolean.So: functionally drop-in, and byte-identical except for one field whose absence
every existing reader already reads as
false. Both shapes are now pinned bytests in this repo and in lanok, so neither can drift again unnoticed.
Why
lanok is the extraction of the plumbing mira and yolop each wrote separately. yolop's
crates/yolop-yep/src/protocol.rsopens with "Conventions (from the mira eval protocol)", and its server SDK says it "mirrors the mira SDK'sserve()design". Both grew the same field-based classification, the sameMAJOR.MINORrule, the same JSON-RPC-shaped error.The classification rule is the one worth sharing. mira's own comment calls it the safety property:
That is a subtle, load-bearing invariant. Having one implementation of it, tested once, is the point of the kit.
It fixes a bug on the way
MIN_PROTOCOL_VERSIONhas always been published inmeta.jsonand never checked.version_compatiblecompared majors only, so a study announcing a version this build had dropped support for connected anyway and failed later at whichever method it could not satisfy. Today the constant equals the major floor so nothing is observably broken; the moment mira raises it, it was silently ignored. A malformed version is now refused instead of parsing as major0.Adopting it required fixing lanok, not mira
This is the part worth reading, because it is what a real adoption surfaces and a design document cannot.
retryablewas in the wrong place. lanok buried it insidedata, on the argument that JSON-RPC enumerates the members of an error object. The spec sayscodeandmessageare required anddatais optional; it does not forbid more. Both protocols lanok exists to serve already had the flag at the top level, in their Rust types and in mira's Python and TypeScript SDKs. The strict reading bought nothing and would have broken every existing study. Fixed in lanok@814f482.serde_json/preserve_orderleaked. Taking the dependency reordered the keys of every committed schema artifact mira generates: 1159 lines of diff, content identical. Cargo unifies features across the whole graph, so lanok-core was changing how mira serialises JSON. Fixed in lanok@d2ce390 and generalised into lanok's architecture concept.Missing
with_code, and thinner field docs than mira's. The schema artifacts a protocol publishes carry those doc comments, so adopting lanok would have replaced better prose with worse. Ported mira's upstream instead.Cancellation was a setting, not a hook, and the handshake was lanok's to name. Both surfaced when this PR tried to take the next step (below). Fixed in lanok@3e865ed and lanok@d824c12: a protocol now supplies an abandon hook and its own handshake method names, rather than choosing among knobs lanok guessed at.
The pattern across all four: lanok kept deciding things that were not its to decide. Each was found by contact with a real protocol, none by design thinking.
Before / After
Gate green:
cargo fmt --check,clippy -D warnings --all-features,cargo test --workspace(167 inmira-eval, no failures),--features protocol-unstable,mira-schema-gen --check, andmira run --study examples/greet.rs.The schema diff is five lines: the same fields with better descriptions, and
int32becomingint64.Behaviour deltas
Both narrow, both tested:
codereads asINTERNAL_ERRORrather than0. That is how an error declining to classify itself should be read, and0is not a JSON-RPC code. Retry behaviour keys onretryableand the message, not this value.resultnorerrorsurfaces as a deserialization failure at the typed call site rather than the string"empty response". Still an error, more specific.What is left
host.rsstill carries ~150 lines thatlanok_peer::Peeralready owns: the pending map, id allocation, the write-under-lock,RequestGuard's cancel-on-drop, andreader_loop's EOF drain. Adopting it would also add two things mira lacks: a connection-end drain that fails pending requests at once rather than per-timeout, and per-request timeouts.That step was blocked on two lanok gaps, both now closed by the fixes above:
mira's
cancelis a request the study acknowledges, not a notification, and itis armed only for cancelable methods against a study that advertises
cancel.The abandon hook expresses both.
study.rs's serve loop is the same story onthe other side. Neither is in this PR, because the peer migration deserves its
own diff.
Risk
Blockers
git+ pinnedrev, which makes mira unpublishable. It must become a version dependency before the next release. This is why the PR is a draft.Checklist
🤖 Generated with Claude Code
https://claude.ai/code/session_014LYBU1vgSfMU9EjmUCku8z