Multi-agent triage walkthrough
A worked example of multi-agent governance: a triage_agent that classifies an incoming customer request and hands off to either a refund_agent or a support_agent. Each handoff goes through an agent.delegate policy check; each downstream tool call goes through a tool_call policy check.
We use the OpenAI Agents SDK adapter (OpenAIAgentsAdapter) since it ships the multi-agent helpers — register_handoff and register_agent_as_tool — that wrap the SDK's handoff machinery with governance.
Prerequisites
- Python 3.11+
- Docker for OPA
- An
OPENAI_API_KEYin the environment - ~10 minutes
pip install kitelogik openai-agents
docker compose up -d opa # OPA on http://localhost:8181(If you don't have a docker-compose.yml yet, run kitelogik init triage-tutorial && cd triage-tutorial first — the generated compose file is fine.)
Step 1 — Policies
Two policies. The first governs which sub-agents the triage agent can hand off to and with what scope. The second sets the per-tool authority for the resulting child sessions.
policies/policy.yaml:
version: 1
rules:
- name: allow_small_refund
when:
action: approve_refund
role: support_agent
scope: approve_refund
args:
amount: { lte: 100 }
then: allow
- name: block_large_refund
when:
action: approve_refund
args:
amount: { gt: 100 }
then: deny
reason: "Refunds over $100 require manager approval"policies/handoff_policy.rego:
package kitelogik.agent_lifecycle
import future.keywords.if
import future.keywords.in
# Only allow handoffs to known sub-agent names
allow if {
input.event_type == "agent.delegate"
input.delegation_target in {"refund_agent", "support_agent"}
input.context.delegation_depth <= 1
}
deny if {
input.event_type == "agent.delegate"
not input.delegation_target in {"refund_agent", "support_agent"}
}Compile the YAML to Rego:
kitelogik compile policies/policy.yaml(The hand-written handoff_policy.rego is loaded as-is.)
Step 2 — Define tools and agents
triage.py:
import asyncio
from agents import Agent, Runner
from kitelogik import OPAClient, PolicyGate, SessionContext
from kitelogik.adapters.openai_agents import OpenAIAgentsAdapter
# ── Governance setup ──────────────────────────────────────────────────────────
gate = PolicyGate(opa_client=OPAClient())
# Parent (triage) session — broad scope, can delegate to children.
triage_context = SessionContext(
session_id="triage_001",
user_role="orchestrator",
session_scopes=["read_customer", "approve_refund", "read_kb"],
)
# Refund-agent session — narrowed to refund authority only.
refund_context = SessionContext(
session_id="triage_001_refund",
user_role="support_agent",
session_scopes=["approve_refund"],
delegation_depth=1,
parent_token_id="triage_001",
parent_session_id="triage_001",
)
# Support-agent session — narrowed to read-only KB access.
support_context = SessionContext(
session_id="triage_001_support",
user_role="support_agent",
session_scopes=["read_kb"],
delegation_depth=1,
parent_token_id="triage_001",
parent_session_id="triage_001",
)
# ── Tools ─────────────────────────────────────────────────────────────────────
async def approve_refund(customer_id: str, amount: float) -> str:
return f"Refunded ${amount:.2f} to {customer_id}"
async def search_kb(query: str) -> str:
return f"KB results for: {query}"
# ── Sub-agents (each with its own adapter using its own context) ──────────────
refund_adapter = OpenAIAgentsAdapter(gate=gate, context=refund_context)
refund_adapter.register(
"approve_refund",
approve_refund,
description="Approve a refund for a customer.",
params={
"customer_id": {"type": "string"},
"amount": {"type": "number"},
},
)
refund_agent = Agent(
name="refund_agent",
instructions="You handle refund requests under $100. Use approve_refund.",
tools=refund_adapter.agent_tools(),
)
support_adapter = OpenAIAgentsAdapter(gate=gate, context=support_context)
support_adapter.register(
"search_kb",
search_kb,
description="Search the knowledge base.",
params={"query": {"type": "string"}},
)
support_agent = Agent(
name="support_agent",
instructions="You answer general support questions using search_kb.",
tools=support_adapter.agent_tools(),
)
# ── Triage agent — wraps handoffs with governance ─────────────────────────────
triage_adapter = OpenAIAgentsAdapter(gate=gate, context=triage_context)
triage_agent = Agent(
name="triage_agent",
instructions=(
"Classify the incoming request. If it asks for a refund, hand off to "
"the refund agent. Otherwise hand off to the support agent."
),
handoffs=[
triage_adapter.register_handoff(refund_agent),
triage_adapter.register_handoff(support_agent),
],
)
# ── Run ───────────────────────────────────────────────────────────────────────
async def main():
refund_result = await Runner.run(
triage_agent,
"Please refund $42 to cust_001.",
)
print("Refund flow:", refund_result.final_output)
support_result = await Runner.run(
triage_agent,
"What are the office hours?",
)
print("Support flow:", support_result.final_output)
asyncio.run(main())Step 3 — Run it
python triage.pyExpected behaviour:
- The first request triggers an
agent.delegateevent withdelegation_target = "refund_agent", whichhandoff_policy.regoallows. The refund agent then fires atool_callforapprove_refund(amount=42)—policies/policy.yamlallows it. - The second request delegates to
support_agent(also in the allowlist), which callssearch_kb.
Each handoff and each tool call writes a structured event to the audit log keyed by the policy version that decided it.
Step 4 — Try the deny paths
Add a malicious-looking handoff target to see the policy reject it:
attacker_agent = Agent(name="attacker_agent", instructions="...")
triage_agent.handoffs.append(
triage_adapter.register_handoff(attacker_agent),
)The next time the model attempts to hand off to attacker_agent, the agent.delegate event will be denied — handoff_policy.rego only allows refund_agent and support_agent as targets — and the SDK will raise.
Try a tool-level deny by asking for a $5,000 refund:
await Runner.run(triage_agent, "Refund $5000 to cust_001.")The handoff to refund_agent succeeds (it's in the allowlist), but the resulting approve_refund(amount=5000) call hits block_large_refund in policy.yaml and returns {"blocked": true, "reason": "Refunds over $100 require manager approval"} to the agent loop — so the model can decide what to do (apologise, escalate, give up).
What the audit log captures
For one Runner.run("Refund $42 to cust_001"):
{event: "agent.delegate", session_id: "triage_001",
delegation_target: "refund_agent", decision.allow: true,
rule_matched: "data.kitelogik.agent_lifecycle.allow", risk_tier: "OPERATIONAL"}
{event: "tool_call", session_id: "triage_001_refund",
tool: "approve_refund", args: {customer_id: "cust_001", amount: 42},
decision.allow: true, rule_matched: "data.kitelogik.financial.allow",
risk_tier: "TRANSACTIONAL_LOW"}session_id differs between the two events — the second one carries the child session's ID. delegation_depth is 0 on the first event and 1 on the second.
Where to go next
- Multi-agent governance pattern — the full mental model behind delegate/spawn events
agent_lifecycle.rego— the OSS lifecycle policy (depth caps, capability subset rules)delegation.rego— the per-tool-call constraints on delegated agents- OpenAI Agents SDK adapter — full reference for
register_handoffandregister_agent_as_tool