This document describes the YAML formatting strategy used in Call's MCP hook debug output and tool result logging. The goal is to provide readable, unbroken YAML output that preserves long strings, multiline content, and complex nested structures.
- No artificial line breaks: Long strings should never be broken mid-line
- Preserve multiline content: Strings with newlines use literal block scalar (
|) style - Escape handling: Properly convert JSON escape sequences (
\n,\t, etc.) to actual characters - Redaction: Remove verbose
contentfields whenstructuredContentis present - Consistent width: Use very large width limit (999999) to prevent wrapping
class _LiteralYamlDumper(yaml.SafeDumper):
"""YAML dumper that renders multiline strings as block scalars."""String representation logic:
- Multiline strings (
\npresent) → literal block scalar style (|) - Long single-line strings (>100 chars) → converted to literal style by adding trailing newline
- Strings starting with YAML special chars (
#-:>|&*![]{}?@`) → single-quoted style (') - Short strings → plain style (no quotes)
Configuration:
yaml.dump(
obj,
Dumper=_LiteralYamlDumper,
allow_unicode=True, # Support non-ASCII characters
sort_keys=False, # Preserve insertion order
default_flow_style=False, # Use block style, not inline
width=999999, # Prevent line wrapping
line_break="\n", # Unix line endings
)Purpose: Convert Python objects to readable YAML
Fallback chain:
- Try custom
_LiteralYamlDumper - Fall back to
yaml.safe_dumpwith same settings - Fall back to
json.dumpswith indent=2 - Fall back to
str(obj)
Width parameter: Default is 999999 to prevent any line wrapping. This is critical for preserving long strings intact.
Processing pipeline:
-
Pydantic model conversion (
_dump_like_mapping):- Recursively convert models using
.model_dump()or.dict() - Handle nested collections (dict, list, tuple, set)
- Recursively convert models using
-
Unescape strings (
_unescape_strings):- Convert
\\n→ newline - Convert
\\t→ tab - Convert
\\r→ carriage return - Convert
\\"→" - Convert
\\'→' - Preserve literal backslashes (
\\\\→\)
- Convert
-
Redact verbose content (
_redact_structured_content):- When
structuredContentfield exists and is non-empty - Remove sibling
contentfield to reduce verbosity - Recursively process nested structures
- When
-
Serialize to YAML:
- Use
_dump_yaml_literal()for dicts, lists, tuples, sets - Fall back to
json.dumpsif YAML fails - For bytes/strings, decode/pass through directly
- Use
-
Collapse whitespace:
- Normalize line endings:
\r\n→\n - Collapse multiple consecutive newlines to single newline:
\n{2,}→\n
- Normalize line endings:
-
Truncate if needed:
- Only when
CALL_DEBUG=0(for display in Telegram/logs) - Truncate at
max_len - 3and append... - Strip trailing newlines
- Important: Truncation affects ONLY display, NOT agent pipeline data
- Only when
Purpose: Format MCP tool arguments for debug output
Processing:
-
Unescape arguments (
_deep_unescape):- Recursively process strings, lists, tuples, sets, dicts
- Convert escape sequences to actual characters
-
Serialize to YAML:
- First attempt:
_dump_yaml_literal(prepared)with default width - Second attempt: Round-trip through JSON, then YAML
- Third attempt: JSON with manual escape replacement
- Final fallback:
str(obj)
- First attempt:
Used in two places:
- Debug print:
[MCP Hook] Arguments (YAML):\n... - Telegram service message:
🛠️ {tool_name}\n\n{yaml_text}
Purpose: Format agent-as-tool invocation payloads for Telegram debug messages
Context: When a parent agent calls a child agent through the agents-as-tools mechanism, a debug message is sent to Telegram showing the invocation details.
Processing:
- Parse input: Convert JSON string input to Python dict
- Format as YAML:
- First attempt:
_dump_yaml_literal(js, width=10000)for YAML output - Fallback:
json.dumps(js, ensure_ascii=False, indent=2)if YAML fails - Language tag in output adjusted accordingly (
yamlorjson)
- First attempt:
- Truncate if needed: Limit to 1500 characters with
...suffix - Wrap in HTML: Use Telegram HTML format with syntax highlighting
Code location: app/call.py:1154-1179
Telegram message format:
🛠️ {child_agent_name}
from {parent_agent_name}
<pre><code class="language-yaml">
{yaml_payload}
</code></pre>
Why YAML instead of JSON:
- Better readability: Multiline strings displayed with proper line breaks
- Consistency: Matches MCP tool debug output format
- Reduced visual noise: No JSON escape sequences (
\n,\t, etc.) - Easier debugging: Can copy-paste YAML directly for testing
- User preference: Request from user to standardize on YAML for all debug output
Example output:
input: день
context: null
replay: nullvs the old JSON format:
{
"input": "день",
"context": null,
"replay": null
}Fallback behavior: If YAML formatting fails (e.g., PyYAML not installed or serialization error), automatically falls back to JSON with proper error handling, ensuring debug messages always reach Telegram.
[MCP Hook][fs] Tool read_text_file returned:
meta: null
content:
- type: text
text: |
# PM Input/Output Format Specification
**Source of Truth для всех PM-агентов (PM-1..PM-11)**
---
## Input Format
Каждый PM-агент получает routing context от **PM.md** (головной orchestrator):
```json
{
"input": "исходный user input",
"context": "исходный context",
"replay": "исходный replay",
"topic": "Название топика",
"interval": {
"from": "YYYY-MM-DD",
"to": "YYYY-MM-DD"
},
"sources": {
"chats": [
{"id": "chat_id", "name": "chat_name"}
]
}
}
```[MCP Hook][fs] Tool read_text_file returned:
meta: null
content:
- type: text
text: "# PM Input/Output Format Specification\n\n**Source of Truth для всех PM-агентов (PM-1..PM-11)**\n\n---\n\n## Input Format\n\nКаждый PM-агент получает routing context от **PM.md** (головной orchestrator):\n\n```json\n{\n \"input\": \"исходный user input\",\n \"context\": \"исходный context\", \n \"replay\": \"исходный replay\",\n \"topic\": \"Название топика\",\n \"interval\": {\n \"from\": \"YYYY-MM-DD\",\n \"to\": \"YYYY-MM-DD\"\n },\n \"sources\": {\n \"chats\": [\n
{\"id\": \"chat_id\", \"name\":"Problem: String exceeds width limit and breaks mid-line with awkward continuation.
def _dump_yaml_literal(obj: Any, *, width: int = 10000) -> str:
# ...
return yaml.dump(..., width=width, ...)
def _to_yaml_text(obj) -> str:
# ...
return _dump_yaml_literal(prepared, width=10000) # ❌ Hardcoded!def _dump_yaml_literal(obj: Any, *, width: int = 999999) -> str:
# ...
return yaml.dump(..., width=width, ...)
def _to_yaml_text(obj) -> str:
# ...
return _dump_yaml_literal(prepared) # ✅ Uses default 999999- PyYAML's
widthparameter controls when to break long scalars - Setting to a very large value (999999) effectively disables line wrapping
- Still allows proper indentation and structure
- Preserves readability while preventing mid-string breaks
Problem: PyYAML's default behavior can wrap long plain scalars at arbitrary points, even with large width settings, making output unreadable.
Solution: Force literal block scalar style (|) for strings longer than 100 characters by artificially adding a trailing newline:
if len(data) > 100:
# Add newline to force literal style
# Dumper automatically uses |- which strips trailing newlines on parse
modified_data = data + "\n"
return dumper.represent_scalar("tag:yaml.org,2002:str", modified_data, style="|")Benefits:
- Long strings never wrap mid-line
- Preserves exact content (trailing newline is stripped by
|-style) - Consistent with multiline string handling
- Improves readability for file contents, JSON payloads, etc.
Example:
# Before (plain scalar, may wrap):
chat_id: -1002710557620
# After (literal scalar, never wraps):
chat_id: |
-1002710557620After changes, verify with:
# Run agents-as-tools tests
pytest app/tests/test_agents_tool_wrapper.py -v
# Enable debug mode and watch MCP hook output
CALL_DEBUG=1 python -m call.telegram_bot.bot --bot-name TestBot
# Test agent-as-tool YAML formatting specifically
python test_read_file_yaml.pyLook for:
- ✅ Long strings remain intact
- ✅ Multiline content uses
|block scalar - ✅ No unexpected line breaks
- ✅ Proper indentation preserved
- ✅ Agent-as-tool messages in Telegram show YAML format (not JSON)
- ✅ Fallback to JSON works when YAML fails
Symptom: Output shows broken string with continuation indent
Cause: width parameter too small or hardcoded
Solution: Ensure all _dump_yaml_literal() calls use default width (999999)
Symptom: Output shows literal \n, \t instead of newlines/tabs
Cause: Missing _unescape_strings() processing
Solution: Apply unescape before YAML serialization
Symptom: Large content arrays when structuredContent present
Cause: No redaction logic
Solution: Use _redact_structured_content() to filter verbose fields
Symptom: Telegram debug messages for agent-as-tool invocations display JSON instead of YAML
Cause: Code not updated or fallback to JSON triggered
Solution:
- Verify
_dump_yaml_literal()is called in_wrapped_on_invoke()atapp/call.py:1166 - Check that PyYAML is installed:
pip list | grep -i yaml - Review error logs for YAML serialization failures
- Verify
langvariable is set to"yaml"when YAML succeeds
Symptom: MCP Hook debug output shows quoted strings ("string\n...") with mid-line breaks instead of literal block scalars (|)
Example from real output (2025-10-19, fs:read_text_file):
# ❌ WRONG - current behavior
text: "# PM Status Report Mapping Configuration\n# Матрица соответствия для
автоматических отчётов\n# Последнее обновление...Expected behavior:
# ✅ CORRECT - should use literal block scalar
text: |
# PM Status Report Mapping Configuration
# Матрица соответствия для автоматических отчётов
# Последнее обновление: 2025-10-16
...Cause: PyYAML Emitter may fall back to quoted style for very long strings even when representer specifies style="|", especially if:
- String contains trailing whitespace on some lines
- String has very long lines that exceed internal buffer limits
- Representer
represent_scalar()call raises an exception
Debug indicators (added in latest version):
[YAML Representer] Using literal style— Representer chose literal block scalar[YAML Representer] Literal style failed: ...—represent_scalar()call raised exception[YAML Dump Result] has_literal=False, has_quoted=True— Final output is quoted, not literal[YAML Dump] _LiteralYamlDumper failed: ...— Entireyaml.dump()failed, falling back tosafe_dump
Solution (fixed 2025-10-19):
- Override
choose_scalar_style()method in_LiteralYamlDumperto force literal style - PyYAML Emitter has hardcoded heuristics that ignore style hints for strings >1024 chars
- By overriding
choose_scalar_style(), we bypass these heuristics - Strip trailing whitespace from each line before serialization (helps PyYAML accept literal style)
- Result: literal block scalar (
|) is now always used for multiline strings, regardless of length
Previous workarounds (no longer needed):
Verify PyYAML version— issue was in Emitter logic, not versionCheck for trailing whitespace— now stripped automaticallyEnable debug mode— still useful for diagnostics
Fixed bugs (2025-10-19):
- Critical: PyYAML Emitter ignored style hints for strings >1024 chars
- Root cause: Emitter has hardcoded heuristics that override representer style hints
- Solution: Override
choose_scalar_style()in_LiteralYamlDumperto force literal style - Result: All multiline strings now use literal block scalar (
|) regardless of length
- Strip trailing whitespace from each line to help PyYAML accept literal style
- Added exception handling in
_literal_yaml_str_representer()to catch and log failures - Added result validation in
_dump_yaml_literal()to detect quoted vs literal output - Added debug logging for YAML formatting diagnostics
app/call.py:64-109—_LiteralYamlDumperand_dump_yaml_literal()app/call.py:1154-1179—_wrapped_on_invoke()for agent-as-tool debug messagesapp/call.py:2770-2898—_format_tool_result()with escape/redact pipelineapp/call.py:3003-3022—_to_yaml_text()for tool argumentsapp/call.py:3024-3025— Debug print of argumentsapp/call.py:3050-3055— Debug print of tool results
- Always use default width: Never hardcode
width=10000or similar - Preserve order: Keep
sort_keys=Falseto match original structure - Handle escapes: Always unescape JSON escape sequences before YAML dump
- Test with long strings: Verify formatting with file contents, markdown, JSON payloads
- Monitor logs: Check debug output regularly during development
The YAML formatting system in Call ensures:
- Readable, properly indented YAML output
- No artificial line breaks in long strings (both multiline and single-line >100 chars)
- Multiline content uses literal block scalars (
|or|-) - Long single-line strings (>100 chars) converted to literal style to prevent wrapping
- Escape sequences properly converted
- Verbose content redacted when appropriate
- Consistent width (999999) prevents wrapping issues
- All strings roundtrip correctly (trailing newlines stripped by
|-)
Application points:
- MCP Hook Debug Output — Tool arguments and results logging
- Agent-as-Tool Messages — Telegram debug messages when parent agent calls child agent
- Tool Result Formatting — Processing and displaying MCP tool responses
- Telegram Bot Payloads —
[bot][PAYLOAD]debug messages that dump the{target, replay, input, context}envelope.contextmay contain Telegram attachment descriptors (e.g.type: resource_linkitems with Telegram file URLs). See Telegram Context Extraction Details inREADME.mdandtg-user-guide*.mdfor concrete examples.
This approach balances human readability with machine parseability and makes debug logs easy to understand and copy-paste. The consistent use of YAML across all debug output (MCP tools and agents-as-tools) provides a unified debugging experience.
When an agent returns JSON arrays or objects as the final output (identified by first and last characters being [] or {}), the system automatically converts them to YAML format for improved readability in Telegram messages.
Trigger conditions:
- Output text is non-empty string
- After stripping whitespace, first character is
[or{ - After stripping whitespace, last character is
]or}
Example inputs that trigger conversion:
[{"id": 1, "name": "Item"}]
{"status": "ok", "data": [...]}
[1, 2, 3] Located in send_digest_notification() at app/call.py:
# Auto-format JSON arrays/objects as YAML for readability
stripped = text.strip()
if stripped and ((stripped[0] == '[' and stripped[-1] == ']') or
(stripped[0] == '{' and stripped[-1] == '}')):
parsed = json.loads(stripped)
yaml_text = _dump_yaml_literal(parsed)
text = f"<pre><code class=\"language-yaml\">{yaml_text}</code></pre>"Before (raw JSON):
[{"type":"routing-item","topic":"VoiceBot UI","sources":[{"project":{"project_id":"123","name":"CRM Voice"}}]}]After (YAML in code block):
- type: routing-item
topic: VoiceBot UI
sources:
- project:
project_id: '123'
name: CRM Voice- Readability: YAML indentation makes nested structures easier to scan
- Telegram rendering: Code blocks with
language-yamlprovide syntax highlighting - Copy-paste friendly: YAML format is easier to read and edit
- Automatic: No agent modification needed - works for any JSON output
If JSON parsing fails, the original text is preserved:
- Invalid JSON syntax → send as-is
- Conversion error → log debug message, send original
- Preserves robustness for edge cases
- Uses existing
_dump_yaml_literal()function (same as MCP tool formatting) - Logged when conversion succeeds:
[format] Converted JSON array/object to YAML format - Errors logged at debug level to avoid noise
Input format expectation:
- Agents receive JSON objects with structured fields at the top level
- Example correct input structure:
{ "input": "user query", "context": "additional context", "topic": "Project Name", "interval": {"from": "2025-10-17", "to": "2025-10-17"}, "sources": {...}, "output": {...} }
Telegram debug message format:
- Displays the input JSON object (parsed from string parameter)
- Uses YAML formatting for readability (with JSON fallback)
- Shows structured fields at top level, NOT wrapped in
{"input": "JSON string"}
Common mistake:
❌ WRONG:
{
"input": "{\"input\":\"query\",\"context\":\"\",\"topic\":\"...\",...}"
}This indicates double serialization - the entire payload was stringified and wrapped in an input field.
Correct display:
✅ CORRECT:
input: user query
context: additional context
topic: Project Name
interval:
from: 2025-10-17
to: 2025-10-17
sources:
chats:
- chat_id: "123456789"
name: Chat NameCode implementation:
_wrapped_on_invoke()atapp/call.py:1164parsesinputstring parameter:json.loads(input)- Result is formatted as YAML (line 1199) or JSON fallback (line 1203)
- Telegram message shows the parsed object, not the raw string
Verified correct formatting (as of 2025-10-19):
- ✅
time:get_current_time— Returns YAML-formatted object withtimezone,datetime,day_of_week,is_dst - ✅
tg-ro:list_messages— Returns YAML withmeta,content,annotations,structuredContent - ✅
fs:read_text_file— Returns YAML withmeta,content[].textusing literal block scalar|for file contents
These tools demonstrate proper YAML literal formatting for multiline content and structured data.