int = 100) -> None:
self._registry: Dict[str, Callable[[Any], str]] = {}
self._max_lines = max_lines
def register(self, tool_name: str, formatter: Callable[[Any], str]) -> "OutputRenderer":
self._registry[tool_name] = formatter
return self
def render(self, tool_name: str, payload: Any) -> str:
if tool_name in self._registry:
return self._registry[tool_name](payload)
return self._dispatch(payload)
def _dispatch(self, payload: Any) -> str:
if isinstance(payload, list) and payload and isinstance(payload[0], dict):
return self._render_table(payload)
if isinstance(payload, dict):
return self._render_record(payload)
if isinstance(payload, str):
return self._render_text(payload)
return f"```json\n{str(payload)}\n```"
**Why this design?** The registry pattern isolates tool-specific formatting logic from the core engine. It allows teams to override default behavior for high-value tools without modifying the dispatcher. Type-based fallback ensures predictable behavior for standard payloads while maintaining extensibility.
### Step 2: Table Alignment Algorithm
Lists of dictionaries are converted into markdown tables. The alignment logic calculates the maximum width for each column, pads cells accordingly, and constructs the header, separator, and body rows. This avoids external dependencies and guarantees deterministic tokenization.
```python
def _render_table(self, rows: List[Dict[str, Any]]) -> str:
if not rows:
return "*Empty result set*"
keys = list(rows[0].keys())
col_widths: Dict[str, int] = {}
for key in keys:
max_val_len = max(len(str(row.get(key, ""))) for row in rows)
col_widths[key] = max(len(key), max_val_len)
header = "| " + " | ".join(key.ljust(col_widths[key]) for key in keys) + " |"
separator = "| " + " | ".join("-" * col_widths[key] for key in keys) + " |"
body_lines = []
for row in rows:
cells = [str(row.get(key, "")).ljust(col_widths[key]) for key in keys]
body_lines.append("| " + " | ".join(cells) + " |")
return f"{header}\n{separator}\n" + "\n".join(body_lines)
Why manual alignment? Third-party table libraries often introduce variable spacing, HTML fallbacks, or inconsistent padding. LLMs tokenize whitespace and delimiters literally. A deterministic, dependency-free algorithm ensures that the same data always produces identical markdown, which stabilizes attention patterns across inference runs.
Step 3: Record and Text Formatting
Single dictionaries are rendered as indented bullet lists. Long strings are wrapped in fenced code blocks with automatic line truncation to prevent context window overflow.
def _render_record(self, record: Dict[str, Any]) -> str:
lines = [f"- {key}: {value}" for key, value in record.items()]
return "\n".join(lines)
def _render_text(self, text: str) -> str:
lines = text.splitlines()
total = len(lines)
if total > self._max_lines:
truncated = lines[:self._max_lines]
header = f"[Truncated to {self._max_lines} lines of {total} total]\n"
return header + "```\n" + "\n".join(truncated) + "\n```"
return f"```\n{text}\n```"
Why bullet lists for single records? Flat key-value pairs in markdown lists provide clear visual hierarchy without the overhead of table delimiters. The model can scan labels and values linearly, which aligns with how transformers process sequential tokens.
Why line-based truncation? Context windows have hard limits. Formatting a 10,000-line log into a code block consumes tokens linearly. Truncating before rendering preserves the most recent or relevant data while keeping the payload within budget. This pairs naturally with dedicated truncation libraries that operate at the byte or line level before serialization occurs.
Step 4: Usage Pattern
The renderer is initialized once and reused across tool executions. Custom formatters are chained during setup.
renderer = (
OutputRenderer(max_lines=150)
.register("query_inventory", lambda data: f"Stock: {data['sku']} | Qty: {data['count']}")
.register("stream_debug_logs", lambda logs: logs.replace("DEBUG", "[D]").replace("ERROR", "[E]"))
)
# Standard dispatch
inventory = [{"sku": "A100", "count": 42}, {"sku": "B200", "count": 7}]
print(renderer.render("query_inventory", inventory))
# Fallback to type dispatch
logs = "line 1\nline 2\n" * 200
print(renderer.render("stream_debug_logs", logs))
This pattern separates configuration from execution. Tool names act as stable identifiers, while payloads remain decoupled from formatting logic. The system scales cleanly across multi-tool agent pipelines.
Pitfall Guide
Explanation: Applying markdown serialization to massive payloads before reducing size causes context window overflow. The alignment characters and table structure add token overhead to data that should have been discarded.
Fix: Always truncate or filter raw payloads first. Pass the reduced dataset into the renderer. Chain truncation libraries before serialization in your tool pipeline.
2. Assuming Auto-Dispatch Handles Complex Types
Explanation: The dispatcher checks for list, dict, and str. Binary objects, file handles, or custom classes fall through to the fallback JSON stringifier, which may raise serialization errors or produce unreadable output.
Fix: Validate tool return types at the boundary. Convert non-serializable objects to primitives before calling the renderer, or register explicit formatters for known complex types.
3. Ignoring Column Order Stability
Explanation: Table columns derive their order from the first dictionary's key iteration. If API responses return keys in inconsistent order, column alignment shifts between runs, breaking attention patterns.
Fix: Enforce deterministic key ordering at the data source. Use OrderedDict or explicitly sort keys before rendering. Document expected column sequences in tool schemas.
4. Treating Markdown as Compression
Explanation: Markdown formatting is a serialization strategy, not a summarization technique. The full dataset remains intact. Developers sometimes assume the model will "ignore" extra data, but LLMs process all injected tokens.
Fix: Pair formatting with explicit budgeting. Use truncation, sampling, or pagination for large datasets. Never rely on markdown alone to reduce context consumption.
5. Overriding Without Testing Token Budget
Explanation: Custom formatters registered via .register() can inadvertently bloat output. A poorly designed lambda might duplicate fields, inject verbose labels, or escape characters unnecessarily.
Fix: Profile custom formatters against expected payload sizes. Set hard character limits in registration callbacks. Log output lengths during integration testing to catch regressions.
6. Unicode and Emoji Width Miscalculation
Explanation: The alignment algorithm uses len() for padding. Unicode characters and emojis often render wider than one terminal column, breaking table alignment in certain environments.
Fix: For production agents, implement a width-aware padding function that accounts for East Asian Width and emoji presentation. Alternatively, accept minor alignment drift if the target LLM tokenizer normalizes whitespace consistently.
Explanation: Registering formatters using string literals scattered across codebases creates brittle dependencies. Renaming a tool breaks the lookup silently, falling back to generic dispatch.
Fix: Centralize tool identifiers in a constants module or enum. Validate registry keys against declared tool schemas during application startup. Fail fast if a registered tool name does not match the agent's tool definitions.
Production Bundle
Action Checklist
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|
| Small structured result (<50 records) | Markdown table via auto-dispatch | Improves field extraction without context penalty | Neutral (+5% tokens) |
| Large dataset (>500 records) | Truncate β Format β Paginate | Prevents overflow while preserving recent data | Low (requires pagination logic) |
| Single record with nested fields | Bullet list or custom formatter | Avoids table overhead for flat data | Neutral |
| Raw logs or stack traces | Fenced code block with line limit | Preserves formatting, enables pattern matching | Low (+8% tokens) |
| Downstream code requires JSON | Keep original payload, format only for model | Separates machine parsing from LLM reasoning | Neutral |
| Multi-agent orchestration | Standardized markdown + content block wrapper | Ensures consistent serialization across teams | Low (requires framework integration) |
Configuration Template
# agent_serialization.py
from typing import Any, Callable, Dict, List
import logging
logger = logging.getLogger(__name__)
class AgentOutputRenderer:
def __init__(self, max_lines: int = 100, strict_mode: bool = False) -> None:
self._registry: Dict[str, Callable[[Any], str]] = {}
self._max_lines = max_lines
self._strict_mode = strict_mode
def register(self, tool_name: str, formatter: Callable[[Any], str]) -> "AgentOutputRenderer":
if self._strict_mode and not callable(formatter):
raise TypeError(f"Formatter for '{tool_name}' must be callable")
self._registry[tool_name] = formatter
return self
def render(self, tool_name: str, payload: Any) -> str:
try:
if tool_name in self._registry:
return self._registry[tool_name](payload)
return self._dispatch(payload)
except Exception as e:
logger.warning(f"Serialization failed for '{tool_name}': {e}. Falling back to JSON.")
return f"```json\n{str(payload)}\n```"
def _dispatch(self, payload: Any) -> str:
if isinstance(payload, list) and payload and isinstance(payload[0], dict):
return self._render_table(payload)
if isinstance(payload, dict):
return self._render_record(payload)
if isinstance(payload, str):
return self._render_text(payload)
return f"```json\n{str(payload)}\n```"
def _render_table(self, rows: List[Dict[str, Any]]) -> str:
if not rows:
return "*Empty result set*"
keys = list(rows[0].keys())
widths = {k: max(len(k), max(len(str(r.get(k, ""))) for r in rows)) for k in keys}
header = "| " + " | ".join(k.ljust(widths[k]) for k in keys) + " |"
sep = "| " + " | ".join("-" * widths[k] for k in keys) + " |"
body = "\n".join(
"| " + " | ".join(str(r.get(k, "")).ljust(widths[k]) for k in keys) + " |"
for r in rows
)
return f"{header}\n{sep}\n{body}"
def _render_record(self, record: Dict[str, Any]) -> str:
return "\n".join(f"- {k}: {v}" for k, v in record.items())
def _render_text(self, text: str) -> str:
lines = text.splitlines()
total = len(lines)
if total > self._max_lines:
header = f"[Truncated to {self._max_lines} lines of {total} total]\n"
return header + "```\n" + "\n".join(lines[:self._max_lines]) + "\n```"
return f"```\n{text}\n```"
Quick Start Guide
- Install or vendor the module: Copy the
AgentOutputRenderer class into your agent utilities package. No external dependencies are required.
- Initialize during startup: Create a singleton instance with your preferred line limit and register custom formatters for high-traffic tools.
- Integrate into tool execution: Wrap your tool return values with
renderer.render(tool_name, result) before injecting into the LLM context.
- Validate with snapshots: Run integration tests comparing rendered output against expected markdown. Verify alignment, truncation boundaries, and fallback behavior.
- Monitor token metrics: Track input token counts across agent runs. Adjust
max_lines or pagination thresholds if context budgets drift.
By treating tool outputs as a presentation layer rather than a raw data dump, you align serialization with how language models actually process information. The result is more reliable reasoning, fewer hallucinations, and predictable context consumption across production agent pipelines.