diff --git a/agent/tool_dispatch_helpers.py b/agent/tool_dispatch_helpers.py index fd56cb0477..42209f7447 100644 --- a/agent/tool_dispatch_helpers.py +++ b/agent/tool_dispatch_helpers.py @@ -145,20 +145,19 @@ def _plan_tool_batch_segments(tool_calls, *, execution_cwd: Optional[Path] = Non current: list = [] reserved_paths: list[tuple[Path, bool]] = [] # (canonical_path, is_writer) for the current run + def _extend_sequential(calls: list) -> None: + if segments and segments[-1][0] == "sequential": + segments[-1][1].extend(calls) + else: + segments.append(("sequential", list(calls))) + def _close_parallel() -> None: nonlocal current, reserved_paths if len(current) >= 2: segments.append(("parallel", current)) elif current: _extend_sequential(current) - current = [] - reserved_paths = [] - - def _extend_sequential(calls: list) -> None: - if segments and segments[-1][0] == "sequential": - segments[-1][1].extend(calls) - else: - segments.append(("sequential", list(calls))) + current, reserved_paths = [], [] for tool_call in tool_calls: admission = _batch_admission(tool_call, execution_cwd) @@ -208,21 +207,14 @@ def _extract_parallel_scope_paths( if tool_name not in _PATH_SCOPED_TOOLS: return [] - raw_paths: List[str] = [] if tool_name == "patch" and (function_args.get("mode") or "replace") == "patch": - raw_paths.extend(_extract_file_mutation_targets(tool_name, function_args)) + raw_paths = _extract_file_mutation_targets(tool_name, function_args) else: raw_path = function_args.get("path") - if isinstance(raw_path, str) and raw_path.strip(): - raw_paths.append(raw_path) - elif tool_name == "search_files": - raw_paths.append(".") # its default root; reserving it beats demoting every bare search - + # search_files defaults its root to the cwd; reserving it beats demoting every bare search. + raw_paths = [raw_path] if isinstance(raw_path, str) and raw_path.strip() else ["."] if tool_name == "search_files" else [] # dict.fromkeys dedupes while preserving first-seen order. - return list(dict.fromkeys( - _canonical_path(raw, execution_cwd) - for raw in raw_paths if isinstance(raw, str) and raw.strip() - )) + return list(dict.fromkeys(_canonical_path(raw, execution_cwd) for raw in raw_paths if isinstance(raw, str) and raw.strip())) def _extract_parallel_scope_path( @@ -256,13 +248,13 @@ def _is_text_part(p: Any) -> bool: def _multimodal_text_summary(value: Any) -> str: """Plain-text view of a tool result (logging, previews, string-only providers).""" + if isinstance(value, str): + return value if _is_multimodal_tool_result(value): if value.get("text_summary"): return str(value["text_summary"]) parts = [str(p.get("text", "")) for p in value.get("content") or [] if _is_text_part(p)] return "\n".join(parts) if parts else "[multimodal tool result]" - if isinstance(value, str): - return value try: return json.dumps(value, default=str) except Exception: @@ -327,13 +319,10 @@ def _extract_landed_file_mutation_paths( return targets if not isinstance(data, dict): return targets - files = data.get("files_modified") landed = [str(p) for p in files if p] if isinstance(files, list) else [] - if landed: - return landed resolved = data.get("resolved_path") - return [str(resolved)] if resolved else targets + return landed or ([str(resolved)] if resolved else targets) def _extract_error_preview(result: Any, max_len: int = 180) -> str: @@ -364,9 +353,7 @@ def _trajectory_normalize_msg(msg: Dict[str, Any]) -> Dict[str, Any]: return {**msg, "content": _multimodal_text_summary(content)} if isinstance(content, list): return {**msg, "content": [ - {"type": "text", "text": "[screenshot]"} - if isinstance(p, dict) and p.get("type") in {"image", "image_url", "input_image"} - else p + {"type": "text", "text": "[screenshot]"} if isinstance(p, dict) and p.get("type") in {"image", "image_url", "input_image"} else p for p in content ]} return msg @@ -524,28 +511,12 @@ def _maybe_wrap_untrusted(name: str, content: Any) -> Any: __all__ = [ - "_NEVER_PARALLEL_TOOLS", - "_PARALLEL_SAFE_TOOLS", - "_PATH_SCOPED_TOOLS", - "_PATH_SCOPED_READERS", - "_PATH_SCOPED_WRITERS", - "_DESTRUCTIVE_PATTERNS", - "_REDIRECT_OVERWRITE", - "_is_destructive_command", - "_plan_tool_batch_segments", - "_should_parallelize_tool_batch", - "_canonical_path", - "_extract_parallel_scope_path", - "_extract_parallel_scope_paths", - "_paths_overlap", - "_is_multimodal_tool_result", - "_multimodal_text_summary", - "_append_subdir_hint_to_multimodal", - "_extract_file_mutation_targets", - "_extract_landed_file_mutation_paths", - "_extract_error_preview", - "_trajectory_normalize_msg", - "_detect_upstream_elision", - "_maybe_append_elision_notice", + "_NEVER_PARALLEL_TOOLS", "_PARALLEL_SAFE_TOOLS", "_PATH_SCOPED_TOOLS", "_PATH_SCOPED_READERS", + "_PATH_SCOPED_WRITERS", "_DESTRUCTIVE_PATTERNS", "_REDIRECT_OVERWRITE", "_is_destructive_command", + "_plan_tool_batch_segments", "_should_parallelize_tool_batch", "_canonical_path", + "_extract_parallel_scope_path", "_extract_parallel_scope_paths", "_paths_overlap", + "_is_multimodal_tool_result", "_multimodal_text_summary", "_append_subdir_hint_to_multimodal", + "_extract_file_mutation_targets", "_extract_landed_file_mutation_paths", "_extract_error_preview", + "_trajectory_normalize_msg", "_detect_upstream_elision", "_maybe_append_elision_notice", "make_tool_result_message", ] diff --git a/agent/tool_executor.py b/agent/tool_executor.py index 9b94eea950..89955bcd19 100644 --- a/agent/tool_executor.py +++ b/agent/tool_executor.py @@ -1395,8 +1395,7 @@ def execute_tool_calls_concurrent(agent, assistant_message, messages: list, effe finally: if spinner: finished = [r for r in batch.results if r is not None] - total_dur = sum(r.duration for r in finished) - spinner.stop(f"โšก {len(finished)}/{num_tools} tools completed in {total_dur:.1f}s total") + spinner.stop(f"โšก {len(finished)}/{num_tools} tools completed in {sum(r.duration for r in finished):.1f}s total") if not _append_batch_results(agent, messages, effective_task_id, batch, _tool_budget): return @@ -1410,15 +1409,12 @@ def execute_tool_calls_concurrent(agent, assistant_message, messages: list, effe def _start_quiet_tool_spinner(agent, function_name: str, function_args: dict, *, gate: bool = True, label: Optional[str] = None): """Start the quiet-mode kawaii spinner for one tool call, or return None; ``gate=False`` skips ``_should_start_quiet_spinner`` (context-engine tools always spin).""" - if not agent._should_emit_quiet_tool_messages(): - return None - if gate and not agent._should_start_quiet_spinner(): + if not agent._should_emit_quiet_tool_messages() or (gate and not agent._should_start_quiet_spinner()): return None face = random.choice(KawaiiSpinner.get_waiting_faces()) if label is None: - emoji = _get_tool_emoji(function_name) display_args = _redact_tool_args_for_display(function_name, function_args) or function_args - label = f"{emoji} {_build_tool_label(function_name, display_args) or function_name}" + label = f"{_get_tool_emoji(function_name)} {_build_tool_label(function_name, display_args) or function_name}" spinner = KawaiiSpinner(f"{face} {label}", spinner_type='dots', print_fn=agent._print_fn) spinner.start() return spinner @@ -1426,19 +1422,18 @@ def _start_quiet_tool_spinner(agent, function_name: str, function_args: dict, *, def _finish_quiet_tool_spinner(agent, spinner, function_name: str, function_args: dict, tool_duration: float, result) -> None: """Stop the spinner with the cute completion line, or print it when no spinner ran.""" - if spinner: - spinner.stop(_get_cute_tool_message_impl(function_name, function_args, tool_duration, result=result)) - elif agent._should_emit_quiet_tool_messages(): - agent._vprint(f" {_get_cute_tool_message_impl(function_name, function_args, tool_duration, result=result)}") + if spinner or agent._should_emit_quiet_tool_messages(): + cute = _get_cute_tool_message_impl(function_name, function_args, tool_duration, result=result) + spinner.stop(cute) if spinner else agent._vprint(f" {cute}") def _delegate_spinner_label(function_args: dict) -> str: - _action_arg = str(function_args.get("action") or "").strip().lower() - tasks_arg = function_args.get("tasks") - if _action_arg in ("list", "steer", "stop"): - return f"๐Ÿ”€ subagent {_action_arg}" - if tasks_arg and isinstance(tasks_arg, list): - return f"๐Ÿ”€ delegating {len(tasks_arg)} tasks ยท (/agents to monitor)" + action = str(function_args.get("action") or "").strip().lower() + tasks = function_args.get("tasks") + if action in ("list", "steer", "stop"): + return f"๐Ÿ”€ subagent {action}" + if tasks and isinstance(tasks, list): + return f"๐Ÿ”€ delegating {len(tasks)} tasks ยท (/agents to monitor)" goal_preview = (function_args.get("goal") or "")[:30] return f"๐Ÿ”€ {goal_preview} ยท (/agents to monitor)" if goal_preview else "๐Ÿ”€ delegating ยท (/agents to monitor)" @@ -1449,16 +1444,13 @@ class _SequentialDispatch: execute: Callable[[dict], Any] spinner: Any = None - # Passed through to the middleware runner; the registry closure reads this list. - middleware_trace_arg: Optional[list] = None - # None โ†’ exceptions propagate (inline / delegate tools own their failures). - error_result: Optional[Callable[[Exception], str]] = None + middleware_trace_arg: Optional[list] = None # forwarded to the middleware runner (registry closure reads it) + error_result: Optional[Callable[[Exception], str]] = None # None โ†’ exceptions propagate (inline/delegate own failures) error_log: str = "" handles_keyboard_interrupt: bool = False is_delegate: bool = False finish_spinner: bool = True - # Inline tools print their completion line only on success (no try/finally). - finish_in_finally: bool = True + finish_in_finally: bool = True # inline tools print their completion line only on success def _resolve_sequential_dispatch(agent, ref: _ToolCallRef, messages: list) -> _SequentialDispatch: @@ -1472,20 +1464,12 @@ def _resolve_sequential_dispatch(agent, ref: _ToolCallRef, messages: list) -> _S # Agent-level tools that need live AIAgent state; table shared with invoke_tool. inline_executor = INLINE_TOOL_EXECUTORS[function_name] inline_ctx = InlineToolContext(effective_task_id=effective_task_id, tool_call_id=tool_call_id, messages=messages) - return _SequentialDispatch( - execute=lambda next_args: inline_executor(agent, next_args, inline_ctx), - finish_in_finally=False, - ) + return _SequentialDispatch(lambda next_args: inline_executor(agent, next_args, inline_ctx), finish_in_finally=False) if function_name == "delegate_task": spinner = _start_quiet_tool_spinner(agent, function_name, function_args, label=_delegate_spinner_label(function_args)) agent._delegate_spinner = spinner - return _SequentialDispatch( - execute=lambda next_args: agent._dispatch_delegate_task(next_args), - spinner=spinner, - is_delegate=True, - ) + return _SequentialDispatch(agent._dispatch_delegate_task, spinner=spinner, is_delegate=True) if agent._context_engine_tool_names and function_name in agent._context_engine_tool_names: - # Context engine tools (lcm_grep, lcm_describe, lcm_expand, etc.) return _SequentialDispatch( execute=lambda next_args: agent.context_compressor.handle_tool_call(function_name, next_args, messages=messages), spinner=_start_quiet_tool_spinner(agent, function_name, function_args, gate=False), @@ -1493,8 +1477,7 @@ def _resolve_sequential_dispatch(agent, ref: _ToolCallRef, messages: list) -> _S error_log="context_engine.handle_tool_call raised for %s: %s", ) if agent._memory_manager and agent._memory_manager.has_tool(function_name): - # Memory provider tools (hindsight_retain, honcho_search, etc.) are not in the - # tool registry โ€” route through MemoryManager. + # Memory-provider tools (hindsight_retain, honcho_search, ...) are not in the registry. return _SequentialDispatch( execute=lambda next_args: agent._memory_manager.handle_tool_call(function_name, next_args), spinner=_start_quiet_tool_spinner(agent, function_name, function_args),