The link protocol machine¶
The link's state machine is code, in funduq_provider_sdk.protocol. Both
halves, sans-io: they consume frames, emit frames and events, perform no I/O
and read no clock.
Writing a transport is still the page that explains why each rule exists. This one is what a transport mounts instead of re-deriving them.
What was actually missing¶
funduq_provider_sdk/contract.py already published a machine-readable half of
the link — LINK_REPORT_METHODS, LINK_QUERY_METHODS,
CONNECTED_PROVIDER_ATTRS: method names and argument orders, with not one
state among them. So what shipped was the half that was never expensive,
and what stayed in prose was the half that is: states, orderings, timers.
The consequence was not hypothetical. "A dropped socket ends nothing" was true
when four downstream implementations were written against it, and stopped
being true when unregister_provider began telling a run's lane
ProviderGone. No frame changed shape.
The I/O boundary¶
Three seams, and the machines sit at the innermost:
bytes on a socket ← the transport's, and never ours
↕
the wire form ← a codec's; a default JSON one ships and is swappable
↕
Frame ← the machines' boundary
↕
Event ← the driver turns each into one Funduq call
Time enters as now and leaves as next_deadline(). That, not the absence of
types, is what sans-io buys — and it is what makes the orderings testable: a
race is an ordered list of feed calls and a clock the test sets, rather than
a sleep. tests/test_protocol_is_io_free.py enforces it, statically and
behaviourally.
Every method on both machines returns a Turn — the frames to send and the
events to act on — so a driver has one shape to handle and the order is never
in question.
Everything crossing either boundary is a pydantic model. This surface is a
specification for people implementing against it, and an annotation nothing
enforces specifies nothing. It also means the default codec is not a module
anyone writes: model_dump(by_alias=True) and model_validate, the same
mechanism DeliveredRun uses and contract-vectors.json
pins. Frames and events differ in one setting — frames carry camelCase
aliases because they go on a wire, events carry none — not in kind.
The frame vocabulary¶
| class | frames | carries id |
|---|---|---|
| handshake | Connect, ConnectOk, ConnectErr |
no — there is exactly one |
| request | Offer, Query, Register, Delete |
yes |
| reply | Ok, Err |
yes, the request's |
| notify | Report, Finish, Cancel |
no |
Ok for an offer carries the three-valued answer as an explicit discriminant
— verdict of accepted, declined or refused — and the machine converts
it to core's own bool | Refusal at the event boundary and nowhere else, so
neither vocabulary leaks into the other. Err is reserved for a request
funduq rejected, so a provider's permanent refusal and a rejection by funduq
never share a shape.
Report.event is Any: the one field the machine must not parse, because an
event whose type funduq does not know is relayed untouched.
A payload the codec cannot read becomes a Malformed — a frame like any
other, never encoded, so the decision about it stays in the transition table
and the codec never makes a protocol judgement.
Two dump rules that pull opposite ways
A frame is dumped by_alias=True and without exclude_none; a
typed AG-UI event is dumped with it.
RunAgentInput has required fields that are legitimately null —
state, forwardedProps — so stripping nulls from a frame yields a
runInput the far side cannot rebuild, and a perfectly good run comes
back as a permanent refusal. Leaving them in an event injects
timestamp: null and rawEvent: null into the caller's stream.
The two rules lived in different paragraphs of writing-a-transport.md
and never met until one function had to do both. The codec carried the
flag in its first draft and a test caught it.
FunduqSide — link states¶
| state | input | → | frames out | events out |
|---|---|---|---|---|
AWAITING_CONNECT |
Connect |
VERIFYING |
— | ConnectRequested |
AWAITING_CONNECT |
any other frame | CLOSED |
ConnectErr |
LinkFailed |
VERIFYING |
accept_connect(answer) |
OPEN |
ConnectOk(answer) |
— |
VERIFYING |
refuse_connect(reason) |
CLOSED |
ConnectErr(reason) |
— |
VERIFYING |
any frame | CLOSED |
ConnectErr |
LinkFailed |
OPEN |
Connect |
CLOSED |
Err |
LinkFailed |
OPEN |
Register(id, agents) |
OPEN |
— | Registering |
OPEN |
Delete(id, name) |
OPEN |
— | Deleting |
OPEN |
Query(id, method, args) |
OPEN |
— | Asking |
OPEN |
Report(run_id, event) |
OPEN |
— | Reported |
OPEN |
Finish(run_id) |
OPEN |
— | Finished |
OPEN |
Ok(id, verdict) |
OPEN |
— | see the offer table |
OPEN |
Malformed(id, reason) |
OPEN |
Err(id, reason) |
— |
OPEN |
offer(run, now) from core |
OPEN |
Offer(id, run) |
— |
OPEN |
cancel(run_id) from core |
OPEN |
Cancel(run_id) |
— |
| any | connection_lost() |
CLOSED |
— | Gone(unanswered, dropped) |
Two rows are absent on purpose, and their absence is the design:
There is no registration state. The machine never learns which agents the
link serves, so an offer arriving before a Register has been answered
violates nothing. The window is real and wide: _Roster.register puts the
roster live and nudges the broker at core.py:267, then does a touch and a
commit — a network round trip on Postgres — before register_agents
returns. A machine that refused to offer until it had answered a Register
would deadlock against its own broker.
There is no ticket frame. "Do not fetch it over the link" is no longer a warning; it is something the vocabulary cannot say.
FunduqSide — one offer's states¶
Keyed by the request id, armed with a deadline of now + deliver_timeout
(core's own deliver_timeout_seconds, handed in rather than defaulted, so one
number has one definition).
| state | input | → | events out |
|---|---|---|---|
| — | offer(run) from core |
OFFERED |
— (deadline armed) |
OFFERED |
Ok(accepted) |
CLAIMED |
Answered(id, True) |
OFFERED |
Ok(declined) |
DECLINED |
Answered(id, False) |
OFFERED |
Ok(refused, reason) |
REFUSED |
Answered(id, Refusal(reason)) |
OFFERED |
deadline reached | UNANSWERED |
Unanswered(id) |
UNANSWERED |
Ok(…) |
UNANSWERED |
Answered(id, …, late=True) |
| settled | Ok(…) |
CLOSED |
LinkFailed("answered twice") |
A timed-out offer keeps its id. Forgetting it is the instinct, and it turns a provider's late honesty into a protocol error.
ProviderSide¶
| state | input | → | frames out | events out |
|---|---|---|---|---|
IDLE |
connect(ticket, …) |
CONNECTING |
Connect |
— |
CONNECTING |
ConnectOk, signature verifies |
OPEN |
— | Opened |
CONNECTING |
ConnectOk, signature does not |
CLOSED |
— | LinkFailed |
CONNECTING |
ConnectErr(reason) |
CLOSED |
— | Refused(reason) |
CONNECTING |
any other frame | CLOSED |
— | LinkFailed |
OPEN |
Offer(id, run) |
OPEN |
— | Offered(id, run) |
OPEN |
Malformed(id, reason) |
OPEN |
Ok(id, refused, reason) |
— |
OPEN |
Cancel(run_id) |
OPEN |
— | Cancelled |
OPEN |
Ok(id, payload) / Err(id, reason) |
OPEN |
— | Replied / Failed |
OPEN |
answer(id, verdict) from runtime |
OPEN |
Ok(id, verdict) |
— |
OPEN |
report / finish |
OPEN |
Report / Finish |
— |
OPEN |
register / delete / ask |
OPEN |
the request frame | — |
The machine signs the connect rather than taking a proof, because the one
thing a transport author must not get wrong there is what is signed: the
pinned funduq key goes into the bytes, so a proof one funduq coaxes out cannot
be relayed to attach at another. And "check the answer before producing
anything" is structural — CONNECTING emits no other frame.
A run that will not decode never becomes an Offer: the codec yields
Malformed and the row above answers it as a permanent refusal, so the agent
never hears about it.
What the machines do not do¶
- They do not gate
ReportorFinishon the offer table. Those are addressed by run, and whether a key may speak for a run is core's question, answered againstclaimed_by— which includes letting a provider claim late by producing for a run funduq had given up waiting for. Gating them here looks obviously right and would make that path unreachable over a wire while leaving it working in-process. - They do not decide a run's outcome on
connection_lost. The machine reportsGone; core holds the verdict. funduq never decides on a provider's behalf. - They do not hold or mint a ticket.
- They do not filter unknown AG-UI event types.
- They do not reorder. One link, frames in arrival order.
The completion half¶
An LLM link opens the same way — FunduqLinkMachine and
ProviderLinkMachine carry the handshake, deleting and querying for both
kinds, so a fix to the ceremony cannot land in one copy and not the other.
What differs is the work. Both kinds are answered with a stream — a run's
answer is its events and then its finish — so that is not the difference. Two
things are:
A run is admitted first. An offer is answered three ways before any output
exists, and funduq holds the next utterance of that conversation until the run
is claimed — a decline answers promptly and holds it anyway, since the run
goes back to the head of its thread's queue. A completion has no admission step: it is assumed taken and can
only fail afterwards. So FunduqLlmSide and ProviderLlmSide have no
three-valued ack, and no delivery deadline to go with one. next_deadline()
is always None, deliberately: the clock that used to sit there was removed
for blaming a slow model for its own slowness, and liveness is a fact funduq
holds — whether the link is still here — not a deduction from how long a chunk
took.
A run outlives any one offer of it; a completion does not. A run declined
once is offered again under a new id, and a provider may claim one late by
producing for it — so its output is addressed by runId, and the machine
deliberately does not gate it. A completion is asked for exactly once, so its
chunks are addressed by the request's own id, and gating those on the table is
correct. The two halves differ here for a reason rather than by accident.
| frame | direction | meaning |
|---|---|---|
register.llm |
provider → funduq | names plus one metadata document |
complete |
funduq → provider | one request, carrying the delivered-completion envelope |
chunk |
provider → funduq | one piece of the answer, never parsed |
completion.end |
provider → funduq | it finished |
completion.failed |
provider → funduq | it did not; refusal present is the provider's policy |
abandon |
funduq → provider | the caller stopped consuming |
A completion is OPEN until exactly one of completion.end,
completion.failed or a lost link. A chunk after the end breaks the link, and
so does ending a completion that was never asked for.
They live under llm/ because they name DeliveredCompletion, which reaches
openai's types — an agent provider must not pay that import, which is why the
completion half is an extra. Each link kind has its own codec, which is the
shape of the thing rather than a workaround: an agent link and an LLM link are
different connections to different rosters.
Four things building it found¶
maxConcurrentRuns had nowhere to travel.
maxConcurrentRuns had nowhere to travel. Core schedules against
ConnectedProvider.max_concurrent_runs; in-process reads it off the runtime;
the frame vocabulary had no field for it. It is on Connect now — declared at
the open, because it is a property of the party on the other end and not of
any agent it publishes. Drawing the tables did not surface this. Wiring a
driver to a real broker did, immediately.
The two links do not publish their rosters the same way. They looked like
one register frame until the LLM roster's metadata had nowhere to travel:
agents are published as records, offerings as names plus one document
describing the link's terms. Registration moved out of the shared base and
into each work family. Sharing the frame would have meant dropping the
metadata or carrying a field that is always empty on one side.
A caller that stops consuming had no way to say so. In-process that is
GeneratorExit arriving in the handler; over a wire nothing reached the
provider at all, so it went on generating into a consumer that had gone. The
abandon frame is the wire's version, and it needs no new core verb —
ConnectedLLMProvider has none to add, and a driver knows when its own stream
was closed.
Core's late-claim path is unreachable from a late answer.
accept_late_ack is called from report_event, when a provider begins
producing for a run funduq gave up waiting for; nothing accepts a late Ok.
So UNANSWERED → Answered(late=True) has no core call behind it and the
driver can only log it. Recorded rather than designed around: either the row
is honest about being evidence only, or accept_late_ack grows an ack-shaped
entry point.
Conformance¶
funduq/tests/integration/test_protocol_loopback.py and
integration/test_protocol_llm_loopback.py wire each pair of machines to each other
through the codec, with a real Funduq at one end and a real
ProviderRuntime at the other. No socket, no sleep. It lives in core's suite
because the SDK may not import core — and because a machine only downstream
exercises would rot the way the prose did.
The drivers in that file are the part a transport author writes, and they are
short on purpose: pump frames, and turn each event into the one Funduq call
it names. Everything else is in the machines.
Adoption without a flag day¶
The machines work in Frame models; the codec is a separate seam. A transport
that already has a wire substitutes its own codec and still takes the state
handling. FunduqLink is unchanged and still supported — it remains the right
surface for provider authors, who should never meet a frame, and stops being
what transport authors subclass.
Core changed nothing: FunduqSide calls only attach_provider,
register_agents, delete_agent, report_event, finish_run,
get_thread_messages and detach_provider, and core does not import the SDK.
What is not built yet¶
- Resume (#214). The
machines are already instantiated per session rather than per connection,
which is what resume needs: a drop is
connection_lost(), a reconnect installs a new send callback, and per-run sequence and delivery watermark can survive both. That state could never have lived in aFunduqLinkinstance, because that instance is what is thrown away on every blip — which is why #214 is not a grace-window keyword argument. The machine would own the resume mechanics; core would own the verdict. Note thatProviderRuntime._report_outputstill drops events emitted while no link is attached, so the runtime has to buffer before any of this is reachable.