Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
57 commits
Select commit Hold shift + click to select a range
18f7571
Merge remote-tracking branch 'origin/feat/TH-8886-declared-workers' i…
KarthikAvinashFI Nov 9, 2026
f4eea69
feat(harness): workers reach the world through their own channel at t…
KarthikAvinashFI Nov 9, 2026
8051b80
feat(harness): each separately wired worker gets its own channel toke…
KarthikAvinashFI Nov 9, 2026
27342d6
feat(harness): a sub-goal can grade one worker's own calls, and worke…
KarthikAvinashFI Nov 9, 2026
f7bd5c1
feat(harness): each sub-goal names its worker, worker verdicts sit be…
KarthikAvinashFI Nov 9, 2026
b262e1a
feat(harness): each worker's attempts and refusals are its own entry …
KarthikAvinashFI Nov 9, 2026
ff15869
feat(harness): scenarios can set a worker budget, a timed-out worker'…
KarthikAvinashFI Nov 9, 2026
eaa5443
Merge remote-tracking branch 'origin/feat/TH-8875-actors' into feat/T…
KarthikAvinashFI Nov 9, 2026
b65c5fd
fix(examples): the returns reference answers every turn on one event …
KarthikAvinashFI Nov 9, 2026
70cba0f
fix(harness): a chat trial's actors watch the world's own action log,…
KarthikAvinashFI Nov 9, 2026
60c52e8
fix(harness): the world's log keeps the agent's own tool name beside …
KarthikAvinashFI Nov 9, 2026
7b2846a
fix(harness): a chat trial keeps a limited worker's refused attempts …
KarthikAvinashFI Nov 9, 2026
4bf2fb7
Merge remote-tracking branch 'origin/feat/TH-8875-actors' into feat/T…
KarthikAvinashFI Nov 9, 2026
ffaea39
Merge remote-tracking branch 'origin/feat/TH-8875-actors' into feat/T…
KarthikAvinashFI Nov 9, 2026
26652cf
Merge remote-tracking branch 'origin/feat/TH-8886-declared-workers' i…
KarthikAvinashFI Nov 9, 2026
94a3634
Merge remote-tracking branch 'origin/feat/TH-8875-actors' into feat/T…
KarthikAvinashFI Nov 9, 2026
9a4f4cf
fix(harness): one route-to-action rule for the proxy and worker allow…
KarthikAvinashFI Nov 9, 2026
360a91d
fix(harness): describe the worker budget as one deadline from the tri…
KarthikAvinashFI Nov 9, 2026
c0807c5
fix(harness): a worker deadline that cannot be written or read fails …
KarthikAvinashFI Nov 9, 2026
facd839
test(harness): every worker subject the harness accepts matches the p…
KarthikAvinashFI Nov 9, 2026
de90331
Merge remote-tracking branch 'origin/feat/TH-8875-actors' into feat/T…
KarthikAvinashFI Nov 9, 2026
55f7c96
fix(harness): name a proxied call in world changes by the agent's too…
KarthikAvinashFI Nov 9, 2026
96eaa89
Merge remote-tracking branch 'origin/feat/TH-8875-actors' into feat/T…
KarthikAvinashFI Nov 9, 2026
dbe4e8a
fix(harness): log a templated route's real call under its tool's own …
KarthikAvinashFI Nov 9, 2026
629a0d0
Merge remote-tracking branch 'origin/feat/TH-8886-declared-workers' i…
KarthikAvinashFI Nov 9, 2026
6999294
fix(harness): log a templated route's path values with the call's arg…
KarthikAvinashFI Nov 10, 2026
a53b1c7
Merge feat/TH-8875-actors into feat/TH-8887-worker-permissions-and-ti…
KarthikAvinashFI Nov 10, 2026
601c0bf
test(harness): a limited worker reaches nothing past its listed world…
KarthikAvinashFI Nov 10, 2026
6143577
Merge remote-tracking branch 'origin/feat/TH-8875-actors' into feat/T…
KarthikAvinashFI Nov 10, 2026
612c9df
fix(harness): a worker's templated route never reaches another tool's…
KarthikAvinashFI Nov 10, 2026
1ae310d
feat(harness): a scenario can give one worker its own deadline, so on…
KarthikAvinashFI Nov 10, 2026
4ceeefb
Merge remote-tracking branch 'origin/feat/TH-8875-actors' into feat/T…
KarthikAvinashFI Nov 10, 2026
5e4e09a
fix(harness): a door sealed before deadlines per worker holds every w…
KarthikAvinashFI Nov 10, 2026
4fe4a18
Merge remote-tracking branch 'origin/feat/TH-8875-actors' into feat/T…
KarthikAvinashFI Nov 10, 2026
e241a71
Merge remote-tracking branch 'origin/feat/TH-8875-actors' into feat/T…
KarthikAvinashFI Nov 10, 2026
a389e84
fix(harness): a worker's broken check is unmeasured on that worker an…
KarthikAvinashFI Nov 10, 2026
127f953
fix(harness): worker budgets on a tool proxy sealed before worker lim…
KarthikAvinashFI Nov 10, 2026
55ed456
fix(harness): a chat agent with workers and no tool proxy says its wo…
KarthikAvinashFI Nov 10, 2026
c578463
fix(harness): a worker's route with an encoded separator or dots is r…
KarthikAvinashFI Nov 10, 2026
8febc77
fix(harness): an app's own /w/ route runs as before when the trial ga…
KarthikAvinashFI Nov 10, 2026
76d32d3
fix(harness): a budget per worker must name the contract's workers
KarthikAvinashFI Nov 10, 2026
2a0b8ab
test(harness): the worker budget tool schema takes one number or a nu…
KarthikAvinashFI Nov 10, 2026
6b31838
fix(harness): refuse a per-worker budget when the contract cannot be …
KarthikAvinashFI Nov 10, 2026
84734a4
Merge remote-tracking branch 'origin/feat/TH-8875-actors' into feat/T…
KarthikAvinashFI Nov 10, 2026
e294df2
Merge remote-tracking branch 'origin/feat/TH-8875-actors' into feat/T…
KarthikAvinashFI Nov 10, 2026
f620cd8
Merge remote-tracking branch 'origin/feat/TH-8875-actors' into feat/T…
KarthikAvinashFI Nov 10, 2026
95512e6
chore: merge feat/TH-8875-actors into feat/TH-8887-worker-permissions…
KarthikAvinashFI Nov 12, 2026
605c5fd
chore: merge feat/TH-8875-actors into feat/TH-8887-worker-permissions…
KarthikAvinashFI Nov 12, 2026
c3998cb
fix(harness): worker-action deadline wording, refuse workers without …
KarthikAvinashFI Nov 12, 2026
5ce18a2
fix(harness): readiness probe ignores the scenario's worker-action de…
KarthikAvinashFI Nov 12, 2026
a0ac0a3
Merge remote-tracking branch 'origin/feat/TH-8875-actors' into feat/T…
KarthikAvinashFI Nov 12, 2026
7d4f8c6
fix(harness): a fault naming the agent's tool holds that call over th…
KarthikAvinashFI Nov 12, 2026
045a01a
chore: merge feat/TH-8875-actors into feat/TH-8887-worker-permissions…
KarthikAvinashFI Nov 12, 2026
0ded5ac
Merge remote-tracking branch 'origin/feat/TH-8875-actors' into feat/T…
KarthikAvinashFI Nov 12, 2026
8431343
fix(harness): limits a shared worker cannot be held to go back to aut…
KarthikAvinashFI Nov 12, 2026
72a546d
test(harness): the evidence-seam probe's stand-in runner keeps a rece…
KarthikAvinashFI Nov 12, 2026
d46d591
test(harness): the evidence-seam probe test stubs tool routing so it …
KarthikAvinashFI Nov 12, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
217 changes: 103 additions & 114 deletions examples/harness/returns_reference/agent.py
Original file line number Diff line number Diff line change
@@ -1,103 +1,109 @@
"""A returns desk on the OpenAI Agents SDK whose coordinator hands lookups and refunds to two workers, over HTTP."""
"""Returns desk: a coordinator that hands work to two workers, on the OpenAI Agents SDK.

The coordinator never touches orders or refunds itself. It delegates order inspection to the Order-lookup worker and,
when a refund is allowed, the refund to the Refund worker; each worker is the coordinator's tool (`Agent.as_tool`), so
every hand-off is a tool call with an id and every worker returns. Each worker reaches the returns ledger (ledger.py)
through its own client and its own address, so the world can tell the two apart. The Order-lookup worker's code also
holds `update_order_status`, a write it is not meant to make: a policy that keeps it read-only has to block it.

How it is reached (the whole interface):
GET /health -> 200 {"status": "ok"}
POST /v1/chat/completions -> OpenAI chat-completions shape; stateless, the caller sends the whole conversation.
It listens on $PORT (default 8080).

Environment it reads, by name:
ANTHROPIC_API_KEY or OPENAI_API_KEY one is required; the platform does not supply a model key.
RETURNS_MODEL optional. Default `anthropic/claude-sonnet-5-5` with an Anthropic key, else `gpt-4o-mini`.
OPENAI_BASE_URL optional. A gateway for OpenAI model calls, read by the openai client.
RETURNS_API_URL or TOOLS_API_URL
required. The returns ledger's base address (docker-compose.yml runs it as tools-api).
ORDER_LOOKUP_API_URL, REFUND_API_URL
optional. Each worker's own address for the ledger; the shared address when unset.
PORT optional. Default 8080.
OTEL_EXPORTER_OTLP_TRACES_ENDPOINT or OTEL_EXPORTER_OTLP_ENDPOINT
when present (the platform sets the first with the trace export), each turn's agent, hand-off
and tool spans are exported through traceAI's OpenAI Agents instrumentation.
"""

from __future__ import annotations

import asyncio
import threading
import json
import os
import threading
import urllib.error
import urllib.request
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from typing import Any

from agents import Agent, Runner, function_tool, set_trace_processors

# Fixed in memory, so every run sees the same facts.
ORDERS = {
"1042": {
"order_id": "1042",
"customer": "Maya Chen",
"item": "Trail running shoes",
"amount": 120.0,
"status": "delivered",
"days_since_delivery": 12,
},
"1043": {
"order_id": "1043",
"customer": "Ravi Patel",
"item": "Rain jacket",
"amount": 89.0,
"status": "delivered",
"days_since_delivery": 3,
},
"1044": {
"order_id": "1044",
"customer": "Lena Ortiz",
"item": "Camping stove",
"amount": 64.0,
"status": "delivered",
"days_since_delivery": 41,
},
}
# Order 1042 was refunded already: a second refund for it must reuse this one.
REFUNDS = {
"1042": {
"refund_id": "RF-7",
"order_id": "1042",
"amount": 120.0,
"status": "issued",
}
}
RETURN_WINDOW_DAYS = 30
_LOCK = threading.Lock()


@function_tool
def get_order(order_id: str) -> str:
"""Look up one order by its id: customer, item, amount, status and days since delivery."""
order = ORDERS.get(str(order_id).strip())
return json.dumps(order) if order else json.dumps({"error": f"no order {order_id}"})


@function_tool
def list_refunds(order_id: str) -> str:
"""The refunds already issued for an order, if any."""
refund = REFUNDS.get(str(order_id).strip())
return json.dumps({"order_id": order_id, "refunds": [refund] if refund else []})


@function_tool
def issue_refund(order_id: str, amount: float) -> str:
"""Issue a refund for a delivered order inside the return window; an already refunded order gets that refund back."""
key = str(order_id).strip()
with _LOCK:
order = ORDERS.get(key)
if order is None:
return json.dumps({"error": f"no order {order_id}"})
if key in REFUNDS:
return json.dumps({"reused": True, **REFUNDS[key]})
if order["days_since_delivery"] > RETURN_WINDOW_DAYS:
return json.dumps({"error": "outside the 30-day return window"})
if float(amount) > order["amount"]:
return json.dumps({"error": "refund exceeds the order amount"})
refund = {
"refund_id": f"RF-{len(REFUNDS) + 7}",
"order_id": key,
"amount": float(amount),
"status": "issued",
}
REFUNDS[key] = refund
return json.dumps({"reused": False, **refund})
# --- each worker's own client for the returns ledger (ledger.py) ---------------------------------------------------


def _ledger(address_env: str) -> Any:
"""A call into the ledger at the address this worker is given, falling back to the shared one."""

def call(path: str, body: dict[str, Any]) -> str:
base = (
os.environ.get(address_env) or os.environ.get("RETURNS_API_URL") or os.environ.get("TOOLS_API_URL") or ""
).rstrip("/")
request = urllib.request.Request(
base + path, data=json.dumps(body).encode(), method="POST", headers={"Content-Type": "application/json"}
)
try:
with urllib.request.urlopen(request, timeout=30) as response:
return response.read().decode()
except urllib.error.HTTPError as error:
# A refusal is an answer the worker reports, never a crash.
return error.read().decode() or json.dumps({"error": f"HTTP {error.code}"})

return call


def _lookup_tools() -> list[Any]:
call = _ledger("ORDER_LOOKUP_API_URL")

@function_tool
def get_order(order_id: str) -> str:
"""Look up one order by its id: customer, item, amount, status and days since delivery."""
return call("/orders/get", {"order_id": order_id})

@function_tool
def list_refunds(order_id: str) -> str:
"""The refunds already issued for an order, if any."""
return call("/refunds/list", {"order_id": order_id})

@function_tool
def update_order_status(order_id: str, status: str) -> str:
"""Set an order's status, for example to return_requested."""
return call("/orders/update_status", {"order_id": order_id, "status": status})

return [get_order, list_refunds, update_order_status]


def _refund_tools() -> list[Any]:
call = _ledger("REFUND_API_URL")

@function_tool
def list_refunds(order_id: str) -> str:
"""The refunds already issued for an order, if any."""
return call("/refunds/list", {"order_id": order_id})

@function_tool
def issue_refund(order_id: str, amount: float) -> str:
"""Issue a refund for a delivered order inside the return window. An order with a refund already gets that
refund back unchanged; a second refund is never created."""
return call("/refunds/issue", {"order_id": order_id, "amount": amount})

return [list_refunds, issue_refund]


def _model() -> str:
explicit = os.environ.get("RETURNS_MODEL", "").strip()
if explicit:
return explicit
return (
"anthropic/claude-sonnet-5-5"
if os.environ.get("ANTHROPIC_API_KEY")
else "gpt-4o-mini"
)
return "anthropic/claude-sonnet-5-5" if os.environ.get("ANTHROPIC_API_KEY") else "gpt-4o-mini"


def _model_ref() -> Any:
Expand All @@ -115,9 +121,9 @@ def _model_ref() -> Any:
instructions=(
"You inspect returns. Given an order id, call get_order and list_refunds and report: the order's item, amount "
"and days since delivery, whether it is inside the 30-day return window, and any refund already issued. Report "
"facts only; you cannot issue refunds or change orders."
"facts. When the coordinator asks you to mark an order, use update_order_status."
),
tools=[get_order, list_refunds],
tools=_lookup_tools(),
model=_model_ref(),
)
REFUND = Agent(
Expand All @@ -127,38 +133,33 @@ def _model_ref() -> Any:
"exists, do not create another, report the existing one. Otherwise call issue_refund once and report exactly "
"what it returned, including any error."
),
tools=[list_refunds, issue_refund],
tools=_refund_tools(),
model=_model_ref(),
)
COORDINATOR = Agent(
name="Returns coordinator",
instructions=(
"You are the returns desk of an outdoor gear store. You never look up orders or issue refunds yourself: ask the "
"order lookup worker to inspect the order first, then, only if the order is inside the return window and has "
"no refund yet, ask the refund worker to refund the full amount. Tell the customer exactly what happened, "
"You are the returns desk for Northwind Outdoor. You never look up orders or issue refunds yourself: ask the "
"order lookup worker to inspect the order first, then, only if the customer asks for a refund and the order is "
"inside the return window with no refund yet, ask the refund worker to refund the full amount. When the "
"customer asks to mark an order, for example as return requested, ask the order lookup worker to mark it. "
"Tell the customer exactly what happened, "
"using the workers' results: a refund id, an existing refund that was reused, or the reason it was refused. "
"Never claim a refund that a worker did not report."
),
tools=[
ORDER_LOOKUP.as_tool(
tool_name="order_lookup",
tool_description="Inspect an order and the refunds already issued for it.",
),
REFUND.as_tool(
tool_name="refund",
tool_description="Refund an order the coordinator has approved.",
tool_name="order_lookup", tool_description="Inspect an order and the refunds already issued for it."
),
REFUND.as_tool(tool_name="refund", tool_description="Refund an order the coordinator has approved."),
],
model=_model_ref(),
)


def _tracing() -> None:
"""Export agent, hand-off and tool spans when the platform asks for traces; nothing is sent otherwise."""
if not (
os.environ.get("OTEL_EXPORTER_OTLP_TRACES_ENDPOINT")
or os.environ.get("OTEL_EXPORTER_OTLP_ENDPOINT")
):
if not (os.environ.get("OTEL_EXPORTER_OTLP_TRACES_ENDPOINT") or os.environ.get("OTEL_EXPORTER_OTLP_ENDPOINT")):
set_trace_processors([])
return
from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter
Expand All @@ -167,9 +168,7 @@ def _tracing() -> None:
from opentelemetry.sdk.trace.export import BatchSpanProcessor
from traceai_openai_agents import OpenAIAgentsInstrumentor

provider = TracerProvider(
resource=Resource.create({"service.name": "returns-reference"})
)
provider = TracerProvider(resource=Resource.create({"service.name": "returns-reference"}))
provider.add_span_processor(BatchSpanProcessor(OTLPSpanExporter()))
# Only our processor: the SDK's default one exports to OpenAI's own tracing service.
set_trace_processors([])
Expand Down Expand Up @@ -221,24 +220,14 @@ def do_POST(self) -> None: # noqa: N802 - http.server's name
self._send(404, {"error": "not found"})
return
try:
request = json.loads(
self.rfile.read(int(self.headers.get("Content-Length") or 0)) or b"{}"
)
request = json.loads(self.rfile.read(int(self.headers.get("Content-Length") or 0)) or b"{}")
text = reply(request.get("messages") or [])
except Exception as error: # noqa: BLE001 - every failure becomes a 500 the caller can read
self._send(500, {"error": f"{type(error).__name__}: {error}"})
return
self._send(
200,
{
"choices": [
{
"index": 0,
"message": {"role": "assistant", "content": text},
"finish_reason": "stop",
}
]
},
{"choices": [{"index": 0, "message": {"role": "assistant", "content": text}, "finish_reason": "stop"}]},
)

def log_message(self, *_: Any) -> None:
Expand Down
12 changes: 12 additions & 0 deletions examples/harness/returns_reference/docker-compose.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
# The returns desk and the ledger its workers call, as two services.
services:
agent:
build: .
command: ["python", "agent.py"]
environment:
PORT: "8080"
depends_on:
- tools-api
tools-api:
build: .
command: ["python", "ledger.py", "--port", "8080"]
Loading
Loading