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:
1 parent
78e478f502
commit
9a5aaf99b7
249 files changed
+37226
-9339
No files matched your search
@@ -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",
|
||||
]
|
||||
@@ -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}"
|
||||
@@ -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'."
|
||||
),
|
||||
}
|
||||
@@ -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'.",
|
||||
}
|
||||
@@ -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}",
|
||||
}
|
||||
Reference in new issue
Block a user