Files
Artemis-Eapil/artemis/tools/tool_wrapper.py
T
somew1nd 9a5aaf99b7 feat: introduce MCP server, cross-platform runtime, and Showcase UI reorganization
- Showcase UI Reorganization:
  * Move web UI dashboard from root directory into apps/showcase_ui/ for cleaner project structure
  * Add 'artemis ui' CLI command (artemis/interfaces/cli/commands/ui.py) and launcher scripts for the interactive showcase console
- Standalone MCP Server & Notifiers (mcp_server/):
  * Implement dedicated MCP server package with mobile automation tools (device_state, inspect_trace, task_manager, task_runner)
  * Add multi-channel notifiers (AgentAPI, Desktop notifications, Webhooks, File output, and Composite routing)
  * Introduce background task runner and trace store for autonomous mobile execution monitoring
- Cross-Platform Runtime & Toolchain Support:
  * Add artemis/platform/ with OS-specific implementations for Linux, macOS (Darwin), and Windows
  * Add artemis/runtime/supervisor.py for resilient process and agent runtime supervision
  * Add artemis/toolchain/ with dependency resolution and SDK descriptors
  * Add cross-platform launcher scripts (start.sh, start.bat) and installer scripts (scripts/install_deps.*)
- Core Enhancements & Diagnostics:
  * Enhance coordinate normalization, OCR API fallback, and perception pipeline
  * Add credential validation (credentials_validator.py) and system diagnostics
  * Update bilingual documentation (README.md, README_CN.md) and architecture diagrams
- Test Suite Expansion:
  * Add integration tests for cloud gateway, remote ADB, and Artemis login
  * Add unit tests covering core diagnostics, platform detection, toolchain, credentials validator, optional OCR, and MCP tools
2026-08-18 13:59:27 -07:00

181 lines
6.6 KiB
Python

# 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.
from collections.abc import Callable
import inspect
import time
from typing import Annotated, Any, get_args, get_origin
from unittest.mock import MagicMock, Mock
import uuid
from langchain_core.tools import BaseTool
from langchain_core.tools.base import InjectedToolCallId
from langgraph.prebuilt import InjectedState
from langgraph.types import Command
from pydantic import BaseModel
from artemis.constants import VALIDATOR_MESSAGES_KEY
from artemis.context import ArtemisContext
from artemis.data_engine import engine as engine_mod
from artemis.data_engine.trace import CURRENT_TRACE_ID, smart_serialize
from artemis.graph.state import State
from artemis.tools.types import CyFunctionDetector
class ToolWrapper(BaseModel):
"""Wrapper holding a tool factory and lifecycle callbacks."""
model_config = {"ignored_types": (CyFunctionDetector,)}
tool_fn_getter: Callable[[ArtemisContext], BaseTool]
on_success_fn: Callable[..., str]
on_failure_fn: Callable[..., str]
is_available_fn: Callable[[ArtemisContext], bool] | None = None
class CompositeToolWrapper(ToolWrapper):
"""Wrapper holding a composite tool factory and lifecycle callbacks."""
composite_tools_fn_getter: Callable[[ArtemisContext], list[BaseTool]]
# pylint: disable=too-many-branches,too-many-locals,too-many-statements
async def invoke_tool_with_injection(
tool: BaseTool,
args: dict,
tool_call_id: str,
state: State | None = None,
record_trace: bool = True,
) -> Any:
"""Invokes a LangChain tool, manually injecting arguments marked with
InjectedState or InjectedToolCallId if calling the underlying function
directly.
Automatically records type='tool' trace in DataEngine.
"""
final_args = dict(args)
if isinstance(tool, (Mock, MagicMock)):
has_real_coro = hasattr(tool, "coroutine") and not isinstance(
tool.coroutine, (Mock, MagicMock)
)
has_real_func = hasattr(tool, "func") and not isinstance(tool.func, (Mock, MagicMock))
if not has_real_coro and not has_real_func:
if (
state is not None
and hasattr(tool, "args")
and isinstance(tool.args, dict)
and "state" in tool.args
):
final_args["state"] = state
return await tool.ainvoke(final_args)
# For StructuredTool, the original function might be in tool.func or tool.coroutine
func = None
if hasattr(tool, "coroutine") and tool.coroutine:
func = tool.coroutine
elif hasattr(tool, "func") and tool.func:
func = tool.func
if func:
try:
sig = inspect.signature(func)
for param_name, param in sig.parameters.items():
annotation = param.annotation
# Handle Annotated types
if get_origin(annotation) is Annotated:
args_list = get_args(annotation)
if InjectedState in args_list:
final_args[param_name] = state
elif InjectedToolCallId in args_list:
final_args[param_name] = tool_call_id
except Exception: # pylint: disable=broad-exception-caught
# If inspection fails, fall back to just using provided args
pass
trace_id = uuid.uuid4()
parent_id = CURRENT_TRACE_ID.get()
token = CURRENT_TRACE_ID.set(trace_id)
start_time = time.time()
live_engine = getattr(engine_mod, "_CURRENT_DATA_ENGINE", None)
if record_trace and live_engine:
step_id = getattr(live_engine, "current_step_id", None)
live_engine.record_trace(
type="tool",
name=tool.name,
payload={"args": {k: smart_serialize(v) for k, v in args.items()}},
status="running",
parent_trace_id=parent_id,
step_id=step_id,
trace_id=trace_id,
)
execution_status = "running"
execution_result = None
execution_error = None
try:
# Call the function
if inspect.iscoroutinefunction(func):
result = await func(**final_args)
else:
result = func(**final_args)
execution_status = "success"
execution_result = result
return result
except Exception as e:
execution_status = "failed"
execution_error = e
raise e
finally:
duration = time.time() - start_time
if record_trace and live_engine:
step_id = getattr(live_engine, "current_step_id", None)
payload = {"args": {k: smart_serialize(v) for k, v in args.items()}}
if execution_status == "success":
payload["result"] = smart_serialize(get_tool_result_content(execution_result))
elif execution_status == "failed":
payload["error"] = str(execution_error)
else:
execution_status = "terminated"
payload["error"] = "Execution terminated unexpectedly."
live_engine.record_trace(
type="tool",
name=tool.name,
payload=payload,
status=execution_status,
duration=duration,
parent_trace_id=parent_id,
step_id=step_id,
trace_id=trace_id,
)
CURRENT_TRACE_ID.reset(token)
else:
# Fallback to ainvoke if no direct func available
return await tool.ainvoke(final_args)
def get_tool_result_content(result: Any) -> Any:
"""Extracts the content from a tool result, handling Command objects."""
if isinstance(result, Command):
updates = result.update
if VALIDATOR_MESSAGES_KEY in updates:
msgs = updates[VALIDATOR_MESSAGES_KEY]
if msgs and hasattr(msgs[0], "content"):
return msgs[0].content
return result
return result