diff --git a/acp_adapter/tools.py b/acp_adapter/tools.py index 6513f1bb55f..4524b8c6f11 100644 --- a/acp_adapter/tools.py +++ b/acp_adapter/tools.py @@ -278,6 +278,26 @@ def _format_search_files_result(result: Optional[str]) -> Optional[str]: data = _json_loads_maybe(result) if not isinstance(data, dict): return None + + files = data.get("files") + if isinstance(files, list): + total = data.get("total_count", len(files)) + shown = min(len(files), 20) + truncated = bool(data.get("truncated")) or len(files) > shown + lines = [ + "File search results", + f"Found {total} file{'s' if total != 1 else ''}; showing {shown}.", + "", + ] + for path in files[:shown]: + lines.append(f"- {path}") + if truncated: + lines.extend([ + "", + "Results truncated. Narrow the search, add path/file_glob, or use offset to page.", + ]) + return _truncate_text("\n".join(lines), limit=7000) + matches = data.get("matches") if not isinstance(matches, list): return None @@ -668,14 +688,114 @@ def _format_media_or_cron_result(tool_name: str, result: Optional[str]) -> Optio return "\n".join(lines) -def _format_generic_structured_result(tool_name: str, result: Optional[str]) -> Optional[str]: +def _format_structured_value( + key: str, + value: Any, + *, + indent: int = 0, + max_depth: int = 3, + max_items: int = 8, +) -> List[str]: + """Render nested JSON-ish values as compact Markdown bullets, not inline blobs.""" + prefix = " " * indent + bullet = f"{prefix}- " + label = f"**{key}:**" if key else "" + + if value in (None, "", [], {}): + return [] + + if max_depth <= 0: + if isinstance(value, (dict, list)): + preview = json.dumps(value, ensure_ascii=False, default=str) + else: + preview = str(value) + return [f"{bullet}{label} {_truncate_text(preview, limit=240)}" if label else f"{bullet}{_truncate_text(preview, limit=240)}"] + + if isinstance(value, dict): + lines = [f"{bullet}{label}" if label else f"{bullet}{len(value)} fields"] + shown = 0 + for child_key, child_value in value.items(): + if child_value in (None, "", [], {}): + continue + lines.extend( + _format_structured_value( + str(child_key), + child_value, + indent=indent + 1, + max_depth=max_depth - 1, + max_items=max_items, + ) + ) + shown += 1 + if shown >= max_items: + remaining = max(0, len(value) - shown) + if remaining: + lines.append(f"{' ' * (indent + 1)}- ... {remaining} more fields") + break + return lines + + if isinstance(value, list): + lines = [f"{bullet}{label} {len(value)} item{'s' if len(value) != 1 else ''}" if label else f"{bullet}{len(value)} item{'s' if len(value) != 1 else ''}"] + for idx, item in enumerate(value[:max_items], 1): + if isinstance(item, dict): + headline = str(item.get("content") or item.get("message") or item.get("title") or item.get("name") or item.get("id") or "").strip() + if headline: + lines.append(f"{' ' * (indent + 1)}{idx}. {_truncate_text(headline, limit=220)}") + for child_key in ("id", "status", "type", "scope", "quality_score", "score", "path", "url"): + child_value = item.get(child_key) + if child_value not in (None, "", [], {}): + lines.append(f"{' ' * (indent + 2)}- **{child_key}:** {_truncate_text(str(child_value), limit=180)}") + else: + lines.append(f"{' ' * (indent + 1)}{idx}.") + for child_key, child_value in list(item.items())[:max_items]: + lines.extend( + _format_structured_value( + str(child_key), + child_value, + indent=indent + 2, + max_depth=max_depth - 1, + max_items=max_items, + ) + ) + elif isinstance(item, list): + lines.append(f"{' ' * (indent + 1)}{idx}. {len(item)} items") + for nested in item[:max_items]: + lines.extend( + _format_structured_value( + "", + nested, + indent=indent + 2, + max_depth=max_depth - 1, + max_items=max_items, + ) + ) + else: + lines.append(f"{' ' * (indent + 1)}{idx}. {_truncate_text(str(item), limit=240)}") + if len(value) > max_items: + lines.append(f"{' ' * (indent + 1)}... {len(value) - max_items} more items") + return lines + + return [f"{bullet}{label} {_truncate_text(str(value), limit=500)}" if label else f"{bullet}{_truncate_text(str(value), limit=500)}"] + + +def _format_generic_structured_result( + tool_name: str, + result: Optional[str], + *, + fallback_to_text: bool = True, +) -> Optional[str]: data = _json_loads_maybe(result) if not isinstance(data, (dict, list)): - return result if isinstance(result, str) and result.strip() else None + return result if fallback_to_text and isinstance(result, str) and result.strip() else None if isinstance(data, list): lines = [f"{tool_name}: {len(data)} item{'s' if len(data) != 1 else ''}"] for item in data[:12]: - lines.append(f"- {_truncate_text(str(item), limit=240)}") + if isinstance(item, (dict, list)): + lines.extend(_format_structured_value("", item, indent=0, max_depth=2, max_items=6)) + else: + lines.append(f"- {_truncate_text(str(item), limit=240)}") + if len(data) > 12: + lines.append(f"... {len(data) - 12} more items") return _truncate_text("\n".join(lines), limit=5000) if data.get("success") is False or data.get("error"): @@ -699,12 +819,9 @@ def _format_generic_structured_result(tool_name: str, result: Optional[str]) -> continue if value in (None, "", [], {}): continue - if isinstance(value, (dict, list)): - preview = json.dumps(value, ensure_ascii=False, default=str) - else: - preview = str(value) - lines.append(f"- **{key}:** {_truncate_text(preview, limit=500)}") - if len(lines) >= 14: + lines.extend(_format_structured_value(str(key), value, indent=0, max_depth=3, max_items=8)) + if len(lines) >= 40: + lines.append("- ... more fields truncated") break content = data.get("content") @@ -744,8 +861,9 @@ def _build_polished_completion_content( if formatter is None and tool_name in _POLISHED_TOOLS: formatter = lambda: _format_generic_structured_result(tool_name, result) if formatter is None: - return None - text = formatter() + text = _format_generic_structured_result(tool_name, result, fallback_to_text=False) + else: + text = formatter() if not text: return None return [_text(text)] @@ -1135,6 +1253,11 @@ def build_tool_start( tool_call_id, title, kind=kind, content=content, locations=locations, ) + if not arguments: + return acp.start_tool_call( + tool_call_id, title, kind=kind, content=None, locations=locations, raw_input=None, + ) + # Generic fallback try: args_text = json.dumps(arguments, indent=2, default=str) @@ -1147,6 +1270,10 @@ def build_tool_start( ) +def _is_structured_json_result(result: Optional[str]) -> bool: + return isinstance(_json_loads_maybe(result), (dict, list)) + + def build_tool_complete( tool_call_id: str, tool_name: str, @@ -1171,7 +1298,7 @@ def build_tool_complete( kind=kind, status="completed", content=content, - raw_output=None if tool_name in _POLISHED_TOOLS else result, + raw_output=None if tool_name in _POLISHED_TOOLS or _is_structured_json_result(result) else result, ) diff --git a/tests/acp/test_tools.py b/tests/acp/test_tools.py index 004b1f32f84..a2d1e3b6d37 100644 --- a/tests/acp/test_tools.py +++ b/tests/acp/test_tools.py @@ -462,6 +462,62 @@ class TestBuildToolComplete: assert "timeout" in text assert result.raw_output is None + def test_build_tool_complete_generically_formats_unknown_json_dict_without_raw_output(self): + result = build_tool_complete( + "tc-recall-search", + "memory_archive_search", + '{"results":[{"id":"obs-1","status":"active","content":"Recall should render as a readable summary."}],"trust":"lower-trust archive evidence"}', + ) + text = result.content[0].content.text + assert "memory_archive_search result" in text + assert "lower-trust archive evidence" in text + assert "Recall should render as a readable summary" in text + assert "{\"results\"" not in text + assert result.raw_output is None + + def test_build_tool_complete_generically_formats_unknown_json_list_without_raw_output(self): + result = build_tool_complete( + "tc-plugin-list", + "some_plugin_tool", + '[{"name":"alpha","status":"ok"},{"name":"beta","status":"ok"}]', + ) + text = result.content[0].content.text + assert "some_plugin_tool: 2 items" in text + assert "alpha" in text + assert result.raw_output is None + + def test_build_tool_complete_generically_formats_nested_json_without_inline_blob(self): + result = build_tool_complete( + "tc-recall-stats", + "memory_archive_stats", + '{"observations_by_status":{"active":12,"rejected":83},"capabilities":["sqlite-fts5-archive","hash-chain-audit"],"audit":{"ok":true,"count":208,"head":"abc123"}}', + ) + text = result.content[0].content.text + assert "**observations_by_status:**" in text + assert "**active:** 12" in text + assert "**rejected:** 83" in text + assert "**capabilities:** 2 items" in text + assert "sqlite-fts5-archive" in text + assert "**audit:**" in text + assert "**ok:** True" in text + assert "{\"active\"" not in text + assert "[\"sqlite" not in text + assert result.raw_output is None + + def test_build_tool_complete_for_search_files_files_only_formats_file_list(self): + result = build_tool_complete( + "tc-search-files", + "search_files", + '{"total_count":36,"files":["/home/nour/.hermes/config.yaml","/home/nour/.hermes/profiles/recall-test/config.yaml"],"truncated":true}', + ) + text = result.content[0].content.text + assert "File search results" in text + assert "Found 36 files; showing 2." in text + assert "/home/nour/.hermes/config.yaml" in text + assert "use offset to page" in text + assert "{\"total_count\"" not in text + assert result.raw_output is None + def test_build_tool_complete_truncates_large_output(self): """Very large outputs should be truncated.""" big_output = "x" * 10000