Skip to content

Commit 47f7ee6

Browse files
committed
add preview feature to speed things up and reduce token usage
1 parent 2ade78b commit 47f7ee6

File tree

5 files changed

+164
-1
lines changed

5 files changed

+164
-1
lines changed

main.py

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,10 +58,11 @@ async def execute_pipeline(pipeline: list[dict]) -> str:
5858
A pipeline chains multiple stages where data flows from one to the next:
5959
- Tool stages: Call external tools (from list_all_tools)
6060
- Command stages: Transform data with jq, grep, sed, awk, etc.
61+
- Preview stages: Inspect data structure before processing (recommended!)
6162
6263
Pipeline Structure:
6364
Each stage is a dict with:
64-
- type: "tool" | "command"
65+
- type: "tool" | "command" | "preview"
6566
- for_each (optional): Process items one-by-one instead of all at once
6667
6768
Tool Stage:
@@ -75,6 +76,27 @@ async def execute_pipeline(pipeline: list[dict]) -> str:
7576
- Runs whitelisted shell commands (see list_available_shell_commands)
7677
- Command and args MUST be separate (security requirement)
7778
79+
Preview Stage:
80+
{"type": "preview", "chars": 3000}
81+
- Shows a SUMMARIZED view of the data (default: 3000 chars)
82+
- ⚠️ OUTPUT IS NOT VALID JSON - uses pseudo-format with /* N more */ markers
83+
- Use this to understand data structure BEFORE writing jq filters
84+
- Example output:
85+
=== PREVIEW (not valid JSON, showing structure only) ===
86+
{
87+
items: [
88+
{ id: 1, name: "First", data: { /* 3 more */ } },
89+
/* 47 more */
90+
]
91+
}
92+
=== END PREVIEW ===
93+
94+
Example - Preview data before processing (RECOMMENDED first step):
95+
[
96+
{"type": "tool", "name": "fetch", "server": "api", "args": {"url": "..."}},
97+
{"type": "preview", "chars": 2000}
98+
]
99+
78100
Example - Chain tools with data transformation:
79101
[
80102
{"type": "tool", "name": "get_data", "server": "database", "args": {"table": "users"}},
@@ -107,6 +129,7 @@ async def execute_pipeline(pipeline: list[dict]) -> str:
107129
This avoids "unexpected additional properties" errors from automatic merging
108130
109131
Best Practices:
132+
- Use preview stages to inspect data BEFORE writing jq filters
110133
- Build complete workflows as single pipelines (don't split unnecessarily)
111134
- Check list_all_tools first to see what's available
112135
- Use get_tool_details(server, tool_name) to see exact tool parameters/schema

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ readme = "README.md"
66
requires-python = ">=3.13"
77
dependencies = [
88
"fastmcp>=2.12.4",
9+
"headson>=0.10.0",
910
"httpx>=0.27.0",
1011
]
1112

shell_engine.py

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@
1313
from pathlib import Path
1414
from typing import Any
1515

16+
import headson
17+
1618

1719
def _running_in_container() -> bool:
1820
"""Detect if we're running inside a container (Docker, Podman, etc.).
@@ -554,6 +556,44 @@ async def execute_pipeline(self, pipeline: list[dict]) -> str:
554556
f"Stage {idx + 1} (tool {server_name}/{tool_name}) failed: {str(e)}"
555557
)
556558

559+
elif item_type == "preview":
560+
# Preview stage: summarize upstream data for the agent to inspect
561+
# Uses headson to create a structure-aware preview within a char budget
562+
# Output is NOT valid JSON - it uses pseudo-format with /* N more */ markers
563+
chars = item.get("chars", 3000)
564+
565+
if not isinstance(chars, int) or chars <= 0:
566+
raise ValueError(
567+
f"Preview 'chars' must be a positive integer, got {chars}"
568+
)
569+
570+
try:
571+
# Collect upstream data
572+
input_data = "".join(upstream)
573+
574+
# Generate preview using headson with detailed style
575+
# detailed style shows /* N more */ markers so agent knows data was truncated
576+
preview = headson.summarize(
577+
input_data,
578+
format="json",
579+
style="detailed",
580+
input_format="json",
581+
byte_budget=chars, # headson uses byte_budget param
582+
)
583+
584+
# Add clear marker that this is a preview, not real data
585+
preview_output = (
586+
"=== PREVIEW (not valid JSON, showing structure only) ===\n"
587+
f"{preview}\n"
588+
"=== END PREVIEW ===\n"
589+
)
590+
591+
upstream = iter([preview_output])
592+
except Exception as e:
593+
raise RuntimeError(
594+
f"Stage {idx + 1} (preview) failed: {str(e)}"
595+
)
596+
557597
else:
558598
raise ValueError(f"Unknown pipeline item type: {item_type}")
559599

tests/test_shell_engine.py

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -534,6 +534,92 @@ async def mock_caller(server, tool, args):
534534
assert "result 3" in result
535535

536536

537+
@pytest.mark.asyncio
538+
class TestPreviewStage:
539+
"""Test preview stage functionality."""
540+
541+
async def test_preview_stage_basic(self):
542+
"""Test that preview stage summarizes JSON data."""
543+
large_data = json.dumps({"items": [{"id": i, "name": f"Item {i}"} for i in range(100)]})
544+
mock_caller = AsyncMock(return_value=MockToolResult(large_data))
545+
engine = ShellEngine(tool_caller=mock_caller)
546+
547+
pipeline = [
548+
{"type": "tool", "name": "get_data", "server": "test", "args": {}},
549+
{"type": "preview", "chars": 500},
550+
]
551+
552+
result = await engine.execute_pipeline(pipeline)
553+
554+
# Should contain preview markers
555+
assert "=== PREVIEW" in result
556+
assert "not valid JSON" in result
557+
assert "=== END PREVIEW ===" in result
558+
# Should show structure but be truncated
559+
assert "items" in result
560+
# The output should be smaller than the input
561+
assert len(result) < len(large_data)
562+
563+
async def test_preview_stage_shows_omission_markers(self):
564+
"""Test that preview shows /* N more */ markers for truncated data."""
565+
large_array = json.dumps(list(range(1000)))
566+
mock_caller = AsyncMock(return_value=MockToolResult(large_array))
567+
engine = ShellEngine(tool_caller=mock_caller)
568+
569+
pipeline = [
570+
{"type": "tool", "name": "get_data", "server": "test", "args": {}},
571+
{"type": "preview", "chars": 200},
572+
]
573+
574+
result = await engine.execute_pipeline(pipeline)
575+
576+
# detailed style should show omission counts
577+
assert "more" in result.lower()
578+
579+
async def test_preview_stage_default_chars(self):
580+
"""Test that preview stage uses default 3000 chars when not specified."""
581+
mock_caller = AsyncMock(return_value=MockToolResult('{"test": "data"}'))
582+
engine = ShellEngine(tool_caller=mock_caller)
583+
584+
pipeline = [
585+
{"type": "tool", "name": "get_data", "server": "test", "args": {}},
586+
{"type": "preview"}, # No chars specified
587+
]
588+
589+
# Should not raise, uses default
590+
result = await engine.execute_pipeline(pipeline)
591+
assert "=== PREVIEW" in result
592+
593+
async def test_preview_stage_invalid_chars(self):
594+
"""Test that preview stage rejects invalid chars parameter."""
595+
mock_caller = AsyncMock(return_value=MockToolResult('{"test": "data"}'))
596+
engine = ShellEngine(tool_caller=mock_caller)
597+
598+
pipeline = [
599+
{"type": "tool", "name": "get_data", "server": "test", "args": {}},
600+
{"type": "preview", "chars": -100},
601+
]
602+
603+
with pytest.raises(RuntimeError, match="chars.*must be a positive integer"):
604+
await engine.execute_pipeline(pipeline)
605+
606+
async def test_preview_stage_in_middle_of_pipeline(self):
607+
"""Test that preview can be used mid-pipeline (though output won't be valid JSON)."""
608+
mock_caller = AsyncMock(return_value=MockToolResult('{"value": 42}'))
609+
engine = ShellEngine(tool_caller=mock_caller)
610+
611+
# Preview in the middle - subsequent stages see preview output, not original data
612+
pipeline = [
613+
{"type": "tool", "name": "get_data", "server": "test", "args": {}},
614+
{"type": "preview", "chars": 500},
615+
{"type": "command", "command": "wc", "args": ["-l"]}, # Count lines
616+
]
617+
618+
result = await engine.execute_pipeline(pipeline)
619+
# wc -l should return a number (the line count of the preview)
620+
assert result.strip().isdigit() or result.strip().split()[0].isdigit()
621+
622+
537623
@pytest.mark.asyncio
538624
class TestErrorHandling:
539625
"""Test error handling and edge cases."""

uv.lock

Lines changed: 13 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)