Compact Tool Output
Feed the LLM a terse view of a tool result while keeping the full payload for display, hooks, and tracing
Compact tool output lets a tool return a rich full result while the model only sees a short summary — fewer tokens per tool call, no change in default behaviour.
Quick Start
- @tool decorator — the simplest surface
Pass
to_model_outputto shrink what the LLM sees.python from praisonaiagents import Agent, tool def _weather_summary(result: dict) -> str: return f"{result['city']}: {result['temp_c']}°C, {result['condition']}" @tool(to_model_output=_weather_summary) def get_weather(city: str) -> dict: return { "city": city, "temp_c": 22, "condition": "sunny", "hourly": [{"h": h} for h in range(24)], # ~2 KB — NOT sent to the LLM "raw_provider_payload": {"noise": "x" * 5000}, # huge — NOT sent } agent = Agent( name="Weather", instructions="Report the weather concisely.", tools=[get_weather], ) agent.start("What's the weather in Paris?") - BaseTool subclass — override the hook
Override
to_model_outputon the class.python from praisonaiagents import Agent from praisonaiagents.tools import BaseTool class SearchTool(BaseTool): name = "search" description = "Search the web and return the top results" def run(self, query: str) -> dict: results = [{"title": f"Result {i}", "html": "<html>" * 500} for i in range(50)] return {"query": query, "results": results} def to_model_output(self, result: dict) -> str: titles = [r["title"] for r in result["results"][:5]] return f"Top 5 for '{result['query']}':\n- " + "\n- ".join(titles) agent = Agent(name="Researcher", tools=[SearchTool()]) agent.start("Find recent papers on retrieval-augmented generation") - Return a ToolResult carrying its own compact view
Set
model_outputon theToolResultdirectly.python from praisonaiagents import Agent, tool from praisonaiagents.tools import ToolResult @tool def analyse_logs(path: str) -> ToolResult: full = {"errors": 3, "warnings": 12, "lines": ["..."] * 9000} summary = f"{full['errors']} errors, {full['warnings']} warnings" return ToolResult( output=full, # display / hooks / downstream get this model_output=summary, # LLM sees only this ) agent = Agent(name="LogTriage", tools=[analyse_logs]) agent.start("Triage today's logs at /var/log/app.log")
How It Works
The executor snapshots the full result for tracing and hooks, then swaps in the compact view for the LLM.
| Step | What happens |
|---|---|
| 1 | Tool runs, returns full output (or a ToolResult) |
| 2 | Executor snapshots the full result for tracing and the AFTER_TOOL hook |
| 3 | _resolve_model_facing_result picks result.model_output, else tool.to_model_output(output) |
| 4 | External tools: the compact override passes through wrap_if_external() |
| 5 | Loop-guard / additional-context notices are mirrored onto the compact view |
| 6 | LLM sees the compact view; the full payload stays with display, hooks, downstream tools |
How to Choose
Pick the surface that matches how your tool is defined.
Configuration Options
| Surface | Signature | Default | Notes |
|---|---|---|---|
ToolResult(model_output=) | model_output: Any = None | None | Highest precedence when the tool returns a ToolResult. Surfaced in to_dict(). |
@tool(to_model_output=fn) | Callable[[Any], Any] | None | result → compact_view. Raise → falls back to full output. |
BaseTool.to_model_output(result) | (self, result: Any) -> Optional[Any] | returns None | Override on a subclass. Receives the raw output value, not the ToolResult. |
resolve_model_output(result) | (Any) -> Optional[Any] | — | Helper reading result.model_output. Does not invoke the hook. |
Resolution order
The executor picks the first hit, then falls back to the full string.
Behavioural guarantees
- Backward compatible. No compact view →
str(result)/json.dumps(...)reaches the LLM exactly as before. - Full result survives. The compact view swaps in after tracing, the
AFTER_TOOLhook, result-aware loop detection, and the loop-guard have all seen the full payload. - Annotations preserved.
AFTER_TOOLadditional_contextand loop-guard messages are mirrored onto the compact view so notices always reach the model. - Prompt-injection fence still applies. For external tools,
wrap_if_external()wraps the compact override too — it never bypasses security markers. - Multimodal untouched. If the result has
content, the compact view is not resolved — image/file parts flow through unchanged. - Overflow spill untouched.
_output_overflow(see Tool Output Spill) still works;model_outputcomposes with it. - Failure is soft. A raising compact-view builder degrades to the full output — never crashes the tool call.
Total-size budget on dict results
After per-field truncation, if the serialised dict is still over the limit, it falls back to the head/tail string that already carries the artifact reference.
from praisonaiagents import Agent, tool
@tool
def search(query: str) -> dict:
# Many medium snippets, each just under the per-field limit, still
# overflow the context in aggregate. The agent now receives the
# head/tail string plus the artifact pointer, not the oversized dict.
return {"results": [{"content": "a" * 5000} for _ in range(50)]}
agent = Agent(name="Researcher", tools=[search])
agent.start("Search for retrieval-augmented generation papers")The per-field pass (_truncate_dict_fields) only shortens fields that each exceed the field limit. A dict of many medium-sized fields each just under that limit stays unbounded, so a second check compares len(str(result)) against the budget and falls back when the dict is still too large.
| Setting | Source | Default | Role |
|---|---|---|---|
tool_output_limit | self.tool_output_limit (falls back to DEFAULT_TOOL_OUTPUT_LIMIT) | module default | Total-size and per-field budget when no context manager is set |
context_manager | agent-level | — | When set, per-field pass runs with max_field_chars=None, and only the total-size budget applies |
A tool returning {"results": [{"content": "a" * (limit - 10)} for _ in range(50)]} previously returned an ~800 KB dict against a 16 KB budget; it now returns the head/tail string plus the artifact ref.
Common Patterns
Turn a heavy result into a headline the model can plan on.
Big JSON → 3-line status
from praisonaiagents import Agent, tool
def _status(result: dict) -> str:
return f"{result['count']} records, {result['anomalies']} anomalies"
@tool(to_model_output=_status)
def fetch_metrics(day: str) -> dict:
return {"count": 142, "anomalies": 3, "rows": [{"i": i} for i in range(142)]}
agent = Agent(name="Metrics", tools=[fetch_metrics])
agent.start("Summarise today's metrics")Web-scrape → head + tail
from praisonaiagents import Agent, tool
def _head_tail(text: str) -> str:
head, tail = text[:200], text[-200:]
return f"{head}\n...[{len(text)} bytes]...\n{tail}"
@tool(to_model_output=_head_tail)
def read_page(url: str) -> str:
return "PAGE " * 5000 # long HTML — LLM only sees head + tail
agent = Agent(name="Scraper", tools=[read_page])
agent.start("Read the landing page and describe it")ToolResult + multimodal
from praisonaiagents import Agent, tool
from praisonaiagents.tools import ToolResult
@tool
def render_report(name: str) -> ToolResult:
full = {"sections": ["intro", "body", "appendix"], "bytes": 40000}
return ToolResult(
output=full,
model_output=f"Report '{name}' ready: {len(full['sections'])} sections",
)
agent = Agent(name="Reporter", tools=[render_report])
agent.start("Render the quarterly report")Opt-out is the default — no hook means the LLM sees the full string, as today.
from praisonaiagents import tool
@tool
def echo(text: str) -> str:
return text # no to_model_output → LLM sees the full stringUser-Interaction Flow
- A user asks the agent to summarise a large API response.
- Without
to_model_output: the whole 20 KB JSON enters the LLM context on every turn → token cost and slow re-planning. - With
to_model_output: the LLM sees"142 records, 3 anomalies"; the full JSON stays available for the display panel and any downstream tool the agent chains next. - Same answer, a fraction of the tokens, unchanged code elsewhere.
Best Practices
Summarise, don't truncate blindly
Return the headline the model needs to decide the next step — counts, status, top items — not a raw byte-slice that hides the signal.
Keep the full payload for downstream tools
The full output still reaches display, hooks, and the next tool. Never fold data the agent chains on into the compact view only.
Make the builder total and cheap
The hook runs on every call. Keep it fast and side-effect free — a raise falls back to the full output, but that silently loses the token savings.
Prefer ToolResult(model_output=) when you already emit a ToolResult
It has the highest precedence and keeps the compact view next to the data that produced it. Reach for @tool(to_model_output=fn) for plain-return functions.