7. Using the client in the agent SDK
This page shows how an agent built on the agents SDK
(services/agents/libs/agents-sdk) uses the bidding client
(ยง6) to take part in bidding. It covers two
ways an agent learns about a bidding task:
- Delegated tasks: Orcade calls the agent with a
bid_request(closed mode) or abid_winner, through the delegate system (ยง5). - Polled tasks: the agent polls for tagged open-mode jobs and applies to the ones it wants.
Most agents use one pattern or the other. The worked example in ยง7.6 uses both.
7.1 How the SDK runs an agent
From the SDK source:
- An agent is a class with this shape:
python
class MyAgent:
def __init__(self, subject, context: Context) -> None: ...
def on_preprocess(self, task: AgentTask) -> Optional[List[AgentTask]]: ...
def on_data(self, task: AgentTask) -> AgentResult: ...
def on_event(self, event_name: str, event_data: dict) -> None: ... # optional
- The file ends with
main(MyAgent).mainreadsSUBJECT_ID, loads the subject fromSUBJECT_DB_URL, and builds anAgentExecutor. The executor creates your agent with(subject, context). on_preprocessruns first. ReturningNoneskips the task. Returning[task]sends it toon_data.on_datareturns anAgentResult. If it raises, the SDK wraps the exception in an error result. The worker keeps running.- Tasks come from a Redis list (
REDIS_URL,REDIS_STREAM). A single worker thread reads them withBRPOPand processes one at a time. Anything slow inon_datablocks every task behind it.
What the agent gets from context:
| Attribute | Source | Use for bidding |
|---|---|---|
context.subject_id |
SUBJECT_ID env var |
This subject's ID. Use it as bid_subject_id |
context.delegator |
AGENT_DELEGATE_URL |
Delegation to other subjects. Not needed for bidding |
context.p2p_manager |
Built-in mesh | Not needed for bidding |
What the agent gets from subject:
| Attribute | Use for bidding |
|---|---|
subject.metadata.subject_search_tags |
Tags to poll with. Tag bidding tasks the same way you tag subjects |
subject.identity.subject_id |
Same value as context.subject_id |
7.2 Adding the client to the SDK
The client is one file with no dependencies beyond requests, which the SDK
already lists in requirements.txt. Copy it into the SDK's core package:
cp services/orcade/client/bidding_client.py \
services/agents/libs/agents-sdk/core/bidding_client.py
Import it the way the sample agents import the SDK:
from core.bidding_client import BiddingClient, BiddingError, BidJob, BidSubmission, BidTagQuery
If you would rather not copy the file, put services/orcade/client on
PYTHONPATH and import bidding_client directly. Copying is preferable when
the agent is packaged into a container, because the file then travels with
the image.
Environment variables
The client takes its URL as a constructor argument and never reads the environment. Your agent reads the variable and passes the value in.
| Variable | Required | Read by | Purpose |
|---|---|---|---|
ORCADE_URL |
yes, if the agent bids | your agent code | Base URL of the Orcade service, e.g. http://orcade-svc.orcade:5000 |
ORCADE_POLL_SECONDS |
no (default 10) |
your agent code | Poll interval for tagged open jobs |
SUBJECT_ID |
yes | SDK | Subject identity, used as bid_subject_id |
SUBJECT_DB_URL |
yes | SDK | Where the subject record is loaded from |
AGENT_DELEGATE_URL |
yes | SDK | Required by Context. Raises ValueError if empty |
REDIS_URL |
no (default redis://localhost:6379/0) |
SDK | Task queue |
REDIS_STREAM |
no (default jobs:inbox) |
SDK | Task queue key |
Set ORCADE_URL in the agent's Docker or Kubernetes configuration, next to
the other variables.
7.3 Pattern A: responding to delegated bidding tasks
When a closed-mode job is created, Orcade sends each eligible subject a
bid_request through the delegate (ยง5.2).
When the job is evaluated, the winner receives a bid_winner
(ยง5.3).
The SDK delivers these to on_data as an AgentTask. Orcade's task_data
is what the agent sees in task.job_data:
# task.job_data for a bid_request
{
"type": "bid_request",
"bid_job": { "...": "full BidJob dict" },
"bid_job_id": "8f14e45f-ceea-4a19-8f6e-8b5a9c1a9f10",
}
# task.job_data for a bid_winner
{
"type": "bid_winner",
"bid_job_id": "8f14e45f-ceea-4a19-8f6e-8b5a9c1a9f10",
"result_data": { "...": "the evaluator's result_data" },
}
Verify before relying on this. The SDK reads
task.job_datafrom the Redis message. Orcade poststask_datato the delegate's/api/submit-and-wait. The delegate service is not in this repository, so I could not confirm how it wrapstask_datainto the Redis message. Confirm thatjob_dataequalstask_datawith the delegate team before shipping.
Handling a bid_request in on_data:
def on_data(self, task: AgentTask) -> AgentResult:
kind = task.job_data.get("type")
if kind == "bid_request":
job = BidJob.from_dict(task.job_data["bid_job"])
bid = self._bid(job, client=self._task_client)
return AgentResult(
task_id=task.task_id,
job_output={"bid_id": bid.bid_id} if bid else {"accepted": False},
)
if kind == "bid_winner":
return AgentResult(
task_id=task.task_id,
job_output={"acknowledged": True},
)
return AgentResult(
task_id=task.task_id,
is_error=True,
error_data={"stage": "on_data", "message": f"unknown task type {kind!r}"},
)
Submit the bid inside on_data, before returning. Orcade records whatever
the delegate returns as a task result (ยง5.4),
but nothing else is confirmed. The bid is not complete until submit_bid
returns.
Orcade waits up to 300 seconds for the delegate to respond. Because the Redis
worker processes tasks one at a time, keep on_data short.
7.4 Pattern B: polling for open-mode bidding tasks
Open-mode jobs send no bid_request (ยง1.3).
Agents find them by polling for their tags. The poller thread calls the
agent's on_bidding_task method, which submits the bid.
poll_client = BiddingClient(os.environ["ORCADE_URL"])
poller = poll_client.poll_bid_tasks(
BidTagQuery(tags=subject.metadata.subject_search_tags, match="any"),
self, # object with on_bidding_task
interval_seconds=float(os.getenv("ORCADE_POLL_SECONDS", "10")),
)
The listener method receives a BidJob. For the threading reason in
ยง7.5, use a separate client for submitting from
the poller thread:
def on_bidding_task(self, bid_task_data: BidJob) -> None:
if bid_task_data.bid_job_mode != "open":
return # closed-mode jobs are handled through delegated bid_request tasks
self._bid(bid_task_data, client=self._poll_client)
7.5 Threads and clients
Two threads can submit bids:
- The Redis worker thread runs
on_datafor delegated tasks. - The poller thread runs
on_bidding_taskfor polled jobs.
Give each thread its own BiddingClient, so they never share a
requests.Session (ยง6.9):
self._task_client = BiddingClient(orcade_url) # used by on_data
self._poll_client = BiddingClient(orcade_url) # used by the poller and on_bidding_task
Both clients must be built before the poller starts, because the poller
can call on_bidding_task right away.
7.6 Worked example
This is a complete agent file that handles both patterns. Save it as
bidding_agent.py next to the SDK's other sample agents. Replace _bid_data
with your own pricing logic.
# bidding_agent.py
import logging
import os
from typing import Optional
from core.agent_executor import AgentTask, AgentResult, Context
from core.main import main
from core.bidding_client import (
BiddingClient, BiddingError, BidJob, BidSubmission, BidTagQuery, Bid,
)
log = logging.getLogger(__name__)
class BiddingAgent:
def __init__(self, subject, context: Context) -> None:
self.subject = subject
self.context = context
self.subject_id = context.subject_id
orcade_url = os.environ["ORCADE_URL"]
self._task_client = BiddingClient(orcade_url) # Redis thread
self._poll_client = BiddingClient(orcade_url) # poller thread
# Start polling before __init__ returns. Build every client first.
tags = list(getattr(getattr(subject, "metadata", None), "subject_search_tags", []) or [])
self._poller = None
if tags:
self._poller = self._poll_client.poll_bid_tasks(
BidTagQuery(tags=tags, match="any"),
self,
interval_seconds=float(os.getenv("ORCADE_POLL_SECONDS", "10")),
)
# ---- pattern B: polled open-mode jobs (poller thread) ----
def on_bidding_task(self, bid_task_data: BidJob) -> None:
if bid_task_data.bid_job_mode != "open":
return
self._bid(bid_task_data, client=self._poll_client)
# ---- pattern A: delegated tasks (Redis thread) ----
def on_preprocess(self, task: AgentTask):
return [task]
def on_data(self, task: AgentTask) -> AgentResult:
kind = task.job_data.get("type")
try:
if kind == "bid_request":
job = BidJob.from_dict(task.job_data["bid_job"])
bid = self._bid(job, client=self._task_client)
output = {"bid_id": bid.bid_id} if bid else {"accepted": False}
return AgentResult(task_id=task.task_id, job_output=output)
if kind == "bid_winner":
log.info("Won bid job %s", task.job_data.get("bid_job_id"))
return AgentResult(
task_id=task.task_id,
job_output={"acknowledged": True},
)
return AgentResult(
task_id=task.task_id,
is_error=True,
error_data={"stage": "on_data", "message": f"unknown task type {kind!r}"},
)
except Exception as e:
log.exception("on_data failed for task %s", task.task_id)
return AgentResult(
task_id=task.task_id,
is_error=True,
error_data={"stage": "on_data", "message": str(e)},
)
# ---- shared ----
def _bid(self, job: BidJob, *, client: BiddingClient) -> Optional[Bid]:
"""Submit this subject's bid. Returns None if Orcade refuses it (409)."""
try:
bid = client.submit_bid(BidSubmission(
bid_job_id=job.bid_job_id,
bid_subject_id=self.subject_id,
bid_data=self._bid_data(job),
))
log.info("Bid %s submitted to job %s", bid.bid_id, job.bid_job_id)
return bid
except BiddingError as e:
if e.status_code == 409:
log.info("Bid refused for job %s: %s", job.bid_job_id, e.message)
return None
raise
def _bid_data(self, job: BidJob) -> dict:
# Replace with real pricing logic. Keys must match what the job's
# PQT and evaluator functions expect.
return {"price_usd": 100.0, "confidence": 0.9}
main(BiddingAgent)
Testing without the full stack
on_data can be exercised directly. Build the agent with a fake context and
feed it an AgentTask:
from core.agent_executor import AgentTask
class FakeContext:
subject_id = "agent-translator-01"
agent = BiddingAgent(subject=None, context=FakeContext()) # ORCADE_URL must be set
task = AgentTask(task_id="t-1", job_data={
"type": "bid_request",
"bid_job": job_dict, # a BidJob.to_dict() from a test job
"bid_job_id": job_dict["bid_job_id"],
})
result = agent.on_data(task)
assert not result.is_error
subject=None works here because the constructor tolerates a missing subject
(getattr guards the tag lookup), so no poller starts when no tags are
available.
7.7 Operational notes
- Idempotency. Both patterns can reach the same job. A closed-mode job
sends
bid_request, and the poller may also see it if its tags match andbid_job_modeis"open". The mode check inon_bidding_taskprevents that. Restarts also redeliver open jobs to the poller. A repeat application returns409, which_bidtreats as "already bid". - Refusals are not errors. A
409means the job moved on (full, closed, or already applied)._bidreturnsNone, so the agent does not report an error for it. - Unexpected bidding errors. A
400or404raises out of_bid. Inon_datathat becomes an error result. In the poller it is logged byBidTaskPoller. - Startup order.
__init__starts the poller, and the poller can callon_bidding_taskat once. Set every attributeon_bidding_taskuses before the poller starts. - Shutdown. The SDK's
mainstops the Redis worker on SIGINT or SIGTERM, but it does not know about the poller. Stop the poller yourself, for example by registering asignalhandler that callsself._poller.stop(timeout=5). Poller threads are daemon threads, so process exit does not wait for them.
7.8 Observations about the SDK
These came up while reading the SDK for this guide. Neither is fixed here.
AgentDelegator.close()incore/delegate.pyrefers toself.session, but the constructor never sets it, so callingclose()raisesAttributeError. It does not affect bidding because nothing callsclose()today.core/input_streams/redis_listener.pyis a separate, generic BRPOP worker. The live path isstart_redis_listenerincore/utils.py, which the bidding flow relies on.