funduq-provider-sdk
What a provider and funduq agree on, stated from the provider's side: an identity and what it signs, the port an agent implements, and the provider's own worker loop.
It carries no transport. Its dependencies are cryptography, pyjwt and
ag-ui-protocol — no httpx, no websockets, no grpcio, and no funduq.
Wrapping the calls in a network is a downstream job, and that dependency list
is what makes the boundary checkable rather than a matter of discipline.
ag-ui-protocol is there because a run input is a RunAgentInput and a
thread's messages are ag_ui.core.Message — the same package funduq itself
validates both against, not a network dependency.
Which SDK is this
Three names are in circulation, and they are not the same package:
| Package | Repo | What it is |
|---|---|---|
funduq-provider-sdk |
here | the interaction itself — identity, the port, the loop. No transport. |
funduq-agent-sdk |
funduq-server | a Python client for the gateway's provider WebSocket |
funduq-client-sdk |
funduq-server | the caller's side |
If you are running an agent against a deployed gateway over a network, you
want funduq-agent-sdk and funduq-server's quick
start. This package is what
you want when funduq is a library in your own process, or when you are building
a binding of your own and need to know what the two sides actually agree on.
An agent is one method
from ag_ui.core import RunAgentInput
class Greeter:
async def run_stream(self, agent_name: str, run_input: RunAgentInput):
ids = {"threadId": run_input.thread_id, "runId": run_input.run_id}
yield {"type": "RUN_STARTED", **ids}
yield {"type": "TEXT_MESSAGE_START", "messageId": "m1", "role": "assistant"}
yield {"type": "TEXT_MESSAGE_CONTENT", "messageId": "m1", "delta": "hello"}
yield {"type": "TEXT_MESSAGE_END", "messageId": "m1"}
yield {"type": "RUN_FINISHED", **ids}
A run input in, AG-UI events out — exactly what AG-UI says an agent is. The
only addition is agent_name, on the method rather than smuggled into the
input, because RunAgentInput carries no agent identity and one provider
serving a translator and a summarizer has to know which run is for which.
This package names nothing of funduq's
That is deliberate, and it is why there is an adapter below rather than a
one-line serve().
Runs arrive as DeliveredRun — this package's own type — and results leave
through two callables you supply. Funduq's model fields, method names and
argument order are not this package's business. It used to be otherwise: the
loop read run.run_id / run.agent.name / run.run_input off funduq's object
and called funduq.report_event / funduq.finish_run by name, with nothing on
either side declaring it, and it broke exactly there — funduq handed over its
dispatch object, whose input field is input_json, and the first real
provider died with an AttributeError on its first run.
The one agreement that cannot be removed is the signing payload in
identity.py: those bytes must match funduq's verifier exactly or registration
fails. That is a wire format both sides implement, not a dependency either
way — and it is stated here independently rather than imported, because
something derived from funduq agrees with funduq by construction and therefore
checks nothing.
The funduq-facing side is a class you subclass
funduq's broker knows a provider by four things and nothing about what carries
them. FunduqLink is that, from the provider's side, with the one step
every transport would otherwise repeat done once:
funduq's ClaimedRun ──▶ DeliveredRun ──▶ however this transport carries it
deliver is concrete and holds every funduq field name this package depends
on. A transport implements only what is actually different:
class InProcessLink(FunduqLink): # ships here — a function call
async def offer(self, run: DeliveredRun) -> bool:
return await self._runtime.deliver(run)
class SocketClient(FunduqLink): # a provider's own socket end
async def offer(self, run: DeliveredRun) -> bool:
self._outbound.put_nowait(encode(run))
return await self._ack(run.run_id)
plus cancel(run_id), public_key and max_concurrent_runs. All four are
abstract, max_concurrent_runs included: funduq sizes its capacity bucket from
it, so a connection that omits it starves or overruns — better to fail at
construction than inside funduq's broker at registration.
In-process is a transport, not a special case. Nothing it gets is a shortcut a remote provider does not: it registers, it proves its identity, and funduq offers it work the same way.
Only the funduq-facing half is here. Reporting events back is not, because it is not always the same object's job — in-process the runtime is local and its callbacks go straight to funduq, but over a wire the connection funduq talks to lives in the gateway and the runtime is across the socket.
Using the in-process one
from funduq_provider_sdk import InProcessLink
runtime = ProviderRuntime(identity, provider)
runtime.start()
await funduq.attach_provider(InProcessLink(funduq, runtime), ["greeter"])
It adds no dependency: funduq is never imported, only duck-typed, so this
package's requirements stay cryptography + pyjwt. It lives here rather
than in funduq for that reason and not a taste one — funduq shipping it would
mean funduq importing this package to build a DeliveredRun.
In-process, end to end
from funduq.config import CoreSettings
from funduq.core import Funduq
from funduq_provider_sdk import (
InProcessLink, ProviderIdentity, ProviderRuntime,
)
funduq = Funduq(CoreSettings(database_url=..., token_signing_secret=...))
await funduq.start()
# 1. Identity is a keypair. Nothing is issued to it and there is no id to hold.
identity = ProviderIdentity.generate() # or .load_or_create(path)
# 2. Register the names, signed. Sharing funduq's process is not a reason to
# skip this — see "In-process is not trusted" in docs/mechanisms/identity.md.
signature, timestamp = identity.sign_registration(["greeter"])
registered = await funduq.register_agents(
identity.public_key, signature, timestamp, [{"name": "greeter"}]
)
agent = registered.agents["greeter"] # an AgentRef
# 3. The loop is yours. Start it, then put a connection in front of it.
runtime = ProviderRuntime(identity, Greeter(), max_queued_runs=4)
runtime.start()
await funduq.attach_provider(InProcessLink(funduq, runtime), ["greeter"])
assert funduq.is_serving(agent)
# 4. Reachable.
handle = await funduq.start_run(agent, {"messages": [{"role": "user", "content": "hi"}]})
async for event in handle.events():
print(event["type"])
Registration and attaching are two steps because they answer two questions:
registration is what makes the name funduq's to serve, attaching is what makes
it reachable. A registered agent nobody serves is online: False, not an
error.
Several agents on one identity
from funduq_provider_sdk import AgentHandle, HandleProvider
provider = HandleProvider([
AgentHandle(
name="translator",
run_stream=translate,
description="translates",
# Merged into the agent's public card. `skills` is what discovery
# searches on — an agent declaring none is findable only by somebody
# who already knows its name.
agent_card_extra={"skills": [
{"id": "translate", "name": "Translate", "tags": ["language"]},
]},
# funduq-internal, deliberately not on the public card.
metadata={"cost_centre": "research"},
),
AgentHandle(name="summarizer", run_stream=summarize, description="summarizes"),
])
signature, timestamp = identity.sign_registration(list(provider.agents))
await funduq.register_agents(
identity.public_key, signature, timestamp,
[h.as_registration() for h in provider.agents.values()],
)
HandleProvider is name-routing done once instead of in every agent. Replace
run_stream to route differently — a dynamic roster, a shared model pool, a
dispatch table of your own — and nothing else changes.
One ProviderRuntime per provider, never per agent: its capacity is a budget
across everything it serves, exactly as one process is.
Capacity is something you say, not something funduq measures
funduq hands work over; it does not wait to be asked. The broker offers each run
to whoever serves its agent, and deliver is where that lands:
- returning True is the ack — funduq records the run as started from there;
- returning False leaves it queued for someone to take later.
Declining is the only channel capacity has. max_queued_runs (how many may
wait) and max_concurrent_runs (how many may run) are what make deliver
say no. Both are deliberately small by default — a deep queue looks like
throughput and is really a promise you have not checked you can keep, and
funduq would believe every one of those runs had started.
Cancelling is a request
runtime.cancel(run_id) is funduq asking a run to stop. Complying is the
provider's choice; this implementation complies by cancelling the task, which
is the only way to interrupt an arbitrary async generator. A run that ignores
it and finishes has finished, and funduq records that — funduq never records an
outcome it has not observed.
Shutdown is two calls, and one is easy to miss
await runtime.aclose() # stop the loop
assert funduq.is_serving(agent) # ← still True
await funduq.detach_provider(identity.public_key) # take it off the roster
assert not funduq.is_serving(agent)
aclose stops this provider's loop and tells funduq nothing. Reachability
lives in the broker, so detach_provider is what makes the agent go offline
at once rather than ageing out of the liveness window.
aclose(cancel_in_flight=True) picks the other shutdown: draining lets funduq
see each run's real outcome, cancelling makes each stream end without a
RUN_FINISHED, which funduq records as failed unless it had already asked for
a stop. Neither is a lie — they are different shutdowns.
What funduq has to be
ProviderRuntime takes no funduq at all. Results leave through report_event
and finish_run on the same FunduqLink funduq hands runs to — a provider and a
funduq are one relationship, so they are one object. Both are async, so a
transport with a wire under it can await its write; the agent is decoupled by
the runtime's output queue, not by their arity.
Inbound, funduq needs four things from the link: public_key,
max_concurrent_runs, deliver, cancel. Funduq's ConnectedProvider is the
same four — which is why InProcessLink is thin — and contract.py states
them as data so the other side can check rather than assume.
Ordering stays here. _report_output is a single consumer, so a run's events
leave in the order the agent produced them; handing out a stream to drain
would make that the caller's problem, and nothing would go red when two of
their tasks raced.
Further
docs/writing-a-transport.md— the connect handshake in order, and what funduq deliberately leaves to you.docs/mechanisms/requests.md— what an offer's three-valued answer means, and what funduq records when a run ends.docs/design-records.md— why the loop is the provider's, why in-process is not trusted, and the decisions that were reversed getting here.- funduq-server's
docs/server-mode.md— the wire contract, if you are writing a binding rather than using one.
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file funduq_provider_sdk-0.0.2.tar.gz.
File metadata
- Download URL: funduq_provider_sdk-0.0.2.tar.gz
- Upload date:
- Size: 59.5 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via:
twine/7.0.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
0fac0a51f232c45e58fccd2ff173f3dd8d394bffff1c4544e4f953491b781042
|
|
| MD5 |
9b19dbb1f8c7c8a688fe157c47a9686f
|
|
| BLAKE2b-256 |
846ba2dd913c2541e0af879e076034d11086d89ce1a88a884c7239e8379ba56c
|
Provenance
The following attestation bundles were made for funduq_provider_sdk-0.0.2.tar.gz:
Publisher:
release.yml on hukaichun/funduq
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
funduq_provider_sdk-0.0.2.tar.gz -
Subject digest:
0fac0a51f232c45e58fccd2ff173f3dd8d394bffff1c4544e4f953491b781042 - Sigstore transparency entry: 2602142145
- Sigstore integration time:
-
Permalink:
hukaichun/funduq@c48b3cff0d795a98ee3a7b4748ce330d83235797 -
Branch / Tag:
refs/heads/main - Owner: https://github.com/hukaichun
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@c48b3cff0d795a98ee3a7b4748ce330d83235797 -
Trigger Event:
push
-
Statement type:
File details
Details for the file funduq_provider_sdk-0.0.2-py3-none-any.whl.
File metadata
- Download URL: funduq_provider_sdk-0.0.2-py3-none-any.whl
- Upload date:
- Size: 26.0 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via:
twine/7.0.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
a42fa9c69d6a4951b82d284475404606013bd950369a07635325b9a2d0bdfa18
|
|
| MD5 |
bade4f894f138ce4d30c13f9e3433ce9
|
|
| BLAKE2b-256 |
87ee89acacc3b849fc63bf92d0dcc016643769a3b2d0a0d70ff831d22d121039
|
Provenance
The following attestation bundles were made for funduq_provider_sdk-0.0.2-py3-none-any.whl:
Publisher:
release.yml on hukaichun/funduq
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
funduq_provider_sdk-0.0.2-py3-none-any.whl -
Subject digest:
a42fa9c69d6a4951b82d284475404606013bd950369a07635325b9a2d0bdfa18 - Sigstore transparency entry: 2602142565
- Sigstore integration time:
-
Permalink:
hukaichun/funduq@c48b3cff0d795a98ee3a7b4748ce330d83235797 -
Branch / Tag:
refs/heads/main - Owner: https://github.com/hukaichun
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@c48b3cff0d795a98ee3a7b4748ce330d83235797 -
Trigger Event:
push
-
Statement type: