"""Tool-dispatch helpers — parallelism gating, multimodal envelopes, mutation tracking. Pure module-level utilities extracted from ``run_agent.py``: * ``_is_destructive_command`` — terminal-command heuristic used to gate parallel batch dispatch. * ``_should_parallelize_tool_batch`` / ``_extract_parallel_scope_path`` / ``_paths_overlap`` — the rules engine deciding when a multi-tool batch can run concurrently. * ``_is_multimodal_tool_result`` / ``_multimodal_text_summary`` / ``_append_subdir_hint_to_multimodal`` — envelope helpers for the ``{"_multimodal": True, "content": [...], "text_summary": ...}`` dict shape returned by tools like ``computer_use``. * ``_extract_file_mutation_targets`` / ``_extract_error_preview`` — per-turn file-mutation verifier inputs. * ``_trajectory_normalize_msg`` — strip image blobs from a message for trajectory saving. All helpers are stateless. ``run_agent`` re-exports each name so existing ``from run_agent import ...`` imports in tests and other modules keep working unchanged. """ from __future__ import annotations import json import logging import os import re from pathlib import Path from typing import Any, Dict, List, Optional from agent.tool_result_classification import ( FILE_MUTATING_TOOL_NAMES as _FILE_MUTATING_TOOLS, ) logger = logging.getLogger(__name__) # Tools that must never run concurrently (interactive / user-facing). # When any of these appear in a batch, we fall back to sequential execution. _NEVER_PARALLEL_TOOLS = frozenset({"clarify"}) # Read-only tools with no shared mutable session state. _PARALLEL_SAFE_TOOLS = frozenset({ "ha_get_state", "ha_list_entities", "ha_list_services", "read_file", "search_files", "session_search", "skill_view", "skills_list", "vision_analyze", "web_extract", "web_search", }) # File tools can run concurrently when they target independent paths. _PATH_SCOPED_TOOLS = frozenset({"read_file", "write_file", "patch"}) # Patterns that indicate a terminal command may modify/delete files. _DESTRUCTIVE_PATTERNS = re.compile( r"""(?:^|\s|&&|\|\||;|`)(?: rm\s|rmdir\s| cp\s|install\s| mv\s| sed\s+-i| truncate\s| dd\s| shred\s| git\s+(?:reset|clean|checkout)\s )""", re.VERBOSE, ) # Output redirects that overwrite files (> but not >>) _REDIRECT_OVERWRITE = re.compile(r'[^>]>[^>]|^>[^>]') def _is_destructive_command(cmd: str) -> bool: """Heuristic: does this terminal command look like it modifies/deletes files?""" if not cmd: return False if _DESTRUCTIVE_PATTERNS.search(cmd): return True if _REDIRECT_OVERWRITE.search(cmd): return True return False def _is_mcp_tool_parallel_safe(tool_name: str) -> bool: """Check if an MCP tool comes from a server with parallel tool calls enabled. Lazy-imports from ``tools.mcp_tool`` to avoid circular dependencies. Returns False if the MCP module is not available. """ try: from tools.mcp_tool import is_mcp_tool_parallel_safe return is_mcp_tool_parallel_safe(tool_name) except Exception: return False def _should_parallelize_tool_batch(tool_calls) -> bool: """Return True when a tool-call batch is safe to run concurrently.""" if len(tool_calls) <= 1: return False tool_names = [tc.function.name for tc in tool_calls] if any(name in _NEVER_PARALLEL_TOOLS for name in tool_names): return False reserved_paths: list[Path] = [] for tool_call in tool_calls: tool_name = tool_call.function.name try: function_args = json.loads(tool_call.function.arguments) except Exception: logging.debug( "Could not parse args for %s — defaulting to sequential; raw=%s", tool_name, tool_call.function.arguments[:200], ) return False if not isinstance(function_args, dict): logging.debug( "Non-dict args for %s (%s) — defaulting to sequential", tool_name, type(function_args).__name__, ) return False if tool_name in _PATH_SCOPED_TOOLS: scoped_path = _extract_parallel_scope_path(tool_name, function_args) if scoped_path is None: return False if any(_paths_overlap(scoped_path, existing) for existing in reserved_paths): return False reserved_paths.append(scoped_path) continue if tool_name not in _PARALLEL_SAFE_TOOLS: # Check if it's an MCP tool from a server that opted into parallel calls. if not _is_mcp_tool_parallel_safe(tool_name): return False return True def _extract_parallel_scope_path(tool_name: str, function_args: dict) -> Optional[Path]: """Return the normalized file target for path-scoped tools.""" if tool_name not in _PATH_SCOPED_TOOLS: return None raw_path = function_args.get("path") if not isinstance(raw_path, str) or not raw_path.strip(): return None expanded = Path(raw_path).expanduser() if expanded.is_absolute(): return Path(os.path.abspath(str(expanded))) # Avoid resolve(); the file may not exist yet. return Path(os.path.abspath(str(Path.cwd() / expanded))) def _paths_overlap(left: Path, right: Path) -> bool: """Return True when two paths may refer to the same subtree.""" left_parts = left.parts right_parts = right.parts if not left_parts or not right_parts: # Empty paths shouldn't reach here (guarded upstream), but be safe. return bool(left_parts) == bool(right_parts) and bool(left_parts) common_len = min(len(left_parts), len(right_parts)) return left_parts[:common_len] == right_parts[:common_len] def _is_multimodal_tool_result(value: Any) -> bool: """True if the value is a multimodal tool result envelope. Multimodal handlers (e.g. tools/computer_use) return a dict with `_multimodal=True`, a `content` key holding OpenAI-style content parts, and an optional `text_summary` for string-only fallbacks. """ return ( isinstance(value, dict) and value.get("_multimodal") is True and isinstance(value.get("content"), list) ) def _multimodal_text_summary(value: Any) -> str: """Extract a plain text view of a multimodal tool result. Used wherever downstream code needs a string — logging, previews, persistence size heuristics, fall-back content for providers that don't support multipart tool messages. """ if _is_multimodal_tool_result(value): if value.get("text_summary"): return str(value["text_summary"]) parts = [] for p in value.get("content") or []: if isinstance(p, dict) and p.get("type") == "text": parts.append(str(p.get("text", ""))) if parts: return "\n".join(parts) return "[multimodal tool result]" if isinstance(value, str): return value try: return json.dumps(value, default=str) except Exception: return str(value) def _append_subdir_hint_to_multimodal(value: Dict[str, Any], hint: str) -> None: """Mutate a multimodal tool-result envelope to append a subdir hint. The hint is added to the first text part so the model sees it; image parts are left untouched. `text_summary` is also updated for string-fallback callers. """ if not _is_multimodal_tool_result(value): return parts = value.get("content") or [] for p in parts: if isinstance(p, dict) and p.get("type") == "text": p["text"] = str(p.get("text", "")) + hint break else: parts.insert(0, {"type": "text", "text": hint}) value["content"] = parts if isinstance(value.get("text_summary"), str): value["text_summary"] = value["text_summary"] + hint def _extract_file_mutation_targets(tool_name: str, args: Dict[str, Any]) -> List[str]: """Return the file paths a ``write_file`` or ``patch`` call is targeting. For ``write_file`` and ``patch`` in replace mode this is just ``args["path"]``. For ``patch`` in V4A patch mode we parse the patch content for ``*** Update File:`` / ``*** Add File:`` / ``*** Delete File:`` headers so the verifier can track each file in a multi-file patch separately. """ if tool_name not in _FILE_MUTATING_TOOLS: return [] if tool_name == "write_file": p = args.get("path") return [str(p)] if p else [] # tool_name == "patch" mode = args.get("mode") or "replace" if mode == "replace": p = args.get("path") return [str(p)] if p else [] if mode == "patch": body = args.get("patch") or "" if not isinstance(body, str) or not body: return [] paths: List[str] = [] for _m in re.finditer( r'^\*\*\*\s+(?:Update|Add|Delete)\s+File:\s*(.+)$', body, re.MULTILINE, ): p = _m.group(1).strip() if p: paths.append(p) return paths return [] def _extract_error_preview(result: Any, max_len: int = 180) -> str: """Pull a one-line error summary out of a tool result for footer display.""" text = _multimodal_text_summary(result) if result is not None else "" if not isinstance(text, str): try: text = str(text) except Exception: return "" # Try to parse JSON and pull the ``error`` field — tool handlers return # ``{"success": false, "error": "..."}``; raw string wins if parse fails. stripped = text.strip() if stripped.startswith("{"): try: data = json.loads(stripped) if isinstance(data, dict) and isinstance(data.get("error"), str): text = data["error"] except Exception: pass # Collapse whitespace, trim to max_len. text = " ".join(text.split()) if len(text) > max_len: text = text[: max_len - 1] + "…" return text def _trajectory_normalize_msg(msg: Dict[str, Any]) -> Dict[str, Any]: """Strip image blobs from a message for trajectory saving. Returns a shallow copy with multimodal tool results replaced by their text_summary, and image parts in content lists replaced by `[screenshot]` placeholders. Keeps the message schema otherwise intact. """ if not isinstance(msg, dict): return msg content = msg.get("content") if _is_multimodal_tool_result(content): return {**msg, "content": _multimodal_text_summary(content)} if isinstance(content, list): cleaned = [] for p in content: if isinstance(p, dict) and p.get("type") in {"image", "image_url", "input_image"}: cleaned.append({"type": "text", "text": "[screenshot]"}) else: cleaned.append(p) return {**msg, "content": cleaned} return msg def make_tool_result_message(name: str, content: Any, tool_call_id: str) -> dict: """Build a tool-result message dict with both the OpenAI-format ``name`` field (required by the wire format and provider adapters) and the internal ``tool_name`` field (written to the session DB messages table).""" return { "role": "tool", "name": name, "tool_name": name, "content": content, "tool_call_id": tool_call_id, } __all__ = [ "_NEVER_PARALLEL_TOOLS", "_PARALLEL_SAFE_TOOLS", "_PATH_SCOPED_TOOLS", "_DESTRUCTIVE_PATTERNS", "_REDIRECT_OVERWRITE", "_is_destructive_command", "_should_parallelize_tool_batch", "_extract_parallel_scope_path", "_paths_overlap", "_is_multimodal_tool_result", "_multimodal_text_summary", "_append_subdir_hint_to_multimodal", "_extract_file_mutation_targets", "_extract_error_preview", "_trajectory_normalize_msg", "make_tool_result_message", ]