Skip to content

Grounded Agent

The GroundedAgent is the framework’s answer to LLM hallucination in tool-driven answers: two models, not one. A gatherer calls tools and sees the raw results; a presenter turns those results into the reply. Because the presenter has no tools and no tool responses — only the curated feed plus a small prompt — it cannot invent values beyond what was actually fetched.

A chit-chat turn that calls no tools skips the presenter entirely: the gatherer answers directly in one pass.

User question
[ Gatherer ]
• its own system prompt
• chat history
• tools (AgentTools)
• runs the tool-call loop
• never speaks to the user
▼ captured tool results
[ ToolOutputCurator ] (default: DataBlockCurator)
• curate() → feed string
[ Presenter ]
• no tools, no tool results
• per-turn prompt ← PresenterPrompt.resolve(primary tool)
• input: question + feed
• writes / streams the final reply

The GroundedAgent takes two already-built agents — a gatherer configured with your tools, and a toolless presenter — plus the AgentTools the gatherer uses (it hooks that instance to capture every tool result). Unlike the Swift version, which builds its inner models from LLM clients, the Python/TypeScript version composes two concrete agents, so you can mix any agent types (Bedrock, Anthropic, OpenAI…) for the gatherer and presenter.

| Option | Notes | |---|---| | gatherer | The brain agent — configured with tools and its own system prompt; runs the tool loop and never speaks to the user. Any agent type. | | presenter | The presenter agent — should have no tools; writes the grounded reply. Can be a cheaper/smaller model. | | tools | The AgentTools the gatherer uses. The GroundedAgent hooks it to capture results. | | curator | Shapes the gathered results into the text feed. Default: DataBlockCurator (one ### toolName section per call). | | presenter_prompt / presenterPrompt | Per-tool presenter system prompts. Default: a generic “present only the data” instruction. |

import {
GroundedAgent,
AnthropicAgent,
AgentTools,
} from 'agent-squad';
const brain = new AnthropicAgent({
name: 'Shop brain',
description: 'Gathers product facts.',
apiKey: process.env.ANTHROPIC_API_KEY,
toolConfig: { tool: myTools }, // myTools: AgentTools
customSystemPrompt: {
template: `You are the data brain of a shopping assistant.
GATHER the facts needed to answer the user — never write the final reply.
Never invent values. Do NOT address the user — the presenter does that.`,
},
});
const voice = new AnthropicAgent({
name: 'Shop presenter',
description: 'Presents product facts.',
apiKey: process.env.ANTHROPIC_API_KEY,
// no toolConfig — the presenter never calls tools
});
const agent = new GroundedAgent({
name: 'Shop',
description: 'Product search with grounded answers.',
gatherer: brain,
presenter: voice,
tools: myTools,
});

Drop the agent into an orchestrator unchanged:

import { AgentSquad } from 'agent-squad';
const orchestrator = new AgentSquad();
orchestrator.addAgent(agent);

Example: a small shopping chatbot (Python)

Section titled “Example: a small shopping chatbot (Python)”

A complete, runnable console chatbot. The gatherer calls a tiny in-memory catalog via two tools; the presenter answers only from the curated results, so every price and stock figure is grounded. A chit-chat turn (“hi there”) calls no tools and is answered by the gatherer directly, skipping the presenter. The full source is in examples/grounded-agent-chatbot in the repository.

import asyncio
import os
from agent_squad.agents import (
AnthropicAgent, AnthropicAgentOptions,
GroundedAgent, GroundedAgentOptions, PresenterPrompt,
)
from agent_squad.types import ConversationMessage, ParticipantRole
from agent_squad.utils import AgentTool, AgentTools
# A tiny in-memory catalog (stands in for a real backend).
CATALOG = {
"p1": {"id": "p1", "name": "Aurora Wireless Headphones", "price": 89.0, "rating": 4.6, "stock": 12},
"p2": {"id": "p2", "name": "Nimbus Bluetooth Speaker", "price": 59.0, "rating": 4.3, "stock": 0},
"p3": {"id": "p3", "name": "Zephyr Earbuds", "price": 39.0, "rating": 4.1, "stock": 40},
}
def search_products(query: str, max_price: float = 1000.0) -> list[dict]:
"""Search the catalog by name substring, cheapest first.
:param query: text to match against product names
:param max_price: only return products at or below this price
"""
q = query.lower()
hits = [p for p in CATALOG.values() if q in p["name"].lower() and p["price"] <= max_price]
return sorted(hits, key=lambda p: p["price"])
def get_product(product_id: str) -> dict:
"""Look up a single product by id.
:param product_id: the product id, e.g. "p1"
"""
return CATALOG.get(product_id, {"error": f"no product with id {product_id}"})
tools = AgentTools([
AgentTool(name="search_products", func=search_products, required=["query"]),
AgentTool(name="get_product", func=get_product, required=["product_id"]),
])
api_key = os.environ["ANTHROPIC_API_KEY"]
# The gatherer owns the tools + its own prompt. It gathers facts and never speaks to the user.
gatherer = AnthropicAgent(AnthropicAgentOptions(
name="Shop brain",
description="Gathers product facts by calling tools.",
api_key=api_key,
model_id="claude-3-5-sonnet-20240620",
tool_config={"tool": tools, "toolMaxRecursions": 5},
custom_system_prompt={"template": """
You are the data brain of a shopping assistant.
GATHER the facts needed to answer the user — never write the final reply.
- Call whatever tools you need; you may chain several.
- Use the chat history to resolve follow-ups ("cheaper ones?", "is it in stock?").
- Never invent values. If a tool returns nothing, say so.
- Do NOT address the user or format anything — the presenter does that.
"""},
))
# The presenter has NO tools; it speaks only from the curated feed. A cheaper model is a fine fit.
presenter = AnthropicAgent(AnthropicAgentOptions(
name="Shop voice",
description="Presents gathered product facts to the user.",
api_key=api_key,
model_id="claude-3-5-haiku-20241022",
))
presenter_prompt = PresenterPrompt(
default="Use ONLY the data provided; never invent a value. Be concise and friendly.",
per_tool={
"search_products": (
"You are presenting product search results. Use ONLY the data block — never invent a "
"price, rating, name, or stock status. Lead with the best match (name + price), then "
"one standout detail. Two short sentences."
),
"get_product": (
"You are presenting one product. State its name, price, rating, and whether it is in "
"stock, in one sentence. Use only the data provided."
),
},
)
shop = GroundedAgent(GroundedAgentOptions(
name="Shop",
description="A grounded shopping assistant.",
gatherer=gatherer,
presenter=presenter,
tools=tools,
presenter_prompt=presenter_prompt,
))
async def main() -> None:
user_id, session_id = "demo-user", "demo-session"
history: list[ConversationMessage] = []
print("Shop assistant ready. Try: 'headphones under €100?' (empty line to quit)\n")
while True:
user_input = input("you> ").strip()
if not user_input:
break
response = await shop.process_request(user_input, user_id, session_id, history)
reply = response.content[0]["text"]
print(f"shop> {reply}\n")
# Persist both sides so follow-ups ("cheaper ones?") have context.
history.append(ConversationMessage(role=ParticipantRole.USER.value, content=[{"text": user_input}]))
history.append(ConversationMessage(role=ParticipantRole.ASSISTANT.value, content=[{"text": reply}]))
if __name__ == "__main__":
asyncio.run(main())

A sample session:

you> wireless headphones under €100?
shop> The best match is Aurora Wireless Headphones at €89 — highly rated at 4.6★.
you> is the speaker in stock?
shop> The Nimbus Bluetooth Speaker (€59) is currently out of stock.
you> hi there
shop> Hi! Ask me about our headphones, speakers, or earbuds. # no tool call → presenter skipped

PresenterPrompt selects the presenter’s system prompt based on which tool drove the turn — the primary tool (the last tool call of the turn). Supply one default, plus optional per-tool overrides; unmapped tools fall back to the default.

import { PresenterPrompt } from 'agent-squad';
const presenterPrompt = new PresenterPrompt(
// default
'You are presenting information to the user. Use ONLY the data provided. Be concise and ' +
'natural, and never invent or infer values that are not present in the data.',
// per-tool overrides
{
search_products:
'You are presenting product search results. Use ONLY the data block provided — never ' +
'invent a price, rating, name, or stock status. Lead with the best match.',
get_order:
'You are presenting an order status. State the order ID, current status, and estimated ' +
'delivery in one sentence. Use only the data provided.',
},
);

Pass it as presenter_prompt / presenterPrompt in the options. The presenter’s system prompt is applied per turn, so the presenter agent must expose set_system_prompt / setSystemPrompt (every built-in LLM agent does).

The curator transforms captured tool results into the text string the presenter is fed. It is a pure synchronous transform — no I/O; a curator that needs external data pre-fetches it and is constructed with it.

Two built-in curators ship with the framework:

  • DataBlockCurator (default) — one ### toolName section per captured call, with the raw string result, or the structured result pretty-printed as JSON. Sections joined by a blank line.
  • PerToolCurator — route each tool to its own formatter; unmapped tools fall back to the lossless data-block section. Use this to trim oversized payloads before the presenter sees them.
import { PerToolCurator, DataBlockCurator } from 'agent-squad';
const curator = new PerToolCurator({
search_products: (tool) => {
// keep only the top 3 results to stay within context budget
const trimmed = /* … */ tool.result;
return `### search_products\n${trimmed}`;
},
});
const agent = new GroundedAgent({ /* … */ curator });

Custom curators implement ToolOutputCurator directly (a single curate(results) -> str method).

When the gatherer makes no tool calls, the GroundedAgent emits the gatherer’s own reply directly and skips the presenter. This handles chit-chat, clarifying questions, and fallback answers without paying an extra LLM call.

GroundedAgent streams when its presenter streams (is_streaming_enabled / isStreamingEnabled tracks the presenter). On a no-tool turn the gatherer’s reply is re-emitted as a single streamed chunk. The gatherer always runs to completion internally so its tool results can be captured before the presenter is invoked.

A tool can return an interactive UI component (an “MCP App” widget) alongside its text. Instead of a plain string, the tool returns a ToolResult:

  • content — the text added to the model’s context (what the gatherer reads).
  • structuredContent / structured_content — the data the widget renders from. Render-only: never added to the model’s context, so the model cannot hallucinate from it. The curator also feeds the presenter from this.
  • ui — a UIPayload: the self-contained component (its resource URI, MIME type, template, render-only structured content, and a UISecurity policy the host turns into a CSP).

When the turn’s primary tool returns a UIPayload, the GroundedAgent forwards it to the caller on the streaming path, before the presenter’s text streams in — in Python as the ui field of an AgentStreamResponse, in TypeScript as a { ui } chunk at the head of the stream (TS streams carry strings, so the widget rides as its own object chunk). Governed by uiPolicy / ui_policy (FORWARD by default, or SUPPRESS to drop the widget and keep the grounded text only). Drawing the widget is the host’s job — the framework only carries the data.

import {
GroundedAgent, AgentTool, AgentTools, ToolResult, UIPolicy,
} from 'agent-squad';
const getOrder = (input: { orderId: string }): ToolResult => {
const data = { orderId: input.orderId, status: 'In transit', eta: '2026-07-02' };
return new ToolResult(
`Order ${input.orderId}: ${data.status}, ETA ${data.eta}.`, // text → the gatherer's context
data, // render-only → widget + presenter feed
{
resourceUri: 'ui://shop/order-card',
mimeType: 'text/html;profile=mcp-app',
template: "<div class='card'>…render from window.WIDGET_DATA…</div>",
structuredContent: data,
security: { resourceDomains: ['https://cdn.myshop.com'], prefersBorder: true },
},
);
};
const tools = new AgentTools([new AgentTool({ name: 'get_order', func: getOrder })]);
const shop = new GroundedAgent({
name: 'Shop',
description: 'Orders, grounded in real data.',
gatherer, // built above, with tools
presenter,
tools,
uiPolicy: UIPolicy.FORWARD, // default; use SUPPRESS for text-only
});
// The widget arrives as a { ui } chunk ahead of the presenter's text chunks:
const stream = await shop.processRequest('where is my order #1234?', userId, sessionId, []);
for await (const chunk of stream as AsyncIterable<any>) {
if (chunk?.ui) renderWidget(chunk.ui); // your host renders the component
else if (typeof chunk === 'string') process.stdout.write(chunk); // grounded text underneath
}

In TypeScript, the widget arrives as a { ui } object chunk interleaved with the text-string chunks — whether you iterate the agent’s stream directly or route through AgentSquad.routeRequest (the orchestrator forwards the widget chunk and keeps it out of the saved text answer). Check each chunk’s type as you consume the stream. (Python delivers it the same way, as AgentStreamResponse.ui.)

An .app-only tool (one the widget calls back into, but the model must never see) is kept out of what the model is offered by filtering the provider’s tool list to model-visible tools — the same way the widget’s Refresh button re-invokes a tool without the LLM’s involvement.

  • GroundedAgent is a composite agent (like ChainAgent and SupervisorAgent): it is interchangeable at the orchestrator call site.
  • The gatherer and presenter can be any agent type; only the gatherer is given tools.
  • The same grounding logic drives the Swift realtime voice runtime.