diff --git a/acp_adapter/tools.py b/acp_adapter/tools.py index 3c0aa3727..8fc9eacf0 100644 --- a/acp_adapter/tools.py +++ b/acp_adapter/tools.py @@ -57,16 +57,24 @@ TOOL_KIND_MAP: Dict[str, ToolKind] = { _POLISHED_TOOLS = { - "todo", - "read_file", - "search_files", - "execute_code", - "skill_view", - "skills_list", - "skill_manage", - "terminal", - "web_search", - "web_extract", + # Core operator loop + "todo", "memory", "session_search", "delegate_task", + # Files / execution + "read_file", "write_file", "patch", "search_files", "terminal", "process", "execute_code", + # Skills / web / browser / media + "skill_view", "skills_list", "skill_manage", "web_search", "web_extract", + "browser_navigate", "browser_click", "browser_type", "browser_press", "browser_scroll", + "browser_back", "browser_snapshot", "browser_console", "browser_get_images", "browser_vision", + "vision_analyze", "image_generate", "text_to_speech", + # Schedulers / platform integrations + "cronjob", "send_message", "clarify", "discord", "discord_admin", + "ha_list_entities", "ha_get_state", "ha_list_services", "ha_call_service", + "feishu_doc_read", "feishu_drive_list_comments", "feishu_drive_list_comment_replies", + "feishu_drive_reply_comment", "feishu_drive_add_comment", + "kanban_create", "kanban_show", "kanban_comment", "kanban_complete", + "kanban_block", "kanban_link", "kanban_heartbeat", + "yb_query_group_info", "yb_query_group_members", "yb_search_sticker", + "yb_send_dm", "yb_send_sticker", "mixture_of_agents", } @@ -104,11 +112,25 @@ def build_tool_title(tool_name: str, args: Dict[str, Any]) -> str: if urls: return f"extract: {urls[0]}" + (f" (+{len(urls)-1})" if len(urls) > 1 else "") return "web extract" + if tool_name == "process": + action = str(args.get("action") or "").strip() or "manage" + sid = str(args.get("session_id") or "").strip() + return f"process {action}: {sid}" if sid else f"process {action}" if tool_name == "delegate_task": + tasks = args.get("tasks") + if isinstance(tasks, list) and tasks: + return f"delegate batch ({len(tasks)} tasks)" goal = args.get("goal", "") if goal and len(goal) > 60: goal = goal[:57] + "..." return f"delegate: {goal}" if goal else "delegate task" + if tool_name == "session_search": + query = str(args.get("query") or "").strip() + return f"session search: {query}" if query else "recent sessions" + if tool_name == "memory": + action = str(args.get("action") or "manage").strip() or "manage" + target = str(args.get("target") or "memory").strip() or "memory" + return f"memory {action}: {target}" if tool_name == "execute_code": code = str(args.get("code") or "").strip() first_line = next((line.strip() for line in code.splitlines() if line.strip()), "") @@ -138,8 +160,23 @@ def build_tool_title(tool_name: str, args: Dict[str, Any]) -> str: if len(target) > 64: target = target[:61] + "..." return f"skill {action}: {target}" + if tool_name == "browser_navigate": + return f"navigate: {args.get('url', '?')}" + if tool_name == "browser_snapshot": + return "browser snapshot" + if tool_name == "browser_vision": + return f"browser vision: {str(args.get('question', '?'))[:50]}" + if tool_name == "browser_get_images": + return "browser images" if tool_name == "vision_analyze": - return f"analyze image: {args.get('question', '?')[:50]}" + return f"analyze image: {str(args.get('question', '?'))[:50]}" + if tool_name == "image_generate": + prompt = str(args.get("prompt") or args.get("description") or "").strip() + return f"generate image: {prompt[:50]}" if prompt else "generate image" + if tool_name == "cronjob": + action = str(args.get("action") or "manage").strip() or "manage" + job_id = str(args.get("job_id") or args.get("id") or "").strip() + return f"cron {action}: {job_id}" if job_id else f"cron {action}" return tool_name @@ -377,6 +414,301 @@ def _format_web_search_result(result: Optional[str]) -> Optional[str]: return _truncate_text("\n".join(lines)) +def _format_web_extract_result(result: Optional[str]) -> Optional[str]: + data = _json_loads_maybe(result) + if not isinstance(data, dict): + return None + if data.get("success") is False and data.get("error"): + return f"Web extract failed: {data.get('error')}" + results = data.get("results") + if not isinstance(results, list): + return None + lines = [f"Web extract: {len(results)} URL{'s' if len(results) != 1 else ''}"] + for item in results[:5]: + if not isinstance(item, dict): + continue + url = str(item.get("url") or "").strip() + title = str(item.get("title") or url or "Untitled").strip() + error = str(item.get("error") or "").strip() + content = str(item.get("content") or "").strip() + lines.extend(["", f"### {title}"]) + if url: + lines.append(url) + if error and error not in {"None", "null"}: + lines.append(f"Error: {error}") + continue + if content: + headings = _extract_markdown_headings(content, limit=5) + lines.append(f"Content: {len(content):,} chars") + if headings: + lines.append("Headings: " + "; ".join(headings)) + excerpt = _truncate_text(content, limit=1200) + lines.extend(["", excerpt]) + else: + lines.append("No content returned.") + if len(results) > 5: + lines.append(f"\n... {len(results) - 5} more result(s) omitted") + return _truncate_text("\n".join(lines), limit=7000) + + +def _format_process_result(result: Optional[str], args: Optional[Dict[str, Any]]) -> Optional[str]: + data = _json_loads_maybe(result) + if not isinstance(data, dict): + return result if isinstance(result, str) and result.strip() else None + if data.get("success") is False and data.get("error"): + return f"Process error: {data.get('error')}" + action = str((args or {}).get("action") or "process").strip() or "process" + if isinstance(data.get("processes"), list): + processes = data["processes"] + lines = [f"Processes: {len(processes)}"] + for proc in processes[:20]: + if not isinstance(proc, dict): + lines.append(f"- {proc}") + continue + sid = str(proc.get("session_id") or proc.get("id") or "?") + status = str(proc.get("status") or ("exited" if proc.get("exited") else "running")) + cmd = str(proc.get("command") or "").strip() + pid = proc.get("pid") + code = proc.get("exit_code") + bits = [status] + if pid is not None: + bits.append(f"pid {pid}") + if code is not None: + bits.append(f"exit {code}") + lines.append(f"- `{sid}` — {', '.join(bits)}" + (f" — {cmd[:120]}" if cmd else "")) + if len(processes) > 20: + lines.append(f"... {len(processes) - 20} more process(es)") + return "\n".join(lines) + + status = str(data.get("status") or data.get("state") or action).strip() + sid = str(data.get("session_id") or (args or {}).get("session_id") or "").strip() + lines = [f"Process {action}: {status}" + (f" (`{sid}`)" if sid else "")] + for key, label in (("command", "Command"), ("pid", "PID"), ("exit_code", "Exit code"), ("returncode", "Exit code"), ("lines", "Lines")): + if data.get(key) is not None: + lines.append(f"- **{label}:** {data.get(key)}") + output = data.get("output") or data.get("new_output") or data.get("log") or data.get("stdout") + error = data.get("error") or data.get("stderr") + if output: + lines.extend(["", "Output:", _truncate_text(str(output), limit=5000)]) + if error: + lines.extend(["", "Error:", _truncate_text(str(error), limit=2000)]) + msg = data.get("message") + if msg and not output and not error: + lines.append(str(msg)) + return _truncate_text("\n".join(lines), limit=7000) + + +def _format_delegate_result(result: Optional[str]) -> Optional[str]: + data = _json_loads_maybe(result) + if not isinstance(data, dict): + return None + if data.get("error") and not isinstance(data.get("results"), list): + return f"Delegation failed: {data.get('error')}" + results = data.get("results") + if not isinstance(results, list): + return None + total = data.get("total_duration_seconds") + lines = [f"Delegation results: {len(results)} task{'s' if len(results) != 1 else ''}" + (f" in {total}s" if total is not None else "")] + icon = {"completed": "✅", "failed": "✗", "error": "✗", "timeout": "⏱", "interrupted": "⚠"} + for item in results: + if not isinstance(item, dict): + lines.append(f"- {item}") + continue + idx = item.get("task_index") + status = str(item.get("status") or "unknown") + model = item.get("model") + dur = item.get("duration_seconds") + role = item.get("_child_role") + header = f"{icon.get(status, '•')} Task {idx + 1 if isinstance(idx, int) else '?'}: {status}" + bits = [] + if model: + bits.append(str(model)) + if role: + bits.append(f"role={role}") + if dur is not None: + bits.append(f"{dur}s") + if bits: + header += " (" + ", ".join(bits) + ")" + lines.extend(["", header]) + summary = str(item.get("summary") or "").strip() + error = str(item.get("error") or "").strip() + if summary: + lines.append(_truncate_text(summary, limit=1200)) + if error: + lines.append("Error: " + _truncate_text(error, limit=800)) + trace = item.get("tool_trace") + if isinstance(trace, list) and trace: + names = [str(t.get("tool") or "?") for t in trace if isinstance(t, dict)] + if names: + lines.append("Tools: " + ", ".join(names[:12]) + (f" (+{len(names)-12})" if len(names) > 12 else "")) + return _truncate_text("\n".join(lines), limit=8000) + + +def _format_session_search_result(result: Optional[str]) -> Optional[str]: + data = _json_loads_maybe(result) + if not isinstance(data, dict): + return None + if data.get("success") is False: + return f"Session search failed: {data.get('error', 'unknown error')}" + results = data.get("results") + if not isinstance(results, list): + return None + mode = data.get("mode") or "search" + query = data.get("query") + lines = ["Recent sessions" if mode == "recent" else f"Session search results" + (f" for `{query}`" if query else "")] + if not results: + lines.append(str(data.get("message") or "No matching sessions found.")) + return "\n".join(lines) + for item in results: + if not isinstance(item, dict): + continue + sid = str(item.get("session_id") or "?") + title = str(item.get("title") or item.get("when") or "Untitled session").strip() + when = str(item.get("last_active") or item.get("started_at") or item.get("when") or "").strip() + count = item.get("message_count") + source = str(item.get("source") or "").strip() + meta = ", ".join(str(x) for x in [when, source, f"{count} msgs" if count is not None else ""] if x) + lines.append(f"- **{title}** (`{sid}`)" + (f" — {meta}" if meta else "")) + summary = str(item.get("summary") or item.get("preview") or "").strip() + if summary: + lines.append(" " + _truncate_text(" ".join(summary.split()), limit=500)) + return _truncate_text("\n".join(lines), limit=7000) + + +def _format_memory_result(result: Optional[str], args: Optional[Dict[str, Any]]) -> Optional[str]: + data = _json_loads_maybe(result) + if not isinstance(data, dict): + return None + action = str((args or {}).get("action") or "memory").strip() or "memory" + target = str(data.get("target") or (args or {}).get("target") or "memory") + if data.get("success") is False: + lines = [f"✗ Memory {action} failed ({target})", str(data.get("error") or "unknown error")] + matches = data.get("matches") + if isinstance(matches, list) and matches: + lines.append("Matches:") + lines.extend(f"- {_truncate_text(str(m), 160)}" for m in matches[:5]) + return "\n".join(lines) + lines = [f"✅ Memory {action} saved ({target})"] + if data.get("message"): + lines.append(str(data.get("message"))) + if data.get("entry_count") is not None: + lines.append(f"Entries: {data.get('entry_count')}") + if data.get("usage"): + lines.append(f"Usage: {data.get('usage')}") + # Avoid dumping all memory entries into ACP UI; show only the explicit new value preview. + preview = str((args or {}).get("content") or (args or {}).get("old_text") or "").strip() + if preview: + lines.append("Preview: " + _truncate_text(preview, limit=300)) + return "\n".join(lines) + + +def _format_edit_result(tool_name: str, result: Optional[str], args: Optional[Dict[str, Any]]) -> Optional[str]: + data = _json_loads_maybe(result) + path = str((args or {}).get("path") or "file").strip() + if isinstance(data, dict): + if data.get("success") is False or data.get("error"): + return f"{tool_name} failed for {path}: {data.get('error', 'unknown error')}" + message = str(data.get("message") or "").strip() + replacements = data.get("replacements") or data.get("replacement_count") + lines = [f"✅ {tool_name} completed" + (f" for `{path}`" if path else "")] + if message: + lines.append(message) + if replacements is not None: + lines.append(f"Replacements: {replacements}") + if data.get("files_modified"): + files = data.get("files_modified") + if isinstance(files, list): + lines.append("Files: " + ", ".join(f"`{f}`" for f in files[:8])) + return "\n".join(lines) + if isinstance(result, str) and result.strip(): + return _truncate_text(result, limit=3000) + return f"✅ {tool_name} completed" + (f" for `{path}`" if path else "") + + +def _format_browser_result(tool_name: str, result: Optional[str], args: Optional[Dict[str, Any]]) -> Optional[str]: + data = _json_loads_maybe(result) + if not isinstance(data, dict): + return result if isinstance(result, str) and result.strip() else None + if data.get("success") is False or data.get("error"): + return f"{tool_name} failed: {data.get('error', 'unknown error')}" + if tool_name == "browser_get_images": + images = data.get("images") or data.get("data") + if isinstance(images, list): + lines = [f"Images found: {len(images)}"] + for img in images[:12]: + if isinstance(img, dict): + alt = str(img.get("alt") or "").strip() + url = str(img.get("url") or img.get("src") or "").strip() + lines.append(f"- {alt or 'image'}" + (f" — {url}" if url else "")) + return _truncate_text("\n".join(lines), limit=5000) + title = str(data.get("title") or data.get("url") or data.get("status") or tool_name) + text = str(data.get("text") or data.get("content") or data.get("snapshot") or data.get("analysis") or data.get("message") or "").strip() + lines = [title] + if data.get("url") and data.get("url") != title: + lines.append(str(data.get("url"))) + if text: + lines.extend(["", _truncate_text(text, limit=5000)]) + return _truncate_text("\n".join(lines), limit=7000) + + +def _format_media_or_cron_result(tool_name: str, result: Optional[str]) -> Optional[str]: + data = _json_loads_maybe(result) + if not isinstance(data, dict): + return result if isinstance(result, str) and result.strip() else None + if data.get("success") is False or data.get("error"): + return f"{tool_name} failed: {data.get('error', 'unknown error')}" + lines = [f"✅ {tool_name} completed"] + for key in ("file_path", "path", "url", "image_url", "job_id", "id", "status", "message", "next_run"): + if data.get(key): + lines.append(f"- **{key}:** {data.get(key)}") + return "\n".join(lines) + + +def _format_generic_structured_result(tool_name: str, result: Optional[str]) -> Optional[str]: + data = _json_loads_maybe(result) + if not isinstance(data, (dict, list)): + return result if 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)}") + return _truncate_text("\n".join(lines), limit=5000) + + if data.get("success") is False or data.get("error"): + return f"{tool_name} failed: {data.get('error', 'unknown error')}" + + lines = [f"✅ {tool_name} completed" if data.get("success") is True else f"{tool_name} result"] + priority_keys = ( + "message", "status", "id", "task_id", "issue_id", "title", "name", "entity_id", + "state", "service", "url", "path", "file_path", "count", "total", "next_run", + ) + seen = set() + for key in priority_keys: + value = data.get(key) + if value in (None, "", [], {}): + continue + seen.add(key) + lines.append(f"- **{key}:** {_truncate_text(str(value), limit=500)}") + + for key, value in data.items(): + if key in seen or key in {"success", "raw", "content", "entries"}: + 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: + break + + content = data.get("content") + if isinstance(content, str) and content.strip(): + lines.extend(["", _truncate_text(content.strip(), limit=1500)]) + return _truncate_text("\n".join(lines), limit=7000) + + def _build_polished_completion_content( tool_name: str, result: Optional[str], @@ -385,12 +717,28 @@ def _build_polished_completion_content( formatter = { "todo": lambda: _format_todo_result(result), "read_file": lambda: _format_read_file_result(result, function_args), + "write_file": lambda: _format_edit_result(tool_name, result, function_args), + "patch": lambda: _format_edit_result(tool_name, result, function_args), "search_files": lambda: _format_search_files_result(result), "execute_code": lambda: _format_execute_code_result(result), + "process": lambda: _format_process_result(result, function_args), + "delegate_task": lambda: _format_delegate_result(result), + "session_search": lambda: _format_session_search_result(result), + "memory": lambda: _format_memory_result(result, function_args), "skill_view": lambda: _format_skill_view_result(result), "skill_manage": lambda: _format_skill_manage_result(result, function_args), "web_search": lambda: _format_web_search_result(result), + "web_extract": lambda: _format_web_extract_result(result), + "browser_navigate": lambda: _format_browser_result(tool_name, result, function_args), + "browser_snapshot": lambda: _format_browser_result(tool_name, result, function_args), + "browser_vision": lambda: _format_browser_result(tool_name, result, function_args), + "browser_get_images": lambda: _format_browser_result(tool_name, result, function_args), + "vision_analyze": lambda: _format_media_or_cron_result(tool_name, result), + "image_generate": lambda: _format_media_or_cron_result(tool_name, result), + "cronjob": lambda: _format_media_or_cron_result(tool_name, result), }.get(tool_name) + 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() @@ -594,7 +942,6 @@ def build_tool_start( content = _build_patch_mode_content(patch_text) return acp.start_tool_call( tool_call_id, title, kind=kind, content=content, locations=locations, - raw_input=arguments, ) if tool_name == "write_file": @@ -603,7 +950,6 @@ def build_tool_start( content = [acp.tool_diff_content(path=path, new_text=file_content)] return acp.start_tool_call( tool_call_id, title, kind=kind, content=content, locations=locations, - raw_input=arguments, ) if tool_name == "terminal": @@ -712,6 +1058,74 @@ def build_tool_start( tool_call_id, title, kind=kind, content=content, locations=locations, ) + if tool_name == "web_extract": + urls = arguments.get("urls") if isinstance(arguments.get("urls"), list) else [] + preview = "\n".join(f"- {url}" for url in urls[:5]) or "Extracting web content" + content = [_text("Extracting content from:\n" + preview if urls else preview)] + return acp.start_tool_call( + tool_call_id, title, kind=kind, content=content, locations=locations, + ) + + if tool_name == "process": + action = str(arguments.get("action") or "").strip() or "manage" + sid = str(arguments.get("session_id") or "").strip() + data_preview = str(arguments.get("data") or "").strip() + text = f"Process action: {action}" + (f"\nSession: {sid}" if sid else "") + if data_preview: + text += "\nInput: " + _truncate_text(data_preview, limit=500) + content = [_text(text)] + return acp.start_tool_call( + tool_call_id, title, kind=kind, content=content, locations=locations, + ) + + if tool_name == "delegate_task": + tasks = arguments.get("tasks") + if isinstance(tasks, list) and tasks: + lines = [f"Delegating {len(tasks)} tasks", ""] + for i, task in enumerate(tasks[:8], 1): + if isinstance(task, dict): + goal = str(task.get("goal") or "").strip() + role = str(task.get("role") or "").strip() + lines.append(f"{i}. " + _truncate_text(goal, limit=160) + (f" ({role})" if role else "")) + if len(tasks) > 8: + lines.append(f"... {len(tasks) - 8} more") + content = [_text("\n".join(lines))] + else: + goal = str(arguments.get("goal") or "").strip() + content = [_text("Delegating task" + (f":\n{_truncate_text(goal, limit=800)}" if goal else ""))] + return acp.start_tool_call( + tool_call_id, title, kind=kind, content=content, locations=locations, + ) + + if tool_name == "session_search": + query = str(arguments.get("query") or "").strip() + content = [_text(f"Searching past sessions for: {query}" if query else "Loading recent sessions")] + return acp.start_tool_call( + tool_call_id, title, kind=kind, content=content, locations=locations, + ) + + if tool_name == "memory": + action = str(arguments.get("action") or "manage").strip() or "manage" + target = str(arguments.get("target") or "memory").strip() or "memory" + preview = str(arguments.get("content") or arguments.get("old_text") or "").strip() + text = f"Memory {action} ({target})" + if preview: + text += "\nPreview: " + _truncate_text(preview, limit=500) + content = [_text(text)] + return acp.start_tool_call( + tool_call_id, title, kind=kind, content=content, locations=locations, + ) + + if tool_name in _POLISHED_TOOLS: + try: + args_text = json.dumps(arguments, indent=2, default=str) + except (TypeError, ValueError): + args_text = str(arguments) + content = [_text(_truncate_text(args_text, limit=1200))] + return acp.start_tool_call( + tool_call_id, title, kind=kind, content=content, locations=locations, + ) + # Generic fallback import json try: @@ -721,7 +1135,7 @@ def build_tool_start( content = [acp.tool_content(acp.text_block(args_text))] return acp.start_tool_call( tool_call_id, title, kind=kind, content=content, locations=locations, - raw_input=arguments, + raw_input=None if tool_name in _POLISHED_TOOLS else arguments, ) diff --git a/tests/acp/test_tools.py b/tests/acp/test_tools.py index fa576b614..40423174a 100644 --- a/tests/acp/test_tools.py +++ b/tests/acp/test_tools.py @@ -353,6 +353,70 @@ class TestBuildToolComplete: assert "Results truncated" in text assert result.raw_output is None + def test_build_tool_complete_for_process_list_formats_table(self): + result = build_tool_complete( + "tc-process", + "process", + '{"processes":[{"session_id":"p1","status":"running","pid":123,"command":"npm run dev"}]}', + function_args={"action":"list"}, + ) + text = result.content[0].content.text + assert "Processes: 1" in text + assert "`p1`" in text + assert "npm run dev" in text + assert result.raw_output is None + + def test_build_tool_complete_for_delegate_task_summarizes_children(self): + result = build_tool_complete( + "tc-delegate", + "delegate_task", + '{"results":[{"task_index":0,"status":"completed","summary":"Reviewed ACP rendering.","model":"gpt-5.5","duration_seconds":3.2,"tool_trace":[{"tool":"read_file"}]}],"total_duration_seconds":3.4}', + ) + text = result.content[0].content.text + assert "Delegation results: 1 task" in text + assert "Reviewed ACP rendering" in text + assert "gpt-5.5" in text + assert "Tools: read_file" in text + assert result.raw_output is None + + def test_build_tool_complete_for_session_search_recent(self): + result = build_tool_complete( + "tc-session", + "session_search", + '{"success":true,"mode":"recent","results":[{"session_id":"s1","title":"ACP work","last_active":"2026-05-02","message_count":12,"preview":"Polished tool rendering."}],"count":1}', + ) + text = result.content[0].content.text + assert "Recent sessions" in text + assert "ACP work" in text + assert "Polished tool rendering" in text + assert result.raw_output is None + + def test_build_tool_complete_for_memory_avoids_dumping_entries(self): + result = build_tool_complete( + "tc-memory", + "memory", + '{"success":true,"target":"user","entries":["private long memory"],"usage":"1% — 19/2000 chars","entry_count":1,"message":"Entry added."}', + function_args={"action":"add","target":"user","content":"User likes concise ACP rendering."}, + ) + text = result.content[0].content.text + assert "Memory add saved" in text + assert "User likes concise ACP rendering" in text + assert "private long memory" not in text + assert result.raw_output is None + + def test_build_tool_complete_for_web_extract_summarizes_urls(self): + result = build_tool_complete( + "tc-web-extract", + "web_extract", + '{"results":[{"url":"https://example.com","title":"Example","content":"# Intro\\nThis is extracted content."}]}', + ) + text = result.content[0].content.text + assert "Web extract: 1 URL" in text + assert "Example" in text + assert "Content:" in text + assert "Intro" 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