Skip to content

Commit 5ca886c

Browse files
committed
Add tool input vs result separation diagnostics
Separately track TOOL_USE (inputs) and TOOL_RESULT (outputs) tokens to enable more precise context optimization guidance. New issue codes: - TOOL_INPUT_BLOAT: tool inputs consume excessive context (compressible) - TOOL_RESULT_DOMINATES: tool results dominate context (needs summarization) Enhanced TOP_TOOL_CONTEXT_HOTSPOT with input/result breakdown in evidence.
1 parent da2e348 commit 5ca886c

3 files changed

Lines changed: 134 additions & 0 deletions

File tree

src/context_profiler/analyzers/token_counter.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@ def analyze(self, request: APIRequest) -> AnalyzerResult:
1818
by_role: dict[str, int] = defaultdict(int)
1919
by_content_type: dict[str, int] = defaultdict(int)
2020
by_tool_name: dict[str, int] = defaultdict(int)
21+
by_tool_name_input: dict[str, int] = defaultdict(int)
22+
by_tool_name_result: dict[str, int] = defaultdict(int)
2123
per_message: list[dict] = []
2224
total_tokens = 0
2325

@@ -30,6 +32,10 @@ def analyze(self, request: APIRequest) -> AnalyzerResult:
3032
by_content_type[block.block_type.value] += block.token_count
3133
if block.tool_name:
3234
by_tool_name[block.tool_name] += block.token_count
35+
if block.block_type == BlockType.TOOL_USE:
36+
by_tool_name_input[block.tool_name] += block.token_count
37+
elif block.block_type == BlockType.TOOL_RESULT:
38+
by_tool_name_result[block.tool_name] += block.token_count
3339

3440
per_message.append({
3541
"index": msg.index,
@@ -43,12 +49,17 @@ def analyze(self, request: APIRequest) -> AnalyzerResult:
4349

4450
top_messages = sorted(per_message, key=lambda x: x["tokens"], reverse=True)[:10]
4551
top_tools = sorted(by_tool_name.items(), key=lambda x: x[1], reverse=True)[:10]
52+
top_tools_input = sorted(by_tool_name_input.items(), key=lambda x: x[1], reverse=True)[:10]
53+
top_tools_result = sorted(by_tool_name_result.items(), key=lambda x: x[1], reverse=True)[:10]
4654

4755
tool_defs_detail = [
4856
{"name": t.name, "tokens": t.token_count}
4957
for t in sorted(request.tools, key=lambda t: t.token_count, reverse=True)
5058
]
5159

60+
tool_use_tokens = by_content_type.get("tool_use", 0)
61+
tool_result_tokens = by_content_type.get("tool_result", 0)
62+
5263
summary = {
5364
"total_input_tokens": total_tokens,
5465
"message_tokens": total_tokens - tool_def_tokens,
@@ -57,7 +68,11 @@ def analyze(self, request: APIRequest) -> AnalyzerResult:
5768
"source_format": request.source_format,
5869
"by_role": dict(by_role),
5970
"by_content_type": dict(by_content_type),
71+
"tool_use_tokens": tool_use_tokens,
72+
"tool_result_tokens": tool_result_tokens,
6073
"top_tools_by_tokens": top_tools,
74+
"top_tools_by_input_tokens": top_tools_input,
75+
"top_tools_by_result_tokens": top_tools_result,
6176
"tool_definitions": tool_defs_detail,
6277
}
6378

src/context_profiler/diagnostics.py

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,10 @@
1212

1313
_TOOL_USE_DOMINATES_RATIO = 0.5
1414
_TOOL_USE_DOMINATES_MIN_TOKENS = 100
15+
_TOOL_INPUT_BLOAT_RATIO = 0.3
16+
_TOOL_INPUT_BLOAT_MIN_TOKENS = 100
17+
_TOOL_RESULT_DOMINATES_RATIO = 0.4
18+
_TOOL_RESULT_DOMINATES_MIN_TOKENS = 100
1519
_TOP_TOOL_HOTSPOT_RATIO = 0.3
1620
_TOP_TOOL_HOTSPOT_MIN_TOKENS = 100
1721
_REPEATED_FIELD_MIN_WASTE_TOKENS = 500
@@ -61,6 +65,40 @@ def diagnose_result(result: ProfileResult, session: Session | None = None) -> di
6165
"recommendation": "Consider externalizing large tool inputs or replacing bulky payloads with artifact references.",
6266
})
6367

68+
# Separate tool input and result analysis
69+
tool_use_tokens = summary.get("tool_use_tokens", 0) or 0
70+
tool_result_tokens = summary.get("tool_result_tokens", 0) or 0
71+
72+
if total and tool_use_tokens >= _TOOL_INPUT_BLOAT_MIN_TOKENS:
73+
tool_input_ratio = tool_use_tokens / total
74+
if tool_input_ratio >= _TOOL_INPUT_BLOAT_RATIO:
75+
issues.append({
76+
"code": "TOOL_INPUT_BLOAT",
77+
"severity": _severity_for_ratio(tool_input_ratio),
78+
"message": "Tool inputs (not results) consume a large share of context.",
79+
"evidence": {
80+
"tool_input_tokens": tool_use_tokens,
81+
"total_input_tokens": total,
82+
"ratio": tool_input_ratio,
83+
},
84+
"recommendation": "Tool inputs are often compressible. Consider using artifact references, shorter identifiers, or externalizing large payloads.",
85+
})
86+
87+
if total and tool_result_tokens >= _TOOL_RESULT_DOMINATES_MIN_TOKENS:
88+
tool_result_ratio = tool_result_tokens / total
89+
if tool_result_ratio >= _TOOL_RESULT_DOMINATES_RATIO:
90+
issues.append({
91+
"code": "TOOL_RESULT_DOMINATES",
92+
"severity": _severity_for_ratio(tool_result_ratio),
93+
"message": "Tool results (not inputs) dominate the context budget.",
94+
"evidence": {
95+
"tool_result_tokens": tool_result_tokens,
96+
"total_input_tokens": total,
97+
"ratio": tool_result_ratio,
98+
},
99+
"recommendation": "Tool results often contain real data that cannot be compressed. Consider summarizing large outputs, paginating results, or using streaming.",
100+
})
101+
64102
top_tools = summary.get("top_tools_by_tokens", [])
65103
if total and top_tools:
66104
top_tool_name, top_tool_tokens = top_tools[0]
@@ -69,13 +107,21 @@ def diagnose_result(result: ProfileResult, session: Session | None = None) -> di
69107
top_tool_tokens >= _TOP_TOOL_HOTSPOT_MIN_TOKENS
70108
and top_tool_ratio >= _TOP_TOOL_HOTSPOT_RATIO
71109
):
110+
# Enhanced message with input/result breakdown
111+
top_tools_input = summary.get("top_tools_by_input_tokens", [])
112+
top_tools_result = summary.get("top_tools_by_result_tokens", [])
113+
input_tokens = dict(top_tools_input).get(top_tool_name, 0)
114+
result_tokens = dict(top_tools_result).get(top_tool_name, 0)
115+
72116
issues.append({
73117
"code": "TOP_TOOL_CONTEXT_HOTSPOT",
74118
"severity": _severity_for_ratio(top_tool_ratio),
75119
"message": f"{top_tool_name} is the largest visible tool context hotspot.",
76120
"evidence": {
77121
"tool_name": top_tool_name,
78122
"tool_tokens": top_tool_tokens,
123+
"tool_input_tokens": input_tokens,
124+
"tool_result_tokens": result_tokens,
79125
"total_input_tokens": total,
80126
"ratio": top_tool_ratio,
81127
},
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
"""Tests for tool input vs result token separation."""
2+
3+
import json
4+
from pathlib import Path
5+
from click.testing import CliRunner
6+
from context_profiler.cli import main
7+
8+
FIXTURES = Path(__file__).parent / "fixtures"
9+
10+
11+
def test_token_counter_separates_tool_input_and_result(tmp_path):
12+
"""Verify that token_counter tracks tool_use and tool_result separately."""
13+
snapshot = FIXTURES / "repeated_tool_calls.json"
14+
out = tmp_path / "analysis.json"
15+
runner = CliRunner()
16+
result = runner.invoke(main, ["analyze", str(snapshot), "-o", str(out)])
17+
assert result.exit_code == 0
18+
19+
data = json.loads(out.read_text())
20+
token_summary = data["analyzers"]["token_counter"]["summary"]
21+
22+
# Check that we have the new fields
23+
assert "tool_use_tokens" in token_summary
24+
assert "tool_result_tokens" in token_summary
25+
assert "top_tools_by_input_tokens" in token_summary
26+
assert "top_tools_by_result_tokens" in token_summary
27+
28+
# Verify the sum makes sense
29+
by_content = token_summary["by_content_type"]
30+
assert token_summary["tool_use_tokens"] == by_content.get("tool_use", 0)
31+
assert token_summary["tool_result_tokens"] == by_content.get("tool_result", 0)
32+
33+
34+
def test_diagnose_detects_tool_input_bloat():
35+
"""Verify TOOL_INPUT_BLOAT issue is detected when tool inputs dominate."""
36+
snapshot = FIXTURES / "repeated_tool_calls.json"
37+
runner = CliRunner()
38+
result = runner.invoke(main, ["diagnose", str(snapshot), "--format", "openai", "--json"])
39+
assert result.exit_code == 0
40+
41+
data = json.loads(result.output)
42+
issue_codes = [issue["code"] for issue in data["issues"]]
43+
44+
# This fixture has high tool_use content, so we should see the issue
45+
if data.get("issues"):
46+
# Check if TOOL_INPUT_BLOAT or TOOL_USE_DOMINATES_CONTEXT is present
47+
assert "TOOL_INPUT_BLOAT" in issue_codes or "TOOL_USE_DOMINATES_CONTEXT" in issue_codes
48+
49+
50+
def test_top_tool_hotspot_includes_input_result_breakdown():
51+
"""Verify TOP_TOOL_CONTEXT_HOTSPOT evidence includes input/result breakdown."""
52+
snapshot = FIXTURES / "repeated_tool_calls.json"
53+
runner = CliRunner()
54+
result = runner.invoke(main, ["diagnose", str(snapshot), "--format", "openai", "--json"])
55+
assert result.exit_code == 0
56+
57+
data = json.loads(result.output)
58+
hotspot_issues = [issue for issue in data["issues"] if issue["code"] == "TOP_TOOL_CONTEXT_HOTSPOT"]
59+
60+
if hotspot_issues:
61+
issue = hotspot_issues[0]
62+
evidence = issue["evidence"]
63+
64+
# Check that the evidence includes the new breakdown fields
65+
assert "tool_input_tokens" in evidence
66+
assert "tool_result_tokens" in evidence
67+
assert "tool_tokens" in evidence
68+
69+
# Verify the breakdown sums to total (or close, accounting for rounding)
70+
total = evidence["tool_tokens"]
71+
input_tokens = evidence["tool_input_tokens"]
72+
result_tokens = evidence["tool_result_tokens"]
73+
assert input_tokens + result_tokens == total

0 commit comments

Comments
 (0)