Docs/Guides/Multi Agent

Multi-Agent Systems

This guide shows how to trace a system where several agents work together: an orchestrator that hands work to sub-agents, sub-agents that run in parallel, and independent agents that call each other. Examples use the Python SDK (ants-platform). For other languages see the JS/TS SDK and Java SDK.

Setup

bash
pip install "ants-platform[openai]" export ANTS_PLATFORM_PUBLIC_KEY="pk-ap-..." export ANTS_PLATFORM_SECRET_KEY="sk-ap-..." export ANTS_PLATFORM_HOST="https://<your-host>" export OPENAI_API_KEY="sk-..."
python
from ants_platform import AntsPlatform from ants_platform.openai import OpenAI ants = AntsPlatform( public_key=os.environ["ANTS_PLATFORM_PUBLIC_KEY"], secret_key=os.environ["ANTS_PLATFORM_SECRET_KEY"], host=os.environ["ANTS_PLATFORM_HOST"], ) llm = OpenAI()

If this is your first trace, start with Monitor Your First Agent.

How agent names work in a trace

Every trace belongs to exactly one agent:

  • The outermost observation of a trace must have an agent_name. Without it the SDK raises ValueError: agent_name is required.
  • Every observation opened inside it inherits that agent name. The SDK stores agent names in lowercase.
  • A nested observation cannot change the agent name. If you pass a different agent_name to a nested observation, the SDK keeps the parent's name and logs a warning: Child span attempted to override parent agent_name (parent: 'orchestrator', child: 'researcher'). Using parent agent_name to maintain consistent attribution.

The AI Agent Center lists agents by these names, so a whole trace, including its cost and latency, counts towards the agent at its root.

That gives you two ways to model a multi-agent system:

GoalPattern
See the whole workflow in one trace and treat it as one agentOne trace, nested sub-agents. Sub-agents are told apart by observation name.
Track each agent separately, with its own requests, cost and riskSeparate traces per agent, linked by a shared session ID.

One trace, nested sub-agents

The orchestrator opens the root observation with the agent name. Each sub-agent is a nested agent observation with a descriptive name, and its LLM calls and tools nest inside it.

python
def search_docs(query: str) -> list[str]: with ants.start_as_current_observation( name="search-docs", as_type="tool", input={"query": query} ) as tool: results = ["Refunds are issued within 14 days.", "Shipping is free over 50 USD."] tool.update(output=results) return results def researcher(question: str) -> str: with ants.start_as_current_observation( name="researcher", as_type="agent", input=question ) as agent: notes = "\n".join(search_docs(question)) agent.update(output=notes) return notes def writer(question: str, notes: str) -> str: with ants.start_as_current_observation( name="writer", as_type="agent", input={"question": question, "notes": notes} ) as agent: completion = llm.chat.completions.create( name="writer-llm", model="gpt-4o-mini", messages=[ {"role": "system", "content": f"Answer using these notes:\n{notes}"}, {"role": "user", "content": question}, ], ) answer = completion.choices[0].message.content agent.update(output=answer) return answer def support_orchestrator(question: str, user_id: str, session_id: str) -> str: with ants.start_as_current_observation( name="support-orchestrator", as_type="agent", agent_name="support-orchestrator", input=question, ) as root: root.update_trace(user_id=user_id, session_id=session_id, input=question) notes = researcher(question) answer = writer(question, notes) root.update(output=answer) root.update_trace(output=answer) return answer if __name__ == "__main__": print(support_orchestrator("How long do refunds take?", "demo-user", "demo-session")) ants.flush()

In Discover AI > Tracing, the trace support-orchestrator shows researcher (with search-docs) and writer (with writer-llm) beneath it. All observations carry the agent name support-orchestrator.

Tips for this pattern:

  • Give each sub-agent's LLM calls a name that identifies the sub-agent, such as writer-llm. Cost is recorded on generations, so the generation name is what lets you split cost by sub-agent.
  • Use as_type="tool" for tool calls, "retriever" for searches, "chain" for fixed steps and "generation" for LLM calls you record yourself. See the Python SDK reference.
  • Do not pass agent_name to nested observations. It has no effect and only produces the warning above.

Routing to one sub-agent

A router that picks one sub-agent is the same shape. Record the decision on the root so you can filter for it later:

python
def route(question: str) -> str: return "billing" if "invoice" in question.lower() else "general" def routed_support(question: str) -> str: with ants.start_as_current_observation( name="support-router", as_type="agent", agent_name="support-router", input=question ) as root: path = route(question) root.update(metadata={"route": path}) with ants.start_as_current_observation( name=f"{path}-agent", as_type="agent", input=question ) as sub_agent: completion = llm.chat.completions.create( name=f"{path}-llm", model="gpt-4o-mini", messages=[{"role": "user", "content": question}], ) answer = completion.choices[0].message.content sub_agent.update(output=answer) root.update(output=answer) return answer

Parallel sub-agents

Nesting follows the current context. asyncio tasks copy the context when they are created, so sub-agents started with asyncio.gather nest under the orchestrator and inherit its agent name.

python
from ants_platform import observe from ants_platform.openai import AsyncOpenAI allm = AsyncOpenAI() @observe(name="analyst", as_type="agent") async def analyst(topic: str) -> str: completion = await allm.chat.completions.create( name=f"analyst-llm-{topic}", model="gpt-4o-mini", messages=[{"role": "user", "content": f"Give one key fact about {topic}."}], ) return completion.choices[0].message.content async def research_team(topics: list[str]) -> list[str]: with ants.start_as_current_observation( name="research-team", as_type="agent", agent_name="research-team", input=topics ) as root: results = await asyncio.gather(*(analyst(topic) for topic in topics)) root.update(output=results) return results if __name__ == "__main__": print(asyncio.run(research_team(["pricing", "security", "latency"]))) ants.flush()

The trace shows three analyst observations side by side under research-team. Open the Timeline view of the trace to see that they overlapped.

⚠

Threads do not inherit the context. A function submitted to a ThreadPoolExecutor from inside the orchestrator starts a new trace without an agent name, and the SDK raises ValueError: agent_name is required. Submit contextvars.copy_context().run with your function instead, for example executor.submit(contextvars.copy_context().run, analyst_sync, topic), or use asyncio.to_thread, which copies the context for you.

Separate traces per agent

When each agent should appear on its own in the AI Agent Center, with its own requests, cost, latency and risk score, give each agent its own trace. Each agent opens its own root observation with its own agent_name. Link the traces with a shared session_id so the whole conversation is visible together.

python
def research_agent(question: str, session_id: str) -> str: with ants.start_as_current_observation( name="research-agent", as_type="agent", agent_name="research-agent", input=question ) as agent: agent.update_trace(session_id=session_id, tags=["support-pipeline"]) notes = "\n".join(search_docs(question)) agent.update(output=notes) return notes def answer_agent(question: str, notes: str, session_id: str) -> str: with ants.start_as_current_observation( name="answer-agent", as_type="agent", agent_name="answer-agent", input=question ) as agent: agent.update_trace(session_id=session_id, tags=["support-pipeline"]) completion = llm.chat.completions.create( name="answer-llm", model="gpt-4o-mini", messages=[ {"role": "system", "content": f"Answer using these notes:\n{notes}"}, {"role": "user", "content": question}, ], ) answer = completion.choices[0].message.content agent.update(output=answer) return answer def support_pipeline(question: str, session_id: str) -> str: notes = research_agent(question, session_id) return answer_agent(question, notes, session_id)

support_pipeline is not wrapped in an observation, so research-agent and answer-agent each start their own trace. If you call one agent from inside the other's with block, it joins the outer trace and takes the outer agent name.

When agents run as separate services, each service creates its own client and opens its own root observation in the same way. Pass the session ID between services with your request so the traces line up under Discover AI > Sessions.

ℹ

Open agent roots with the start_as_current_observation context manager. In the current SDK, an agent observation started with start_observation(...) outside any trace leaves its agent name in the calling context after .end(), so the next root observation opened in that context inherits the old name and logs the override warning.

Compare agents

QuestionWhere to look
Which agents are running, and what do they cost?Discover AI > AI Agent Center. One entry per agent name. See AI Agent Center.
Which sub-agent in a trace was slow?The trace Timeline view; Gauge AI > Latency & Analytics for p50 to p99 per span and generation name. See Cost & Performance Metrics.
Which sub-agent costs the most?Gauge AI > Cost Dashboard, widget Top 20 Use Cases (Observation) by Cost (spend per observation name). See Cost & Latency Dashboards.
Which agent (trace name) costs the most?Top 20 Use Cases (Trace) by Cost on the same dashboard.
Can an agent move to a cheaper model?Gauge AI > AI Optimizer, which treats each trace name as an agent. See AI Optimizer.
A view of your ownCustom dashboards with widgets over traces or observations, broken down by trace name, observation name, model or tags.

To score the quality of each agent's output, attach scores in code with score_current_trace(...) or set up LLM-as-a-Judge evaluators filtered by trace name. The managed Goal Accuracy and Topic Adherence templates suit agent outputs.

Framework integrations

  • CrewAI. from ants_platform.crewai import EventListener traces crews, agents, tasks and tool calls. See CrewAI.
  • LangChain and LangGraph. Pass CallbackHandler() in config={"callbacks": [handler]} and set the agent name with "metadata": {"ants_platform_agent_name": "..."}. LangGraph traces can also be viewed as a graph. See LangChain and Agent Graphs.

Common issues

SymptomFix
ValueError: agent_name is required in a workerThe worker runs in a thread without the orchestrator's context. Use contextvars.copy_context().run or asyncio.to_thread, or give the worker its own root observation with an agent_name.
Warning Child span attempted to override parent agent_nameA nested observation passed a different agent_name. Remove it, or move that agent into its own trace.
Sub-agents show up as separate tracesThe sub-agent ran outside the orchestrator's with block or in a thread without context.
Every sub-agent shows the orchestrator's name in AI Agent CenterExpected for nested sub-agents. Use separate traces if each agent needs its own entry.
Trace missing after a script endsCall ants.flush() before the process exits.

Next steps

© 2026 ANTS Platform, Inc.Docs v1.0 · Last updated June 2026