HaotianChen616 756eac0d1a
feat(tool):Add structured synopsis for oversized tool output previews (#3377)
* Improve tool output preview synopsis

* Add JSON path anchors to tool output synopsis

* Fix JSON synopsis line anchors

* fix(synopsis): tighten detectors and fix CSV first-row join

Address review feedback from @willem-bd on PR #3377.

Detectors:
- _looks_yaml now requires >=3 key-shaped lines and refuses bare
  uppercase-tag lines ('INFO: ...', 'ERROR: ...') that look like log
  lines and would round-trip into a flat string dict via safe_load.
  Previously a 200-line log file was classified as 'YAML object with
  3 top-level keys' and lost every line, count, and middle signal.
- _try_yaml refuses payloads that safe_load collapses to a dict of
  all strings (the shape tracebacks and log lines collapse into).
- _try_table applies the header-must-look-like-identifiers and
  minimum-row-count guards only to TSV, since the same safeguards
  would reject legitimate small CSVs. Refuses tab-indented bash
  output, ls -l listings, and tree dumps.

Rendering:
- CSV first data row is now rendered as a key=value list joined by
  ' | ' (e.g. 'name=Ada | description="a fine, brilliant logician"
  | score=98'). The previous delimiter.join(rows[1]) silently
  re-split cells that contained the delimiter inside a quoted cell,
  which made the synopsis report a 3-column table as 5 columns and
  misled the model about column count and content.

Text summary:
- _summarize_text now omits the closing excerpt entirely when the
  input is shorter than 2 * _TEXT_EXCERPT_CHARS, since the previous
  opener/closer slices overlapped and duplicated text for short
  inputs (build_tool_output_synopsis is reachable directly from
  tests and other callers that pass small inputs).

Tests:
- Update test_table_preview_extracts_columns to assert the new
  key=value list format.

* fix(synopsis): drop JSON path line/byte offset hints

The path-location hint was computed by string-searching for the
quoted key in the original content and reporting its byte offset and
line number. This anchors at the first textual occurrence of the
key string, which is wrong when the key also appears as a value
earlier in the document, or when the same key recurs at multiple
depths. With nested paths the anchor drifts further on every step
because the search cursor is advanced past each previous match.

Concrete cases:
  content = '{"label": "items", "items": {"id": 1}}'
  _json_path_location(content, ['items'])
  -> ' (line 1, byte offset 10)'  # the value, not the key

  content = '{"data": {"info": 1, "data": {"info": 2}}}'
  _json_path_location(content, ['data','data','info'])
  -> ' (line 1, byte offset 30)'  # the inner first 'info', not the second

The synopsis instructed the model to 'Start near the line hints
above when present', so a wrong anchor would send read_file into
the wrong region of the persisted .tool-results file.

Drop the hint entirely. The path itself ('$.data.items') is
already useful navigation; the agent uses read_file with start_line
based on its own judgement of where the relevant slice is.

Tests:
- Update test_json_preview_reports_nested_paths to assert no 'line '
  or 'byte offset ' appears in the body before the Access section.
- Rename test_json_line_hints_use_original_content_offsets to
  test_json_paths_are_emitted_without_line_hints and invert the
  assertions to check the hints are absent.

* fix(synopsis): bound _scalar_examples recursion depth

Mirror the _JSON_STRUCTURE_DEPTH cap used by _json_container_paths
and _json_shape so that deeply nested JSON cannot trigger
RecursionError inside build_tool_output_synopsis.

In ToolOutputBudgetMiddleware.awrap_tool_call the synopsis is built
inside asyncio.to_thread(_patch_result, ...); a RecursionError
would surface as a tool-call failure and the user would lose the
entire output. 300-level nested JSON is well inside what an
attacker-controlled MCP tool, a JSON-RPC-over-JSON-RPC chain, or a
buggy serializer can produce.

* feat(synopsis): restore inline raw head/tail sample

The synopsis-only preview silently dropped the raw head/tail bytes
that preview_head_chars / preview_tail_chars used to inline. For
text/code/log outputs the agent lost first/last KB of the actual
content and had to issue a follow-up read_file round-trip to see
the trailing region (last paragraph of a fetched article, final
error line in a traceback, closing diagnostics of a bash run).

Restore an inline 'Raw sample (head + tail)' section in the preview.
The section is composed by slicing head_chars from the start and
tail_chars from the end of the content (with a '...' separator
between them, and the tail suppressed when it would overlap the
head). For binary-like output, the synopsis's own sample is reused
unchanged.

This makes preview_head_chars / preview_tail_chars operational
again for every kind except binary, which already had a sample
channel.

Tests:
- Rename test_json_preview_extracts_structure_instead_of_head_tail
  to test_json_preview_includes_structure_and_raw_sample and assert
  the raw sample section is present and the payload is reachable
  in the head slice.

* test(synopsis): add regression tests for willem-bd review findings

Add 8 regression tests under TestToolOutputSynopsis, one per
finding in @willem-bd's review of PR #3377:

- test_review_5_log_lines_are_not_misclassified_as_yaml
  Pins the YAML detector to refuse 'LEVEL: message' log lines.
- test_review_6_json_paths_are_emitted_without_byte_offset
  Pins the removal of byte/line hint from JSON path descriptions.
- test_review_7_scalar_examples_respects_depth_cap
  Pins that 500-deep nested JSON does not raise.
- test_review_8_csv_first_row_quoted_cells_round_trip
  Pins the new key=value list format for CSV first-row rendering
  and asserts that quoted cells with embedded delimiters survive.
- test_review_9_tsv_detector_rejects_tab_indented_bash
  Pins that tab-indented bash output is not classified as TSV.
- test_review_10_preview_includes_raw_head_and_tail_sample
  Pins the restored inline 'Raw sample (head + tail)' section.
- test_review_11_short_text_does_not_duplicate_excerpts
  Pins that closer is suppressed for inputs shorter than
  2 * _TEXT_EXCERPT_CHARS.
- test_review_12_preview_head_tail_chars_are_operational
  Pins that head_chars / tail_chars are wired into the rendered
  preview and not silently dropped.

Also removes the now-stale 'byte offsets are approximate anchors'
sentence from render_tool_output_preview's Access block; the
synopsis no longer emits byte/line hints, so the guidance to
'start near the line hints' was misleading.

* fix(synopsis): resolve lint errors on tool output budget tests

Local 'make lint' on feat/tool-output-synopsis-preview (after fast-forward
to current main) failed with three errors in tests added by PR #3377:

- E501: 307-char bash_out literal in test_review_9_tsv_detector_rejects_tab_indented_bash
- E741: ambiguous single-letter 'l' in test_review_11_short_text_does_not_duplicate_excerpts
- E741: same ambiguous 'l' on the closing assert

Replace the long literal with a join of per-row entries, rename the loop
variable from 'l' to 'ln', and run ruff format on the two touched files
to absorb the formatting drift introduced by the merge with main.

Verification:
- make lint   -> All checks passed; 643 files already formatted
- pytest tests/test_tool_output_budget_middleware.py -> 110 passed

* fix: address willem-bd review findings (code/csv misclassification, text duplication, line snapping, dead constant, xml hardening, depth consistency)

- _CODE_HINTS: require stronger signals for use/fn (trailing ; or parenthesised)
- _try_table: apply _TABLE_MIN_DATA_ROWS gate to CSV too (not just TSV)
- config.example.yaml: correct misleading comment about preview_head/tail_chars
- _summarize_text: skip opener/closer excerpts when raw sample will be appended
- _build_raw_sample: snap to line boundaries for clean truncation
- Remove dead constant _TABLE_FIRST_ROW_CHARS
- Prefer defusedxml for XML parsing (billion-laughs protection), fallback to stdlib
- Replace _json_shape magic number 2 with named _JSON_SHAPE_MAX_DEPTH constant
- Update tests to match new CSV gate (>=5 rows) and line-snapped sample counts

* style: ruff format fix for tool_output_synopsis.py and test_tool_output_budget_middleware.py

* fix(tool-output): address 4 review comments - DoS hardening + size cap

1. XML entity-expansion DoS: skip _try_xml when defusedxml is not
   available (SafeET is None), falling through to text + raw sample.
   (cid=3587721336)

2. YAML alias-bomb DoS: refuse to parse YAML content > 500 KB.
   (cid=3587721340)

3. Unbounded content parse: add _MAX_SYNOPSIS_INPUT_BYTES=5MB cap;
   oversized output falls back to raw head/tail sample instead of full
   parse. (cid=3587721346)

4. Scalar examples surface mid-document values: add docstring note
   that the synopsis is a structural summary, not a confidentiality
   filter. (cid=3587721353)

* fix(tool-output): ruff format the synopsis string to one line

---------

Co-authored-by: qinchenghan <qinchenghan@huawei.com>
2026-07-16 16:41:04 +08:00

636 lines
23 KiB
Python

"""Deterministic summaries for oversized tool output previews."""
from __future__ import annotations
import csv
import io
import json
import re
import xml.etree.ElementTree as ET
from collections import Counter
from dataclasses import dataclass
from typing import Any, Literal
try:
from defusedxml import ElementTree as SafeET # type: ignore[import-not-found]
except ImportError:
SafeET = None # Fall back to stdlib; risk is limited (agent-requested output).
import yaml
ToolOutputKind = Literal["json", "csv", "tsv", "yaml", "xml", "code", "text", "unknown"]
_KEY_LIMIT = 12
_SCALAR_LIMIT = 6
_TABLE_SAMPLE_ROWS = 50
_TABLE_COLUMN_LIMIT = 18
_TEXT_HEADER_LIMIT = 16
_TEXT_EXCERPT_CHARS = 420
_CODE_IMPORT_LIMIT = 12
_CODE_SYMBOL_LIMIT = 24
_JSON_SHAPE_MAX_DEPTH = 2
_JSON_STRUCTURE_LIMIT = 24
_JSON_STRUCTURE_DEPTH = 4
# Hard cap on the synopsis input size. Beyond this threshold the full parse
# is skipped and only a raw head/tail sample is emitted. This bounds the
# worst-case memory/CPU when externalized tool output is pathologically large
# (e.g. 50+ MB log dumps) and prevents DoS via XML/YAML entity-expansion.
_MAX_SYNOPSIS_INPUT_BYTES = 5_000_000
_CODE_HINTS = (
re.compile(r"^\s*(?:from\s+\S+\s+import|import\s+\S+)", re.MULTILINE),
re.compile(r"^\s*(?:class|def|async\s+def|function|export\s+function)\s+[A-Za-z_]\w*", re.MULTILINE),
# Require stronger signals for Rust/Java: `use ...;` (trailing semicolon),
# `fn ...(` (parenthesised), `pub fn ...(`, `public class ...`. Bare
# `use <word>` or `fn <word>` misclassifies prose (e.g. "use the following …").
re.compile(r"^\s*(?:package\s+[A-Za-z_][\w.]*|use\s+[A-Za-z_][\w:]*\s*;|pub\s+fn\s+[A-Za-z_]\w*\s*\(|fn\s+[A-Za-z_]\w*\s*\(|public\s+class\s+[A-Za-z_]\w*)", re.MULTILINE),
)
@dataclass(frozen=True)
class ToolOutputSynopsis:
"""Structured preview data for an oversized tool output."""
kind: ToolOutputKind
title: str
summary: list[str]
structure: list[str]
notable_items: list[str]
sample: str = ""
def build_tool_output_synopsis(content: str, *, tool_name: str = "") -> ToolOutputSynopsis:
"""Return a typed synopsis for *content* without using an LLM."""
if content == "":
return ToolOutputSynopsis(
kind="unknown",
title="Empty output",
summary=["The tool returned an empty string."],
structure=[],
notable_items=[],
)
# Size guard: parsing the full content above the threshold is a DoS risk
# (XML entity expansion, YAML alias bombs, memory/CPU from raw text).
# Fall back to a raw head/tail sample to bound the worst case.
if len(content.encode("utf-8")) > _MAX_SYNOPSIS_INPUT_BYTES:
return ToolOutputSynopsis(
kind="unknown",
title="Oversized output",
summary=[
f"The output has {len(content)} characters ({len(content.encode('utf-8')) / 1024 / 1024:.1f} MB). Parsing skipped due to size limit.",
],
structure=[],
notable_items=[],
sample=_head_tail_sample(content, _TEXT_EXCERPT_CHARS * 2),
)
if _looks_binary(content):
return ToolOutputSynopsis(
kind="unknown",
title="Binary-like output",
summary=[f"The output has {len(content)} characters and includes non-text control bytes."],
structure=[],
notable_items=[],
sample=_head_tail_sample(content, _TEXT_EXCERPT_CHARS * 2),
)
stripped = content.strip()
json_synopsis = _try_json(content)
if json_synopsis is not None:
return json_synopsis
xml_synopsis = _try_xml(stripped)
if xml_synopsis is not None:
return xml_synopsis
if "\t" in content:
table = _try_table(content, delimiter="\t", kind="tsv")
if table is not None:
return table
if "," in content:
table = _try_table(content, delimiter=",", kind="csv")
if table is not None:
return table
yaml_synopsis = _try_yaml(content)
if yaml_synopsis is not None:
return yaml_synopsis
if _looks_code(content):
return _summarize_code(content)
return _summarize_text(content, tool_name=tool_name)
def render_tool_output_preview(
content: str,
*,
tool_name: str,
virtual_path: str,
head_chars: int,
tail_chars: int,
) -> str:
"""Render a file-backed preview as a typed synopsis plus a raw head/tail sample.
The synopsis is the primary signal; the raw sample restores the
inline head/tail bytes that operators used to get from
preview_head_chars / preview_tail_chars before the synopsis was
added. For binary-like output the synopsis already carries a raw
sample; for everything else we slice head_chars from the start and
tail_chars from the end of *content*.
"""
total = len(content)
synopsis = build_tool_output_synopsis(content, tool_name=tool_name)
head_budget = max(0, head_chars)
tail_budget = max(0, tail_chars)
# For text kind, skip excerpts in the synopsis when a raw sample will be
# appended (avoids duplicating head/tail bytes in both places).
if synopsis.kind == "text" and head_budget + tail_budget > 0 and len(content) > head_budget + tail_budget:
synopsis = _summarize_text(content, tool_name=tool_name, include_excerpts=False)
lines = [
f"[Full {tool_name} output saved to {virtual_path} ({total} chars, ~{total // 4} tokens).]",
f"[Preview kind: {synopsis.kind}. This is a structured synopsis, not a raw head/tail truncation.]",
"",
f"{synopsis.title}:",
]
lines.extend(f"- {item}" for item in synopsis.summary)
if synopsis.structure:
lines.append("")
lines.append("Structure:")
lines.extend(f"- {item}" for item in synopsis.structure)
if synopsis.notable_items:
lines.append("")
lines.append("Notable items:")
lines.extend(f"- {item}" for item in synopsis.notable_items)
raw_sample = _build_raw_sample(content, head_budget=head_budget, tail_budget=tail_budget, existing=synopsis.sample)
if raw_sample:
lines.append("")
lines.append("Raw sample (head + tail, clipped to head_chars / tail_chars):")
lines.append(raw_sample)
lines.append("")
lines.append("Access:")
lines.append(f"- Use read_file on {virtual_path} with start_line and end_line to inspect the raw output.")
return "\n".join(lines)
def _clip(value: str, limit: int) -> str:
if limit <= 0:
return ""
if len(value) <= limit:
return value
return value[: max(0, limit - 3)] + "..."
def _build_raw_sample(content: str, *, head_budget: int, tail_budget: int, existing: str) -> str:
"""Compose the inline head/tail raw sample.
If the synopsis already provides a sample (binary-like output), use
it directly. Otherwise slice head_budget bytes from the start and
tail_budget bytes from the end, snapping to line boundaries so
previews end on clean line breaks. Avoids duplicate bytes when the
two slices would overlap.
"""
if existing:
return existing
if head_budget <= 0 and tail_budget <= 0:
return ""
if len(content) <= head_budget + tail_budget:
return content
parts: list[str] = []
if head_budget > 0:
head = content[:head_budget]
# Snap to the last newline within the budget for clean truncation.
snap = head.rfind("\n")
if snap > 0:
head = head[:snap]
parts.append(head)
if tail_budget > 0 and head_budget + tail_budget < len(content):
tail = content[-tail_budget:]
# Snap to the first newline within the tail for clean truncation.
snap = tail.find("\n")
if snap >= 0 and snap < len(tail) - 1:
tail = tail[snap + 1 :]
parts.append(tail)
if len(parts) == 2:
return f"{parts[0]}\n...\n{parts[1]}"
return parts[0]
def _one_line(value: str, limit: int) -> str:
return _clip(re.sub(r"\s+", " ", value).strip(), limit)
def _head_tail_sample(content: str, limit: int) -> str:
if limit <= 0:
return ""
if len(content) <= limit:
return content
half = max(1, limit // 2)
return f"{content[:half]}\n...\n{content[-half:]}"
def _looks_binary(content: str) -> bool:
if "\x00" in content:
return True
sample = content[:1000]
controls = sum(1 for char in sample if ord(char) < 32 and char not in "\n\r\t")
return controls / max(1, len(sample)) > 0.05
def _type_name(value: Any) -> str:
if isinstance(value, dict):
return "object"
if isinstance(value, list):
return "array"
if isinstance(value, bool):
return "boolean"
if value is None:
return "null"
if isinstance(value, (int, float)):
return "number"
return "string"
def _short_value(value: Any) -> str:
if isinstance(value, str):
return json.dumps(_clip(value, 80), ensure_ascii=False)
return _clip(repr(value), 80)
def _json_shape(value: Any, *, depth: int = 0) -> str:
if depth >= _JSON_SHAPE_MAX_DEPTH:
return "..."
if isinstance(value, dict):
keys = [str(key) for key in list(value.keys())[:_KEY_LIMIT]]
suffix = f": {', '.join(keys)}" if keys else ""
return f"object(keys={len(value)}{suffix})"
if isinstance(value, list):
samples = ", ".join(_json_shape(item, depth=depth + 1) for item in value[:3])
suffix = f", first=[{samples}]" if samples else ""
return f"array(len={len(value)}{suffix})"
return _type_name(value)
def _json_path(parent: str, key: Any) -> str:
key_text = str(key)
if re.match(r"^[A-Za-z_][A-Za-z0-9_]*$", key_text):
return f"{parent}.{key_text}"
return f"{parent}[{json.dumps(key_text, ensure_ascii=False)}]"
def _json_container_description(value: Any) -> str:
if isinstance(value, dict):
keys = [str(key) for key in list(value.keys())[:_KEY_LIMIT]]
suffix = f"; keys {', '.join(keys)}" if keys else ""
return f"object keys {len(value)}{suffix}"
if isinstance(value, list):
detail = f"array length {len(value)}"
if value:
detail += f"; first item {_type_name(value[0])}"
return detail
return _type_name(value)
def _json_container_paths(value: Any, *, limit: int = _JSON_STRUCTURE_LIMIT) -> list[str]:
"""Summarize nested JSON container paths.
Locations are intentionally omitted: an approximate '(line N, byte
offset M)' anchor based on string search is wrong whenever a key
string also appears as a value earlier in the document, or when
the same key occurs at multiple depths. The path itself is already
useful navigation; the agent uses read_file with start_line from
its own judgement of where the relevant slice is.
"""
paths: list[str] = []
def walk(node: Any, current_path: str, depth: int) -> None:
if len(paths) >= limit or depth >= _JSON_STRUCTURE_DEPTH:
return
if isinstance(node, dict):
for key, child in list(node.items())[:_KEY_LIMIT]:
if len(paths) >= limit:
break
next_path = _json_path(current_path, key)
if isinstance(child, (dict, list)):
paths.append(f"{next_path}: {_json_container_description(child)}")
walk(child, next_path, depth + 1)
return
if isinstance(node, list) and node:
first = node[0]
if isinstance(first, (dict, list)):
walk(first, f"{current_path}[]", depth + 1)
walk(value, "$", 0)
return paths
def _scalar_examples(value: Any, *, path: str = "$", limit: int = _SCALAR_LIMIT) -> list[str]:
examples: list[str] = []
def walk(node: Any, current: str, depth: int) -> None:
if len(examples) >= limit or depth >= _JSON_STRUCTURE_DEPTH:
return
if isinstance(node, dict):
for key, child in list(node.items())[:_KEY_LIMIT]:
walk(child, f"{current}.{key}", depth + 1)
if len(examples) >= limit:
break
return
if isinstance(node, list):
for index, child in enumerate(node[:2]):
walk(child, f"{current}[{index}]", depth + 1)
if len(examples) >= limit:
break
return
examples.append(f"{current}: {_short_value(node)}")
walk(value, path, 0)
return examples
def _try_json(content: str) -> ToolOutputSynopsis | None:
stripped = content.strip()
if not stripped.startswith(("{", "[")):
return None
try:
decoder = json.JSONDecoder()
value, end = decoder.raw_decode(stripped)
except Exception:
return None
trailing = len(stripped[end:].strip())
summary: list[str] = []
structure: list[str] = [f"shape: {_json_shape(value)}"]
structure.extend(_json_container_paths(value))
notable = _scalar_examples(value)
# NOTE: scalar examples may surface values from anywhere in the parsed
# structure (not just head/tail bytes). This is expected behaviour — the
# synopsis is a structural summary, not a confidentiality filter. Operators
# who relied on the old preview to only expose head/tail snippets should
# review their tool outputs for sensitive mid-document values.
if isinstance(value, dict):
keys = [str(key) for key in value.keys()]
summary.append(f"JSON object with {len(keys)} top-level keys.")
summary.append(f"Top-level keys: {', '.join(keys[:_KEY_LIMIT]) or '(none)'}")
elif isinstance(value, list):
summary.append(f"JSON array with {len(value)} items.")
if value:
structure.append(f"first item type: {_type_name(value[0])}")
else:
summary.append(f"JSON {_type_name(value)}.")
if trailing:
notable.append(f"Trailing non-JSON characters after first value: {trailing}")
return ToolOutputSynopsis(
kind="json",
title="JSON output",
summary=summary,
structure=structure,
notable_items=notable,
)
def _try_xml(stripped: str) -> ToolOutputSynopsis | None:
if not stripped.startswith("<"):
return None
if SafeET is None: # defusedxml not available; skip XML parsing to avoid entity-expansion DoS
return None
try:
root = (SafeET or ET).fromstring(stripped)
except Exception:
return None
child_counts = Counter(child.tag for child in list(root))
structure = [f"root tag: {root.tag}", f"root attributes: {len(root.attrib)}"]
structure.extend(f"{tag}: {count}" for tag, count in child_counts.most_common(_KEY_LIMIT))
return ToolOutputSynopsis(
kind="xml",
title="XML output",
summary=[f"XML document with root tag {root.tag}."],
structure=structure,
notable_items=[],
)
_TABLE_MIN_DATA_ROWS = 5
_TABLE_HEADER_IDENT_RE = re.compile(r"^[A-Za-z0-9_][A-Za-z0-9_.\-]*$")
def _try_table(content: str, *, delimiter: str, kind: Literal["csv", "tsv"]) -> ToolOutputSynopsis | None:
sample_text = "\n".join(content.splitlines()[:_TABLE_SAMPLE_ROWS])
try:
rows = list(csv.reader(io.StringIO(sample_text), delimiter=delimiter))
except csv.Error:
return None
rows = [row for row in rows if any(cell.strip() for cell in row)]
if len(rows) < 2 or len(rows[0]) < 2:
return None
width = len(rows[0])
consistent = [row for row in rows[1:11] if len(row) == width]
# Require >= _TABLE_MIN_DATA_ROWS same-width data rows for both TSV and CSV.
# TSV sees a lot of false positives (indented bash, ls -l listings, tree
# dumps). CSV is rarer but prose with a comma in the first line also slips
# through without this gate.
if len(consistent) < _TABLE_MIN_DATA_ROWS:
return None
# Header row must look like identifiers (no whitespace, no leading ws).
# Refuses tab-indented bash output, ls -l listings, and tree dumps that
# happen to be tab-delimited.
raw_header = rows[0]
if any(not _TABLE_HEADER_IDENT_RE.match(cell.strip()) for cell in raw_header):
return None
if any(cell.startswith((" ", "\t")) for cell in raw_header):
return None
columns = [cell.strip() or f"column_{idx + 1}" for idx, cell in enumerate(raw_header)]
total_nonempty_lines = sum(1 for line in content.splitlines() if line.strip())
data_rows = max(0, total_nonempty_lines - 1)
# Render the first data row as a key=value list so quoted cells that
# contain the delimiter do not get rejoined into a comma-separated
# string that misleads the model about column count.
first_data_pairs: list[str] = []
if len(rows) > 1:
for col_name, cell in list(zip(columns, rows[1]))[:_TABLE_COLUMN_LIMIT]:
first_data_pairs.append(f"{col_name}={_clip(cell, 80)}")
title = "CSV table output" if kind == "csv" else "TSV table output"
label = kind.upper()
return ToolOutputSynopsis(
kind=kind,
title=title,
summary=[f"{label} table with {data_rows} data rows and {width} columns."],
structure=[
f"columns: {', '.join(columns[:_TABLE_COLUMN_LIMIT])}",
f"first data row: {' | '.join(first_data_pairs) or '(none)'}",
],
notable_items=[],
)
_YAML_KEY_LINE_RE = re.compile(r"^\s*[A-Za-z_][A-Za-z0-9_.\-]*:\s*\S.*$")
def _looks_yaml(content: str) -> bool:
"""Heuristic detector for YAML-shaped content.
Returns True only when the content looks structurally like YAML
(a document start marker, or multiple nested key/value lines with
values that are not bare uppercase log prefixes). Plain logs,
Python tracebacks, and `key: value` lines that consist entirely
of uppercase tags (which YAML treats as string keys) are refused.
"""
stripped = content.lstrip()
if stripped.startswith("---"):
return True
if _looks_code(content):
return False
key_like = 0
for line in content.splitlines()[:80]:
if not _YAML_KEY_LINE_RE.match(line):
continue
# Reject log-style lines where the key is an uppercase tag and
# the value is a free-form message: e.g. "INFO: starting service".
key = line.split(":", 1)[0].strip()
if key.isupper() and "_" not in key:
continue
key_like += 1
if key_like >= 3:
return True
return False
def _try_yaml(content: str) -> ToolOutputSynopsis | None:
if not _looks_yaml(content):
return None
# Bound the parse size to prevent alias-bomb DoS (yaml.safe_load resolves
# YAML aliases which can expand exponentially). The heuristic detector
# already rejects most non-YAML content, but a crafted alias bomb can
# trivially pass the heuristic.
if len(content) > 500_000:
return None
try:
value = yaml.safe_load(content)
except Exception:
return None
if not isinstance(value, (dict, list)):
return None
# Refuse flat "all values are strings" payloads: that shape is what
# log lines and Python tracebacks collapse into after safe_load, and
# it gives the model a misleadingly small "YAML with N keys" summary
# for outputs that are really free-form text.
if isinstance(value, dict):
non_string_children = sum(1 for v in value.values() if not isinstance(v, str))
if non_string_children == 0 and len(value) > 0:
return None
summary: list[str]
structure: list[str] = []
if isinstance(value, dict):
keys = [str(key) for key in value.keys()]
summary = [f"YAML object with {len(keys)} top-level keys.", f"Top-level keys: {', '.join(keys[:_KEY_LIMIT])}"]
for key, child in list(value.items())[:_KEY_LIMIT]:
structure.append(f"{key}: {_type_name(child)}")
else:
summary = [f"YAML array with {len(value)} items."]
if value:
structure.append(f"first item type: {_type_name(value[0])}")
return ToolOutputSynopsis(
kind="yaml",
title="YAML output",
summary=summary,
structure=structure,
notable_items=[],
)
def _looks_code(content: str) -> bool:
return any(pattern.search(content) for pattern in _CODE_HINTS)
def _summarize_code(content: str) -> ToolOutputSynopsis:
imports: list[str] = []
symbols: list[str] = []
lines = content.splitlines()
for line in lines:
stripped = line.strip()
import_match = re.match(r"^(?:from\s+(\S+)\s+import|import\s+(\S+))", stripped)
if import_match:
imports.append(_one_line(import_match.group(1) or import_match.group(2) or "", 160))
continue
symbol_match = re.match(
r"^(class|def|async\s+def|function|export\s+function|pub\s+fn|fn)\s+([A-Za-z_]\w*)",
stripped,
)
if symbol_match:
symbols.append(_one_line(f"{symbol_match.group(1)} {symbol_match.group(2)}", 180))
structure = [f"line count: {len(lines)}"]
if imports:
structure.append(f"imports: {', '.join(imports[:_CODE_IMPORT_LIMIT])}")
return ToolOutputSynopsis(
kind="code",
title="Code-like output",
summary=[f"Code-like text with {len(lines)} lines."],
structure=structure,
notable_items=symbols[:_CODE_SYMBOL_LIMIT],
)
def _summarize_text(content: str, *, tool_name: str = "", include_excerpts: bool = True) -> ToolOutputSynopsis:
lines = content.splitlines()
normalized = re.sub(r"\s+", " ", content).strip()
headers: list[str] = []
seen: set[str] = set()
for line in lines:
stripped = line.strip()
if not stripped:
continue
if not (re.match(r"^#{1,6}\s+", stripped) or re.match(r"^[A-Z0-9][A-Z0-9\s:_-]{6,}$", stripped)):
continue
header = _one_line(stripped, 160)
if header in seen:
continue
seen.add(header)
headers.append(header)
if len(headers) >= _TEXT_HEADER_LIMIT:
break
tool_hint = f" from {tool_name}" if tool_name else ""
summary_lines = [
f"Text output{tool_hint} with {len(content)} characters, {len(normalized.split()) if normalized else 0} words, and {len(lines)} lines.",
f"Detected section headers: {' | '.join(headers) if headers else 'none detected'}.",
]
# Include opening/closing excerpts only when no raw head/tail sample will
# be appended by render_tool_output_preview (avoids duplicating the same
# head/tail bytes in both the synopsis summary and the raw sample).
if include_excerpts:
opener = _one_line(content[:_TEXT_EXCERPT_CHARS], _TEXT_EXCERPT_CHARS)
if len(content) <= _TEXT_EXCERPT_CHARS:
closer = ""
else:
close_start = max(_TEXT_EXCERPT_CHARS, len(content) - _TEXT_EXCERPT_CHARS)
closer = _one_line(content[close_start:], _TEXT_EXCERPT_CHARS) if close_start < len(content) else ""
summary_lines.append(f"Opening excerpt: {opener or '(empty)'}")
if closer:
summary_lines.append(f"Closing excerpt: {closer}")
return ToolOutputSynopsis(
kind="text",
title="Text output",
summary=summary_lines,
structure=[],
notable_items=[],
)