Skip to content

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: