Multiple agents
Run several agents in one process, each signing as itself: when to make a separate agent rather than a sub-agent, agents that call each other, and testing them.
Every agent you make from one Guardian, or from several, signs its requests as itself: its name is on each request, and the proxy signs each one with that agent’s key (One proxy, many agents). So any number of agents can run in one process, through one proxy.
An agent, or a sub-agent
- A separate agent is enrolled on its own (
viper agent add), with its own key, agent ID and definition. Make one for work the Guardian Platform should see, govern and revoke on its own: a planner and a researcher with different tools and different owners, say. - A sub-agent is declared in its parent’s definition and signs as its parent (Sub-agents). Make one for a part of an agent’s own work, such as Deep Agents’ sub-agents or a critic your agent hands drafts to. It isn’t enrolled.
Running one agent in several processes or containers is neither: it’s one agent with several keys (Several instances of one agent).
Agents that call each other
An agent’s tool can call another agent made with create_agent or create_deep_agent. The other agent signs its own requests, under its own name and definition, so the SDK allows it where it refuses a tool that calls an unrouted model (Tools that call a model). A model bound to another agent, guardian.agent("summarizer", model=llm).bind(llm), is allowed for the same reason.
This test has a planner whose tool asks a researcher. The proxy’s replies are scripted, so the planner calls its tool, the researcher answers, and the planner finishes. It’s a test module, which starts its own FakeProxy with both agents enrolled (Test your agents). Save it under a test_ name and run it with pytest, or uv run pytest:
import json
from collections.abc import Iterator
from typing import Any
import pytest
from langchain_core.tools import tool
from langchain_openai import ChatOpenAI
from guardian_agent.langchain import create_agent
from guardian_agent.testing import FakeProxy, FakeRequest
@pytest.fixture
def proxy(monkeypatch: pytest.MonkeyPatch) -> Iterator[FakeProxy]:
with FakeProxy(agents=["planner", "researcher"]) as proxy:
monkeypatch.setenv("VIPER_PROXY_URL", proxy.url)
monkeypatch.setenv("OPENAI_API_KEY", "test")
yield proxy
def completion(content: str | None, calls: list[Any] | None = None) -> dict[str, Any]:
"""A Chat Completions response, carrying the message the model returns."""
message: dict[str, Any] = {"role": "assistant", "content": content}
if calls:
message["tool_calls"] = calls
return {
"id": "chatcmpl-test",
"object": "chat.completion",
"created": 0,
"model": "gpt-4.1",
"choices": [{"index": 0, "message": message, "finish_reason": "stop"}],
}
def script(request: FakeRequest) -> dict[str, Any]:
"""The planner asks the researcher once, then answers."""
if request.agent == "researcher":
return completion("The release adds signed definitions.")
if any(m["role"] == "tool" for m in request.json["messages"]):
return completion("Done: the release adds signed definitions.")
question = json.dumps({"question": "What does the release add?"})
call = {
"id": "call-1",
"type": "function",
"function": {"name": "ask_researcher", "arguments": question},
}
return completion(None, [call])
def test_each_agent_signs_its_own_requests(proxy: FakeProxy) -> None:
researcher = create_agent(
ChatOpenAI(model="gpt-4.1"),
system_prompt="You research one question and cite your sources.",
name="researcher",
)
# A tool can call another agent: it signs its own requests, as itself
@tool
def ask_researcher(question: str) -> str:
"""Ask the researcher a question."""
answer = researcher.invoke(
{"messages": [{"role": "user", "content": question}]}
)
return str(answer["messages"][-1].content)
planner = create_agent(
ChatOpenAI(model="gpt-4.1"),
tools=[ask_researcher],
system_prompt="You plan the work and ask the researcher what you need.",
name="planner",
)
proxy.reply(script)
result = planner.invoke(
{"messages": [{"role": "user", "content": "Plan the notes."}]}
)
assert (
result["messages"][-1].content == "Done: the release adds signed definitions."
)
assert [r.agent for r in proxy.requests] == ["planner", "researcher", "planner"]
assert sorted(proxy.definitions) == ["planner", "researcher"]The requests go out as planner, then researcher, from inside the planner’s tool call, then planner again, and each agent registers its own definition. The planner’s definition records its ask_researcher tool, not the researcher’s model or prompt: those are the researcher’s own.
In a hand-built graph, such an agent can also be a node, or be called from one. The graph records it by name, and the agent still signs as itself (Agents inside a graph).
Raw clients for several agents
With model clients you call yourself, bind one client per agent. Each copy names its own agent, and each request sends its own agent’s instructions:
from openai import OpenAI
import guardian_agent as ga
PLANNING = "You plan the work and say which step needs research."
RESEARCH = "You research questions and cite your sources."
guardian = ga.Guardian()
planner = guardian.agent(
"planner", model=ga.Model("openai", "gpt-4.1"), instructions=PLANNING
)
researcher = guardian.agent(
"researcher", model=ga.Model("openai", "gpt-4.1"), instructions=RESEARCH
)
# One client per agent: each copy names its own agent on every request
planning = planner.bind(OpenAI())
researching = researcher.bind(OpenAI())
plan = planning.chat.completions.create(
model="gpt-4.1",
messages=[
{"role": "system", "content": PLANNING},
{"role": "user", "content": "Plan this week's release notes."},
],
)
notes = researching.chat.completions.create(
model="gpt-4.1",
messages=[
{"role": "system", "content": RESEARCH},
{"role": "user", "content": "What changed in this week's release?"},
],
)
print(plan.choices[0].message.content)
print(notes.choices[0].message.content)Each agent registers its definition on its first request, or when you call its register().
Enrolling them
Each separate agent is enrolled on the machine its proxy runs on, as the Before you start page does for planner and report-writer. The proxy loads enrolled agents when it starts, so restart it after enrolling another. An agent the proxy doesn’t hold can’t register: register() raises ga.AgentNotEnrolled, whose hint names the viper agent add command to run, and a bound client’s first request fails with the provider’s error, from which ga.proxy_error(error) returns the ga.AgentNotEnrolled.