"""Tool execution pipeline. Runs one tool use end to end: input normalization, validation, permission checks, hooks, execution, error classification, result formatting, and optional large-output persistence. The agent loop receives a flat ``ToolCallResult`` so the runtime can keep observability in ``event_sink`` rather than yielding streaming UI objects from this layer. Bash ``_simulatedSedEdit`` remains model-hidden but is accepted internally after permission preview. PermissionDenied hooks are best-effort: hook failures never change a deny result. """ from __future__ import annotations import asyncio import inspect import json import os import re import time from dataclasses import dataclass, field from pathlib import Path from typing import Any, Callable, Iterable, Sequence import jsonschema from openspace.grounding.core.tool.base import BaseTool, PermissionCheckResult from openspace.grounding.core.types import ToolResult, ToolStatus from openspace.services.conversation.messages import ( CANCEL_MESSAGE, INTERRUPT_MESSAGE_FOR_TOOL_USE, build_tool_result_message, build_tool_result_stop_message, extract_discovered_tool_names, ) from openspace.services.conversation.content_blocks import ( content_has_multimodal_block, content_text_size, extract_text_from_content, make_text_block, ) from openspace.services.tooling.results import maybe_persist_large_result from openspace.services.tooling.context import ToolUseContext, active_skill_scope_payload from openspace.utils.logging import Logger logger = Logger.get_logger(__name__) # ═══════════════════════════════════════════════════════════════════════ # §1 Constants # ═══════════════════════════════════════════════════════════════════════ TOOL_ERROR_MAX_CHARS: int = 10_000 """Errors longer than this are head/tail truncated.""" TOOL_ERROR_HEAD_CHARS: int = 5_000 TOOL_ERROR_TAIL_CHARS: int = 5_000 EMPTY_RESULT_TEMPLATE = "({tool_name} completed with no output)" """Used when content is empty so the model does not treat it as a stop signal.""" # Tool name constants — used by normalize_tool_input, COMPACTABLE_TOOLS, etc. # These are the canonical names used by the tool runtime. BASH_TOOL_NAME = "bash" FILE_READ_TOOL_NAME = "read" FILE_EDIT_TOOL_NAME = "edit" FILE_WRITE_TOOL_NAME = "write" NOTEBOOK_EDIT_TOOL_NAME = "notebook_edit" GREP_TOOL_NAME = "grep" GLOB_TOOL_NAME = "glob" WEB_SEARCH_TOOL_NAME = "web_search" WEB_FETCH_TOOL_NAME = "web_fetch" TASK_OUTPUT_TOOL_NAME = "TaskOutput" EXIT_PLAN_MODE_TOOL_NAME = "ExitPlanMode" # ToolSearch integration — populated in step 11.1 TOOL_SEARCH_TOOL_NAME = "tool_search" LIST_DIR_TOOL_NAME = "ls" PATH_ARGUMENT_TARGETS = { FILE_READ_TOOL_NAME: "file_path", FILE_EDIT_TOOL_NAME: "file_path", FILE_WRITE_TOOL_NAME: "file_path", GREP_TOOL_NAME: "path", GLOB_TOOL_NAME: "path", LIST_DIR_TOOL_NAME: "path", } PATH_ARGUMENT_ALIASES = { "file", "file_path", "filepath", "filename", "path", } # Invisible Unicode characters that LLMs sometimes inject into code strings. # Implementation: normalizeFileEditInput in tools/FileEditTool/utils.ts _INVISIBLE_CHARS_RE = re.compile( "[\u200b\u200c\u200d\u200e\u200f\u2060\ufeff\u2062\u2063\u2064" "\u00ad\u034f\u061c\u180e\u2000-\u200a\u2028\u2029\u202a-\u202e" "\u2066-\u2069\ufff9-\ufffb]" ) # ═══════════════════════════════════════════════════════════════════════ # §2 Data types # ═══════════════════════════════════════════════════════════════════════ @dataclass class ToolCallResult: """Result of a single tool call execution. Implementation: the ``MessageUpdateLazy[]`` array returned by ``checkPermissionsAndCallTool``. OS flattens the async generator into a collected result. Consumed by ``run_tools`` (step 5.2) and the agent loop (step 7.1). """ tool_use_id: str tool_name: str messages: list[dict[str, Any]] = field(default_factory=list) """Messages to append to the conversation (tool_result + hook messages).""" prevent_continuation: bool = False """If True, the agent loop should break after processing this result. Implementation: ``shouldPreventContinuation`` from pre/post hooks.""" stop_reason: str | None = None """Reason for stopping continuation (from hooks).""" context_modifier: Callable[[ToolUseContext], ToolUseContext] | None = None """Optional modifier to apply to ToolUseContext after this call. Implementation: ``contextModifier`` in ``MessageUpdateLazy``.""" def tool_call_result_to_tool_result(result: ToolCallResult) -> ToolResult: """Convert pipeline output to the legacy/public ``ToolResult`` shape. Public non-agent entrypoints such as ``GroundingClient.invoke_tool()`` and ``BaseSession.call_tool()`` should still execute through ``run_tool_use()``; this helper is the single boundary back to Grounding's historical result object. """ tool_message = None for message in result.messages: if not isinstance(message, dict): continue meta = message.get("_meta") if ( message.get("role") == "tool" and isinstance(meta, dict) and meta.get("type") == "tool_result" ): tool_message = message break if tool_message is None: content = "\n".join( extract_text_from_content(message.get("content")) for message in result.messages if isinstance(message, dict) ).strip() error = content or "Tool execution produced no tool result message" return ToolResult( status=ToolStatus.ERROR, content=content, error=error, metadata={ "tool": result.tool_name, "tool_call_id": result.tool_use_id, }, ) meta = tool_message.get("_meta") or {} raw_status = str(meta.get("status") or "").lower() is_success = raw_status == ToolStatus.SUCCESS.value content = tool_message.get("content", "") metadata = {} tool_result_metadata = meta.get("tool_result_metadata") if isinstance(tool_result_metadata, dict): metadata.update(tool_result_metadata) metadata["tool"] = result.tool_name metadata["tool_call_id"] = result.tool_use_id if raw_status and raw_status not in { ToolStatus.SUCCESS.value, ToolStatus.ERROR.value, }: metadata["pipeline_status"] = raw_status if meta.get("error_type"): metadata["error_type"] = meta["error_type"] execution_time = meta.get("execution_time") if execution_time is not None: try: execution_time = float(execution_time) except (TypeError, ValueError): execution_time = None error = None if not is_success: error_text = extract_text_from_content(content) error = error_text.removeprefix("Error: ").strip() or error_text return ToolResult( status=ToolStatus.SUCCESS if is_success else ToolStatus.ERROR, content=content, error=error, execution_time=execution_time, metadata=metadata, ) @dataclass(frozen=True) class PermissionAskResolution: """Outcome of an interactive permission ask.""" deny_message: dict[str, Any] | None = None updated_input: dict[str, Any] | None = None skip_permission_recheck: bool = False prevent_continuation: bool = False stop_reason: str | None = None # ═══════════════════════════════════════════════════════════════════════ # §3 Error formatting # ═══════════════════════════════════════════════════════════════════════ def _get_error_parts(error: BaseException) -> list[str]: """Extract displayable parts from an exception. Shell execution errors expose exit-code, interrupted, stderr, and stdout attributes. Generic exceptions fall back to their string message plus any stderr/stdout attributes they provide. """ parts: list[str] = [] # ShellError equivalent: exceptions with exit_code/stderr/stdout exit_code = getattr(error, "exit_code", None) or getattr(error, "returncode", None) if exit_code is not None: interrupted = getattr(error, "interrupted", False) if interrupted: parts.append(f"Exit code {exit_code}") parts.append(INTERRUPT_MESSAGE_FOR_TOOL_USE) else: parts.append(f"Exit code {exit_code}") stderr = getattr(error, "stderr", None) if stderr and isinstance(stderr, str): parts.append(stderr) stdout = getattr(error, "stdout", None) if stdout and isinstance(stdout, str): parts.append(stdout) return parts # Generic Error: message + optional stderr/stdout attributes msg = str(error) if msg: parts.append(msg) stderr = getattr(error, "stderr", None) if stderr and isinstance(stderr, str): parts.append(stderr) stdout = getattr(error, "stdout", None) if stdout and isinstance(stdout, str): parts.append(stdout) return parts def format_tool_error(error: BaseException | str) -> str: """Format a tool execution error into a user-readable string. Truncation strategy: if the message exceeds ``TOOL_ERROR_MAX_CHARS``, keep the first 5000 and last 5000 chars with a ``[N characters truncated]`` indicator in the middle. """ if isinstance(error, str): full_message = error else: if _is_abort_error(error): msg = str(error) return msg if msg else INTERRUPT_MESSAGE_FOR_TOOL_USE parts = _get_error_parts(error) full_message = "\n".join(p for p in parts if p).strip() if not full_message: full_message = "Command failed with no output" if len(full_message) <= TOOL_ERROR_MAX_CHARS: return full_message truncated_count = len(full_message) - TOOL_ERROR_MAX_CHARS head = full_message[:TOOL_ERROR_HEAD_CHARS] tail = full_message[-TOOL_ERROR_TAIL_CHARS:] return f"{head}\n... [{truncated_count} characters truncated] ...\n{tail}" def format_validation_error( tool_name: str, validation_error: jsonschema.ValidationError, ) -> str: """Format a JSON Schema validation error into a user-readable message. Categorizes schema issues into: - missing required params (``invalid_type`` + ``received undefined``) - unexpected params (``unrecognized_keys``) - type mismatch (``invalid_type`` + not undefined) jsonschema provides ``validator``, ``path``, and ``message`` on each error. """ error_parts: list[str] = [] # jsonschema may have sub-errors in context errors = list(validation_error.context) if validation_error.context else [validation_error] for err in errors: path_str = ".".join(str(p) for p in err.absolute_path) if err.absolute_path else "(root)" validator = err.validator if validator == "required": # Extract missing property names from message match = re.search(r"'(\w+)' is a required property", err.message) param = match.group(1) if match else path_str error_parts.append(f"The required parameter `{param}` is missing.") elif validator == "additionalProperties": match = re.search(r"Additional properties are not allowed \((.+) (?:was|were)", err.message) if match: params = match.group(1).replace("'", "`") error_parts.append(f"An unexpected parameter {params} was provided.") else: error_parts.append(f"An unexpected parameter was provided: {err.message}") elif validator == "type": error_parts.append( f"The parameter `{path_str}` has wrong type: {err.message}" ) else: error_parts.append(f"Parameter `{path_str}`: {err.message}") if not error_parts: return f"{tool_name} input validation failed: {validation_error.message}" issue_word = "issue" if len(error_parts) == 1 else "issues" return ( f"{tool_name} failed due to the following {issue_word}:\n" + "\n".join(error_parts) ) async def _validate_input_for_phase( tool: BaseTool, input_data: dict[str, Any], context: ToolUseContext, *, phase: str, ) -> str | None: """Run tool input validation for the requested permission phase.""" if phase == "pre_permission": validator = getattr(tool, "pre_permission_validate_input", None) if validator is None: validator = tool.validate_input elif phase == "post_permission": validator = getattr(tool, "post_permission_validate_input", None) if validator is None: return None else: raise ValueError(f"Unknown validation phase: {phase}") return await validator(input_data, context) def _build_input_validation_error_message( tool_use_id: str, tool_name: str, validation_error_msg: str, ) -> dict[str, Any]: """Produce the tool result message for custom input validation errors.""" return { "role": "tool", "name": tool_name, "content": f"Error: {validation_error_msg}", "tool_call_id": tool_use_id, "_meta": { "type": "tool_result", "tool_name": tool_name, "tool_call_id": tool_use_id, "status": "error", "error_type": "input_validation_error", "timestamp": time.time(), }, } def classify_tool_error(error: BaseException | str) -> str: """Classify a tool error for telemetry / analytics. Checks exception attributes such as ``telemetry_message`` and ``errno`` when present, then falls back to exception type names. """ if isinstance(error, str): return "StringError" telemetry_msg = getattr(error, "telemetry_message", None) if telemetry_msg and isinstance(telemetry_msg, str): return telemetry_msg[:200] errno_code = getattr(error, "errno", None) or getattr(error, "code", None) if errno_code and isinstance(errno_code, (str, int)): return f"Error:{errno_code}" err_name = type(error).__name__ if err_name and err_name != "Exception" and err_name != "Error" and len(err_name) > 3: return err_name[:60] return "Error" def _json_preview(value: Any, max_chars: int = 500) -> str: try: return json.dumps(value, ensure_ascii=False, sort_keys=True, default=str)[:max_chars] except Exception: return str(value)[:max_chars] def _is_abort_error(error: BaseException) -> bool: """Check if error is an abort/cancellation error.""" return isinstance(error, (asyncio.CancelledError, KeyboardInterrupt)) or ( type(error).__name__ in ("AbortError", "CancelledError") ) def _resolve_tool_backend_value(tool: BaseTool) -> str | None: """Best-effort backend string for hook runtime state.""" runtime_info = getattr(tool, "_runtime_info", None) runtime_backend = getattr(runtime_info, "backend", None) if hasattr(runtime_backend, "value"): return runtime_backend.value if isinstance(runtime_backend, str) and runtime_backend: return runtime_backend backend_type = getattr(tool, "backend_type", None) if hasattr(backend_type, "value"): return backend_type.value if isinstance(backend_type, str) and backend_type: return backend_type return None def _is_tool_result_like(value: Any) -> bool: """Return whether a value looks like a ToolResult-compatible object.""" return all( hasattr(value, attr) for attr in ("status", "content", "metadata", "error", "execution_time") ) def _coerce_hook_updated_tool_output( original_result: ToolResult, updated_output: Any, ) -> ToolResult: """Normalize hook-updated tool output back into a ToolResult.""" if isinstance(updated_output, ToolResult) or _is_tool_result_like(updated_output): return updated_output return ToolResult( status=original_result.status, content=updated_output, error=original_result.error, execution_time=original_result.execution_time, metadata=original_result.metadata, ) def _is_last_tool_call_in_iteration( tool_use_id: str, assistant_message: dict[str, Any] | None, ) -> bool: """Return whether the tool call is the final tool call in the assistant turn.""" if not assistant_message: return False tool_calls = assistant_message.get("tool_calls") if not isinstance(tool_calls, list) or not tool_calls: return False last_tool_call = tool_calls[-1] if isinstance(last_tool_call, dict): return last_tool_call.get("id") == tool_use_id return getattr(last_tool_call, "id", None) == tool_use_id def _build_tool_result_message_from_result( tool_use_id: str, tool_name: str, result: ToolResult, ) -> tuple[dict[str, Any], str, bool]: """Serialize a ToolResult into the transcript message format.""" result_content_raw = result.content if result.content is not None else "" result_content_text = extract_text_from_content(result_content_raw) is_error = result.status == ToolStatus.ERROR if is_error: error_str = result.error or result_content_text or "Unknown error" error_text = ( format_tool_error(error_str) if isinstance(error_str, str) else str(error_str) ) if content_has_multimodal_block(result_content_raw): if isinstance(result_content_raw, list): result_content_raw = [make_text_block(f"Error: {error_text}")] + result_content_raw else: result_content_raw = [ make_text_block(f"Error: {error_text}"), result_content_raw, ] else: result_content_raw = error_text result_content_text = extract_text_from_content(result_content_raw) if ( not result_content_text.strip() and not content_has_multimodal_block(result_content_raw) and not is_error ): result_content_raw = EMPTY_RESULT_TEMPLATE.format(tool_name=tool_name) result_content_text = result_content_raw tool_result_msg = build_tool_result_message( tool_call_id=tool_use_id, tool_name=tool_name, result=ToolResult( status=result.status, content=result_content_raw, error=result.error if is_error else None, execution_time=result.execution_time, metadata=result.metadata, ), ) return tool_result_msg, result_content_text, is_error def _maybe_persist_tool_result_for_pipeline( tool: BaseTool, tool_use_id: str, tool_name: str, result: ToolResult, context: ToolUseContext, ) -> ToolResult: """Persist oversized tool output at the execution-pipeline boundary. OpenSpace persists after ``tool.call`` returns, where the real tool-use id is available. Keeping this here avoids the old BaseTool-level fallback that had to use random file names outside the agent pipeline. """ content = result.content if result.content is not None else "" if content_has_multimodal_block(content): return result content_text = extract_text_from_content(content) new_content, was_persisted, meta = maybe_persist_large_result( content=content_text, tool_use_id=tool_use_id, tool_name=tool_name, max_result_size_chars=tool.max_result_size_chars, results_dir=getattr(context, "tool_results_dir", None), ) if not was_persisted: return result return ToolResult( status=result.status, content=new_content, error=result.error, execution_time=result.execution_time, metadata={**(result.metadata or {}), **meta}, ) def _get_tool_additional_messages( result: ToolResult, *, tool_use_id: str, tool_name: str, ) -> list[dict[str, Any]]: """Return OpenSpace extra messages produced by a tool result.""" raw_messages = getattr(result, "additional_messages", None) if not isinstance(raw_messages, list): return [] messages: list[dict[str, Any]] = [] for raw in raw_messages: if not isinstance(raw, dict): continue msg = dict(raw) meta = dict(msg.get("_meta") or {}) meta.update({ "type": meta.get("type") or "tool_result_attachment", "tool_name": tool_name, "tool_call_id": tool_use_id, }) msg["_meta"] = meta messages.append(msg) return messages def _mark_dynamic_skill_paths_from_result( result: ToolResult, context: ToolUseContext, ) -> None: """Feed file-like tool result metadata into dynamic skill discovery.""" if result.status != ToolStatus.SUCCESS: return marker = getattr(context, "mark_dynamic_skill_path", None) if not callable(marker): return metadata = result.metadata or {} raw_paths: list[Any] = [] if metadata.get("file_path"): raw_paths.append(metadata.get("file_path")) filenames = metadata.get("filenames") if isinstance(filenames, Sequence) and not isinstance( filenames, (str, bytes, bytearray), ): raw_paths.extend(filenames) cwd = Path(getattr(context, "cwd", "") or ".") for raw_path in raw_paths: path_text = str(raw_path or "").strip() if not path_text: continue path = Path(path_text).expanduser() if not path.is_absolute(): path = cwd / path marker(str(path)) # ═══════════════════════════════════════════════════════════════════════ # §4 Tool lookup (Implementation: Tool.ts findToolByName) # ═══════════════════════════════════════════════════════════════════════ def find_tool_by_name( tools: Sequence[BaseTool], name: str, ) -> BaseTool | None: """Find a tool by exact name or alias. Implementation: ``findToolByName(tools, name)`` in ``Tool.ts`` L348-360. OpenSpace also uses ``toolMatchesName`` which checks name + aliases. """ for tool in tools: if tool.name == name: return tool # Alias fallback for tool in tools: if name in (tool.aliases or []): return tool return None # ═══════════════════════════════════════════════════════════════════════ # §5 Deferred tool handling (Implementation: toolExecution.ts buildSchemaNotSentHint) # ═══════════════════════════════════════════════════════════════════════ def build_schema_not_sent_hint( tool: BaseTool, messages: list[dict[str, Any]], tools: Sequence[BaseTool], *, deferred_tool_names: Iterable[str] | None = None, ) -> str | None: """Build a hint when a deferred tool's schema wasn't sent to the LLM. Implementation: ``buildSchemaNotSentHint(tool, messages, tools)`` in ``toolExecution.ts`` L577-597. OpenSpace checks: 1. isToolSearchEnabledOptimistic() — tool search feature flag 2. isToolSearchToolAvailable(tools) — ToolSearchTool in active tools 3. isDeferredTool(tool) — tool is deferred 4. extractDiscoveredToolNames(messages) — tool NOT already discovered OS live state is ``discovered_tool_names`` / metadata only. OpenSpace's Anthropic-only tool reference block is intentionally not parsed. """ deferred_names = {str(name) for name in (deferred_tool_names or ())} if not tool.is_deferred and tool.name not in deferred_names: return None # Check if ToolSearchTool is available tool_search_available = any(t.name == TOOL_SEARCH_TOOL_NAME for t in tools) if not tool_search_available: return None if tool.name in extract_discovered_tool_names(messages): return None return ( f"\n\nNote: The tool `{tool.name}` is available but its schema was not " f"included in the current prompt. To use it, first run " f"`{TOOL_SEARCH_TOOL_NAME}` with `select:{tool.name}` to load its " f"schema, then retry your call." ) def _check_deferred_tool_not_loaded( tool: BaseTool, active_tools: Sequence[BaseTool], deferred_tool_names: Iterable[str] | None = None, ) -> str | None: """Pipeline step 0: intercept calls to deferred tools whose schema was not sent to the LLM. Implementation: implicit in ``checkPermissionsAndCallTool`` — when the model calls a deferred tool, Zod validation fails and ``buildSchemaNotSentHint`` is appended. OS makes this explicit as a preemptive check (DEC-003 / 04_module). Returns an error message if the tool should be intercepted, None otherwise. """ deferred_names = {str(name) for name in (deferred_tool_names or ())} if not tool.is_deferred and tool.name not in deferred_names: return None # If the tool is in the active_tools list, its schema was sent active_names = {t.name for t in active_tools} if tool.name in active_names: return None hint_parts = [ f"Tool '{tool.name}' is available but not yet loaded. " f"Use {TOOL_SEARCH_TOOL_NAME} to discover and load it first.", ] tool_search_available = TOOL_SEARCH_TOOL_NAME in active_names if tool_search_available: hint_parts.append( f"\nRun `{TOOL_SEARCH_TOOL_NAME}` with query `select:{tool.name}` " f"to load the tool schema." ) return "\n".join(hint_parts) def _build_deferred_not_loaded_result( *, tool_name: str, tool_use_id: str, content: str, ) -> ToolCallResult: """Return a tool result that tells the model to load a deferred schema.""" return ToolCallResult( tool_use_id=tool_use_id, tool_name=tool_name, messages=[{ "role": "tool", "name": tool_name, "content": content, "tool_call_id": tool_use_id, "_meta": { "type": "tool_result", "tool_name": tool_name, "tool_call_id": tool_use_id, "status": "error", "error_type": "deferred_not_loaded", "timestamp": time.time(), }, }], ) # ═══════════════════════════════════════════════════════════════════════ # §6 Input normalization (Implementation: utils/api.ts normalizeToolInput L566-718) # ═══════════════════════════════════════════════════════════════════════ def normalize_tool_input( tool: BaseTool, input: dict[str, Any], context: ToolUseContext | None = None, ) -> dict[str, Any]: """Normalize tool input before execution. Implementation: ``normalizeToolInput(tool, input, agentId)`` in ``utils/api.ts`` L566-681. Per-tool normalization: - BashTool: strip ``cd $cwd && `` prefix, ``\\\\;`` → ``\\;`` - FileEditTool: fix invisible Unicode chars in old_string/new_string - FileWriteTool: strip trailing whitespace for non-markdown files - TaskOutput: normalize legacy AgentOutputTool/BashOutputTool params - ExitPlanMode: inject local plan content and file path - Default: return input unchanged """ tool_name = tool.name input = _normalize_path_argument_aliases(tool_name, input) if tool_name == EXIT_PLAN_MODE_TOOL_NAME: return _normalize_exit_plan_mode_input(input, context) elif tool_name == BASH_TOOL_NAME: return _normalize_bash_input(input, context) elif tool_name == FILE_EDIT_TOOL_NAME: return _normalize_file_edit_input(input) elif tool_name == FILE_WRITE_TOOL_NAME: return _normalize_file_write_input(input) elif tool_name == TASK_OUTPUT_TOOL_NAME: return _normalize_task_output_input(input) else: return input def _normalize_path_argument_aliases( tool_name: str, input: dict[str, Any], ) -> dict[str, Any]: target = PATH_ARGUMENT_TARGETS.get(tool_name) if not target or target in input: return input alias_keys: list[str] = [] for key, value in input.items(): if not isinstance(key, str) or not isinstance(value, str): continue normalized = key.strip().lstrip("-/").replace("-", "_").lower() if normalized in PATH_ARGUMENT_ALIASES: alias_keys.append(key) if len(alias_keys) != 1: return input alias = alias_keys[0] result = dict(input) result[target] = result.pop(alias) return result def normalize_tool_input_for_api( tool: BaseTool, input: dict[str, Any], ) -> dict[str, Any]: """Strip locally-injected fields before sending tool_use back to the API. Implementation: ``normalizeToolInputForAPI(tool, input)`` in ``utils/api.ts`` L683-718. OpenSpace strips: - ExitPlanModeV2: removes ``plan`` and ``planFilePath`` - FileEditTool (legacy sessions): removes ``old_string``/``new_string``/ ``replace_all`` when ``edits`` array is present """ if tool.name == EXIT_PLAN_MODE_TOOL_NAME: return { key: value for key, value in input.items() if key not in {"plan", "planFilePath", "filePath"} } if tool.name == FILE_EDIT_TOOL_NAME and "edits" in input: return { key: value for key, value in input.items() if key not in {"old_string", "new_string", "replace_all"} } return input def _normalize_exit_plan_mode_input( input: dict[str, Any], context: ToolUseContext | None = None, ) -> dict[str, Any]: from openspace.services.runtime_support.plan_mode import get_plan, get_plan_file_path result = dict(input) session_id = getattr(context, "session_id", None) if context else None agent_id = getattr(context, "agent_id", None) if context else None file_path = get_plan_file_path(session_id, agent_id) plan = get_plan(session_id, agent_id) if plan is not None and "plan" not in result: result["plan"] = plan result["planFilePath"] = str(file_path) return result def _normalize_bash_input( input: dict[str, Any], context: ToolUseContext | None = None, ) -> dict[str, Any]: """Normalize BashTool input. Implementation: ``normalizeToolInput`` BashTool case (api.ts L589-631). 1. Strip ``cd $cwd && `` prefix from command 2. Replace ``\\\\;`` with ``\\;`` (find -exec compatibility) 3. Pass through timeout, description, run_in_background """ command = input.get("command", "") # Strip cd prefix (Implementation: normalizedCommand.replace(`cd ${cwd} && `, '')) if context and context.cwd: cwd = context.cwd prefix = f"cd {cwd} && " if command.startswith(prefix): command = command[len(prefix):] # Replace \\; with \; (Implementation: commonly needed for find -exec commands) command = command.replace("\\\\;", "\\;") result = {**input, "command": command} return result def _normalize_task_output_input(input: dict[str, Any]) -> dict[str, Any]: """Normalize OpenSpace legacy AgentOutputTool/BashOutputTool parameters. Implementation: ``utils/api.ts`` L661-L676. """ task_id = input.get("task_id") or input.get("agentId") or input.get("bash_id") timeout = input.get("timeout") wait_up_to = input.get("wait_up_to") if timeout is None and isinstance(wait_up_to, (int, float)): timeout = int(wait_up_to * 1000) return { "task_id": task_id or "", "block": input.get("block", True), "timeout": timeout if timeout is not None else 30_000, } def _normalize_file_edit_input(input: dict[str, Any]) -> dict[str, Any]: """Normalize FileEditTool input. Implementation: ``normalizeFileEditInput`` in ``tools/FileEditTool/utils.ts`` (L581-657) + invisible char stripping. Three normalization passes: 1. Strip invisible Unicode characters from old_string / new_string 2. Strip trailing whitespace from new_string (non-markdown files) 3. If old_string not found in file, try desanitization fallback """ result = dict(input) # Pass 1: invisible characters for key in ("old_string", "new_string"): value = result.get(key) if isinstance(value, str) and _INVISIBLE_CHARS_RE.search(value): cleaned = _INVISIBLE_CHARS_RE.sub("", value) if cleaned != value: logger.debug( "Stripped %d invisible chars from %s", len(value) - len(cleaned), key, ) result[key] = cleaned # Pass 2: trailing whitespace on new_string (Implementation: stripTrailingWhitespace) file_path = result.get("file_path", "") is_markdown = bool( isinstance(file_path, str) and re.search(r"\.(md|mdx)$", file_path, re.IGNORECASE) ) new_string = result.get("new_string") if isinstance(new_string, str) and not is_markdown: stripped = "\n".join(line.rstrip() for line in new_string.split("\n")) if stripped != new_string: result["new_string"] = stripped # Pass 3: desanitization fallback (Implementation: DESANITIZATIONS in utils.ts L531-550) old_string = result.get("old_string") if isinstance(old_string, str) and isinstance(file_path, str) and file_path: try: from openspace.grounding.backends.shell.file_tools import ( desanitize_match_string, ) import os from pathlib import Path full_path = os.path.abspath(os.path.expanduser(file_path)) if os.path.isfile(full_path): file_content = Path(full_path).read_text( encoding="utf-8", errors="replace", ) if old_string not in file_content: desanitized, applied = desanitize_match_string(old_string) if applied and desanitized in file_content: result["old_string"] = desanitized ns = result.get("new_string", "") if isinstance(ns, str): for short, long in applied: ns = ns.replace(short, long) result["new_string"] = ns except Exception: pass return result def _normalize_file_write_input(input: dict[str, Any]) -> dict[str, Any]: """Normalize FileWriteTool input — strip trailing whitespace. Implementation: ``normalizeToolInput`` FileWriteTool case (api.ts L653-665). Markdown uses trailing spaces for hard line breaks, so .md/.mdx files are exempt from stripping. """ file_path = input.get("file_path", "") content = input.get("content", "") if not isinstance(content, str) or not isinstance(file_path, str): return input # Implementation: const isMarkdown = /\.(md|mdx)$/i.test(parsedInput.file_path) is_markdown = bool(re.search(r"\.(md|mdx)$", file_path, re.IGNORECASE)) if is_markdown: return input # Strip trailing whitespace from each line stripped = "\n".join(line.rstrip() for line in content.split("\n")) if stripped != content: return {**input, "content": stripped} return input # ═══════════════════════════════════════════════════════════════════════ # §7 Main pipeline — run_tool_use # ═══════════════════════════════════════════════════════════════════════ async def run_tool_use( tool_call: dict[str, Any], tool_map: dict[str, BaseTool], context: ToolUseContext, *, assistant_message: dict[str, Any] | None = None, ) -> ToolCallResult: """Execute a single tool call through the runtime pipeline. Pipeline steps: 0. Deferred tool interception 1. Schema validation 2. Custom ``validate_input`` 3. Input normalization 4. Pre-tool hooks 5. Permission resolution 6. Tool execution 7. Result processing 8. Post-tool hooks Parameters ---------- tool_call : dict OpenAI-format tool call: ``{id, type, function: {name, arguments}}``. ``arguments`` may be a provider JSON string or a dict; this function is the canonical parse/fallback point. tool_map : dict Map of tool names → BaseTool instances from ``ModelResponse.tool_map``. context : ToolUseContext Turn-scoped runtime context (messages, abort, hooks, etc.). assistant_message : dict, optional The full assistant message containing this tool_call. Returns ------- ToolCallResult Contains messages to append and continuation control flags. """ # ── Parse tool_call ────────────────────────────────────────────── tool_use_id = tool_call.get("id", "") func = tool_call.get("function", {}) tool_name = func.get("name", "") tool_input: dict[str, Any] = func.get("arguments", {}) if isinstance(tool_input, str): import json try: tool_input = json.loads(tool_input) except (json.JSONDecodeError, TypeError): tool_input = {} start_time = time.time() processed_input_for_evidence: dict[str, Any] = dict(tool_input) pipeline_status = "error" pipeline_error_type: str | None = None pipeline_execution_time_ms: float | None = None pipeline_result_size_chars = 0 pipeline_tool_result_metadata: dict[str, Any] = {} pipeline_message_meta: dict[str, Any] = {} pipeline_result_preview = "" pipeline_permission_status: str | None = None result_messages: list[dict[str, Any]] = [] prevent_continuation = False stop_reason: str | None = None context_modifier: Callable | None = None pipeline_complete_emitted = False tool: BaseTool | None = None if os.environ.get("OPENSPACE_DEBUG_TOOL_CALLS", "").strip().lower() in { "1", "true", "yes", "y", "on", }: print( "OPENSPACE_DEBUG run_tool_use " f"tool={tool_name} id={tool_use_id} " f"permission_mode={getattr(context, 'permission_mode', None)} " f"input={_json_preview(tool_input)}", flush=True, ) async def _complete(result: ToolCallResult) -> ToolCallResult: nonlocal pipeline_complete_emitted nonlocal pipeline_error_type nonlocal pipeline_execution_time_ms nonlocal pipeline_message_meta nonlocal pipeline_result_preview nonlocal pipeline_result_size_chars nonlocal pipeline_status nonlocal pipeline_tool_result_metadata nonlocal pipeline_permission_status if pipeline_complete_emitted: return result pipeline_complete_emitted = True messages_for_evidence = list(result.messages or []) if not pipeline_message_meta: for message in messages_for_evidence: meta = message.get("_meta") if isinstance(message, dict) else None if isinstance(meta, dict) and meta.get("type") == "tool_result": pipeline_message_meta = dict(meta) break if pipeline_message_meta: pipeline_status = str( pipeline_message_meta.get("status") or pipeline_status or ("error" if pipeline_error_type else "success") ) if pipeline_message_meta.get("error_type"): pipeline_error_type = str(pipeline_message_meta.get("error_type")) metadata = pipeline_message_meta.get("tool_result_metadata") if isinstance(metadata, dict) and not pipeline_tool_result_metadata: pipeline_tool_result_metadata = dict(metadata) execution_time = pipeline_message_meta.get("execution_time") if pipeline_execution_time_ms is None and isinstance( execution_time, (int, float), ): pipeline_execution_time_ms = float(execution_time) * 1000 if not pipeline_result_preview: for message in messages_for_evidence: if not isinstance(message, dict): continue content = message.get("content") text = extract_text_from_content(content) if text: pipeline_result_preview = text[:500] break if pipeline_result_size_chars == 0: pipeline_result_size_chars = sum( content_text_size(message.get("content")) for message in messages_for_evidence if isinstance(message, dict) ) total_duration_ms = (time.time() - start_time) * 1000 try: complete_payload = { "session_id": getattr(context, "session_id", None), "task_id": getattr(context, "task_id", None), "agent_id": getattr(context, "agent_id", None), "parent_task_id": getattr(context, "parent_task_id", None), "current_iteration": getattr(context, "current_iteration", None), "tool_name": tool_name, "tool_use_id": tool_use_id, "backend": _resolve_tool_backend_value(tool) if tool is not None else None, "server_name": ( getattr(getattr(tool, "_runtime_info", None), "server_name", None) if tool is not None else None ) or "default", "status": pipeline_status, "error_type": pipeline_error_type, "permission_status": pipeline_permission_status, "execution_time_ms": pipeline_execution_time_ms, "total_duration_ms": total_duration_ms, "input_preview": _json_preview(processed_input_for_evidence), "result_size_chars": pipeline_result_size_chars, "result_preview": pipeline_result_preview, "tool_result_metadata": pipeline_tool_result_metadata, "message_meta": pipeline_message_meta, "message_count": len(messages_for_evidence), "prevent_continuation": result.prevent_continuation, } complete_payload.update(active_skill_scope_payload(context)) await context.emit_event("tool_pipeline_complete", complete_payload) except Exception: logger.debug( "Failed to emit tool_pipeline_complete for %s/%s", tool_name, tool_use_id, exc_info=True, ) await _record_pipeline_quality_outcome( context, tool=tool, tool_use_id=tool_use_id, status=pipeline_status, error_type=pipeline_error_type, permission_status=pipeline_permission_status, execution_time_ms=( pipeline_execution_time_ms if pipeline_execution_time_ms is not None else total_duration_ms ), result_preview=pipeline_result_preview, ) return result # ── Find tool ──────────────────────────────────────────────────── # Implementation: findToolByName(toolUseContext.options.tools, toolName) tool = tool_map.get(tool_name) if tool is None: # Alias fallback — Implementation: findToolByName(getAllBaseTools(), toolName) # with aliases?.includes(toolName) guard tool = find_tool_by_name(list(tool_map.values()), tool_name) if tool is None: # The LLM only receives active tool schemas, so a direct call to a # deferred tool usually misses ``tool_map``. Check the full tool # universe before returning a generic "No such tool" error. deferred_tool = find_tool_by_name(context.all_tools, tool_name) if deferred_tool is not None: deferred_msg = _check_deferred_tool_not_loaded( deferred_tool, context.tools, context.deferred_tool_names, ) if deferred_msg is not None: logger.debug("Deferred tool call found outside active tool map: %s", tool_name) await context.emit_event("tool_deferred_intercepted", { "tool_name": tool_name, "tool_use_id": tool_use_id, }) return await _complete(_build_deferred_not_loaded_result( tool_name=tool_name, tool_use_id=tool_use_id, content=deferred_msg, )) # Tool not found — Implementation: yield error user message logger.warning("Tool not found: %s", tool_name) await context.emit_event("tool_error", { "tool_name": tool_name, "error": "no_such_tool", "tool_use_id": tool_use_id, }) error_msg = ( f"Error: No such tool: `{tool_name}`. " f"Available tools: {', '.join(sorted(tool_map.keys()))}" ) return await _complete(ToolCallResult( tool_use_id=tool_use_id, tool_name=tool_name, messages=[{ "role": "tool", "name": tool_name, "content": error_msg, "tool_call_id": tool_use_id, "_meta": { "type": "tool_result", "tool_name": tool_name, "tool_call_id": tool_use_id, "status": "error", "error_type": "no_such_tool", "timestamp": time.time(), }, }], )) # ── Check abort ────────────────────────────────────────────────── # Implementation: if (abortController.signal.aborted) → yield cancel message if context.is_aborted(): logger.debug("Tool call aborted: %s/%s", tool_name, tool_use_id) await context.emit_event("tool_cancelled", { "tool_name": tool_name, "tool_use_id": tool_use_id, }) return await _complete(ToolCallResult( tool_use_id=tool_use_id, tool_name=tool_name, messages=[build_tool_result_stop_message(tool_use_id, tool_name)], )) tool_input = _normalize_path_argument_aliases(tool.name, tool_input) processed_input_for_evidence = dict(tool_input) # ── Emit tool_start event ──────────────────────────────────────── await context.emit_event("tool_start", { "tool_name": tool_name, "tool_use_id": tool_use_id, "tool_input": tool_input, }) try: # ── Step 0: Deferred tool interception ─────────────────────── # DEC-003 / 04_module: if tool is deferred and schema was not # sent, return a hint instead of executing. deferred_msg = _check_deferred_tool_not_loaded( tool, context.tools, context.deferred_tool_names, ) if deferred_msg is not None: logger.debug("Deferred tool intercepted: %s", tool_name) await context.emit_event("tool_deferred_intercepted", { "tool_name": tool_name, "tool_use_id": tool_use_id, }) return await _complete(_build_deferred_not_loaded_result( tool_use_id=tool_use_id, tool_name=tool_name, content=deferred_msg, )) # ── Step 1: Schema validation ──────────────────────────────── # Implementation: tool.inputSchema.safeParse(input) schema_error = _validate_schema(tool, tool_input) if schema_error is not None: error_text = format_validation_error(tool_name, schema_error) # Implementation: buildSchemaNotSentHint — append hint for deferred tools hint = build_schema_not_sent_hint(tool, context.messages, context.tools) if hint: error_text += hint logger.debug("Schema validation failed for %s: %s", tool_name, error_text) await context.emit_event("tool_validation_error", { "tool_name": tool_name, "tool_use_id": tool_use_id, "error": error_text, }) result_messages.append({ "role": "tool", "name": tool_name, "content": f"Error: {error_text}", "tool_call_id": tool_use_id, "_meta": { "type": "tool_result", "tool_name": tool_name, "tool_call_id": tool_use_id, "status": "error", "error_type": "validation_error", "timestamp": time.time(), }, }) return await _complete(ToolCallResult( tool_use_id=tool_use_id, tool_name=tool_name, messages=result_messages, )) # ── Step 2: Custom validate_input ──────────────────────────── # Implementation: tool.validateInput?.(parsedInput.data, toolUseContext) validation_error_msg = await _validate_input_for_phase( tool, tool_input, context, phase="pre_permission", ) if validation_error_msg is not None: logger.debug("validate_input failed for %s: %s", tool_name, validation_error_msg) await context.emit_event("tool_validation_error", { "tool_name": tool_name, "tool_use_id": tool_use_id, "error": validation_error_msg, }) result_messages.append( _build_input_validation_error_message( tool_use_id, tool_name, validation_error_msg ) ) return await _complete(ToolCallResult( tool_use_id=tool_use_id, tool_name=tool_name, messages=result_messages, )) # ── Step 3: Input normalization ────────────────────────────── # Implementation: normalizeToolInput(tool, input, agentId) in api.ts processed_input = normalize_tool_input(tool, tool_input, context) # ── Step 4: Pre-tool hooks ─────────────────────────────────── # Implementation: for await (runPreToolUseHooks(...)) → process yields hook_registry = context.hook_registry hook_permission_result: dict[str, Any] | None = None pre_hook_base_input = processed_input should_stop_from_hooks = False if hook_registry: from openspace.services.tooling.hooks import run_pre_tool_use_hooks pre_hook_start = time.time() async for yield_item in run_pre_tool_use_hooks( hook_registry, tool_name, processed_input, tool_use_id, context, ): yield_type = yield_item.type if yield_type == "message" and yield_item.message: # Hook messages may be dict or str; ensure dict for messages list msg = yield_item.message if isinstance(msg, dict): result_messages.append(msg) elif yield_type == "hook_permission_result" and yield_item.hook_permission_result: hook_permission_result = _merge_hook_permission_result( hook_permission_result, yield_item.hook_permission_result, ) updated = hook_permission_result.get("updated_input") if isinstance(updated, dict): processed_input = updated else: processed_input = pre_hook_base_input elif yield_type == "hook_updated_input" and yield_item.updated_input: processed_input = yield_item.updated_input pre_hook_base_input = processed_input elif yield_type == "prevent_continuation": prevent_continuation = True elif yield_type == "stop_reason" and yield_item.stop_reason: stop_reason = yield_item.stop_reason elif yield_type == "stop": should_stop_from_hooks = True break elif yield_type == "additional_context" and yield_item.message: msg = yield_item.message if isinstance(msg, dict): result_messages.append(msg) pre_hook_duration_ms = (time.time() - pre_hook_start) * 1000 if pre_hook_duration_ms > 500: logger.debug( "Pre-tool hooks for %s took %.0fms", tool_name, pre_hook_duration_ms ) if should_stop_from_hooks: # Implementation: pre-hook returned stop → push stop tool_result → return stop_msg = build_tool_result_stop_message(tool_use_id, tool_name) if stop_reason: stop_msg["content"] = f"Error: {stop_reason}" result_messages.append(stop_msg) await context.emit_event("tool_hook_stopped", { "tool_name": tool_name, "tool_use_id": tool_use_id, "phase": "pre", "stop_reason": stop_reason, }) return await _complete(ToolCallResult( tool_use_id=tool_use_id, tool_name=tool_name, messages=result_messages, prevent_continuation=prevent_continuation, stop_reason=stop_reason, )) # ── Step 5: Permission resolution ──────────────────────────── # Implementation: resolveHookPermissionDecision(hookPermissionResult, tool, # input, toolUseContext, canUseTool, assistantMessage, toolUseID) permission_decision = await _resolve_permissions( tool, processed_input, context, hook_permission_result=hook_permission_result, ) pipeline_permission_status = permission_decision.behavior if os.environ.get("OPENSPACE_DEBUG_TOOL_CALLS", "").strip().lower() in { "1", "true", "yes", "y", "on", }: print( "OPENSPACE_DEBUG permission_decision " f"tool={tool_name} behavior={permission_decision.behavior} " f"mode={getattr(context, 'permission_mode', None)}", flush=True, ) ask_resolution: PermissionAskResolution | None = None if permission_decision.behavior != "allow": # Denied or needs user interaction if permission_decision.behavior == "deny": pipeline_permission_status = "denied" deny_content = permission_decision.message or CANCEL_MESSAGE logger.debug("Tool %s denied: %s", tool_name, deny_content) hook_says_retry = await _emit_permission_denied( hook_registry, tool_name, processed_input, deny_content, tool_use_id, context, ) result_messages.append( _build_tool_denied_message(tool_use_id, tool_name, deny_content) ) if hook_says_retry: result_messages.append( _build_permission_denied_retry_message(tool_use_id, tool_name) ) return await _complete(ToolCallResult( tool_use_id=tool_use_id, tool_name=tool_name, messages=result_messages, )) # behavior == "ask" — delegate to permission engine / TUI. # Implementation: PermissionsLayer modal with rule-suggestions. OS surfaces # ``suggestions`` (PermissionUpdate tuple) so the TUI can offer # "always allow" persistence (Q2 = localSettings). pipeline_permission_status = "asked" ask_resolution = await _handle_permission_ask( tool, processed_input, context, tool_use_id=tool_use_id, tool_name=tool_name, message=permission_decision.message, suggestions=getattr(permission_decision, "suggestions", None), blocked_path=getattr(permission_decision, "blocked_path", None), decision_reason=getattr(permission_decision, "decision_reason", None), ) if ask_resolution.deny_message is not None: pipeline_permission_status = "denied" # User denied await _record_skill_permission_denied_from_tool_execution( tool_name, processed_input, context, reason=_extract_permission_denied_reason( ask_resolution.deny_message ), ) hook_says_retry = await _emit_permission_denied( hook_registry, tool_name, processed_input, _extract_permission_denied_reason(ask_resolution.deny_message), tool_use_id, context, ) result_messages.append(ask_resolution.deny_message) if hook_says_retry: result_messages.append( _build_permission_denied_retry_message(tool_use_id, tool_name) ) return await _complete(ToolCallResult( tool_use_id=tool_use_id, tool_name=tool_name, messages=result_messages, prevent_continuation=ask_resolution.prevent_continuation, stop_reason=ask_resolution.stop_reason, )) # User allowed — continue execution pipeline_permission_status = "allowed_after_ask" await _record_skill_permission_granted_from_tool_execution( tool_name, processed_input, context, reason="permission ask allowed", ) # Apply any updatedInput from permission resolution if ask_resolution is not None and ask_resolution.updated_input is not None: processed_input = ask_resolution.updated_input schema_error = _validate_schema(tool, processed_input) if schema_error is not None: error_text = format_validation_error(tool_name, schema_error) result_messages.append({ "role": "tool", "name": tool_name, "content": f"Error: {error_text}", "tool_call_id": tool_use_id, "_meta": { "type": "tool_result", "tool_name": tool_name, "tool_call_id": tool_use_id, "status": "error", "error_type": "validation_error", "timestamp": time.time(), }, }) return await _complete(ToolCallResult( tool_use_id=tool_use_id, tool_name=tool_name, messages=result_messages, )) validation_error_msg = await _validate_input_for_phase( tool, processed_input, context, phase="pre_permission", ) if validation_error_msg is not None: result_messages.append( _build_input_validation_error_message( tool_use_id, tool_name, validation_error_msg ) ) return await _complete(ToolCallResult( tool_use_id=tool_use_id, tool_name=tool_name, messages=result_messages, )) processed_input = normalize_tool_input(tool, processed_input, context) if not ask_resolution.skip_permission_recheck: edited_hook_permission_result: dict[str, Any] | None = None edited_pre_hook_base_input = processed_input edited_should_stop_from_hooks = False if hook_registry: from openspace.services.tooling.hooks import run_pre_tool_use_hooks async for yield_item in run_pre_tool_use_hooks( hook_registry, tool_name, processed_input, tool_use_id, context, ): yield_type = yield_item.type if yield_type == "message" and yield_item.message: msg = yield_item.message if isinstance(msg, dict): result_messages.append(msg) elif yield_type == "hook_permission_result" and yield_item.hook_permission_result: edited_hook_permission_result = _merge_hook_permission_result( edited_hook_permission_result, yield_item.hook_permission_result, ) updated = edited_hook_permission_result.get("updated_input") if isinstance(updated, dict): processed_input = updated else: processed_input = edited_pre_hook_base_input elif yield_type == "hook_updated_input" and yield_item.updated_input: processed_input = yield_item.updated_input edited_pre_hook_base_input = processed_input elif yield_type == "prevent_continuation": prevent_continuation = True elif yield_type == "stop_reason" and yield_item.stop_reason: stop_reason = yield_item.stop_reason elif yield_type == "stop": edited_should_stop_from_hooks = True break elif yield_type == "additional_context" and yield_item.message: msg = yield_item.message if isinstance(msg, dict): result_messages.append(msg) if edited_should_stop_from_hooks: stop_msg = build_tool_result_stop_message(tool_use_id, tool_name) if stop_reason: stop_msg["content"] = f"Error: {stop_reason}" result_messages.append(stop_msg) await context.emit_event("tool_hook_stopped", { "tool_name": tool_name, "tool_use_id": tool_use_id, "phase": "pre", "stop_reason": stop_reason, "after_permission_edit": True, }) return await _complete(ToolCallResult( tool_use_id=tool_use_id, tool_name=tool_name, messages=result_messages, prevent_continuation=prevent_continuation, stop_reason=stop_reason, )) rechecked_decision = await _resolve_permissions( tool, processed_input, context, hook_permission_result=edited_hook_permission_result, ) if rechecked_decision.behavior == "deny": pipeline_permission_status = "denied" deny_content = rechecked_decision.message or CANCEL_MESSAGE hook_says_retry = await _emit_permission_denied( hook_registry, tool_name, processed_input, deny_content, tool_use_id, context, ) result_messages.append( _build_tool_denied_message(tool_use_id, tool_name, deny_content) ) if hook_says_retry: result_messages.append( _build_permission_denied_retry_message(tool_use_id, tool_name) ) return await _complete(ToolCallResult( tool_use_id=tool_use_id, tool_name=tool_name, messages=result_messages, )) if rechecked_decision.behavior == "ask": pipeline_permission_status = "asked_after_edit" second_ask_resolution = await _handle_permission_ask( tool, processed_input, context, tool_use_id=tool_use_id, tool_name=tool_name, message=rechecked_decision.message, suggestions=getattr(rechecked_decision, "suggestions", None), blocked_path=getattr(rechecked_decision, "blocked_path", None), decision_reason=getattr(rechecked_decision, "decision_reason", None), ) if second_ask_resolution.deny_message is not None: pipeline_permission_status = "denied" hook_says_retry = await _emit_permission_denied( hook_registry, tool_name, processed_input, _extract_permission_denied_reason( second_ask_resolution.deny_message ), tool_use_id, context, ) result_messages.append(second_ask_resolution.deny_message) if hook_says_retry: result_messages.append( _build_permission_denied_retry_message( tool_use_id, tool_name ) ) return await _complete(ToolCallResult( tool_use_id=tool_use_id, tool_name=tool_name, messages=result_messages, )) pipeline_permission_status = "allowed_after_second_ask" if second_ask_resolution.updated_input is not None: deny_content = ( "Edited input still requires another permission edit; " "refusing to execute without a fresh tool call." ) hook_says_retry = await _emit_permission_denied( hook_registry, tool_name, processed_input, deny_content, tool_use_id, context, ) result_messages.append( _build_tool_denied_message( tool_use_id, tool_name, deny_content, ) ) if hook_says_retry: result_messages.append( _build_permission_denied_retry_message( tool_use_id, tool_name ) ) return await _complete(ToolCallResult( tool_use_id=tool_use_id, tool_name=tool_name, messages=result_messages, )) if rechecked_decision.updated_input is not None: processed_input = rechecked_decision.updated_input elif permission_decision.updated_input is not None: processed_input = permission_decision.updated_input post_permission_validation_error = await _validate_input_for_phase( tool, processed_input, context, phase="post_permission", ) if post_permission_validation_error is not None: await context.emit_event("tool_validation_error", { "tool_name": tool_name, "tool_use_id": tool_use_id, "error": post_permission_validation_error, }) result_messages.append( _build_input_validation_error_message( tool_use_id, tool_name, post_permission_validation_error ) ) return await _complete(ToolCallResult( tool_use_id=tool_use_id, tool_name=tool_name, messages=result_messages, )) # ── Step 6: Tool execution ─────────────────────────────────── # Implementation: await tool.call(callInput, {...toolUseContext}, canUseTool, ...) # Public BaseTool.invoke() routes into this runtime. The runtime # boundary uses the private raw executor after permissions, hooks, # validation, persistence, and quality tracking are in place. There # is intentionally no public arun() facade. # Inject ToolUseContext for tools that need it (e.g. FileEditTool # needs read_file_state for mtime check). if hasattr(tool, "set_context"): tool.set_context(context) set_tool_use = getattr(tool, "set_current_tool_use", None) if callable(set_tool_use): set_tool_use(tool_use_id=tool_use_id, tool_name=tool_name) processed_input_for_evidence = dict(processed_input) await context.emit_event("tool_executing", { "tool_name": tool_name, "tool_use_id": tool_use_id, }) exec_start = time.time() try: result = await tool._execute_raw(**processed_input) except asyncio.CancelledError as exc: if not context.is_aborted(): raise error_content = format_tool_error(exc) exec_duration_ms = (time.time() - exec_start) * 1000 await context.emit_event("tool_cancelled", { "tool_name": tool_name, "tool_use_id": tool_use_id, "duration_ms": exec_duration_ms, }) if hook_registry: from openspace.services.tooling.hooks import run_post_tool_use_failure_hooks async for hook_result in run_post_tool_use_failure_hooks( hook_registry, tool_name, processed_input, error_content, tool_use_id, is_interrupt=True, context=context, ): if hook_result.message and isinstance(hook_result.message, dict): result_messages.append(hook_result.message) result_messages.append({ "role": "tool", "name": tool_name, "content": f"Error: {error_content or CANCEL_MESSAGE}", "tool_call_id": tool_use_id, "_meta": { "type": "tool_result", "tool_name": tool_name, "tool_call_id": tool_use_id, "status": "error", "error_type": "interrupt", "execution_time": exec_duration_ms / 1000, "timestamp": time.time(), }, }) return await _complete(ToolCallResult( tool_use_id=tool_use_id, tool_name=tool_name, messages=result_messages, prevent_continuation=prevent_continuation, stop_reason=stop_reason, )) except Exception as exc: # Implementation: catch block in checkPermissionsAndCallTool error_content = format_tool_error(exc) error_class = classify_tool_error(exc) exec_duration_ms = (time.time() - exec_start) * 1000 logger.debug( "Tool %s failed (%s) in %.0fms: %s", tool_name, error_class, exec_duration_ms, error_content[:200], ) await context.emit_event("tool_error", { "tool_name": tool_name, "tool_use_id": tool_use_id, "error_class": error_class, "duration_ms": exec_duration_ms, }) # Implementation: runPostToolUseFailureHooks if hook_registry: from openspace.services.tooling.hooks import run_post_tool_use_failure_hooks async for hook_result in run_post_tool_use_failure_hooks( hook_registry, tool_name, processed_input, error_content, tool_use_id, is_interrupt=_is_abort_error(exc), context=context, ): if hook_result.message and isinstance(hook_result.message, dict): result_messages.append(hook_result.message) result_messages.append({ "role": "tool", "name": tool_name, "content": f"Error: {error_content}", "tool_call_id": tool_use_id, "_meta": { "type": "tool_result", "tool_name": tool_name, "tool_call_id": tool_use_id, "status": "error", "error_type": error_class, "execution_time": exec_duration_ms / 1000, "timestamp": time.time(), }, }) return await _complete(ToolCallResult( tool_use_id=tool_use_id, tool_name=tool_name, messages=result_messages, prevent_continuation=prevent_continuation, stop_reason=stop_reason, )) exec_duration_ms = (time.time() - exec_start) * 1000 # ── Step 7: Result processing ──────────────────────────────── # Implementation: processPreMappedToolResultBlock → persist-to-disk + empty check tool_result_msg, result_content, is_error = _build_tool_result_message_from_result( tool_use_id, tool_name, result, ) logger.debug( "Tool %s completed (%s) in %.0fms, result: %d text chars", tool_name, "error" if is_error else "success", exec_duration_ms, content_text_size(result.content), ) await context.emit_event("tool_complete", { "tool_name": tool_name, "tool_use_id": tool_use_id, "status": "error" if is_error else "success", "duration_ms": exec_duration_ms, "result_size_chars": content_text_size(result.content), "has_multimodal_content": content_has_multimodal_block(result.content), }) # ── Step 8: Post-tool hooks ────────────────────────────────── # Implementation: runPostToolUseHooks → collect messages, check preventContinuation if hook_registry: from openspace.services.tooling.hooks import ( PostToolHookRuntimeState, run_post_tool_use_hooks, ) backend = _resolve_tool_backend_value(tool) if not backend: raise RuntimeError(f"Unable to resolve backend for tool {tool_name}") post_tool_hook_state = PostToolHookRuntimeState( tool_call=tool_call, backend=backend, tool=tool, execution_time_ms=exec_duration_ms, is_last_tool_call_in_iteration=_is_last_tool_call_in_iteration( tool_use_id, assistant_message, ), ) post_hook_start = time.time() async for hook_result in run_post_tool_use_hooks( hook_registry, tool_name, processed_input, result, tool_use_id, context, post_tool_hook_state=post_tool_hook_state, ): if hook_result.message and isinstance(hook_result.message, dict): result_messages.append(hook_result.message) if hook_result.updated_tool_output is not None: result = _coerce_hook_updated_tool_output( result, hook_result.updated_tool_output, ) if hook_result.prevent_continuation: prevent_continuation = True stop_reason = hook_result.stop_reason or stop_reason post_hook_duration_ms = (time.time() - post_hook_start) * 1000 if post_hook_duration_ms > 500: logger.debug( "Post-tool hooks for %s took %.0fms", tool_name, post_hook_duration_ms, ) tool_result_msg, result_content, is_error = _build_tool_result_message_from_result( tool_use_id, tool_name, result, ) # Persist only after post-tool hooks have had a chance to inspect or # replace the raw result; the final message gets the real tool_use_id. result = _maybe_persist_tool_result_for_pipeline( tool, tool_use_id, tool_name, result, context, ) _mark_dynamic_skill_paths_from_result(result, context) tool_result_msg, result_content, is_error = _build_tool_result_message_from_result( tool_use_id, tool_name, result, ) pipeline_status = "error" if is_error else "success" pipeline_execution_time_ms = exec_duration_ms pipeline_result_size_chars = content_text_size(result.content) pipeline_tool_result_metadata = dict(result.metadata or {}) pipeline_message_meta = dict(tool_result_msg.get("_meta") or {}) pipeline_error_type = ( str(pipeline_message_meta.get("error_type")) if pipeline_message_meta.get("error_type") else None ) pipeline_result_preview = str(result_content or "")[:500] # Implementation: contextModifier — tool.call can return a context modifier # OS: tools can set this on the ToolResult metadata cm = getattr(result, "context_modifier", None) if cm is None: cm = (result.metadata or {}).get("context_modifier") if callable(cm): context_modifier = cm # Insert tool_result message before hook messages. The orchestration # layer later moves all follow-up attachment messages after every # tool_result in the assistant turn to preserve OpenAI tool pairing. result_messages.insert(0, tool_result_msg) additional_messages = _get_tool_additional_messages( result, tool_use_id=tool_use_id, tool_name=tool_name, ) if additional_messages: result_messages[1:1] = additional_messages except asyncio.CancelledError: if not context.is_aborted(): raise logger.debug("Tool pipeline cancelled after abort: %s/%s", tool_name, tool_use_id) await context.emit_event("tool_cancelled", { "tool_name": tool_name, "tool_use_id": tool_use_id, }) result_messages.append(build_tool_result_stop_message(tool_use_id, tool_name)) except Exception as exc: # Implementation: outer try/catch in runToolUse — catch unexpected errors error_content = format_tool_error(exc) logger.error( "Unexpected error in tool execution pipeline for %s: %s", tool_name, error_content[:500], exc_info=True, ) result_messages.append({ "role": "tool", "name": tool_name, "content": f"Error: \nError calling tool {tool_name}: {error_content}\n", "tool_call_id": tool_use_id, "_meta": { "type": "tool_result", "tool_name": tool_name, "tool_call_id": tool_use_id, "status": "error", "error_type": "pipeline_error", "timestamp": time.time(), }, }) pipeline_status = "error" pipeline_error_type = "pipeline_error" return await _complete(ToolCallResult( tool_use_id=tool_use_id, tool_name=tool_name, messages=result_messages, prevent_continuation=prevent_continuation, stop_reason=stop_reason, context_modifier=context_modifier, )) # ═══════════════════════════════════════════════════════════════════════ # §8 Internal helpers # ═══════════════════════════════════════════════════════════════════════ async def _record_pipeline_quality_outcome( context: ToolUseContext, *, tool: BaseTool | None, tool_use_id: str, status: str, error_type: str | None, permission_status: str | None, execution_time_ms: float, result_preview: str, ) -> None: """Record every terminal pipeline outcome once for quality accounting.""" if tool is None or context.quality_manager is None: return recorded_ids = getattr(context, "quality_recorded_tool_use_ids", None) if isinstance(recorded_ids, set) and tool_use_id in recorded_ids: return qm = context.quality_manager record_outcome = getattr(qm, "record_outcome", None) if not callable(record_outcome): return normalized_status = str(status or "").lower() success = normalized_status == "success" error_message = None if not success: parts = [ value for value in ( error_type or normalized_status or "error", f"permission={permission_status}" if permission_status else "", result_preview[:400] if result_preview else "", ) if value ] error_message = " | ".join(parts)[:500] try: quality_record = record_outcome( tool, success=success, execution_time_ms=float(execution_time_ms or 0.0), error_message=error_message, ) if inspect.isawaitable(quality_record): quality_record = await quality_record if isinstance(recorded_ids, set): recorded_ids.add(tool_use_id) if quality_record is not None: try: from openspace.services.tooling.hooks import ( _emit_tool_quality_evidence, ) await _emit_tool_quality_evidence( context, qm, quality_record, tool_use_id=tool_use_id, execution_time_ms=float(execution_time_ms or 0.0), source="pipeline_complete", ) except Exception: logger.debug("Tool quality evidence emit failed", exc_info=True) except Exception: logger.warning( "Pipeline quality accounting failed for %s/%s", getattr(tool, "name", "unknown"), tool_use_id, exc_info=True, ) async def _emit_permission_denied( hook_registry: Any | None, tool_name: str, processed_input: dict[str, Any], deny_content: str, tool_use_id: str, context: ToolUseContext, ) -> bool: """Emit runtime and hook notifications for a terminal permission deny.""" await context.emit_event("tool_permission_denied", { "tool_name": tool_name, "tool_use_id": tool_use_id, "reason": deny_content, }) try: from openspace.services.tooling.hooks import run_permission_denied_hooks return await run_permission_denied_hooks( hook_registry, tool_name, processed_input, deny_content, tool_use_id, context, ) except Exception: logger.warning( "PermissionDenied hooks failed for %s/%s", tool_name, tool_use_id, exc_info=True, ) return False async def _record_skill_permission_denied_from_tool_execution( tool_name: str, processed_input: dict[str, Any], context: ToolUseContext, *, reason: str, ) -> None: try: from openspace.skill_engine.protocol import ( SKILL_TOOL_NAME, record_skill_permission_decision_for_context, ) if tool_name != SKILL_TOOL_NAME: return await record_skill_permission_decision_for_context( context, processed_input, "permission_denied", source="tool_execution_permission", metadata={"reason": reason or "permission ask denied"}, ) except Exception: logger.debug("Skill permission deny event record failed", exc_info=True) async def _record_skill_permission_granted_from_tool_execution( tool_name: str, processed_input: dict[str, Any], context: ToolUseContext, *, reason: str, ) -> None: try: from openspace.skill_engine.protocol import ( SKILL_TOOL_NAME, record_skill_permission_decision_for_context, ) if tool_name != SKILL_TOOL_NAME: return await record_skill_permission_decision_for_context( context, processed_input, "permission_granted", source="tool_execution_permission", metadata={"reason": reason or "permission ask allowed"}, ) except Exception: logger.debug("Skill permission grant event record failed", exc_info=True) def _extract_permission_denied_reason(message: dict[str, Any]) -> str: """Return the deny reason stored in a tool result message.""" content = message.get("content", "") if isinstance(content, str): return content.removeprefix("Error: ").strip() return extract_text_from_content(content).removeprefix("Error: ").strip() def _validate_schema( tool: BaseTool, input: dict[str, Any], ) -> jsonschema.ValidationError | None: """Validate input against tool's JSON Schema. Implementation: ``tool.inputSchema.safeParse(input)`` (Zod validation). OS uses jsonschema. Returns the ValidationError on failure, None on success. """ schema = tool.schema.parameters if tool.schema else {} if not schema or not isinstance(schema, dict): return None # Only validate if schema has properties defined if "properties" not in schema and "type" not in schema: return None validation_input = dict(input) if getattr(tool, "name", "") == "bash": # OpenSpace keeps _simulatedSedEdit in BashTool's full internal input type but # omits it from the model-facing schema. Permission UI can inject it # after preview approval, so schema validation must ignore it without # exposing it to the model. validation_input.pop("_simulatedSedEdit", None) try: jsonschema.validate(instance=validation_input, schema=schema) return None except jsonschema.ValidationError as ve: return ve async def _resolve_permissions( tool: BaseTool, input: dict[str, Any], context: ToolUseContext, *, hook_permission_result: dict[str, Any] | None = None, ) -> PermissionCheckResult: """Resolve final permission decision. Resolution order: 1. Hook's ``deny`` wins immediately. 2. Hook's ``ask`` propagates (bypass-immune). 3. Hook's ``allow`` still goes through the permission engine so settings deny/ask rules cannot be bypassed. 4. Otherwise, call :func:`has_permissions_to_use_tool`. """ from openspace.tool_runtime.permissions import ( has_permissions_to_use_tool as _engine_has_perms, ) from openspace.grounding.core.permissions.types import ( PermissionAllow, PermissionAsk, PermissionDeny, ) # 1. Hook hard decisions take precedence if hook_permission_result: behavior = hook_permission_result.get("behavior") if behavior == "deny": return PermissionDeny( message=hook_permission_result.get("message") or "Denied by hook", decision_reason=_hook_reason(hook_permission_result), ) if behavior == "ask": return PermissionAsk( message=hook_permission_result.get("message") or f"Allow {tool.name}?", updated_input=hook_permission_result.get("updated_input"), decision_reason=_hook_reason(hook_permission_result), ) if behavior == "allow": # Implementation: "hook allow doesn't bypass settings deny/ask" updated = hook_permission_result.get("updated_input") if _should_skip_permission_recheck_after_user_interaction(tool, updated): return PermissionAllow( updated_input=dict(updated), decision_reason=_hook_reason(hook_permission_result), ) next_input = updated if updated is not None else input engine_decision = await _engine_has_perms(tool, next_input, context) if isinstance(engine_decision, PermissionAllow): # Merge hook's updated_input if engine didn't provide one if engine_decision.updated_input is None and updated is not None: return PermissionAllow( updated_input=updated, decision_reason=engine_decision.decision_reason, ) return engine_decision # 2. No hook — run the engine return await _engine_has_perms(tool, input, context) def _should_skip_permission_recheck_after_user_interaction( tool: BaseTool | None, updated_input: Any, ) -> bool: """Return True when an updated input already contains the user response. OpenSpace treats a PermissionRequest/PreToolUse hook ``allow`` with ``updatedInput`` for ``requiresUserInteraction`` tools as completed user interaction; the local UI is not shown again. OS keeps that behavior guarded by an optional tool-level completeness check. """ if tool is None or not getattr(tool, "requires_user_interaction", False): return False if not isinstance(updated_input, dict): return False checker = getattr(tool, "is_user_interaction_complete", None) if callable(checker): try: return bool(checker(updated_input)) except Exception: logger.debug( "User interaction completeness check failed for %s", getattr(tool, "name", "tool"), exc_info=True, ) return False return True def _hook_reason(hook_result: dict[str, Any]): """Convert a hook payload into a :class:`DecisionReasonHook`.""" from openspace.grounding.core.permissions.types import DecisionReasonHook return DecisionReasonHook( hook_name=hook_result.get("hook_name", "pre_tool_use"), hook_source=hook_result.get("hook_source"), reason=hook_result.get("reason"), ) def _merge_hook_permission_result( current: dict[str, Any] | None, incoming: dict[str, Any], ) -> dict[str, Any]: """Merge PreToolUse permission decisions with strict precedence. Multiple hooks can emit permission decisions for the same tool call. The effective ordering is ``deny > ask > allow``; a later permissive hook must not erase an earlier ask/deny decision. """ if current is None: return dict(incoming) order = {"deny": 3, "ask": 2, "allow": 1} current_behavior = str(current.get("behavior") or "") incoming_behavior = str(incoming.get("behavior") or "") current_rank = order.get(current_behavior, 0) incoming_rank = order.get(incoming_behavior, 0) if incoming_rank > current_rank: return dict(incoming) merged = dict(current) if incoming_rank == current_rank and incoming_rank > 0: merged.update({k: v for k, v in incoming.items() if v is not None}) return merged return merged # ═══════════════════════════════════════════════════════════════════════ # TUI permission dialog — 4-option protocol # ═══════════════════════════════════════════════════════════════════════ # # When ``_handle_permission_ask`` runs, it emits a ``tool_permission_ask`` # event whose payload includes the 4 options the TUI should render: # # option_id | label | effect # ----------------+---------------------+-------------------------------- # allow_once | "Allow once" | proceed, no rule change # allow_always | "Always allow" | add allow rule(s) via loader # | (derived patterns)| to ``localSettings`` (Q2=A) # deny | "Deny" | stop, return tool_result error # provide_input | "Provide custom" | user edits ``tool_input`` and # | | the tool is re-invoked via # | | ``updated_input`` # # A fully native TUI may respond with ``tool_permission_response`` carrying # ``{option_id, tool_use_id, permission_ask_id?, selected_suggestion?, # edited_input?}``. The current Python bridge also supports this as a # transitional shim by converting ``tool_permission_ask`` into prompt_request # round trips before resolving the same pending future. In both paths, # malformed responses fail closed. _PENDING_ASKS: dict[str, asyncio.Future] = {} _PENDING_TOOL_USE_ID_BY_ASK_ID: dict[str, str] = {} _PENDING_ASK_IDS_BY_TOOL_USE_ID: dict[str, set[str]] = {} _ASK_TIMEOUT_SECONDS: float = 300.0 # 5-minute UX timeout def _permission_ask_id(context: ToolUseContext, tool_use_id: str) -> str: session_id = str(getattr(context, "session_id", None) or "no-session") agent_id = str(getattr(context, "agent_id", None) or "primary") return f"{session_id}:{agent_id}:{tool_use_id}" def _register_pending_ask( *, ask_id: str, tool_use_id: str, future: asyncio.Future, ) -> None: _PENDING_ASKS[ask_id] = future _PENDING_TOOL_USE_ID_BY_ASK_ID[ask_id] = tool_use_id _PENDING_ASK_IDS_BY_TOOL_USE_ID.setdefault(tool_use_id, set()).add(ask_id) def _drop_pending_ask(ask_id: str) -> None: tool_use_id = _PENDING_TOOL_USE_ID_BY_ASK_ID.pop(ask_id, None) _PENDING_ASKS.pop(ask_id, None) if tool_use_id is None: return ask_ids = _PENDING_ASK_IDS_BY_TOOL_USE_ID.get(tool_use_id) if ask_ids is None: return ask_ids.discard(ask_id) if not ask_ids: _PENDING_ASK_IDS_BY_TOOL_USE_ID.pop(tool_use_id, None) def _live_ask_ids_for_tool_use_id(tool_use_id: str) -> list[str]: ask_ids = _PENDING_ASK_IDS_BY_TOOL_USE_ID.get(tool_use_id, set()) return [ ask_id for ask_id in sorted(ask_ids) if (future := _PENDING_ASKS.get(ask_id)) is not None and not future.done() ] def _resolve_permission_ask_by_id( ask_id: str, response: dict[str, Any], ) -> bool: fut = _PENDING_ASKS.get(ask_id) if fut is None or fut.done(): _drop_pending_ask(ask_id) return False _drop_pending_ask(ask_id) fut.set_result(response) return True async def _emit_tool_permission_ask( context: ToolUseContext, payload: dict[str, Any], ) -> bool: """Emit a permission ask without silently masking delivery failure.""" sink = getattr(context, "event_sink", None) if sink is None: return False try: result = sink("tool_permission_ask", payload) if inspect.isawaitable(result): await result except Exception: logger.warning( "tool_permission_ask delivery failed for %s", payload.get("tool_use_id"), exc_info=True, ) return False return True async def _emit_tool_permission_cancel( context: ToolUseContext, tool_use_id: str, reason: str, permission_ask_id: str | None = None, ) -> None: """Best-effort notification that the ask future is no longer live.""" sink = getattr(context, "event_sink", None) if sink is None: return try: result = sink( "tool_permission_cancel", { "tool_use_id": tool_use_id, "permission_ask_id": permission_ask_id, "reason": reason, }, ) if inspect.isawaitable(result): await result except Exception: logger.debug( "tool_permission_cancel delivery failed for %s", tool_use_id, exc_info=True, ) def _build_tool_permission_interaction_payload( tool: BaseTool | None, input: dict[str, Any], ) -> dict[str, Any]: """Add structured interaction metadata for tools with dedicated prompts.""" if tool is None: return {} tool_name = getattr(tool, "name", "") aliases = set(getattr(tool, "aliases", ()) or ()) if tool_name != "ask_user_question" and "AskUserQuestion" not in aliases: return {} payload: dict[str, Any] = { "interaction": "ask_user_question", "requires_user_interaction": True, "questions": input.get("questions", []), } metadata = input.get("metadata") if isinstance(metadata, dict): payload["metadata"] = metadata return payload _HIGH_RISK_BASH_RE = re.compile( r"(?is)" r"(\brm\s+[^|;&]*(-[^\s]*r|-R|--recursive|--no-preserve-root)\b" r"|\bsudo\b" r"|\bdd\s+" r"|\bmkfs(?:\.[a-z0-9]+)?\b" r"|\bchmod\s+-R\b" r"|\bchown\s+-R\b" r"|\bpkill\b|\bkillall\b" r"|\bshutdown\b|\breboot\b" r"|\bcurl\b[^|;&]*\|\s*(?:sh|bash)\b" r"|\bwget\b[^|;&]*\|\s*(?:sh|bash)\b)" ) _LOW_RISK_BASH_RE = re.compile( r"(?is)^\s*(" r"pwd\b|ls\b|cat\b|head\b|tail\b|wc\b|rg\b|grep\b|find\b|" r"git\s+(?:status|diff|log|show|branch)\b|" r"python(?:3)?\s+-m\s+pytest\b|pytest\b|npm\s+(?:test|run\s+test)\b" r")" ) def _infer_tool_permission_risk_level( tool_name: str, input: dict[str, Any], *, blocked_path: str | None, decision_reason: dict[str, Any] | None, ) -> str: """Return the TUI risk badge for tool-level permission prompts.""" if blocked_path: return "high" reason_type = decision_reason.get("type") if decision_reason else None if ( reason_type == "safetyCheck" and decision_reason and decision_reason.get("classifier_approvable") is False ): return "high" if tool_name == BASH_TOOL_NAME: command = str(input.get("command") or "") if _HIGH_RISK_BASH_RE.search(command): return "high" if _LOW_RISK_BASH_RE.search(command) and not re.search(r"(?s)(^|[^>])>{1,2}[^>]", command): return "low" return "medium" if tool_name in {FILE_WRITE_TOOL_NAME, FILE_EDIT_TOOL_NAME, NOTEBOOK_EDIT_TOOL_NAME}: return "medium" return "medium" def resolve_permission_ask(tool_use_id: str, response: dict[str, Any]) -> bool: """Called by TUI bridge to resolve a pending ask. ``response`` keys: - ``option_id``: one of "allow_once"/"allow_always"/"deny"/"provide_input" - ``suggestion_index`` / ``selected_suggestion``: index into ``suggestions`` when option_id is "allow_always" (None → no persistence) - ``edited_input``: dict when option_id is "provide_input" Returns True if a pending ask was found and resolved. """ requested_ask_id = "" if isinstance(response, dict): requested_ask_id = str( response.get("permission_ask_id") or response.get("ask_id") or "" ).strip() if requested_ask_id: expected_tool_use_id = _PENDING_TOOL_USE_ID_BY_ASK_ID.get(requested_ask_id) if expected_tool_use_id is not None and expected_tool_use_id != tool_use_id: return False return _resolve_permission_ask_by_id(requested_ask_id, response) if tool_use_id in _PENDING_ASKS: return _resolve_permission_ask_by_id(tool_use_id, response) ask_ids = _live_ask_ids_for_tool_use_id(tool_use_id) if len(ask_ids) != 1: return False return _resolve_permission_ask_by_id(ask_ids[0], response) def reject_permission_ask(tool_use_id: str, reason: str) -> bool: """Fail a pending permission ask closed with an explicit deny response.""" response = { "option_id": "deny", "message": reason, } if tool_use_id in _PENDING_ASKS: return _resolve_permission_ask_by_id(tool_use_id, response) ask_ids = _live_ask_ids_for_tool_use_id(tool_use_id) resolved = False for ask_id in ask_ids: resolved = _resolve_permission_ask_by_id(ask_id, response) or resolved return resolved def is_permission_ask_pending(tool_use_id: str) -> bool: """Return whether *tool_use_id* still has a live unresolved ask.""" fut = _PENDING_ASKS.get(tool_use_id) if fut is not None and not fut.done(): return True return bool(_live_ask_ids_for_tool_use_id(tool_use_id)) def pending_permission_ask_ids() -> tuple[str, ...]: """Return live unresolved ask ids in deterministic registry order.""" tool_use_ids: list[str] = [] seen: set[str] = set() for ask_id in sorted(_PENDING_ASKS): fut = _PENDING_ASKS.get(ask_id) if fut is None or fut.done(): continue tool_use_id = _PENDING_TOOL_USE_ID_BY_ASK_ID.get(ask_id, ask_id) if tool_use_id in seen: continue seen.add(tool_use_id) tool_use_ids.append(tool_use_id) return tuple(tool_use_ids) async def _handle_permission_ask( tool: BaseTool, input: dict[str, Any], context: ToolUseContext, *, tool_use_id: str, tool_name: str, message: str | None = None, suggestions: tuple[Any, ...] | None = None, blocked_path: str | None = None, decision_reason: Any = None, ) -> PermissionAskResolution: """Prompt the user for an ``ask`` decision and persist "always allow" rules when chosen. Implementation: ``canUseTool`` when decision is ``ask`` (PermissionRequest flow in ``PermissionsLayer`` React component). Returns: - ``deny_message`` populated when the user denied or timed out — the caller appends this and short-circuits execution. - ``updated_input`` populated when the user edited the tool input. When ``option_id == "allow_always"`` and the tool result carried ``suggestions``, the selected suggestion is persisted via :func:`openspace.grounding.core.permissions.persist_permission_updates` to ``.openspace/settings.local.json`` (Q2 = A). """ from openspace.tool_runtime.permissions import ( persist_permission_updates, apply_permission_update, ) from openspace.grounding.core.permissions.types import ( AddRulesUpdate, PermissionRuleValue, ) hook_resolution = await _run_permission_request_hooks( input, context, tool_use_id=tool_use_id, tool_name=tool_name, tool=tool, suggestions=suggestions, ) if hook_resolution is not None: return hook_resolution if ( not getattr(context, "tui_available", True) or getattr(context, "event_sink", None) is None ): logger.info( "tool_permission_ask for %s in headless mode -> auto-deny", tool_name ) return PermissionAskResolution( deny_message=_build_tool_denied_message( tool_use_id, tool_name, "No interactive TUI available (headless mode); add an allow rule " "in .openspace/settings.local.json or re-run with " "--permission-mode acceptEdits/bypassPermissions.", ) ) # Assemble the 4-option payload. options: list[dict[str, Any]] = [ {"option_id": "allow_once", "label": "Allow once"}, ] if suggestions: for idx, sug in enumerate(suggestions): # OpenSpace formats the suggestion label from the rule values. label = _format_suggestion_label(sug) options.append( { "option_id": "allow_always", "suggestion_index": idx, "label": f"Always allow: {label}", } ) else: # Fallback — offer a tool-wide always-allow when no suggestions. options.append( { "option_id": "allow_always", "suggestion_index": None, "label": f"Always allow: {tool_name}", } ) options.append({"option_id": "deny", "label": "Deny"}) options.append({"option_id": "provide_input", "label": "Edit input and retry"}) # Register a pending ask and wait for TUI response. ask_id = _permission_ask_id(context, tool_use_id) existing = _PENDING_ASKS.get(ask_id) if existing is not None and not existing.done(): return PermissionAskResolution( deny_message=_build_tool_denied_message( tool_use_id, tool_name, "Duplicate permission prompt id is already pending.", ) ) loop = asyncio.get_running_loop() fut: asyncio.Future = loop.create_future() _register_pending_ask(ask_id=ask_id, tool_use_id=tool_use_id, future=fut) try: serialized_decision_reason = _serialize_decision_reason(decision_reason) payload = { "tool_name": tool_name, "tool_use_id": tool_use_id, "permission_ask_id": ask_id, "message": message or f"Allow {tool_name}?", "description": message or f"Allow {tool_name}?", "tool_input": input, "blocked_path": blocked_path, "request_kind": "tool", "risk_level": _infer_tool_permission_risk_level( tool_name, input, blocked_path=blocked_path, decision_reason=serialized_decision_reason, ), "options": options, "decision_reason": serialized_decision_reason, } payload.update(_build_tool_permission_interaction_payload(tool, input)) if not await _emit_tool_permission_ask(context, payload): return PermissionAskResolution( deny_message=_build_tool_denied_message( tool_use_id, tool_name, "Interactive permission prompt unavailable because the permission event could not be delivered.", ) ) response = await asyncio.wait_for(fut, timeout=_ASK_TIMEOUT_SECONDS) except asyncio.TimeoutError: timeout_message = ( f"Permission prompt timed out after {_ASK_TIMEOUT_SECONDS:.0f}s." ) await _emit_tool_permission_cancel( context, tool_use_id, timeout_message, ask_id, ) return PermissionAskResolution( deny_message=_build_tool_denied_message( tool_use_id, tool_name, timeout_message, ) ) finally: if _PENDING_ASKS.get(ask_id) is fut: _drop_pending_ask(ask_id) # Fail-closed parsing of the bridge response. An old TUI, a malformed # payload, or even an empty ``{}`` must NEVER silently fall through to # "allow_once" — that would be a privilege escalation for protected tools. if not isinstance(response, dict): logger.warning( "tool_permission_ask for %s returned non-dict response (%r) -> deny", tool_name, type(response).__name__, ) return PermissionAskResolution( deny_message=_build_tool_denied_message( tool_use_id, tool_name, "Permission response was malformed (expected JSON object).", ) ) option_id = response.get("option_id") _VALID_OPTION_IDS = {"allow_once", "allow_always", "deny", "provide_input"} if option_id not in _VALID_OPTION_IDS: logger.warning( "tool_permission_ask for %s returned unrecognized option_id=%r -> deny", tool_name, option_id, ) return PermissionAskResolution( deny_message=_build_tool_denied_message( tool_use_id, tool_name, f"Permission response had an unrecognized option_id={option_id!r}; " "refusing to proceed.", ) ) if option_id == "deny": deny_reason = str(response.get("message") or "Denied by user.").strip() return PermissionAskResolution( deny_message=_build_tool_denied_message( tool_use_id, tool_name, deny_reason or "Denied by user.", ) ) if option_id == "allow_once": updated = response.get("updated_input") if updated is not None and not isinstance(updated, dict): return PermissionAskResolution( deny_message=_build_tool_denied_message( tool_use_id, tool_name, "Permission response updated_input must be a JSON object.", ) ) candidate_input = updated if isinstance(updated, dict) else input if getattr(tool, "requires_user_interaction", False): checker = getattr(tool, "is_user_interaction_complete", None) try: complete = bool(checker(candidate_input)) if callable(checker) else True except Exception: complete = False if not complete: return PermissionAskResolution( deny_message=_build_tool_denied_message( tool_use_id, tool_name, "Interactive permission response did not include complete user answers.", ) ) return PermissionAskResolution( updated_input=dict(updated) if isinstance(updated, dict) else None, skip_permission_recheck=_should_skip_permission_recheck_after_user_interaction( tool, candidate_input, ), ) if option_id == "allow_always" and getattr(tool, "requires_user_interaction", False): return PermissionAskResolution( deny_message=_build_tool_denied_message( tool_use_id, tool_name, "Interactive tools cannot be always-allowed without a user answer.", ) ) if option_id == "allow_always": updates: tuple[Any, ...] = () sug_idx = response.get("suggestion_index", response.get("selected_suggestion")) if suggestions: # Suggestions were offered: require a valid index. Do NOT degrade # to a tool-wide allow rule — that would be broader than what the # user was actually presented with in the TUI. if ( not isinstance(sug_idx, int) or isinstance(sug_idx, bool) or not (0 <= sug_idx < len(suggestions)) ): logger.warning( "tool_permission_ask for %s: allow_always with invalid " "suggestion_index=%r (have %d suggestions) -> deny", tool_name, sug_idx, len(suggestions), ) return PermissionAskResolution( deny_message=_build_tool_denied_message( tool_use_id, tool_name, "Permission response selected 'allow_always' but " f"suggestion_index={sug_idx!r} is invalid.", ) ) updates = (suggestions[sug_idx],) else: # No suggestions were offered; the TUI only showed a tool-wide # "Always allow: " button, so tool-wide persistence is the # expected outcome here. if sug_idx is not None: logger.warning( "tool_permission_ask for %s: allow_always returned " "unexpected suggestion_index=%r with no suggestions -> deny", tool_name, sug_idx, ) return PermissionAskResolution( deny_message=_build_tool_denied_message( tool_use_id, tool_name, "Permission response selected 'allow_always' with " f"unexpected suggestion_index={sug_idx!r}.", ) ) updates = ( AddRulesUpdate( destination="localSettings", rules=(PermissionRuleValue(tool_name=tool_name),), behavior="allow", ), ) for update in updates: try: if context.permission_context is not None: context.permission_context = apply_permission_update( update, context.cwd, context.permission_context ) # persist_permission_updates is sync disk I/O — offload. await asyncio.to_thread( persist_permission_updates, (update,), context.cwd ) logger.info( "Persisted always-allow rule for %s from user prompt", tool_name, ) except Exception as e: # pragma: no cover — IO errors logger.warning( "Failed to persist always-allow rule: %s", e ) return PermissionAskResolution() if option_id == "provide_input": edited = response.get("edited_input") if isinstance(edited, dict): return PermissionAskResolution(updated_input=dict(edited)) return PermissionAskResolution( deny_message=_build_tool_denied_message( tool_use_id, tool_name, "Edited input must be a JSON object.", ) ) # option_id == "allow_once" is handled above. The branch below is retained # for defensive exhaustiveness if _VALID_OPTION_IDS changes. return PermissionAskResolution() async def _run_permission_request_hooks( input: dict[str, Any], context: ToolUseContext, *, tool_use_id: str, tool_name: str, tool: BaseTool | None = None, suggestions: tuple[Any, ...] | None = None, ) -> PermissionAskResolution | None: """Run OpenSpace PermissionRequest hooks before the interactive ask UI. Hooks can allow or deny the pending request. Returning ``ask`` or no decision falls through to the existing OS prompt path. """ hook_registry = getattr(context, "hook_registry", None) if hook_registry is None: return None from openspace.services.tooling.hooks import HookEvent if not hook_registry.has_hook_for_event(HookEvent.PERMISSION_REQUEST, tool_name): return None serialized_suggestions = [ _serialize_permission_update_for_hook(item) for item in (suggestions or ()) ] async for agg in hook_registry.execute_hooks( HookEvent.PERMISSION_REQUEST, tool_name, hook_kwargs={ "tool_name": tool_name, "tool_input": input, "tool_use_id": tool_use_id, "permission_suggestions": serialized_suggestions, "context": context, }, context=context, abort_event=getattr(context, "abort_event", None), ): if agg.blocking_error: return PermissionAskResolution( deny_message=_build_tool_denied_message( tool_use_id, tool_name, agg.blocking_error.blocking_error, ), prevent_continuation=agg.prevent_continuation, stop_reason=agg.stop_reason, ) if agg.prevent_continuation: return PermissionAskResolution( deny_message=_build_tool_denied_message( tool_use_id, tool_name, agg.stop_reason or "Permission request stopped by hook.", ), prevent_continuation=True, stop_reason=agg.stop_reason, ) if agg.permission_behavior == "allow": if agg.updated_permissions: await _apply_permission_updates_from_hook( agg.updated_permissions, context, tool_name=tool_name, ) updated_input = ( dict(agg.updated_input) if isinstance(agg.updated_input, dict) else None ) return PermissionAskResolution( updated_input=updated_input, skip_permission_recheck=_should_skip_permission_recheck_after_user_interaction( tool, updated_input, ), ) if agg.permission_behavior == "deny": return PermissionAskResolution( deny_message=_build_tool_denied_message( tool_use_id, tool_name, agg.hook_permission_decision_reason or "Permission request denied by hook.", ), prevent_continuation=agg.prevent_continuation, stop_reason=agg.stop_reason, ) if agg.permission_behavior == "ask": continue return None async def _apply_permission_updates_from_hook( updates_payload: list[Any], context: ToolUseContext, *, tool_name: str, ) -> None: from openspace.tool_runtime.permissions import ( apply_permission_update, persist_permission_updates, ) updates = _permission_updates_from_hook_payload(updates_payload) if not updates: return for update in updates: try: if context.permission_context is not None: context.permission_context = apply_permission_update( update, context.cwd, context.permission_context ) await asyncio.to_thread(persist_permission_updates, (update,), context.cwd) logger.info("Applied PermissionRequest hook update for %s", tool_name) except Exception as exc: logger.warning("Failed to apply PermissionRequest hook update: %s", exc) def _permission_updates_from_hook_payload(items: list[Any]) -> tuple[Any, ...]: from openspace.grounding.core.permissions.types import ( AddDirectoriesUpdate, AddRulesUpdate, RemoveDirectoriesUpdate, RemoveRulesUpdate, ReplaceRulesUpdate, SetModeUpdate, parse_rule_value, ) parsed: list[Any] = [] for item in items: if not isinstance(item, dict): continue update_type = item.get("type") destination = str(item.get("destination") or "localSettings") try: if update_type in {"addRules", "replaceRules", "removeRules"}: rules = tuple(parse_rule_value(str(raw)) for raw in item.get("rules") or []) behavior = str(item.get("behavior") or "allow") if update_type == "addRules": parsed.append(AddRulesUpdate(destination, rules, behavior)) # type: ignore[arg-type] elif update_type == "replaceRules": parsed.append(ReplaceRulesUpdate(destination, rules, behavior)) # type: ignore[arg-type] else: parsed.append(RemoveRulesUpdate(destination, rules, behavior)) # type: ignore[arg-type] elif update_type == "setMode": parsed.append(SetModeUpdate(destination, str(item.get("mode") or "default"))) # type: ignore[arg-type] elif update_type == "addDirectories": parsed.append( AddDirectoriesUpdate( destination, tuple(str(path) for path in item.get("directories") or ()), ) ) elif update_type == "removeDirectories": parsed.append( RemoveDirectoriesUpdate( destination, tuple(str(path) for path in item.get("directories") or ()), ) ) except Exception as exc: logger.warning("Ignoring malformed PermissionRequest hook update: %s", exc) return tuple(parsed) def _serialize_permission_update_for_hook(update: Any) -> dict[str, Any] | str: """Best-effort PermissionUpdate JSON for hook payloads.""" from openspace.grounding.core.permissions.types import ( AddDirectoriesUpdate, AddRulesUpdate, RemoveDirectoriesUpdate, RemoveRulesUpdate, ReplaceRulesUpdate, SetModeUpdate, format_rule_value, ) if isinstance(update, (AddRulesUpdate, ReplaceRulesUpdate, RemoveRulesUpdate)): return { "type": update.type, "destination": update.destination, "behavior": update.behavior, "rules": [format_rule_value(rule) for rule in update.rules], } if isinstance(update, (AddDirectoriesUpdate, RemoveDirectoriesUpdate)): return { "type": update.type, "destination": update.destination, "directories": list(update.directories), } if isinstance(update, SetModeUpdate): return { "type": update.type, "destination": update.destination, "mode": update.mode, } return repr(update) def _format_suggestion_label(suggestion: Any) -> str: """Best-effort human label for a PermissionUpdate suggestion.""" from openspace.grounding.core.permissions.types import ( AddRulesUpdate, AddDirectoriesUpdate, format_rule_value, ) if isinstance(suggestion, AddRulesUpdate): rules = ", ".join(format_rule_value(r) for r in suggestion.rules) return f"{rules} ({suggestion.behavior})" if isinstance(suggestion, AddDirectoriesUpdate): return "+dir " + ", ".join(suggestion.directories) return repr(suggestion) def _serialize_decision_reason(reason: Any) -> dict[str, Any] | None: """Serialize a :class:`PermissionDecisionReason` for the event sink.""" if reason is None: return None t = getattr(reason, "type", None) out: dict[str, Any] = {"type": t} for attr in ("reason", "mode", "classifier_approvable", "hook_name"): if hasattr(reason, attr): out[attr] = getattr(reason, attr) return out def _build_tool_denied_message( tool_use_id: str, tool_name: str, reason: str ) -> dict[str, Any]: """Produce the tool result message shown to the model after a deny.""" return { "role": "tool", "name": tool_name, "content": f"Error: {reason}", "tool_call_id": tool_use_id, "_meta": { "type": "tool_result", "tool_name": tool_name, "tool_call_id": tool_use_id, "status": "denied", "error_type": "permission_denied", "timestamp": time.time(), }, } def _build_permission_denied_retry_message( tool_use_id: str, tool_name: str ) -> dict[str, Any]: """Build the model-visible retry hint for PermissionDenied hooks.""" return { "role": "user", "content": ( "The PermissionDenied hook indicated this command is now approved. " "You may retry it if you would like." ), "_meta": { "type": "permission_denied_retry", "hook_event": "PermissionDenied", "tool_name": tool_name, "tool_call_id": tool_use_id, "is_meta": True, "timestamp": time.time(), }, } # ═══════════════════════════════════════════════════════════════════════ # §9 Import guard for asyncio (used by _is_abort_error) # ═══════════════════════════════════════════════════════════════════════ import asyncio # noqa: E402 — needed for CancelledError check