Skip to main content

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

funduq_provider_sdk-0.0.5.tar.gz (79.9 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

funduq_provider_sdk-0.0.5-py3-none-any.whl (45.5 kB view details)

Uploaded Python 3

File details

Details for the file funduq_provider_sdk-0.0.5.tar.gz.

File metadata

  • Download URL: funduq_provider_sdk-0.0.5.tar.gz
  • Upload date:
  • Size: 79.9 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/7.0.0 CPython/3.13.14

File hashes

Hashes for funduq_provider_sdk-0.0.5.tar.gz
Algorithm Hash digest
SHA256 7a5b6f3353441955585ea787fae9dc752cb031ff4f8dfc865b7a6147da82c66b
MD5 3e6abcb6252744e6e56a0aac7984ad32
BLAKE2b-256 3db81b66901373124ab58ef7d3b2ee86e4e2e9ac2ecc23353ab23dc02ce814f5

See more details on using hashes here.

Provenance

The following attestation bundles were made for funduq_provider_sdk-0.0.5.tar.gz:

Publisher: release.yml on hukaichun/funduq

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file funduq_provider_sdk-0.0.5-py3-none-any.whl.

File metadata

File hashes

Hashes for funduq_provider_sdk-0.0.5-py3-none-any.whl
Algorithm Hash digest
SHA256 ed99c47865d38146bceaf6af6d684003aef92209fb1565f6f0af75c858c90895
MD5 31e0e6fee5ac94e05761ddceea534c33
BLAKE2b-256 cc0e3d0a22fba71f06b538fb5779b30f5553214ef1869a6ad268be79f7fae841

See more details on using hashes here.

Provenance

The following attestation bundles were made for funduq_provider_sdk-0.0.5-py3-none-any.whl:

Publisher: release.yml on hukaichun/funduq

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

Release history Release notifications | RSS feed

0.0.8

2 files

0.0.7

2 files

0.0.6

2 files

This release

0.0.5 This release

2 files

0.0.4

2 files

0.0.3

2 files

0.0.2

2 files

0.0.1

2 files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page