Support¶
Part of core components.
Change notifications (changes.py for the event types, core.py
for the subscription) — Funduq.on_change(callback) adds the callback to
a set and returns an unsubscribe function; every state transition worth
showing (roster changed, LLM
roster changed, a run's status changed) constructs a small typed event
and calls each subscriber synchronously. A serving layer subscribes once
and updates what it shows when told — no polling loop, no missed-poll
staleness. Events say that something changed; reading the new state is
the subscriber's own query.
Health sweeps (health.py) — one background loop, started with the
Funduq object, ticks on an interval and fails what nobody will answer:
a paused run past its deadline is marked failed by a direct
database update (it works even if the run's provider vanished), and a
paused run past its resume deadline the same way, when that timeout is
configured. A queued run whose agent has been without a serving provider
past its window is failed as "no provider took it" by the dispatch loop
itself, not this sweep — expire_queued in broker.py, where the
window (a broker argument, 45 s by default) lives; while a provider is
attached, a queued run waits indefinitely. All of these write a reason
and a terminal event; none
guesses an outcome — the sweeps time out on the absence of one, which
is itself an observation. Funduq.health() is the companion snapshot:
whether the database is reachable, which schema revision it found
alongside the one funduq expected, and two separate liveness facts —
whether the sweep task is running and whether the dispatch loop is. The
Health it returns draws the conclusions too: schema_current compares
the two revisions, and ready is database and schema-current and
dispatching. A deployment that wants a different bar reads the fields
instead.
Settings (config.py) — CoreSettings, a pydantic-settings object
reading environment variables under the FUNDUQ_ prefix. Resolution
happens when Funduq(...) is constructed, not at import: there is no
module-level singleton.
Every core setting¶
The whole list. It is short on purpose — core knows a database and nothing else, so anything describing a wire is absent by design, not by omission.
| setting | default | governs |
|---|---|---|
database_url |
sqlite+aiosqlite:///./funduq.db |
which database, and whether the engine is built the SQLite way |
db_schema |
public |
the Postgres search_path; applied only when the backend is not SQLite and the value is not the default |
stale_hidden_window_seconds |
7 days | how long since last check-in before an agent drops out of the roster listings |
token_signing_secret |
required | signs KYOK tokens |
identity_private_key |
none | funduq's own Ed25519 seed; unset means funduq has no identity and sign() raises |
Two timeouts a reader looks for here are deliberately not settings:
the delivery timeout on a single offer (5 s) and the unserved window
before a queued run is given up on (45 s) are RunBroker constructor
arguments. They describe dispatch, which an embedder can replace by
passing its own broker to Funduq(broker=...).
A third is not a setting anywhere, because it no longer exists: there is
no deadline on a paused run. paused_timeout_seconds and the
health-sweep loop that read it were removed — a question funduq did not
ask is not funduq's to time
out.
The public URL is content, not configuration¶
A serving layer knows the URL callers reach it at; core does not, and
must not — naming one would make core describe a wire. So no setting
carries it, and the adapters take none: A2AAdapter(funduq) is the whole
constructor.
Where a public URL genuinely has to appear — an agent card advertising where to call the agent — it is passed per call as content:
await A2AAdapter(funduq).agent_card(agent, interfaces=[served])
Each ServedInterface carries its own url and binding, so one funduq
can advertise the same agent over several wires, and omitting
interfaces omits the block from the card. The serving layer supplies
what it alone knows, once, at the point it is needed.
The card's version follows the same rule: it is read off the agent's
own registered card, falling back to 0.1.0 only when the agent
declared none. funduq publishes what the agent said about itself rather
than a number of its own.
Attach authentication has no switch¶
A link either proves its key or is refused, so the handshake is the same on every funduq. The sequencing matters as much as the rule: the proof is verified before the registered-names check, so an attach that cannot prove itself never learns whether a name is registered.
Starting and stopping¶
Funduq.start() runs once, and it does the one thing a restart needs:
it fails the runs a previous process left queued or running (a
paused input-required run survives untouched — it is waiting on a
caller, not on dispatch state). A second call returns immediately,
because a second pass cannot reap runs queued after the first. It is a
guard, not a permanent latch: aclose() clears it, so a closed funduq
can be started again.
mark_run_status is the single funnel for every status change, and the
status machine lives in it: the repository's legal-transition table
rides in the UPDATE's own WHERE clause, so a write from a state the new
status can't legally follow matches zero rows — the database arbitrates
racing writers, in one process or many. A refused transition is logged,
returns False, and fires nothing; RunStatusChanged fires only for the
transition that actually won the row. The funnel is enforced rather
than asked for: a test walks the AST of every module in the package
except the repository and the funnel itself, and fails on any direct
call to the repository's mark_run_status.
One nuance worth knowing before you build on the hook: since only a winning transition fires, a repeated or illegal write is silent to subscribers. Detaching something not attached fires nothing. Subscribers are called synchronously, before the causing call returns, and an exception one raises is logged and swallowed.
Design records¶
Why this is shaped the way it is, and what it was shaped like first: