# Copyright 2026 Google LLC # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. import json from collections.abc import Awaitable from typing import Any from uuid import uuid4 from langchain_core.messages import BaseMessage, SystemMessage, ToolMessage from langchain_core.tools import BaseTool from artemis.agents.validator.tool_declarations import normalize_coordinate_target from artemis.config.settings import settings from artemis.context import ArtemisContext from artemis.core.tool_failure import is_tool_failure from artemis.data_engine.trace import ( TraceSpan, trace, trace_langchain_tool, ) from artemis.graph.state import State from artemis.graph.visibility import strict_state from artemis.mcp.action_specs import OPERATOR_SHELL_ORDER, operator_shell_tool from artemis.llm.usage import usage_from_message from artemis.llm.structured import content_to_text from artemis.runtime.task_control import TaskControl, has_pending_instruction from artemis.services.llm import acomplete, get_llm, invoke_llm_with_timeout_message from artemis.tools.command_tool import ( analyze_task_output_wrapper, get_adb_task_registry, ) from artemis.tools.index import get_tool_by_name from artemis.tools.tool_wrapper import ( get_tool_result_content, invoke_tool_with_injection, tool_result_messages, ) from artemis.utils.coordinates import ( COORDINATE_SPACE_KEY, COORDINATE_SPACE_PIXEL, compute_smart_swipe_coordinates, parse_swipe_parameters, ) from artemis.utils.decorators import wrap_with_callbacks from artemis.utils.logger import get_logger from artemis.utils.notes import get_note_file_path from artemis.utils.plan_grammar import parse_plan from artemis.memory.context_policy import build_history_for from artemis.utils.task_tree import get_active_subgoal_hashes from artemis.utils.visualization import format_minimal_list_with_elements logger = get_logger(__name__) class OperatorNoProgressError(RuntimeError): """The model repeatedly returned no usable tool call; stop without device actions.""" DEFERRING_TOOLS = { "ask_diagnoser", "video_analyzer", "read_note", "list_notes", "search_history", "replay_steps", "get_step_screenshot", "run_adb_command", "manage_task", "analyze_task_output", "ask_explorer", } INTERRUPTIBLE_HELPERS = {"ask_diagnoser", "ask_explorer", "video_analyzer", "analyze_task_output"} # Bare key names press_key accepts (case-insensitive, optional KEYCODE_ prefix). SUPPORTED_PRESS_KEYS = ("ENTER", "BACK", "HOME", "APP_SWITCH") from artemis.agents.operator.prompts import ( OPERATOR_MAX_TOOL_ITERATIONS, PromptBuilder, PromptComponent, TemplatePromptComponent, ObservationPromptComponent, PlanRecitationPromptComponent, ScreenshotSimilarityPromptComponent, HistoricalStateHintPromptComponent, FeedbackPromptComponent, ExecutionIncidentPromptComponent, BackgroundTasksPromptComponent, render_plan_ledger_bounce, ReasoningReminderPromptComponent, unwritten_action_streak, ToolLimitWarningPromptComponent, InjectedInstructionPromptComponent, render_transcript_static_system, ) from artemis.agents.operator.prompts import load_operator_prompts def _described(value: Any) -> str | None: """Trim target_description, returning None for missing or blank values.""" if isinstance(value, str) and value.strip(): return value.strip() return None class OperatorNode: def __init__( self, ctx: ArtemisContext, tools: list[BaseTool] | None = None, prompt_components: list[PromptComponent] | None = None, last_n_detailed: int = 1, transcript_config=None, ): self.ctx = ctx self.tools = tools or [] self.prompt_components = prompt_components or [] self.last_n_detailed = last_n_detailed try: self.prompts = load_operator_prompts() except (OSError, ValueError, json.JSONDecodeError) as e: raise RuntimeError(f"Failed to load operator prompts: {e}") from e # M2 transcript flag (agent.memory.transcript.enabled). Off keeps the # legacy 2-message prompt path byte-for-byte; an explicit # ``transcript_config`` overrides the loaded configuration (tests). if transcript_config is not None: self._transcript_cfg = transcript_config else: try: from artemis.config import load_agent_config self._transcript_cfg = load_agent_config().memory.transcript except Exception: from artemis.config.agent import MemoryTranscriptConfig self._transcript_cfg = MemoryTranscriptConfig() # Keep the actual observation, not an index: plugin/exploration context # may be prepended after render, shifting every message's position. self._transcript_turn_tail: BaseMessage | None = None self._turn_plan_baseline: str | None = None self._turn_ledger_streak: int = 0 # Whether the previous turn's replies carried no visible reasoning text; # the next observation tail then carries a one-line reminder (no extra # model call). Legacy-path fallback only: with a transcript ledger the # ledger's own ``last_turn_silent`` is authoritative. self._previous_turn_silent: bool = False def _previous_turn_was_silent(self) -> bool: """Whether the previous turn carried no visible reasoning text. The transcript ledger judges its committed turns itself; the local flag only serves the legacy path, which has no ledger. """ from artemis.memory import TranscriptLedger ledger = getattr(self.ctx, "transcript_ledger", None) if isinstance(ledger, TranscriptLedger): return bool(ledger.last_turn_silent) return self._previous_turn_silent def _available_device_actions(self) -> frozenset[str]: """Device actions the installed actuator backend provides. Without an explicit ``ctx.actuator`` the full manifest set applies (the default AdbActuator implements everything), so behavior is unchanged until a partial backend is actually installed. """ from artemis.mcp.action_manifest import ( OPTIONAL_ACTIONS, REQUIRED_ACTIONS, available_device_actions, ) if getattr(self.ctx, "execution_target", None) == "computer": return frozenset() actuator = getattr(self.ctx, "actuator", None) if actuator is not None and callable(getattr(actuator, "capabilities", None)): try: extension_names = frozenset( e.name for e in actuator.extensions() if "operator" in e.targets ) return available_device_actions(actuator) | extension_names except Exception as e: logger.warning(f"Failed to read actuator capabilities: {e}") return REQUIRED_ACTIONS | OPTIONAL_ACTIONS def _get_history_and_plan(self) -> tuple[list[dict], str]: steps = [] if self.ctx.data_engine: steps = self.ctx.data_engine.get_agent_friendly_steps() task_plan = "No task plan yet." if self.ctx.data_engine: task_plan_path = get_note_file_path(self.ctx.data_engine.base_dir, "task_plan") if task_plan_path.exists(): try: task_plan = task_plan_path.read_text(encoding="utf-8") except Exception as e: task_plan = f"Error reading task plan: {e}" return steps, task_plan async def _build_prompt( self, state: State, latest_screenshot_b64: str, fused_xml: list[dict], minimal_list: str, current_step_num: int, steps: list[dict], task_plan: str, active_background_tasks: list[dict] = None, newly_finished_tasks: list[dict] = None, ) -> list: self._transcript_turn_tail = None if getattr(self._transcript_cfg, "enabled", False): try: return await self._build_prompt_transcript( state, latest_screenshot_b64, minimal_list, current_step_num, steps, task_plan, active_background_tasks=active_background_tasks, newly_finished_tasks=newly_finished_tasks, ) except Exception as e: logger.error( f"Transcript prompt path failed; falling back to the legacy" f" 2-message build for this turn: {e}" ) self._transcript_turn_tail = None plan_and_history = self._build_plan_and_history(steps, task_plan) builder = PromptBuilder() # One constant component list: the prompt template is never switched by # verification results. Check-related components are append-only and # render nothing when there is nothing to say. components = self.prompt_components if not components: components = [ ( TemplatePromptComponent(), {"template_name": "main_template"}, ), (ExecutionIncidentPromptComponent(), {}), (ObservationPromptComponent(), {}), (ScreenshotSimilarityPromptComponent(), {}), (HistoricalStateHintPromptComponent(), {}), (InjectedInstructionPromptComponent(), {}), (BackgroundTasksPromptComponent(), {}), (FeedbackPromptComponent(), {}), (ReasoningReminderPromptComponent(), {}), (ToolLimitWarningPromptComponent(), {}), ] else: components = [(c, {}) for c in components] + [ (ReasoningReminderPromptComponent(), {}), (ToolLimitWarningPromptComponent(), {}), ] # Active and finished tasks are now passed as arguments active_tasks = active_background_tasks or [] newly_finished = newly_finished_tasks or [] for component, extra_kwargs in components: kwargs_to_pass = { "prompts": self.prompts, "plan_and_history": plan_and_history, "task_plan": task_plan, "latest_screenshot_b64": latest_screenshot_b64, "minimal_list": minimal_list, "current_step_num": current_step_num, "active_background_tasks": active_tasks, "newly_finished_tasks": newly_finished, "previous_turn_silent": self._previous_turn_was_silent(), "steps": steps, } kwargs_to_pass.update(extra_kwargs) await component(builder, state, self.ctx, **kwargs_to_pass) messages = builder.build() if getattr(state, "structured_decisions", None): from langchain_core.messages import HumanMessage # The legacy prompt has no preceding AI tool calls. Deliver the # same evidence as observations, without orphan function outputs. for results in self._plugin_result_messages(state.last_execution_result).values(): for result in results: messages.append(HumanMessage(content=result.content)) return messages # ------------------------------------------------------------------ # Transcript prompt path (M2, flag agent.memory.transcript.enabled) # ------------------------------------------------------------------ def _ensure_transcript_ledger(self, state=None): """Session ledger on the composition root, created on first use.""" from artemis.memory import TranscriptLedger, ensure_step_memory ledger = getattr(self.ctx, "transcript_ledger", None) if isinstance(ledger, TranscriptLedger): return ledger cfg = self._transcript_cfg # Anchor the ``T+mm:ss`` clock to the DataEngine session start so the # tail offsets agree with the chunk ledger lines, the restored-history # block and the video analyzer's action timeline. session_start = getattr(self.ctx.data_engine, "session_start_time", None) ledger = TranscriptLedger( step_memory=ensure_step_memory(self.ctx), image_scrub_depth=getattr(cfg, "image_scrub_depth", 3), pending_grace_steps=getattr(cfg, "pending_grace_steps", 3), xml_scrub_depth=getattr(cfg, "xml_scrub_depth", 1), image_scrub_depth_relaxed=getattr(cfg, "image_scrub_depth_relaxed", None), context_budget_tokens=getattr(cfg, "context_budget_tokens", None), start_ratio=getattr(cfg, "start_ratio", None), soft_ratio=getattr(cfg, "soft_ratio", 0.7), hard_ratio=getattr(cfg, "hard_ratio", 0.9), session_start=session_start if isinstance(session_start, (int, float)) else None, ) # L2/L3 chunk compression (M3) rides the same flag; without a # DataEngine there are no step records to chunk, so the ledger simply # runs scrub-edge-only. if self.ctx.data_engine is not None: try: from artemis.memory import HistoryChunkManager chunking_cfg = None try: from artemis.config import load_agent_config chunking_cfg = load_agent_config().memory.chunking except Exception as exc: logger.debug( "Chunking config unavailable; using chunker defaults: %s", exc, exc_info=True, ) ledger.attach_chunker( HistoryChunkManager( engine=self.ctx.data_engine, ctx=self.ctx, chunking_config=chunking_cfg, transcript_config=cfg, goal=getattr(state, "initial_goal", None) if state else None, ) ) except Exception as e: logger.error(f"History chunk manager unavailable: {e}") try: self.ctx.transcript_ledger = ledger except (AttributeError, TypeError, ValueError): pass return ledger def _plugin_result_messages(self, report: Any) -> dict[str, list[BaseMessage]]: from artemis.plugins.results import plugin_result_messages return plugin_result_messages(report, self.ctx.data_engine) async def _build_prompt_transcript( self, state: State, latest_screenshot_b64: str, minimal_list: str, current_step_num: int, steps: list[dict], task_plan: str, active_background_tasks: list[dict] = None, newly_finished_tasks: list[dict] = None, ) -> list: """Build ``S + F + A + tail`` from the session transcript ledger. The static system message renders once per session (byte-stable S region); the previous turn is committed into the append-only active region together with its step key and validator result; the fresh tail carries the current observation plus the task-plan recitation. """ from langchain_core.messages import HumanMessage from artemis.memory.transcript import OBSERVATION_SCOPE_RULE, mark_ephemeral ledger = self._ensure_transcript_ledger(state) # 1. S region: rendered exactly once per session. if not ledger.has_static_prefix: static_text = render_transcript_static_system(self.prompts, self.ctx, state) prefix = [] if getattr(self.ctx, "exploration_id", None): from artemis.exploration.runtime import exploration_prompt prefix.append(exploration_prompt(self.ctx)) if getattr(self.ctx, "plugins", None): prefix.append(self.ctx.plugins.prompt()) prefix.append(static_text) prefix.append(OBSERVATION_SCOPE_RULE) ledger.set_static_prefix([SystemMessage(content="\n".join(prefix))]) # 2. F region cold start: an empty ledger over an existing step record # trail means the process restarted — freeze the compiled history once. # A staged (not yet committed) turn means this is turn 2+ of a live # session, not a cold start — step records already exist then, and # seeding would be rejected by the empty-ledger invariant. if ( ledger.turn_count == 0 and not ledger.has_staged_turn and not ledger.has_restored_history and steps ): restored = build_history_for( "operator_cold_start", task_plan, steps, self._get_active_subgoal_hash(), ) ledger.set_restored_history( "[Restored history] Rebuilt from the step records of this task" " after a process restart; step times below are relative to the" " original session start.\n" f"{restored}" ) # 3. Commit the previous turn (its step id and validator result only # exist now). A turn without a terminal action never ran the validator, # and ``structured_decisions`` is cleared on planner rejection — the # gate keeps stale reports out of the ledger. validator_result = ( getattr(state, "last_execution_result", None) if getattr(state, "structured_decisions", None) else None ) ledger.commit_staged( step_key=getattr(state, "current_step_id", None), validator_result=validator_result, tool_results=self._plugin_result_messages(validator_result), ) # 4. Current tail: observation + plan recitation + injected components. builder = PromptBuilder() guidance = None if getattr(self.ctx, "exploration_id", None): from artemis.exploration.runtime import exploration_messages # Guidance belongs at the append-only tail, never before the # reusable rules/history. Assistant progress does not change it. guidance = exploration_messages(self.ctx)[1].content if guidance != getattr(self, "_last_exploration_guidance", None): builder.add_human_content(guidance) self._pending_exploration_guidance = guidance builder.add_human_content(f"# CURRENT OBSERVATION [{ledger.elapsed_label()}]") components = [ (PlanRecitationPromptComponent(), {}), (ExecutionIncidentPromptComponent(), {}), (ObservationPromptComponent(), {}), (ScreenshotSimilarityPromptComponent(), {}), (HistoricalStateHintPromptComponent(), {}), (InjectedInstructionPromptComponent(), {}), (BackgroundTasksPromptComponent(), {}), (FeedbackPromptComponent(), {}), (ReasoningReminderPromptComponent(), {}), (ToolLimitWarningPromptComponent(), {}), ] for component, extra_kwargs in components: kwargs_to_pass = { "prompts": self.prompts, "task_plan": task_plan, "latest_screenshot_b64": latest_screenshot_b64, "minimal_list": minimal_list, "current_step_num": current_step_num, "active_background_tasks": active_background_tasks or [], "newly_finished_tasks": newly_finished_tasks or [], "steps": steps, "previous_turn_silent": self._previous_turn_was_silent(), } kwargs_to_pass.update(extra_kwargs) await component(builder, state, self.ctx, **kwargs_to_pass) tail_content = [] for part in builder.human_parts: if isinstance(part, str): tail_content.append({"type": "text", "text": part}) else: tail_content.append(part) tail = HumanMessage(content=tail_content) # Per-turn notices (reminders, hints, guidance, incident blocks) are # deleted only when phase compaction rewrites the recent window. if builder.ephemeral_indices: mark_ephemeral(tail, builder.ephemeral_indices) messages = ledger.render([tail]) # Compaction can remove the original guidance message. Restore the # exact current instructions once at the new tail, without changing # the reusable prefix or relying on a lossy history capsule. if guidance is not None and not any( guidance in ( message.content if isinstance(message.content, str) else "\n".join( block.get("text", "") for block in message.content if isinstance(block, dict) ) ) for message in messages if isinstance(message, HumanMessage) ): tail_content.append({"type": "text", "text": guidance}) tail.content = tail_content self._pending_exploration_guidance = guidance self._transcript_turn_tail = tail return messages def _stage_transcript_turn(self, messages: list) -> None: """Hand this turn's tail + tool-loop products to the ledger.""" tail = self._transcript_turn_tail self._transcript_turn_tail = None if tail is None: return try: ledger = getattr(self.ctx, "transcript_ledger", None) if ledger is not None: base = next(i for i, message in enumerate(messages) if message is tail) ledger.stage_turn(messages[base:]) if hasattr(self, "_pending_exploration_guidance"): self._last_exploration_guidance = self._pending_exploration_guidance del self._pending_exploration_guidance except Exception as e: logger.error(f"Failed to stage transcript turn: {e}") async def _await_or_pause[T](self, operation: Awaitable[T]) -> tuple[bool, T | None]: control = getattr(self.ctx, "task_control", None) if isinstance(control, TaskControl): return await control.run_interruptibly( operation, on_pause=getattr(self.ctx, "on_task_pause", None), on_resume=getattr(self.ctx, "on_task_resume", None), ) return False, await operation async def _invoke_llm_loop( self, base_llm, current_messages: list, traced_tools: list, new_subagent_calls: list, state: State, ) -> tuple[list[dict] | None, str | None, str | None, bool]: max_iterations = OPERATOR_MAX_TOOL_ITERATIONS action_result = None raw_thoughts = [] native_thoughts = [] tool_limit_exceeded = False plan_gate_bounced = False no_progress_attempts = 0 no_progress_limit = settings.ARTEMIS_OPERATOR_MAX_NO_PROGRESS_ATTEMPTS fallback_limit = settings.ARTEMIS_TOOL_FALLBACK_MAX_NO_PROGRESS_ATTEMPTS active_llm = base_llm using_tool_fallback = False state.execution_report = None report_requested = False other_tool_failed = False for iteration in range(max_iterations): control = getattr(self.ctx, "task_control", None) if isinstance(control, TaskControl) and await control.checkpoint( on_pause=getattr(self.ctx, "on_task_pause", None), on_resume=getattr(self.ctx, "on_task_resume", None), ): # The device may have changed while another task held its lease. return None, None, None, False if ( not report_requested and not other_tool_failed and self._exploration_ready_for_report(state) ): current_messages.append( SystemMessage( content=( "探索计划已完成。现在交付最终答复,不要为了结束再操作设备。" "沿用本次上下文,按用户要求给出完整报告正文,而不是仅引用笔记、" "承诺稍后提供或重复计划。需要时可读取已保存报告再原样交付," "不重复调查或重写已有完整报告。明确区分实际发现、未测范围与" "调查完成度,不能将执行完成等同于测试通过。" "最后以不含工具调用的正文回复;系统会保存并交给独立验收。" ) ) ) report_requested = True if iteration == max_iterations - 1 and not report_requested: logger.warning( "Operator has reached the maximum number of tool calls. Adding reminder." ) current_messages.append( SystemMessage( content=( "You have reached the maximum number of tool calls" " for this turn. You must make a final action, or" " save your current research results via notes to" " continue later." ) ) ) plugins = getattr(self.ctx, "plugins", None) if plugins: traced_tools[:] = [ t for t in traced_tools if not t.name.startswith("plug_") and t.name not in {"plugin_load", "plugin_read_file", "plugin_read_result"} ] traced_tools.extend(plugins.langchain_tools()) bound_llm = active_llm.bind_tools(tools=traced_tools) interrupted, response = await self._await_or_pause( invoke_llm_with_timeout_message(acomplete(bound_llm, current_messages)) ) if interrupted: return None, None, None, False assert response is not None if isinstance(control, TaskControl) and has_pending_instruction(control.directory): # The model answered a prompt that predates the user's new direction. return None, None, None, False if getattr(self.ctx, "exploration_id", None): from artemis.exploration.runtime import needs_exploration_turn if needs_exploration_turn(self.ctx): return None, None, None, False usage = usage_from_message(response) # The ledger's compaction thresholds read the operator's own # prompt size, never the session-wide meter (which the # Planner/Checker/sub-agent calls overwrite). The messages ride # along so the ledger can calibrate its chars-per-token ratio. transcript_ledger = getattr(self.ctx, "transcript_ledger", None) if usage is not None: logger.info( f"LLM usage: prompt_tokens={usage['prompt_tokens']}" f" (cached={usage['cached_tokens']})," f" completion_tokens={usage['completion_tokens']}" ) if hasattr(transcript_ledger, "record_prompt_tokens"): transcript_ledger.record_prompt_tokens( usage.get("prompt_tokens"), messages=current_messages ) # Without provider usage the ledger estimates the complete next # prompt at render time; do not label that estimate as a measurement. if response.content: if isinstance(response.content, str): raw_thoughts.append(response.content) elif isinstance(response.content, list): for item in response.content: if isinstance(item, dict): if item.get("type") == "text": raw_thoughts.append(item["text"]) elif item.get("type") == "thinking": native_thoughts.append(item["thinking"]) additional_kwargs = getattr(response, "additional_kwargs", None) if ( not response.tool_calls and type(additional_kwargs) is dict and additional_kwargs.get("function_call") ): fc = additional_kwargs["function_call"] try: arguments_str = fc.get("arguments", "{}") if isinstance(arguments_str, str): arguments = json.loads(arguments_str) else: arguments = arguments_str tool_call = { "name": fc["name"], "args": arguments, "id": f"call_{uuid4().hex[:8]}", "type": "tool_call", } response.tool_calls = [tool_call] logger.info( "Fallback: Manually parsed function_call from" f" additional_kwargs: {tool_call}" ) except Exception as e: logger.error(f"Failed to parse function_call fallback: {e}") if not response.tool_calls and not getattr(response, "invalid_tool_calls", None): report_text = content_to_text(response.content).strip() if report_requested and report_text and self._exploration_ready_for_report(state): state.execution_report = report_text current_messages.append(response) logger.info("Exploration final report prepared; returning for final review.") break if getattr(self.ctx, "exploration_id", None): from artemis.exploration.runtime import publish_response publish_response(self.ctx, response) if not response.tool_calls: no_progress_attempts += 1 if no_progress_attempts >= ( fallback_limit if using_tool_fallback else no_progress_limit ): if using_tool_fallback: if report_requested: raise OperatorNoProgressError( "模型未生成可用的最终报告正文,已停止空转。" ) raise OperatorNoProgressError( f"首选模型连续 {no_progress_limit} 次、兜底模型连续 " f"{no_progress_attempts} 次未提供可执行的工具调用;" "已停止任务以避免空转。" ) active_llm = get_llm(ctx=self.ctx, name="operator", use_fallback=True) using_tool_fallback = True no_progress_attempts = 0 logger.warning("Operator 首选模型未调用工具,当前步骤切换到兜底模型") current_messages.append( SystemMessage( content=( "已切换兜底模型。请直接交付完整报告正文,不要再次操作设备。" if report_requested else "本步骤已切换兜底模型。请使用工具完成操作或明确报告阻碍。" ) ) ) continue logger.warning("LLM returned no usable result; requesting bounded recovery.") current_messages.append(response) current_messages.append( SystemMessage( content=( "尚未收到可用的最终报告正文。请用文字交付实际结果;" "缺少证据时如实说明,不要为了结束再操作设备。" if report_requested else "No tool call was returned, so no device action occurred. " "Use the available tool interface now, not a prose promise or JSON " "inside text. Use an index from the current element list or " "normalized coordinates with target_description. If blocked, " "use an observation/diagnostic tool; do not invent a click or " "claim success. This recovery has a limited retry budget." ) ) ) continue current_messages.append(response) # Manifest-driven, so a backend's extension actions are classified as # turn-ending device actions without touching this file. action_tool_names = sorted(self._available_device_actions()) if plugins: action_tool_names.extend(plugins.bindings) def normalize_name(name: str) -> str: return name.split(":")[-1] if ":" in name else name action_calls = [ tc for tc in response.tool_calls if normalize_name(tc["name"]) in action_tool_names ] other_calls = [ tc for tc in response.tool_calls if normalize_name(tc["name"]) not in action_tool_names ] tool_outputs = [] validation_errors = False other_tool_failed = False other_tool_failure_msg = "" has_terminal_calls = bool(action_calls) if other_calls: results = [] for tc in other_calls: if ( isinstance(control, TaskControl) and (control.directory / "pause.request").exists() ): results.append(("已请求暂停,未执行该工具;恢复后重新观察。", "error")) continue tool_name = tc["name"] logger.info(f"Operator requested tool: {tool_name}") if tool_name in ["ask_diagnoser", "video_analyzer"]: new_subagent_calls.append(tool_name) if ":" in tool_name: tool_to_run = get_tool_by_name(tool_name, traced_tools) else: tool_to_run = next( (t for t in traced_tools if t.name == tool_name), None, ) if tool_to_run: args = dict(tc["args"]) span = TraceSpan(name=tool_name, trace_type="tool", ctx=self.ctx) span.payload = {"args": args} with span: try: state.operator_raw_thinking = ( "\n".join(raw_thoughts) if raw_thoughts else None ) state.operator_native_thinking = ( "\n".join(native_thoughts) if native_thoughts else None ) tool_call = invoke_tool_with_injection( tool=tool_to_run, args=args, tool_call_id=tc["id"], state=state, ) if normalize_name(tool_name) in INTERRUPTIBLE_HELPERS or ( plugins and tool_name in plugins.tool_names ): interrupted, result_obj = await self._await_or_pause(tool_call) if interrupted: span.result = "任务被暂停或指导打断;重新观察屏幕后继续" results.append((span.result, "error")) for remaining in other_calls[len(results) :]: results.append(("任务已打断,未执行该工具", "error")) for completed, (content, status) in zip( other_calls, results ): current_messages.extend( tool_result_messages( completed["id"], content, name=normalize_name(completed["name"]), status=status, llm=base_llm, ) ) for action in action_calls: current_messages.append( ToolMessage( tool_call_id=action["id"], content="任务已打断,未执行屏幕操作", status="error", ) ) return None, None, None, False else: result_obj = await tool_call content = get_tool_result_content(result_obj) # Failure is structural (a ToolMessage status or a # ToolFailure text), never sniffed from the words. is_err = is_tool_failure(result_obj) status = "error" if is_err else "success" if is_err: other_tool_failed = True other_tool_failure_msg = str(content) status = "error" span.status = "failed" span.error = str(content) else: span.result = content results.append((content, status)) except Exception as e: other_tool_failed = True other_tool_failure_msg = str(e) span.status = "failed" span.error = str(e) results.append( ( f"Error running tool {tool_name}: {e}", "error", ) ) else: other_tool_failed = True other_tool_failure_msg = f"Tool {tool_name} not supported" results.append((f"Error: Tool {tool_name} not supported", "error")) for tc, (content, status) in zip(other_calls, results): # A step screenshot (content blocks) travels in the carrier # this model's provider accepts; text stays a ToolMessage. tool_outputs.extend( tool_result_messages( tc["id"], content, name=normalize_name(tc["name"]), status=status, llm=base_llm, ) ) if other_tool_failed: if action_calls: logger.warning( "Scratchpad/helper tool call failed. Deferring" f" screen actions: {other_tool_failure_msg}" ) # Every rejected call needs its own ToolMessage, but the # full explanation travels once; the rest point at it. for idx, tc in enumerate(action_calls): tool_outputs.append( ToolMessage( tool_call_id=tc["id"], name=normalize_name(tc["name"]), content=( "Not executed: this screen action was" " rejected because an accompanying tool" f" call failed. Error: {other_tool_failure_msg}." " No screen action ran this turn. Correct" " the error, then re-issue the action." if idx == 0 else "Not executed: see the accompanying" " tool-call error above." ), status="error", ) ) action_calls = [] elif has_terminal_calls: has_deferring_calls = any( normalize_name(tc["name"]) in DEFERRING_TOOLS for tc in other_calls ) if has_deferring_calls: logger.info( "Operator mixed pre-decision tools" " (memory/exploration) and terminal action calls." " Deferring terminal actions." ) for idx, tc in enumerate(action_calls): tool_outputs.append( ToolMessage( tool_call_id=tc["id"], name=normalize_name(tc["name"]), content=( "Not executed: this screen action was" " deferred because the same turn also" " called a result-dependent pre-decision" " tool. No screen action ran this turn." " Read the tool results above, then" " re-issue the action." if idx == 0 else "Not executed: deferred for the same" " reason as the screen action above." ), status="error", ) ) action_calls = [] if isinstance(control, TaskControl) and ( has_pending_instruction(control.directory) or (control.directory / "pause.request").exists() ): current_messages.extend(tool_outputs) for action in action_calls: current_messages.append( ToolMessage( tool_call_id=action["id"], content="探索已暂停或收到新指导,未执行该操作。", status="error", ) ) return None, None, None, False if action_calls: logger.info( f"Operator requested {len(action_calls)} action(s)." " Translating and processing all." ) actions_list = [] burst_cap = self._max_burst_actions() bounce = None if ( not plan_gate_bounced and iteration < max_iterations - 1 and len(action_calls) == 1 and not ( plugins and plugins.is_read_only(normalize_name(action_calls[0]["name"])) ) and not getattr(state, "open_incident", None) and self._plan_changed_this_turn() is False ): bounce = self._plan_ledger_bounce_reason() if bounce: plan_gate_bounced = True validation_errors = True logger.info(f"Plan ledger gate: bouncing action. {bounce}") for tc in action_calls: tool_outputs.append( ToolMessage( tool_call_id=tc["id"], name=normalize_name(tc["name"]), content=bounce, status="error", ) ) action_calls_to_translate = [] elif len(action_calls) > burst_cap: # A multi-action turn is a fast-action burst that the Validator # fires without the safety net; cap its length before it runs. validation_errors = True burst_error = ( f"Error: {len(action_calls)} Turn-Ending Actions in one" " turn exceed the fast-action burst limit of" f" {burst_cap}. Nothing was executed. Re-issue at most" f" {burst_cap} actions (a burst is for sub-second" " sequences on transient UI, not for batching normal" " steps), or a single vetted action." ) # One ToolMessage per rejected call, full text only on the first. for idx, tc in enumerate(action_calls): tool_outputs.append( ToolMessage( tool_call_id=tc["id"], name=normalize_name(tc["name"]), content=( burst_error if idx == 0 else "Not executed: see the burst-limit error above." ), status="error", ) ) action_calls_to_translate = [] else: if plan_gate_bounced and self._plan_changed_this_turn() is False: logger.warning( "Plan ledger gate: executing actions after one bounce without" " a task_plan change (ledger gap this turn)." ) action_calls_to_translate = action_calls for tc in action_calls_to_translate: translated_actions, error = self._translate_and_validate_tool(tc, state) if error: validation_errors = True tool_outputs.append( ToolMessage( tool_call_id=tc["id"], name=normalize_name(tc["name"]), content=error, status="error", ) ) else: actions_list.extend(translated_actions) tool_outputs.append( ToolMessage( tool_call_id=tc["id"], name=normalize_name(tc["name"]), content="Action Recorded", status="success", ) ) if not validation_errors: action_result = actions_list for tm in tool_outputs: current_messages.append(tm) if action_calls and not validation_errors: break if validation_errors: no_progress_attempts += 1 if no_progress_attempts >= ( fallback_limit if using_tool_fallback else no_progress_limit ): if using_tool_fallback: raise OperatorNoProgressError( f"首选模型连续 {no_progress_limit} 次、兜底模型连续 " f"{no_progress_attempts} 次未提供可执行的工具调用," "工具参数校验仍失败;已停止任务。" ) active_llm = get_llm(ctx=self.ctx, name="operator", use_fallback=True) using_tool_fallback = True no_progress_attempts = 0 logger.warning("Operator 首选模型工具调用校验失败,当前步骤切换到兜底模型") current_messages.append( SystemMessage( content="本步骤已切换兜底模型。请修正上面的工具参数后再调用。" ) ) continue logger.warning("Some action calls failed validation. Feeding back to LLM.") continue no_progress_attempts = 0 else: if no_progress_attempts: raise OperatorNoProgressError( "模型在本轮工具调用预算内未恢复有效调用,已停止任务以避免空转。" ) tool_limit_exceeded = True raw_thinking = "\n".join(raw_thoughts) if raw_thoughts else None native_thinking = "\n".join(native_thoughts) if native_thoughts else None return ( action_result, raw_thinking, native_thinking, tool_limit_exceeded, ) @wrap_with_callbacks( before=lambda: logger.info("Starting Operator Agent..."), on_success=lambda _: logger.success("Operator Agent"), on_failure=lambda _: logger.error("Operator Agent"), ) @trace(type="agent", name="operator") async def __call__(self, state: State): state = strict_state(state, "operator") # 1. Get perception data from state (populated by Perception node) operator_raw_data = getattr(state, "operator_raw_data", {}) or {} latest_screenshot_b64 = operator_raw_data.get("screenshot_b64") xml_hierarchy = operator_raw_data.get("xml_hierarchy") operator_raw_data.get("ocr_results") fused_xml = state.latest_ui_hierarchy if hasattr(state, "latest_ui_hierarchy") else [] state.latest_screenshot if hasattr(state, "latest_screenshot") else None if ( not latest_screenshot_b64 and getattr(self.ctx, "execution_target", "android") != "computer" ) or xml_hierarchy is None: logger.error( "Operator requires perception data in state (populated by Perception node)" ) raise ValueError("Operator requires perception data in state") # 3. Get history and plan steps, task_plan = self._get_history_and_plan() self._turn_plan_baseline = self._plan_ledger_baseline(task_plan) self._turn_ledger_streak = unwritten_action_streak(steps) # 4. Format minimal list width = 1080 height = 2400 w_raw = operator_raw_data.get("width") h_raw = operator_raw_data.get("height") if isinstance(w_raw, int) and isinstance(h_raw, int): width = w_raw height = h_raw else: try: if self.ctx and hasattr(self.ctx, "device") and self.ctx.device: w = getattr(self.ctx.device, "device_width", 1080) h = getattr(self.ctx.device, "device_height", 2400) if isinstance(w, int): width = w if isinstance(h, int): height = h except (AttributeError, TypeError): pass minimal_list, elements, labels = format_minimal_list_with_elements(fused_xml, width, height) if getattr(self.ctx, "execution_target", "android") == "computer": minimal_list = operator_raw_data.get("observation_text", "尚未取得电脑观察结果") # Save transient indexed_points and indexed_elements on the state instance state.indexed_points = [el["center"] for el in elements] state.indexed_elements = elements current_step_num = len(steps) + 1 # 5. Prepare Tools. Device-action shells come from the canonical manifest # (artemis/mcp/action_specs.py) and are assembled against the installed # actuator backend's capabilities: an action the backend does not implement # is simply never declared (and the prompt assembly drops its teaching # segments in lockstep). available_actions = self._available_device_actions() all_tools = [ operator_shell_tool(name) for name in OPERATOR_SHELL_ORDER if name in available_actions ] + self.tools # 5. Mount the task-output analyzer and collect background ADB task state. # The analyzer is always available: the "output truncated, use # analyze_task_output" hint arrives mid-turn, and tools are bound once per # turn, so a conditional mount would be one turn late. if not any(t.name == "analyze_task_output" for t in all_tools): all_tools.append(analyze_task_output_wrapper.tool_fn_getter(self.ctx)) traced_tools = [trace_langchain_tool(t, self.ctx) for t in all_tools] adb_registry = get_adb_task_registry(self.ctx) active_background_tasks = adb_registry.active_task_summaries() newly_finished_tasks = adb_registry.pop_unnotified_finished() # 6. Prepare LLM llm = get_llm(ctx=self.ctx, name="operator") # 7. Build Prompt messages = await self._build_prompt( state, latest_screenshot_b64, fused_xml, minimal_list, current_step_num, steps, task_plan, active_background_tasks=active_background_tasks, newly_finished_tasks=newly_finished_tasks, ) if self._transcript_turn_tail is None and getattr(self.ctx, "plugins", None): from langchain_core.messages import SystemMessage messages.insert(0, SystemMessage(content=self.ctx.plugins.prompt())) if self._transcript_turn_tail is None and getattr(self.ctx, "exploration_id", None): from artemis.exploration.runtime import exploration_messages rules, guidance = exploration_messages(self.ctx) messages.insert(0, rules) messages.append(guidance) # 8. Invoke LLM Loop new_subagent_calls = [] ( action_result, raw_thinking, native_thinking, tool_limit_exceeded, ) = await self._invoke_llm_loop( base_llm=llm, current_messages=messages, traced_tools=traced_tools, new_subagent_calls=new_subagent_calls, state=state, ) self._previous_turn_silent = not (raw_thinking and raw_thinking.strip()) # 8b. Transcript path: hold this turn's messages (observation tail + # tool-loop products) for commit at the next build, when the turn's # step id and validator result exist. self._stage_transcript_turn(messages) # 9. Update State structured_decisions = json.dumps(action_result) if action_result else None return { "structured_decisions": structured_decisions, "execution_report": state.execution_report, "operator_raw_thinking": raw_thinking, "operator_native_thinking": native_thinking, "indexed_points": state.indexed_points, "indexed_elements": state.indexed_elements, "current_step_id": state.current_step_id, "subagent_calls": ((state.subagent_calls or []) + new_subagent_calls), "operator_tool_limit_exceeded": tool_limit_exceeded, } def _exploration_ready_for_report(self, state: State) -> bool: if not getattr(self.ctx, "exploration_id", None): return False from artemis.exploration.runtime import needs_exploration_turn snapshot = parse_plan(self._read_task_plan_text() or "") return bool( snapshot.has_top_level and snapshot.all_top_level_done and (not snapshot.continuous_top_level or getattr(state, "user_stop_requested", False)) and not needs_exploration_turn(self.ctx) ) def _plan_ledger_gate_enabled(self) -> bool: try: from artemis.config import load_agent_config return bool(load_agent_config().pro.execution.plan_ledger_gate) except (AttributeError, OSError, TypeError, ValueError): return True def _read_task_plan_text(self) -> str | None: """Current ``task_plan`` note text, or ``None`` when it cannot be read.""" engine = getattr(self.ctx, "data_engine", None) if not engine: return None try: path = get_note_file_path(engine.base_dir, "task_plan") if not path.exists(): return None return path.read_text(encoding="utf-8") except (AttributeError, OSError, TypeError) as e: logger.warning(f"Plan ledger gate: failed to read task_plan: {e}") return None def _plan_ledger_baseline(self, task_plan: str) -> str | None: """Return the plan text used as the current turn's baseline.""" if not self._plan_ledger_gate_enabled() or not getattr(self.ctx, "data_engine", None): return None if not parse_plan(task_plan).has_top_level: return None return task_plan def _plan_ledger_stale_turns(self) -> int: try: from artemis.config import load_agent_config return int(load_agent_config().pro.execution.plan_ledger_stale_turns) except (AttributeError, OSError, TypeError, ValueError): return 4 def _plan_ledger_bounce_reason(self) -> str | None: """Bounce message when the (unchanged) ledger is broken or stale, else ``None``.""" current = self._read_task_plan_text() if current is None: return None return render_plan_ledger_bounce( current, self._turn_ledger_streak, self._plan_ledger_stale_turns() ) def _plan_changed_this_turn(self) -> bool | None: """Whether ``task_plan`` differs from the turn's baseline (``None``: gate inactive).""" baseline = self._turn_plan_baseline if baseline is None: return None current = self._read_task_plan_text() if current is None: return None return current != baseline def _get_active_subgoal_hash(self) -> str: """Parses task_plan.md to find the active top-level subgoal hash.""" if not self.ctx.data_engine: return "default" task_plan_path = get_note_file_path(self.ctx.data_engine.base_dir, "task_plan") if not task_plan_path.exists(): return "default" try: content = task_plan_path.read_text(encoding="utf-8") parent_hash, _ = get_active_subgoal_hashes(content) return parent_hash except Exception as e: logger.error(f"Failed to parse active subgoal: {e}") return "default" def _build_plan_and_history(self, steps: list[dict[str, Any]], task_plan: str) -> str: """Builds a plan checklist and separate execution history.""" active_subgoal_hash = self._get_active_subgoal_hash() return build_history_for( "operator", task_plan, steps, active_subgoal_hash, last_n_detailed=self.last_n_detailed, ) def _max_burst_actions(self) -> int: """Configured ceiling for actions in one fast-action burst (default 4).""" try: from artemis.config import load_agent_config return int(load_agent_config().pro.execution.max_burst_actions) except Exception: return 4 def _translate_and_validate_tool(self, tc: dict, state: State) -> tuple[list[dict], str | None]: actions, err = self._translate_and_validate_tool_inner(tc, state) if err: return [], err return actions, None def _translate_and_validate_tool_inner( self, tc: dict, state: State ) -> tuple[list[dict], str | None]: tool_name = tc["name"].split(":")[-1] if ":" in tc["name"] else tc["name"] args = tc["args"] from artemis.plugins.runtime import get_plugin_runtime plugins = get_plugin_runtime(self.ctx) if plugins and tool_name in plugins.bindings: return [{"action": tool_name, "args": args, "tool_call_id": tc["id"]}], None # Get device resolution fallbacks width = 1080 height = 2400 operator_raw_data = getattr(state, "operator_raw_data", {}) or {} w_raw = operator_raw_data.get("width") h_raw = operator_raw_data.get("height") if isinstance(w_raw, int) and isinstance(h_raw, int): width = w_raw height = h_raw else: try: if self.ctx and hasattr(self.ctx, "device") and self.ctx.device: w = getattr(self.ctx.device, "device_width", 1080) h = getattr(self.ctx.device, "device_height", 2400) if isinstance(w, int): width = w if isinstance(h, int): height = h except (AttributeError, TypeError): pass def resolve_target_element(target: Any, description: Any) -> tuple[dict | None, str | None]: """Resolves a target to ``{"center": [x, y], **semantics}``. An element index carries observed semantics (text, bounds, id, class straight from the indexed list); a coordinate pair carries only the model's own ``target_description``. The two never mix. """ target = normalize_coordinate_target(target) if isinstance(target, bool): return None, "Error: Boolean values are not valid target indices." if isinstance(target, (int, float)): try: target_int = int(target) except (ValueError, TypeError): return None, f"Error: Invalid target index {target}." indexed_elements = getattr(state, "indexed_elements", None) or [] if 1 <= target_int <= len(indexed_elements): el = indexed_elements[target_int - 1] return { "center": el.get("center"), "target_text": el.get("text"), "target_bounds": el.get("bounds"), "target_resource_id": el.get("resource_id"), "target_class": el.get("class"), }, None return ( None, ( f"Error: Invalid target index {target_int}. Active" f" index range is 1 to {len(indexed_elements)}." ), ) elif isinstance(target, list) and len(target) == 2: described = _described(description) if described is None: return ( None, ( f"Error: Coordinate target {target} requires" " 'target_description' (what the element is, in a few" " words). Provide it, or address the element by index." ), ) try: nx, ny = map(float, target) x = int(max(0, min(width - 1, nx * width / 1000))) y = int(max(0, min(height - 1, ny * height / 1000))) return {"center": [x, y], "target_description": described}, None except (ValueError, TypeError): return ( None, (f"Error: Invalid coordinates {target}. Must be list of 2 numbers."), ) return ( None, ( f"Error: Invalid target parameter {target}. Must be index" " (int/float) or coordinates [x, y]." ), ) def target_fields(el: dict) -> dict: """The recorded target semantics: observed (index) or described (coords).""" return {k: v for k, v in el.items() if k != "center"} if tool_name == "click": target = args.get("target") times = args.get("times", 1) delay_ms = args.get("delay_ms", 100) el, err = resolve_target_element(target, args.get("target_description")) if err: return [], err norm_c = [ int(round(el["center"][0] * 1000.0 / width)), int(round(el["center"][1] * 1000.0 / height)), ] return [ { "action": "tap", "coordinates": el["center"], COORDINATE_SPACE_KEY: COORDINATE_SPACE_PIXEL, "normalized_coordinates": norm_c, "times": times, "delay_ms": delay_ms, **target_fields(el), } ], None elif tool_name == "long_press": target = args.get("target") duration = args.get("duration", 1000) el, err = resolve_target_element(target, args.get("target_description")) if err: return [], err norm_c = [ int(round(el["center"][0] * 1000.0 / width)), int(round(el["center"][1] * 1000.0 / height)), ] return [ { "action": "long_press_on", "coordinates": el["center"], COORDINATE_SPACE_KEY: COORDINATE_SPACE_PIXEL, "normalized_coordinates": norm_c, "duration": duration, **target_fields(el), } ], None elif tool_name == "input_text": text = args.get("text") target = args.get("target") clear_exist = args.get("clear_exist", True) if text is None: return [], "Error: Missing required argument 'text'." if not isinstance(text, str): return ( [], (f"Error: 'text' must be a string, got {type(text).__name__}."), ) el, err = resolve_target_element(target, args.get("target_description")) if err: return [], err norm_c = [ int(round(el["center"][0] * 1000.0 / width)), int(round(el["center"][1] * 1000.0 / height)), ] actions = [] actions.append( { "action": "focus_and_input_text", "coordinates": el["center"], COORDINATE_SPACE_KEY: COORDINATE_SPACE_PIXEL, "normalized_coordinates": norm_c, "text": text, "clear_before_input": clear_exist, **target_fields(el), } ) return actions, None elif tool_name == "swipe": kind, target, parsed_duration = parse_swipe_parameters(args, default_duration=None) duration = args.get("duration") if duration is not None: if not isinstance(duration, (int, float)): try: duration = int(duration) except (ValueError, TypeError): return ( [], (f"Error: 'duration' must be a number, got {type(duration).__name__}."), ) else: duration = int(duration) else: duration = parsed_duration # Only a coordinate gesture needs the model to say what it drags; a # directional scroll targets the page, not an element. described: str | None = None if kind == "direction": x1, y1, x2, y2, smart_dur = compute_smart_swipe_coordinates( direction=target, target=args.get("target"), indexed_elements=getattr(state, "indexed_elements", None), ui_hierarchy=getattr(state, "latest_ui_hierarchy", None), width=width, height=height, duration=duration, ) if duration is None: duration = smart_dur elif kind == "coords" and isinstance(target, list) and len(target) == 4: described = _described(args.get("target_description")) if described is None: return ( [], ( f"Error: Coordinate swipe {target} requires" " 'target_description' (what is being dragged, in a few" " words). Provide it, or use a directional swipe." ), ) try: nx1, ny1, nx2, ny2 = map(float, target) x1 = int(max(0, min(width - 1, nx1 * width / 1000))) y1 = int(max(0, min(height - 1, ny1 * height / 1000))) x2 = int(max(0, min(width - 1, nx2 * width / 1000))) y2 = int(max(0, min(height - 1, ny2 * height / 1000))) except (ValueError, TypeError): return ( [], f"Error: Invalid custom swipe coordinates {target}.", ) else: return ( [], ( f"Error: Invalid swipe gesture parameters {args}. Must be" " direction string or custom coordinates." ), ) # Calculate duration automatically if not provided (safe range 350-900ms to eliminate Android inertial fling) if duration is None: dist = ((x2 - x1) ** 2 + (y2 - y1) ** 2) ** 0.5 if dist <= 300: duration = 350 elif dist <= 600: duration = 550 elif dist <= 1000: duration = 800 else: duration = 900 nx1, ny1 = int(round(x1 * 1000.0 / width)), int(round(y1 * 1000.0 / height)) nx2, ny2 = int(round(x2 * 1000.0 / width)), int(round(y2 * 1000.0 / height)) return [ { "action": "swipe", "coordinates": [x1, y1, x2, y2], COORDINATE_SPACE_KEY: COORDINATE_SPACE_PIXEL, "normalized_coordinates": [nx1, ny1, nx2, ny2], "normalized_start_coordinates": [nx1, ny1], "normalized_end_coordinates": [nx2, ny2], "duration": duration, # A directional scroll keeps the direction the agent issued so # history and replays render "Swiped up (...)" rather than a # bare coordinate path. **({"direction": str(target)} if kind == "direction" else {}), **({"target_description": described} if described else {}), } ], None elif tool_name == "press_key": key = args.get("key") or args.get("keycode") if not key: return [], "Error: Missing required argument 'key'." if not isinstance(key, str): return ( [], f"Error: 'key' must be a string, got {type(key).__name__}.", ) normalized_key = key.strip().upper() if normalized_key.startswith("KEYCODE_"): normalized_key = normalized_key[len("KEYCODE_") :] if normalized_key not in SUPPORTED_PRESS_KEYS: return ( [], f"Error: Unsupported key '{key}'. Supported keys:" f" {', '.join(SUPPORTED_PRESS_KEYS)}.", ) return [{"action": "press_key", "keycode": f"KEYCODE_{normalized_key}"}], None elif tool_name == "manage_app": action = args.get("action") app_name = args.get("app_name") if action not in ["launch", "stop"]: return ( [], f"Error: Unsupported app management action '{action}'.", ) if not app_name or not isinstance(app_name, str): return [], "Error: 'app_name' must be a valid non-empty string." action_str = "launch_app" if action == "launch" else "stop_app" return [{"action": action_str, "app_name": app_name}], None elif tool_name == "wait_for_delay": time_in_ms = args.get("time_in_ms") if time_in_ms is None: return [], "Error: Missing required argument 'time_in_ms'." try: time_in_ms = int(time_in_ms) except (ValueError, TypeError): return [], f"Error: Invalid time_in_ms value {time_in_ms}." return [{"action": "wait_for_delay", "time_in_ms": time_in_ms}], None return [], f"Error: Unsupported tool name '{tool_name}'."