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
This commit is contained in:
somew1nd committed 2026-08-18 13:59:27 -07:00
1 parent 78e478f502
commit 9a5aaf99b7
249 files changed
+37226 -9339

No files matched your search

+27
View File
@@ -0,0 +1,27 @@
# 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.
"""MCP Tools package for ARTEMIS."""
from mcp_server.tools.device_state import mobile_get_device_state
from mcp_server.tools.inspect_trace import mobile_inspect_trace
from mcp_server.tools.task_manager import mobile_manage_task
from mcp_server.tools.task_runner import mobile_run_task
__all__ = [
"mobile_run_task",
"mobile_manage_task",
"mobile_get_device_state",
"mobile_inspect_trace",
]
+104
View File
@@ -0,0 +1,104 @@
# 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.
"""MCP Tool: mobile_get_device_state."""
import base64
import os
from mcp_server.base import mcp
from artemis.mcp.adb_server import _get_controller
from mcp_server.utils import env_utils
from artemis.utils.ocr_xml_fusion import (
fuse_ocr_with_xml,
_detect_status_bar_height,
_crop_image_remove_status_bar,
_map_coordinates_back,
)
from artemis.utils.ocr_api import perform_ocr
from artemis.utils.visualization import format_minimal_list_with_elements
@mcp.tool()
async def mobile_get_device_state(view_type: str) -> str:
"""Real-time mobile device state observer (for debugging and validation).
Use this tool to inspect the latest mobile status or to assist in tracking
the subagent's task progress.
Retrieves a real-time screenshot (returns local file URI) or a simplified
element tree text list of the currently connected mobile device.
Args:
view_type: The observation type. Supported values: - "screenshot":
Captures the real-time mobile screen and saves it to the workspace,
returning the local URI path of the image file (e.g.,
"file:///path/to/screenshot.jpg"). - "hierarchy": Directly returns the
simplified text-labeled element list, which is identical to what the
mobile UI automation subagent sees when making decisions.
"""
try:
controller = _get_controller()
device_width = controller.ctx.device.device_width
device_height = controller.ctx.device.device_height
except Exception as e:
return f"Error: Failed to initialize/lock Android device controller: {e}"
try:
device_data = await controller.get_screen_data()
latest_screenshot_b64 = device_data.base64
xml_hierarchy = device_data.elements
project_root = env_utils.get_project_root()
if view_type == "screenshot":
screenshot_bytes = base64.b64decode(latest_screenshot_b64)
device_id = controller.ctx.device.device_id
safe_device_id = "".join(
[c if c.isalnum() or c in ("-", "_") else "_" for c in device_id]
)
screenshot_filename = f"live_screenshot_{safe_device_id}.jpg"
screenshot_path = os.path.join(project_root, screenshot_filename)
with open(screenshot_path, "wb") as f:
f.write(screenshot_bytes)
return f"file://{screenshot_path}"
elif view_type == "hierarchy":
try:
screen_height = device_data.height
status_bar_height = _detect_status_bar_height(xml_hierarchy, screen_height)
if status_bar_height > 0:
cropped_b64, _, _ = _crop_image_remove_status_bar(
latest_screenshot_b64, status_bar_height
)
raw_ocr_results = await perform_ocr(cropped_b64)
ocr_results = _map_coordinates_back(raw_ocr_results, status_bar_height)
else:
ocr_results = await perform_ocr(latest_screenshot_b64)
except Exception:
ocr_results = []
fused_xml = fuse_ocr_with_xml(xml_hierarchy, ocr_results)
hierarchy_text, elements, labels = format_minimal_list_with_elements(
fused_xml, width=device_width, height=device_height
)
return hierarchy_text
else:
return f"Error: Invalid view_type '{view_type}'. Supported types are 'screenshot' and 'hierarchy'."
except Exception as e:
return f"Error: An unexpected error occurred while communicating with the device: {e}"
+465
View File
@@ -0,0 +1,465 @@
# 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.
"""MCP Tool: mobile_inspect_trace."""
import json
import math
import os
import sqlite3
import sys
from typing import Any
from PIL import Image, ImageDraw
from mcp_server.base import mcp
from mcp_server.utils import env_utils, trace_store
from artemis.utils.task_tree import (
_render_step_detailed,
build_plan_and_history,
)
def _draw_action_overlay(bg_image_path: str, action_obj: dict, output_path: str) -> bool:
"""Draws the action (tap, swipe, etc.) on the image and saves it."""
try:
with Image.open(bg_image_path) as img:
draw_img = img.convert("RGBA")
draw = ImageDraw.Draw(draw_img)
act_type = action_obj.get("action") or action_obj.get("name")
coords = action_obj.get("coordinates") or action_obj.get("target")
if not act_type or not coords:
return False
fill_color = (255, 0, 0, 80)
outline_color = (255, 0, 0, 255)
outline_width = 4
if act_type in (
"tap",
"click",
"long_press",
"focus_and_input_text",
"focus_and_clear_text",
):
if isinstance(coords, list) and len(coords) == 2:
x, y = coords[0], coords[1]
r = 25
draw.ellipse(
[x - r, y - r, x + r, y + r],
fill=fill_color,
outline=outline_color,
width=outline_width,
)
r_center = 4
draw.ellipse(
[
x - r_center,
y - r_center,
x + r_center,
y + r_center,
],
fill=outline_color,
outline=outline_color,
)
elif act_type == "swipe":
if isinstance(coords, list) and len(coords) == 4:
x1, y1, x2, y2 = coords[0], coords[1], coords[2], coords[3]
draw.line([x1, y1, x2, y2], fill=outline_color, width=6)
r_start = 8
draw.ellipse(
[
x1 - r_start,
y1 - r_start,
x1 + r_start,
y1 + r_start,
],
fill=outline_color,
)
dx = x2 - x1
dy = y2 - y1
angle = math.atan2(dy, dx)
arrow_length = 30
arrow_angle = math.pi / 6
p1_x = x2 - arrow_length * math.cos(angle - arrow_angle)
p1_y = y2 - arrow_length * math.sin(angle - arrow_angle)
p2_x = x2 - arrow_length * math.cos(angle + arrow_angle)
p2_y = y2 - arrow_length * math.sin(angle + arrow_angle)
draw.polygon([x2, y2, p1_x, p1_y, p2_x, p2_y], fill=outline_color)
final_img = draw_img.convert("RGB")
final_img.save(output_path, "JPEG", quality=85)
return True
except Exception as e:
print(f"Error drawing action overlay: {e}", file=sys.stderr)
return False
@mcp.tool()
async def mobile_inspect_trace(action: str, trace_id: str, step_number: int | None = None) -> Any:
"""A specialized trace data retrieval tool designed to extract execution details of mobile automation tasks.
Use this tool DURING a running task or AFTER it has finished (or failed) to
monitor progress, verify answers, diagnose agent errors, and debug.
It provides comprehensive runtime information, raw agent outputs, and visual
screenshots for both **Flash** and **Pro** tasks.
### Available Actions:
- **'view_summary'**: Retrieves a high-level execution summary across all
steps.
* **Pro Model**: Displays a hierarchical task plan summary, showing the
status and descriptions of all completed, active, and pending steps.
* **Flash Model**: Displays an all-in-one execution chain containing the
complete reasoning (thoughts/motivation) and action taken (`click`,
`input_text`, etc.) for every single step.
- **'view_step_screenshots'**: Retrieves local file paths of screenshots for
a specific step (supports both Flash and Pro). Returns:
1. `before_screenshot`: The original device screenshot observed by the
agent right before taking the action.
2. `after_screenshot`: A post-action screenshot capturing the failure or
mismatch state (if the action failed or was intercepted). Note: This is
conditional/optional and will be `None` (`null`) for normal/successful
steps where no extra post-action screenshot was captured.
3. `action_overlay_screenshot`: The `before_screenshot` with the agent's
action visually marked (e.g., red circle for taps, red arrow for
swipes). CRITICAL for verifying if the agent tapped the correct UI
element.
- **'view_step_details'**: Retrieves deep runtime logs, raw VLM thoughts,
and exact actions for a specific step.
* **Pro Model**: Fully supported. Returns detailed per-step diagnostic
data.
* **Flash Model**: DO NOT USE. (Flash reasoning and actions are already
fully included in 'view_summary', making this action redundant).
Args:
action: STR. **REQUIRED**. The retrieval action to perform
(`"view_summary"`, `"view_step_screenshots"`, or
`"view_step_details"`).
trace_id: STR. **REQUIRED**. The unique session identifier of the task
(returned by `mobile_run_task`).
step_number: INT. **CONDITIONAL**. The 1-indexed step number to query. -
You MUST provide this if `action="view_step_screenshots"` or
`action="view_step_details"`. - You MUST omit or leave this empty if
`action="view_summary"`.
"""
project_root = env_utils.get_project_root()
db_path = os.path.join(trace_store.TRACES_DIR, "data_engine.db")
if not os.path.exists(db_path):
db_path = os.path.join(project_root, "traces", "data_engine.db")
if not os.path.exists(db_path):
return {
"error": "Database not found",
"message": f"Data engine database does not exist at {db_path} yet.",
}
# 1. Handle Action: "view_summary"
if action == "view_summary":
plan_content = None
plan_path = os.path.join(trace_store.get_trace_dir(trace_id), "notes", "task_plan.md")
if not os.path.exists(plan_path):
plan_path = os.path.join(trace_store.TRACES_DIR, "notes", "task_plan.md")
if os.path.exists(plan_path):
try:
with open(plan_path, encoding="utf-8") as f:
plan_content = f.read()
except Exception:
pass
try:
conn = sqlite3.connect(db_path)
conn.row_factory = sqlite3.Row
cursor = conn.cursor()
cursor.execute(
"SELECT start_time FROM sessions WHERE session_id = ?",
(trace_id,),
)
session_row = cursor.fetchone()
session_start_time = session_row["start_time"] if session_row else None
cursor.execute(
"SELECT * FROM steps WHERE session_id = ? ORDER BY step_number ASC",
(trace_id,),
)
rows = cursor.fetchall()
steps = []
for r in rows:
step_dict = dict(r)
for col in ("action_taken", "last_execution_result", "extra_metadata"):
if step_dict.get(col):
try:
step_dict[col] = json.loads(step_dict[col])
except Exception:
pass
cursor.execute(
"SELECT type, name, status, timestamp, duration, payload FROM traces WHERE step_id = ? ORDER BY timestamp ASC",
(step_dict["step_id"],),
)
trace_rows = cursor.fetchall()
events = []
for tr in trace_rows:
t_dict = dict(tr)
if t_dict.get("payload"):
try:
t_dict["payload"] = json.loads(t_dict["payload"])
except Exception:
pass
events.append(t_dict)
step_dict["interleaved_events"] = events
if session_start_time and step_dict.get("timestamp"):
relative_time_val = step_dict["timestamp"] - session_start_time
step_dict["relative_time"] = f"{relative_time_val:.1f}s"
else:
step_dict["relative_time"] = "N/A"
steps.append(step_dict)
conn.close()
except Exception as e:
return {
"error": "Database query error",
"message": f"Failed to retrieve steps from database: {e}",
}
status_data = trace_store.read_status(trace_id)
is_flash = status_data and status_data.get("model", "").lower() == "flash"
if is_flash:
summary_lines = [
"# ARTEMIS Flash Execution Summary",
f"**Session ID:** `{trace_id}` | **Model:** Flash\n",
"---",
"## Step-by-Step Execution Chain\n",
]
if not steps:
summary_lines.append("_No action steps have been recorded yet._")
else:
for s in steps:
s_num = s.get("step_number", "?")
rel_t = s.get("relative_time", "N/A")
dur_val = s.get("duration")
if dur_val is None and s.get("interleaved_events"):
events = s["interleaved_events"]
timestamps = [e.get("timestamp", 0) for e in events if e.get("timestamp")]
if timestamps:
dur_val = max(timestamps) - min(timestamps)
dur_str = f"{dur_val:.1f}s" if dur_val is not None and dur_val > 0 else "< 0.5s"
thought = s.get("operator_raw_thinking")
if not thought or not thought.strip():
for ev in s.get("interleaved_events", []):
if ev.get("type") in ("raw_thinking", "llm_call") and ev.get("payload"):
p = ev["payload"]
if isinstance(p, dict) and p.get("response_text"):
thought = p["response_text"]
break
if not thought or not thought.strip():
thought = "(Direct action without preliminary text reasoning)"
act = s.get("action_taken") or "N/A"
if isinstance(act, dict) and "action" in act:
act_str = f"{act['action']}({act.get('args', act.get('coordinates', ''))})"
else:
act_str = str(act)
res = s.get("last_execution_result") or "N/A"
summary_lines.append(f"### Step {s_num} (at {rel_t}, duration {dur_str})")
summary_lines.append(f"- **Thinking / Motivation:** {thought}")
summary_lines.append(f"- **Action Taken:** `{act_str}` -> `{res}`\n")
return "\n".join(summary_lines)
try:
summary_markdown = build_plan_and_history(
task_plan=plan_content or "No task plan created yet.",
steps=steps,
current_subgoal_hash="default",
min_summaries=len(steps),
last_n_detailed=0,
all_detailed=False,
)
return summary_markdown
except Exception as e:
return {
"error": "Formatting error",
"message": f"Failed to format plan and history using Artemis task tree: {e}",
}
# 2. Handle Actions requiring step_number
elif action in ("view_step_screenshots", "view_step_details"):
status_data = trace_store.read_status(trace_id)
is_flash = status_data and status_data.get("model", "").lower() == "flash"
if action == "view_step_details" and is_flash:
return {
"error": "Not applicable for Flash model",
"message": (
"Action 'view_step_details' is not needed for Flash model runs. "
"For Flash tasks, 'view_summary' already outputs the complete "
"step-by-step reasoning (thoughts) and executed actions for the entire run."
),
}
if step_number is None:
return {
"error": "Missing parameter",
"message": f"Parameter 'step_number' is required for action '{action}'.",
}
try:
conn = sqlite3.connect(db_path)
conn.row_factory = sqlite3.Row
cursor = conn.cursor()
cursor.execute(
"SELECT * FROM steps WHERE session_id = ? AND step_number = ?",
(trace_id, step_number),
)
row = cursor.fetchone()
if not row:
conn.close()
return {
"error": "Step not found",
"message": f"Step number {step_number} not found for trace '{trace_id}'.",
}
step_dict = dict(row)
for col in ("action_taken", "last_execution_result", "extra_metadata"):
if step_dict.get(col):
try:
step_dict[col] = json.loads(step_dict[col])
except Exception:
pass
cursor.execute(
"SELECT start_time FROM sessions WHERE session_id = ?",
(trace_id,),
)
session_row = cursor.fetchone()
session_start_time = session_row["start_time"] if session_row else None
cursor.execute(
"SELECT type, name, status, timestamp, duration, payload FROM traces WHERE step_id = ? ORDER BY timestamp ASC",
(step_dict["step_id"],),
)
trace_rows = cursor.fetchall()
events = []
for tr in trace_rows:
t_dict = dict(tr)
if t_dict.get("payload"):
try:
t_dict["payload"] = json.loads(t_dict["payload"])
except Exception:
pass
events.append(t_dict)
step_dict["interleaved_events"] = events
conn.close()
except Exception as e:
return {
"error": "Database error",
"message": f"Database query failed: {e}",
}
# 3. Handle Action: "view_step_screenshots"
if action == "view_step_screenshots":
images_dir = os.path.join(trace_store.TRACES_DIR, "images")
if not os.path.exists(images_dir):
images_dir = os.path.join(project_root, "traces", "images")
pre_image = None
post_image = None
overlay_image = None
if step_dict.get("pre_image_name"):
name = step_dict["pre_image_name"]
if not name.endswith(".jpg"):
name += ".jpg"
path = os.path.join(images_dir, name)
if os.path.exists(path):
pre_image = f"file://{path}"
overlay_dir = trace_store.get_trace_dir(trace_id)
os.makedirs(overlay_dir, exist_ok=True)
overlay_filename = f"step_{step_dict['step_number']}_overlay.jpg"
overlay_abs_path = os.path.join(overlay_dir, overlay_filename)
action_obj = step_dict.get("action_taken")
action_parsed = (
action_obj[0] if isinstance(action_obj, list) and action_obj else action_obj
)
if isinstance(action_parsed, dict) and action_parsed.get("action"):
if not os.path.exists(overlay_abs_path):
success_draw = _draw_action_overlay(
path, action_parsed, overlay_abs_path
)
if success_draw:
overlay_image = f"file://{overlay_abs_path}"
else:
overlay_image = f"file://{overlay_abs_path}"
if step_dict.get("post_image_name"):
name = step_dict["post_image_name"]
if not name.endswith(".jpg"):
name += ".jpg"
path = os.path.join(images_dir, name)
if os.path.exists(path):
post_image = f"file://{path}"
return {
"trace_id": trace_id,
"step_number": step_dict["step_number"],
"before_screenshot": pre_image,
"after_screenshot": post_image,
"action_overlay_screenshot": overlay_image,
}
# 4. Handle Action: "view_step_details"
elif action == "view_step_details":
relative_time_str = "N/A"
if session_start_time and step_dict.get("timestamp"):
relative_time_val = step_dict["timestamp"] - session_start_time
relative_time_str = f"{relative_time_val:.1f}s"
rendered_text = _render_step_detailed(
step=step_dict,
relative_time=relative_time_str,
summary=step_dict.get("summary"),
action=step_dict.get("action_taken"),
result=step_dict.get("last_execution_result"),
is_most_recent=False,
for_failure_analyzer=False,
)
return {
"trace_id": trace_id,
"step_number": step_dict["step_number"],
"details": rendered_text,
}
else:
return {
"error": "Invalid action",
"message": (
f"Action '{action}' is not supported. Supported actions: "
"'view_summary', 'view_step_screenshots', 'view_step_details'."
),
}
+335
View File
@@ -0,0 +1,335 @@
# 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.
"""MCP Tool: mobile_manage_task."""
import json
import os
import signal
import sqlite3
import sys
import time
from typing import Any
from mcp_server.base import mcp
from mcp_server.notifiers import notify
from mcp_server.utils import env_utils, trace_store
@mcp.tool()
def mobile_manage_task(
action: str, trace_id: str, instruction: str | None = None
) -> dict[str, Any]:
"""Manages the lifecycle and retrieves the status of a background mobile automation task.
This is your primary diagnostic and control tool. You MUST use this tool to
poll the task status
when your 1-minute fallback timer triggers (as requested by
`mobile_run_task`). You can also use
it to gracefully abort a task or forcefully correct the subagent's behavior
mid-flight.
### Available Actions:
- **'status'**: Retrieves execution progress, elapsed time, and metadata.
Returns a JSON object.
* **How to use the output**:
- For **Pro Model**, check `progress.task_plan` to see if the
agent's long-term plan aligns with your goal.
- For **Flash Model**, monitor `current_turn`, `latest_action`, and
`latest_thought`. If the turn count increases rapidly without
progressing, or the thought indicates it is stuck, you should
intervene.
- **'inject_instruction'**: Injects real-time guidance when the subagent
makes a mistake, gets stuck, or loops.
* **Pro Model**: Applied at the start of the next turn's planning phase.
* **Flash Model**: Applied at the start of the next reactive execution
loop.
* *Note*: You MUST provide the `instruction` argument for this action.
- **'stop'**: Forcefully terminates the subagent (Pro or Flash), immediately
halting device interactions and releasing the device. Use this if the task
is complete, irreparably broken, or running out of control.
Args:
action: STR. **REQUIRED**. The management action to perform. Must be
exactly `"status"`, `"stop"`, or `"inject_instruction"`.
trace_id: STR. **REQUIRED**. The unique session identifier of the task
(returned by `mobile_run_task`).
instruction: STR. **CONDITIONAL**. The real-time correction or guidance
string. - You MUST provide this if `action="inject_instruction"`. -
You MUST leave this empty or omit it if `action="status"` or
`action="stop"`.
"""
status_data = trace_store.read_status(trace_id)
if not status_data:
return {
"trace_id": trace_id,
"status": "unknown",
"message": f"Trace ID '{trace_id}' not found.",
}
current_status = status_data.get("status", "unknown")
pid = status_data.get("pid")
is_alive = False
if pid and current_status in ("running", "pending"):
try:
os.kill(pid, 0)
is_alive = True
except OSError:
current_status = "failed"
error_text = "Task runner process terminated unexpectedly."
trace_store.update_trace_status(trace_id, "failed", error=error_text)
conv_id = status_data.get("conversation_id")
if conv_id:
try:
notify(
conversation_id=conv_id,
message=f"Artemis background task died unexpectedly for trace '{trace_id}'. Error: {error_text}",
event_type="failed",
payload={"trace_id": trace_id, "error": error_text},
)
except Exception:
pass
status_data = trace_store.read_status(trace_id) or status_data
trace_dir = trace_store.get_trace_dir(trace_id)
if action == "status":
start_time = status_data.get("start_time")
end_time = status_data.get("end_time") or time.time()
elapsed = round(end_time - start_time, 1) if start_time else 0
response: dict[str, Any] = {
"trace_id": trace_id,
"status": current_status,
"elapsed_seconds": elapsed,
"task_desc": status_data.get("task_desc"),
"model": status_data.get("model"),
"stdout_log": os.path.join(trace_dir, "stdout.log"),
"stderr_log": os.path.join(trace_dir, "stderr.log"),
}
if status_data.get("model", "").lower() != "flash":
response["notes_dir"] = os.path.join(trace_dir, "notes")
if current_status == "failed":
response["error"] = status_data.get("error")
elif current_status == "completed":
response["result"] = status_data.get("result")
if current_status in ("running", "pending") and is_alive:
progress: dict[str, Any] = {}
project_root = env_utils.get_project_root()
db_path = os.path.join(trace_store.TRACES_DIR, "data_engine.db")
if not os.path.exists(db_path):
db_path = os.path.join(project_root, "traces", "data_engine.db")
session_id = None
if os.path.exists(db_path):
try:
conn = sqlite3.connect(db_path)
conn.row_factory = sqlite3.Row
cursor = conn.cursor()
cursor.execute(
"SELECT session_id FROM sessions WHERE pid = ? ORDER BY start_time DESC LIMIT 1",
(pid,),
)
session_row = cursor.fetchone()
if session_row:
session_id = session_row["session_id"]
conn.close()
except Exception as db_err:
print(f"Error querying data_engine.db for session: {db_err}", file=sys.stderr)
if session_id:
if status_data.get("model", "").lower() == "flash":
progress["phase"] = "running_flash_loop"
if os.path.exists(db_path):
try:
conn2 = sqlite3.connect(db_path)
conn2.row_factory = sqlite3.Row
cur2 = conn2.cursor()
cur2.execute(
"SELECT count(*) as cnt FROM traces WHERE session_id = ? AND type = 'llm_call'",
(session_id,),
)
row_cnt = cur2.fetchone()
if row_cnt:
progress["current_turn"] = row_cnt["cnt"]
cur2.execute(
"SELECT payload FROM traces WHERE session_id = ? AND type in ('raw_thinking', 'llm_call') "
"AND status = 'success' ORDER BY timestamp DESC LIMIT 1",
(session_id,),
)
row_thought = cur2.fetchone()
if row_thought and row_thought["payload"]:
try:
p_obj = json.loads(row_thought["payload"])
if isinstance(p_obj, dict):
if "thought" in p_obj:
progress["latest_thought"] = p_obj["thought"]
elif (
"response" in p_obj
and isinstance(p_obj["response"], list)
and p_obj["response"]
):
progress["latest_thought"] = p_obj["response"][0].get(
"text", ""
)
except Exception:
pass
cur2.execute(
"SELECT name, payload FROM traces WHERE session_id = ? AND type = 'tool' "
"AND status = 'success' ORDER BY timestamp DESC LIMIT 1",
(session_id,),
)
row_action = cur2.fetchone()
if row_action:
act_name = row_action["name"]
act_payload = {}
if row_action["payload"]:
try:
act_payload = json.loads(row_action["payload"]).get(
"args", {}
)
except Exception:
pass
progress["latest_action"] = (
f"{act_name}({act_payload})" if act_payload else act_name
)
conn2.close()
except Exception as q_err:
print(
f"Error querying flash progress from data_engine.db: {q_err}",
file=sys.stderr,
)
else:
plan_content = None
plan_path = os.path.join(
trace_store.TRACES_DIR, session_id, "notes", "task_plan.md"
)
if not os.path.exists(plan_path):
plan_path = os.path.join(trace_dir, "notes", "task_plan.md")
if os.path.exists(plan_path):
try:
with open(plan_path, encoding="utf-8") as f:
plan_content = f.read()
except Exception:
pass
progress["task_plan"] = plan_content
else:
progress["phase"] = "initializing"
progress["status_message"] = "Spawning background runner..."
response["progress"] = progress
return response
elif action == "stop":
if current_status not in ("running", "pending"):
return {
"trace_id": trace_id,
"status": current_status,
"message": f"Cannot stop task because it is already in a terminal state: {current_status}",
}
if not pid:
return {
"trace_id": trace_id,
"status": current_status,
"message": "Process ID (PID) is missing from the task status. Cannot stop task.",
}
try:
os.kill(pid, signal.SIGTERM)
trace_store.update_trace_status(
trace_id, "cancelled", error="Task aborted by user request."
)
return {
"trace_id": trace_id,
"status": "cancelled",
"message": f"Successfully sent termination signal to background process {pid}.",
}
except ProcessLookupError:
trace_store.update_trace_status(
trace_id, "cancelled", error="Task aborted (process was already stopped)."
)
return {
"trace_id": trace_id,
"status": "cancelled",
"message": f"Process {pid} was not found; it may have already exited. Status marked as cancelled.",
}
except Exception as e:
return {
"trace_id": trace_id,
"status": current_status,
"message": f"Failed to terminate process {pid}: {e}",
}
elif action == "inject_instruction":
if not instruction:
return {
"trace_id": trace_id,
"status": current_status,
"message": "Missing required argument 'instruction' for action 'inject_instruction'.",
}
if current_status not in ("running", "pending"):
return {
"trace_id": trace_id,
"status": current_status,
"message": f"Cannot inject instruction because task is in a terminal state: {current_status}",
}
if not os.path.exists(trace_dir):
return {
"trace_id": trace_id,
"status": current_status,
"message": f"Trace directory not found: {trace_dir}",
}
instruction_path = os.path.join(trace_dir, "injected_instruction.json")
try:
with open(instruction_path, "w", encoding="utf-8") as f:
json.dump(
{
"instruction": instruction,
"timestamp": time.time(),
"status": "pending",
},
f,
indent=2,
)
return {
"trace_id": trace_id,
"status": current_status,
"message": f"Successfully injected instruction: '{instruction}'",
}
except Exception as e:
return {
"trace_id": trace_id,
"status": current_status,
"message": f"Failed to inject instruction: {e}",
}
else:
return {
"trace_id": trace_id,
"status": current_status,
"message": f"Invalid action '{action}'. Supported actions are 'status', 'stop', and 'inject_instruction'.",
}
+205
View File
@@ -0,0 +1,205 @@
# 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.
"""MCP Tool: mobile_run_task."""
import os
import subprocess
from typing import Any
import uuid
from mcp_server.base import mcp
from mcp_server.utils import env_utils, trace_store
@mcp.tool()
def mobile_run_task(
task_desc: str,
conversation_id: str,
model: str = "Flash",
locked_app_package: str | None = None,
app_path: str | None = None,
expected_output_desc: str | None = None,
) -> dict[str, Any]:
"""Starts a specialized mobile UI automation subagent to autonomously plan and execute tasks on a connected Android device.
With this tool, you can delegate complex mobile workflows and UI
interactions to a background agent.
This tool supports two execution models: **Flash** (for rapid,
straightforward UI scripts) and **Pro** (for complex, exploratory tasks
requiring deep reasoning).
### Model Selection Guide (CRITICAL FOR ROUTING)
You MUST choose the appropriate `model` based on the task complexity:
- **Flash**: Best for simple, deterministic tasks that can be completed
within **30 UI steps**. You should prioritize using this model to complete
the task.
*Limitations*: Flash CANNOT analyze video recordings, query system logs,
execute arbitrary ADB shell commands, maintain a structured task plan, or
write custom execution notes/reports. Do not use Flash if the task
requires exploration, complex error recovery, continuous monitoring/polling
(due to step count limits and lack of state persistence), or outputting
large amounts of detailed information (as Flash cannot access the
notes/scratchpad system to retain extensive details across steps; use
**Pro** instead).
- **Pro**: Required for complex, non-linear, or exploratory tasks (e.g.,
browsing deep lists, troubleshooting app crashes, tasks requiring multi-step
planning), continuous monitoring or polling tasks (e.g., periodically checking
UI state, polling until specific conditions or events occur, watching progress,
or waiting for background changes), OR when you need the subagent to extract,
remember, and output large amounts of detailed information. Pro is slower but
fully featured.
*Capabilities*: Supports full video analysis, system log querying, ADB
shell access, robust monitoring/polling execution loops with multi-agent
planning, and generating custom text outputs/reports via the expected
output agent and structured notes (`notes_dir`).
Regardless of the model, precise timing for waiting actions cannot be
guaranteed due to latency. If you need to implement a wait, please note that
the total waiting time comprises both the time required for the Artemis large
model to generate output and the actual waiting duration. (For Flash, the
interval between output steps is typically 3-7 seconds, whereas Pro requires
25-35 seconds.)
For long-term repetitive tasks, run the workflow once with this tool to
discover the path, then author a dedicated automation script instead of
repeatedly delegating.
This tool is non-blocking and will immediately return a dictionary
containing:
- `trace_id`: The unique session identifier of the task for status tracking
and inspection.
- `notes_dir`: (Pro Model Only) The directory where the subagent's execution
files/notes are located.
- `stdout_log`: The path to the execution framework log.
- `stderr_log`: The path to the error log containing critical failures and
Python tracebacks (essential for debugging).
Upon task completion (success or failure), the system will trigger a
Reactive Wakeup to notify you.
CRITICAL BEHAVIORAL RULES:
1. **Fallback Timer Constraint**: You **MUST** set a background timer to
check the task status at least once every 1 minute as a safety fallback.
2. **Post-Task Inspection**: After completion, retrieve and inspect the
execution logs (`stderr_log`/`stdout_log`) to verify success.
3. **Inspecting Notes (Pro Model Only)**: If running with Pro, you can
inspect the notes/plans recorded in `notes_dir` to extract critical
execution results passed back by the subagent.
Args:
task_desc: A clear, highly detailed, actionable task description for the
mobile subagent. Since execution is autonomous and one-shot, provide
all necessary context, exact UI actions to take, and clear termination
conditions. If `model="Pro"`, you can explicitly instruct the subagent
on what specific information it must record in the notes.
conversation_id: Your current active Conversation ID. Critical for
routing the wakeup notification.
model: Must be either `"Flash"` or `"Pro"`. - Choose `"Flash"` for fast,
simple UI tasks under 30 steps without log/video analysis or
monitoring/polling needs. - Choose `"Pro"` for complex tasks requiring
planning, ADB shell commands, logs, video exploration, or continuous
monitoring and polling tasks.
locked_app_package: Optional package name of the app to lock execution
to. The agent will auto-launch and restrict actions to this app.
app_path: Optional path to a local APK to install on the device before
running the task.
expected_output_desc: Optional (Pro Model Only). If provided, a
summarization agent will aggregate the interaction history
(motivation, actions, videos) into a summary report saved as
"output.md" in `notes_dir`. Ignored if `model="Flash"`.
"""
# 0. Validate and normalize model
if model.lower() not in ("flash", "pro"):
raise ValueError(f"Invalid model '{model}'. Must be either 'Flash' or 'Pro'.")
canonical_model = "Flash" if model.lower() == "flash" else "Pro"
# 1. Generate a unique trace_id
trace_id = str(uuid.uuid4())
# 2. Initialize the trace store directory and status.json
trace_store.init_trace(
trace_id=trace_id,
task_desc=task_desc,
model=canonical_model,
conversation_id=conversation_id,
)
# 3. Resolve project root, python executable, and background runner module
project_root = env_utils.get_project_root()
python_exe = env_utils.resolve_python_executable(project_root)
# 4. Spawn the background task runner as an independent subprocess
try:
cmd = [
python_exe,
"-m",
"mcp_server.background.task_runner",
"--trace-id",
trace_id,
"--task-desc",
task_desc,
"--model",
canonical_model,
"--conversation-id",
conversation_id,
]
if locked_app_package:
cmd.extend(["--locked-app-package", locked_app_package])
if app_path:
cmd.extend(["--app-path", app_path])
if expected_output_desc and canonical_model != "Flash":
cmd.extend(["--expected-output-desc", expected_output_desc])
env = os.environ.copy()
env["ARTEMIS_SESSION_ID"] = trace_id
proc_kwargs = env_utils.get_detached_process_kwargs()
proc = subprocess.Popen(
cmd,
env=env,
cwd=project_root,
**proc_kwargs,
)
# 5. Record the PID of the spawned subprocess
status_data = trace_store.read_status(trace_id)
if status_data:
status_data["pid"] = proc.pid
trace_store.write_status(trace_id, status_data)
trace_dir = trace_store.get_trace_dir(trace_id)
stdout_log_path = os.path.join(trace_dir, "stdout.log")
stderr_log_path = os.path.join(trace_dir, "stderr.log")
response_dict: dict[str, Any] = {
"trace_id": trace_id,
"message": "Successfully started background task.",
"stdout_log": stdout_log_path,
"stderr_log": stderr_log_path,
}
if canonical_model != "Flash":
response_dict["notes_dir"] = os.path.join(trace_dir, "notes")
return response_dict
except Exception as e:
trace_store.update_trace_status(
trace_id, "failed", error=f"Failed to spawn background runner: {e}"
)
return {
"trace_id": trace_id,
"status": "failed",
"message": f"Error starting background task: {e}",
}