Skip to content

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_KEY in the environment
  • ~10 minutes
bash
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:

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:

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:

bash
kitelogik compile policies/policy.yaml

(The hand-written handoff_policy.rego is loaded as-is.)

Step 2 — Define tools and agents

triage.py:

python
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

bash
python triage.py

Expected behaviour:

  • The first request triggers an agent.delegate event with delegation_target = "refund_agent", which handoff_policy.rego allows. The refund agent then fires a tool_call for approve_refund(amount=42)policies/policy.yaml allows it.
  • The second request delegates to support_agent (also in the allowlist), which calls search_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:

python
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:

python
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"):

text
{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

Released under the Apache 2.0 License.