feat: add plugin runtime and cross-device task orchestration

This commit is contained in:
chenmingxuan committed 2026-10-14 20:21:44 +08:00
1 parent 3ee4a5f7a8
commit 6663233ab8
82 files changed
+4147 -124

No files matched your search

+3 -1
View File
@@ -317,7 +317,7 @@ def build_checker_tools(
*get_history_tools(ctx),
get_read_note_tool_pure(ctx),
]
if probes_enabled(ctx):
if getattr(ctx, "execution_target", "android") != "computer" and probes_enabled(ctx):
tools.append(get_probe_tool(ctx))
return tools
@@ -758,6 +758,8 @@ async def _capture_final_screen(ctx: ArtemisContext) -> tuple[str | None, str]:
controller = UnifiedMobileController(ctx)
device_data = await controller.get_screen_data()
if getattr(device_data, "platform", "android") == "computer":
return device_data.base64 or None, device_data.observation_text
screenshot_b64 = device_data.base64
xml_hierarchy = device_data.elements
width = device_data.width
+2 -2
View File
@@ -7,7 +7,7 @@
# ROLE & OBJECTIVE
You are an autonomous and highly efficient Android Device Execution Agent. Your goal is to accomplish the user's objective on the device.
You are an autonomous UI execution agent. Accomplish the user's objective using the actual available device and plugin tools. Only Android action tools use normalized 0–1000 coordinates; plugin tools use their own documented coordinates and element indices.
**Objective: {{ goal }}**
@@ -26,7 +26,7 @@ Keep in mind that the mobile device is an asynchronous, constantly evolving envi
{% if "wait_for_delay" in available_tools %}- **Timed Waiting & Transitions**: When facing mid-transition animations, loading spinners, required watch durations, or ad countdown timers, use the precise `wait_for_delay` command to allow time to pass or the UI state to stabilize. Some transition states are subtle and may even resemble a phone bug or system freeze; it is always advisable to wait first, and only consider retrying or choosing an alternative path if the state remains broken.{% endif %}
- **Time-Sensitive Tasks & Transient UI**: Because of the turn latency above, a target element that you see on the current screenshot (e.g., auto-dismissing overlays, temporary message bars, menus or control bars with short timeouts) may have already vanished by the time your physical click command reaches the device! This is one of the most common reasons why the UI does not change as expected after a single step. We call these **Time-Sensitive Tasks**.
- *Mental Model*: When facing these tasks, any attempt to interact step-by-step (such as tapping once to wake up a menu/bar and waiting for the next turn to tap the target button) WILL fail because the element will auto-dismiss during your decision delay. You MUST combine the summon action on the trigger element (`Trigger`) and the target action (`Target`) into a single atomic sequence.
- *Chained Execution*: Use `click_sequence(sequence=[[trigger_x, trigger_y], [target_x, target_y]])` in `0-1000` normalized scale to execute both taps consecutively with a 50ms delay, catching the transient element before the timeout closes it.
{% if "click_sequence" in available_tools %} - *Chained Execution*: Use `click_sequence(sequence=[[trigger_x, trigger_y], [target_x, target_y]])` in `0-1000` normalized scale to execute both taps consecutively with a 50ms delay, catching the transient element before the timeout closes it.{% endif %}
---
+74 -4
View File
@@ -217,6 +217,15 @@ class FlashRunner:
tools.append(VIDEO_ANALYZER_TOOL)
tools.append(REPORT_TASK_STATUS_TOOL)
if getattr(self.ctx, "execution_target", None) == "computer":
from artemis.mcp.action_manifest import DEVICE_ACTIONS
tools = [
t
for t in tools
if t.name not in DEVICE_ACTIONS
and t.name not in {"ask_explorer", "video_analyzer", "click_sequence"}
]
# With an actuator installed, drop declarations for device actions the
# backend does not implement (and append its extension tools); without
# one, the full historical declaration set is kept.
@@ -225,6 +234,8 @@ class FlashRunner:
from artemis.mcp.action_manifest import filter_declarations
tools = filter_declarations(tools, actuator, "flash")
if getattr(self.ctx, "plugins", None):
tools.extend(self.ctx.plugins.declarations())
return tools
def _prune_intermediate_screenshots(self, messages: list[BaseMessage]) -> None:
@@ -305,7 +316,10 @@ class FlashRunner:
prompt_path = Path(__file__).parent / "flash_runner.md"
prompt_template = prompt_path.read_text(encoding="utf-8")
available_tools = frozenset(t.name for t in tools_declaration)
return Template(prompt_template).render(goal=self.goal, available_tools=available_tools)
prompt = Template(prompt_template).render(goal=self.goal, available_tools=available_tools)
if getattr(self.ctx, "plugins", None):
prompt += self.ctx.plugins.prompt()
return prompt
# ------------------------------------------------------------------
# Per-turn helpers (observe / think)
@@ -612,6 +626,11 @@ class FlashRunner:
elements. Nothing is captured for non-action tools.
"""
post_img_bytes = exec_result.screenshot_bytes
if (
exec_result.metadata.get("plugin_id")
and getattr(self.ctx, "execution_target", "android") != "computer"
):
return post_img_bytes
if post_img_bytes or name not in action_names:
return post_img_bytes
if state is not None:
@@ -705,6 +724,15 @@ class FlashRunner:
action_dict["normalized_start_coordinates"] = norm_start
action_dict["normalized_end_coordinates"] = norm_end
action_dict.update(metadata.get("target_semantics") or {})
if metadata.get("plugin_id"):
action_dict.update(
{
key: metadata[key]
for key in ("plugin_id", "plugin_version", "resource_ids", "plugin_status")
}
)
action_dict.pop("coordinates", None)
action_dict.pop(COORDINATE_SPACE_KEY, None)
return action_dict
def _record_action_step(
@@ -800,10 +828,39 @@ class FlashRunner:
# Dynamic dispatch set: manifest device actions plus any backend
# extension tools, so extension steps are recorded like actions.
action_names = self.executor.action_tool_names
plugin_action = False
try:
exec_result = await self.executor.execute(
operation = self.executor.execute(
name, args, tc_id, state, index_elements=index_elements
)
from artemis.plugins.runtime import get_plugin_runtime
plugins = get_plugin_runtime(self.ctx)
if plugins and name in plugins.bindings:
plugin_action = True
# A phone observation cannot serve as the before-image for a desktop action.
pre_screenshot_bytes = plugins.last_images.get(plugins.bindings[name][0])
xml_list = ""
control = getattr(self.ctx, "task_control", None)
if plugins and name in plugins.tool_names and isinstance(control, TaskControl):
interrupted, exec_result = await control.run_interruptibly(
operation,
on_pause=getattr(self.ctx, "on_task_pause", None),
on_resume=getattr(self.ctx, "on_task_resume", None),
)
if interrupted:
messages.extend(
tool_result_messages(
tc_id,
"操作被暂停或指导打断,结果不确定。先重新观察,不能重放旧动作。",
name=name,
status="error",
llm=self._llm,
)
)
return None, "操作已打断,必须重新观察。", action_sequence
else:
exec_result = await operation
post_img_bytes = await self._capture_post_screenshot(
exec_result, name, action_names, state
@@ -869,7 +926,11 @@ class FlashRunner:
# device actions always report text — their post screenshot is the
# next observation tail, never a tool-message image. The image
# carrier follows the model's provider (tool_result_messages).
raw_blocks = exec_result.raw_result if name not in action_names else None
raw_blocks = (
exec_result.raw_result
if name not in action_names or exec_result.metadata.get("plugin_id")
else None
)
content = (
raw_blocks
if isinstance(raw_blocks, list) and raw_blocks
@@ -896,6 +957,15 @@ class FlashRunner:
status="error",
)
)
if plugin_action and getattr(self.ctx, "execution_target", "android") != "computer":
# The loop's observation always belongs to Android in a mixed task.
# Plugin images travel in their labelled tool result and step record;
# never pair a desktop bitmap with the phone's element indexes.
shot_path, observation_image, observation_text = await capture_screenshot_and_parse_ui(
self.ctx, state, self.controller
)
state.latest_screenshot = shot_path
return observation_image, observation_text, action_sequence
return pre_screenshot_bytes, xml_list, action_sequence
async def _invoke_model(self, llm, current_tools: list, messages: list[BaseMessage]):
@@ -1156,7 +1226,7 @@ class FlashRunner:
# S + F + A + tail: chunk compression and the scrub edge advance here.
messages = ledger.render([tail])
turn_base = len(messages) - 1
current_tools = report_only_tools if is_final else tools_declaration
current_tools = report_only_tools if is_final else self._get_tools()
if isinstance(control, TaskControl):
interrupted, response = await control.run_interruptibly(
+32 -2
View File
@@ -183,6 +183,8 @@ class OperatorNode:
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:
@@ -535,6 +537,15 @@ class OperatorNode:
)
)
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"}
]
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))
@@ -650,6 +661,8 @@ class OperatorNode:
# 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
@@ -707,7 +720,9 @@ class OperatorNode:
tool_call_id=tc["id"],
state=state,
)
if normalize_name(tool_name) in INTERRUPTIBLE_HELPERS:
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 = "任务被暂停或指导打断;重新观察屏幕后继续"
@@ -1002,7 +1017,10 @@ class OperatorNode:
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 or xml_hierarchy is 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)"
)
@@ -1034,6 +1052,8 @@ class OperatorNode:
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]
@@ -1079,6 +1099,10 @@ class OperatorNode:
active_background_tasks=active_background_tasks,
newly_finished_tasks=newly_finished_tasks,
)
if getattr(self.ctx, "plugins", None):
from langchain_core.messages import SystemMessage
messages.insert(0, SystemMessage(content=self.ctx.plugins.prompt()))
# 8. Invoke LLM Loop
new_subagent_calls = []
@@ -1226,6 +1250,12 @@ class OperatorNode:
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}], None
# Get device resolution fallbacks
width = 1080
height = 2400
+12 -7
View File
@@ -380,13 +380,18 @@ class ObservationPromptComponent(PromptComponent):
latest_screenshot_b64 = kwargs.get("latest_screenshot_b64")
minimal_list = kwargs.get("minimal_list")
builder.add_human_content("--- Current Screenshot ---")
builder.add_human_content(
{
"type": "image_url",
"image_url": {"url": f"data:image/jpeg;base64,{latest_screenshot_b64}"},
}
)
if latest_screenshot_b64:
builder.add_human_content("--- Current Screenshot ---")
builder.add_human_content(
{
"type": "image_url",
"image_url": {"url": f"data:image/jpeg;base64,{latest_screenshot_b64}"},
}
)
else:
builder.add_human_content(
"--- No screenshot available: use the text observation and request a target window state before coordinate actions. ---"
)
builder.add_human_content(f"--- Visible UI Elements ---\n{minimal_list}")
+7 -2
View File
@@ -274,7 +274,7 @@ class PlannerNode:
screenshot_b64 = await controller.take_screenshot()
# Save to Data Engine if available to keep trace
if self.ctx.data_engine:
if self.ctx.data_engine and screenshot_b64:
img_bytes = base64.b64decode(screenshot_b64)
image_name = self.ctx.data_engine.get_or_create_image(img_bytes)
saved_path = str(self.ctx.data_engine.get_image_path(image_name))
@@ -284,7 +284,7 @@ class PlannerNode:
except Exception as e:
logger.error(f"Failed to take screenshot for Initial Plan: {e}")
if not screenshot_b64:
if not screenshot_b64 and getattr(self.ctx, "execution_target", "android") != "computer":
logger.error("Planner failed to obtain a screenshot.")
raise ValueError("Planner failed to obtain a screenshot.")
@@ -296,6 +296,11 @@ class PlannerNode:
human_message = Template(human_content).render(**render_kwargs)
human_message_content = [{"type": "text", "text": human_message}]
if getattr(self.ctx, "execution_target", "android") == "computer":
screen = await UnifiedMobileController(self.ctx).get_screen_data()
human_message_content.append(
{"type": "text", "text": screen.observation_text + self.ctx.plugins.prompt()}
)
if screenshot_b64:
human_message_content.append(
{
+16 -1
View File
@@ -84,6 +84,16 @@ class SummarizerNode:
action_name, action_args = self._extract_action(record, state, ctx=self.ctx)
result = getattr(state, "last_execution_result", None)
if getattr(self.ctx, "execution_target", "android") == "mixed" and isinstance(result, dict):
if any(
action.get("plugin_id")
for action in result.get("execution", [])
if isinstance(action, dict)
):
# The step began with a mobile observation. Never describe a phone-to-desktop
# image pair as a transition on one screen.
pre_bytes = None
post_bytes = None
# format_result_clean reports only errors/repairs; a clean run keeps
# the controller's bare status word.
exec_outcome = format_result_clean(result)
@@ -181,7 +191,12 @@ class SummarizerNode:
device = getattr(ctx, "device", None) if ctx is not None else None
width = getattr(device, "device_width", None) or 1080
height = getattr(device, "device_height", None) or 2400
actions = normalize_any_structure(actions, width, height)
actions = [
action
if str(action.get("action", "")).startswith("plug_")
else normalize_any_structure(action, width, height)
for action in actions
]
first = actions[0]
action_name = first.get("action") or "unknown"
action_args = {k: v for k, v in first.items() if k != "action"}
+38 -1
View File
@@ -181,6 +181,33 @@ async def _process_action(
outcome = _ActionOutcome(action_item=action_item)
attempts_log = []
from artemis.plugins.runtime import get_plugin_runtime
from artemis.runtime.task_control import TaskControl
plugins = get_plugin_runtime(node.ctx)
if plugins and action_name in plugins.bindings:
operation = plugins.execute(action_name, action_item.get("args", {}), str(uuid.uuid4()))
control = getattr(node.ctx, "task_control", None)
if isinstance(control, TaskControl):
interrupted, result = await control.run_interruptibly(
operation,
on_pause=getattr(node.ctx, "on_task_pause", None),
on_resume=getattr(node.ctx, "on_task_resume", None),
)
if interrupted:
outcome.success = False
outcome.error_msg = "插件操作已打断,结果不确定;继续前必须重新观察。"
return outcome
else:
result = await operation
outcome.success = result.status == "success"
outcome.error_msg = "" if outcome.success else result.text_summary
outcome.action_item.update(result.metadata)
outcome.action_item["result"] = result.text_summary
if result.screenshot_bytes:
outcome.post_screenshot_b64 = base64.b64encode(result.screenshot_bytes).decode()
return outcome
validation_passed = True
if not burst:
# 1. Pre-execution validation (Safety Net)
@@ -338,6 +365,8 @@ def _model_facing_action(ctx, action_item: dict) -> dict:
The live item carries the controller's pixels; every phrase rendered for
a model is normalized first, and the space marker makes that idempotent.
"""
if action_item.get("plugin_id"):
return action_item
width = getattr(getattr(ctx, "device", None), "device_width", None) or 1080
height = getattr(getattr(ctx, "device", None), "device_height", None) or 2400
return normalize_action_dict(action_item, width, height)
@@ -419,11 +448,19 @@ async def run_validation_loop(node, state: State) -> dict:
execution.append(outcome.action_item)
post_image_name = None
if outcome.post_screenshot_b64:
plugin_action = bool(outcome.action_item.get("plugin_id"))
mixed_plugin_action = (
plugin_action and getattr(ctx, "execution_target", "android") == "mixed"
)
if outcome.post_screenshot_b64 and not mixed_plugin_action:
post_image_name = _absorb_post_screenshot(ctx, state, outcome.post_screenshot_b64)
last_screenshot_b64 = outcome.post_screenshot_b64
if post_image_name:
last_screenshot_name = post_image_name
elif mixed_plugin_action:
# Keep the Android step observation on its own resource. Plugin
# images remain attached to the tool/action trace and its exports.
post_image_name = outcome.action_item.get("post_image_name")
_record_action_end(
ctx,
+9
View File
@@ -86,12 +86,21 @@ class ValidatorNode:
which on Windows was the *only* path that ever ran -- is gone: the server now
lives in-process, so there is no spawn to fail and exactly one execution path.
"""
if getattr(self.ctx, "execution_target", None) == "computer":
from artemis.plugins.desktop import PluginReadSession
return PluginReadSession(self.ctx)
return await get_action_session(self.ctx)
def _parse_decisions(self, structured_decisions: str) -> tuple[list[dict] | None, str | None]:
return action_execution.parse_decisions(structured_decisions)
async def _get_initial_screenshot(self, session, state: State) -> tuple[str, str | None]:
if (
getattr(self.ctx, "execution_target", None) == "computer"
and not state.latest_screenshot
):
return "", None
return await action_execution.read_initial_screenshot(state)
async def _execute_validation_loop(self, state: State):
+11
View File
@@ -86,6 +86,17 @@ def is_placeholder_key(val: str | SecretStr | None) -> bool:
class Settings(BaseSettings):
"""Centralized ARTEMIS runtime settings loaded from environment and .env files."""
ARTEMIS_PLUGIN_MARKETPLACE_URL: str = (
"https://git.playones.com/chenmingxuan/Artemis-Plugins/raw/branch/main/catalog.json"
)
ARTEMIS_PLUGIN_START_TIMEOUT_SECONDS: float = Field(default=30, gt=0, le=300)
ARTEMIS_PLUGIN_TOOL_TIMEOUT_SECONDS: float = Field(default=60, gt=0, le=3600)
ARTEMIS_PLUGIN_SHUTDOWN_TIMEOUT_SECONDS: float = Field(default=15, gt=0, le=60)
ARTEMIS_PLUGIN_DOWNLOAD_MAX_BYTES: int = Field(default=134217728, gt=0)
ARTEMIS_PLUGIN_DOWNLOAD_TIMEOUT_SECONDS: float = Field(default=300, gt=0, le=1800)
ARTEMIS_PLUGIN_EXTRACT_MAX_BYTES: int = Field(default=536870912, gt=0)
ARTEMIS_PLUGIN_RESULT_MAX_BYTES: int = Field(default=16777216, gt=0)
# LLM Provider Authentication
OPENAI_API_KEY: SecretStr | None = None
+4
View File
@@ -71,6 +71,7 @@ class DevicePlatform(StrEnum):
"""Mobile device platform enumeration."""
ANDROID = "android"
COMPUTER = "computer"
class DeviceContext(BaseModel):
@@ -185,6 +186,7 @@ class ArtemisContext(BaseModel):
)
device: DeviceContext
execution_target: Literal["android", "computer", "mixed"] = "android"
llm_config: LLMConfig | None = None
agent_config: Any = None
adb_client: Any | None = None
@@ -251,6 +253,8 @@ class ArtemisContext(BaseModel):
actuator: Any | None = None
"""Optional actuator backend override (artemis.mcp.actuators). ``None`` selects
the default AdbActuator; installing e.g. a robot-arm backend happens here."""
plugins: Any | None = None
"""Task-owned external MCP sessions and pinned plugin configuration."""
step_memory: Any | None = None
"""Shared :class:`~artemis.memory.step_memory.StepMemoryService` composition
+1
View File
@@ -50,6 +50,7 @@ class ScreenDataResponse(BaseModel):
width: int = Field(default=1080, description="Device screen width in pixels")
height: int = Field(default=2400, description="Device screen height in pixels")
platform: str = Field(default="android", description="Platform identifier")
observation_text: str = ""
class MobileDeviceController(ABC):
@@ -267,6 +267,7 @@ class UnifiedMobileController:
width=data.width,
height=data.height,
platform=data.platform,
observation_text=data.observation_text,
)
async def find_element(
+29 -2
View File
@@ -134,6 +134,14 @@ class StorageManager:
)
""")
conn.execute("""
CREATE TABLE IF NOT EXISTS plugin_image_references (
session_id TEXT NOT NULL,
image_name TEXT NOT NULL,
PRIMARY KEY (session_id, image_name)
)
""")
conn.execute("""
CREATE TABLE IF NOT EXISTS steps (
step_id TEXT PRIMARY KEY,
@@ -1102,6 +1110,7 @@ class StorageManager:
with self._get_connection() as conn:
conn.execute("PRAGMA foreign_keys = OFF")
tables = [
"plugin_image_references",
"video_analysis_observations",
"video_analysis_segments",
"failed_outputs",
@@ -1154,6 +1163,15 @@ class StorageManager:
except ValueError:
return False
def reference_plugin_image(self, session_id: UUID | str, image_name: str):
"""Keep tool-only images alive until their last owning task is deleted."""
with self._get_connection() as conn:
conn.execute(
"INSERT OR IGNORE INTO plugin_image_references VALUES (?, ?)",
(str(session_id), image_name),
)
conn.commit()
def delete_session(self, session_id: UUID):
"""Delete all data associated with a session, including files on disk."""
session_id_str = str(session_id)
@@ -1173,6 +1191,13 @@ class StorageManager:
for name in row
if name
}
image_names.update(
row[0]
for row in conn.execute(
"SELECT image_name FROM plugin_image_references WHERE session_id=?",
(session_id_str,),
)
)
cursor = conn.execute(
"SELECT video_id, local_video_path FROM video_recordings WHERE session_id = ?",
(session_id_str,),
@@ -1205,6 +1230,7 @@ class StorageManager:
except sqlite3.OperationalError as e:
logger.warning(f"Failed to delete video memory from {table}: {e}")
tables = [
"plugin_image_references",
"failed_outputs",
"traces",
"background_tasks",
@@ -1223,8 +1249,9 @@ class StorageManager:
raise
for name in image_names:
if not conn.execute(
"SELECT 1 FROM steps WHERE pre_image_name=? OR post_image_name=? LIMIT 1",
(name, name),
"SELECT 1 FROM steps WHERE pre_image_name=? OR post_image_name=? "
"UNION ALL SELECT 1 FROM plugin_image_references WHERE image_name=? LIMIT 1",
(name, name, name),
).fetchone():
conn.execute("DELETE FROM images WHERE image_name=?", (name,))
orphaned_images.append(name)
+1
View File
@@ -60,6 +60,7 @@ class ScreenData(BaseModel):
height: int = Field(default=2400, description="Device screen height in pixels")
timestamp: float = Field(default_factory=time.time, description="Capture timestamp")
platform: str = Field(default="android", description="Platform identifier")
observation_text: str = ""
model_config = ConfigDict(arbitrary_types_allowed=True)
+5
View File
@@ -33,6 +33,11 @@ logger = get_logger(__name__)
def create_driver(ctx: "ArtemisContext") -> BaseDeviceDriver:
"""Instantiates the appropriate BaseDeviceDriver based on the runtime context."""
if getattr(ctx, "execution_target", None) == "computer":
from artemis.plugins.desktop import PluginScreenDriver
return PluginScreenDriver(ctx)
# 1. Cloud mode check. Cloud devices are reached through the gateway's
# RemoteUIAutomatorClient; ARTEMIS_HIERARCHY_BACKEND does not apply there
# because the Accessibility Helper needs a local adb forward.
+2
View File
@@ -1023,6 +1023,8 @@ async def get_graph(ctx: ArtemisContext) -> CompiledStateGraph:
ask_explorer_wrapper,
*HISTORY_TOOL_WRAPPERS,
]
if getattr(ctx, "execution_target", "android") == "computer":
operator_specialized_wrappers = list(HISTORY_TOOL_WRAPPERS)
# ask_committee does not follow the Operator's pre-decision / turn-ending contract.
operator_specialized_tools = get_tools_from_wrappers(ctx, operator_specialized_wrappers)
+3 -2
View File
@@ -169,7 +169,7 @@ async def perception_node(state: State, ctx: ArtemisContext) -> dict:
# 3. Perform OCR (if configured, else fallback to pure XML layout)
ocr_results = []
if is_ocr_configured():
if is_ocr_configured() and getattr(device_data, "platform", "android") != "computer":
screen_height = device_data.height
status_bar_height = _detect_status_bar_height(xml_hierarchy, screen_height)
try:
@@ -201,7 +201,7 @@ async def perception_node(state: State, ctx: ArtemisContext) -> dict:
# 5. Save to Data Engine in background thread
image_name = None
screenshot_path = None
if ctx.data_engine:
if ctx.data_engine and latest_screenshot_bytes:
image_name = hashlib.sha256(latest_screenshot_bytes).hexdigest()
screenshot_path = str(ctx.data_engine.get_image_path(image_name))
asyncio.create_task(
@@ -224,6 +224,7 @@ async def perception_node(state: State, ctx: ArtemisContext) -> dict:
"ocr_results": ocr_results,
"width": device_data.width,
"height": device_data.height,
"observation_text": getattr(device_data, "observation_text", ""),
},
"injected_instruction": injected_instruction,
# sticky_or reducer: once latched True, later False updates keep it True
+58 -4
View File
@@ -33,6 +33,8 @@ import signal
from rich.console import Console
from rich.panel import Panel
import typer
from pydantic import TypeAdapter
from artemis.plugins.models import ExecutionTarget, PluginSelection
logger = get_logger(__name__)
@@ -59,6 +61,8 @@ async def execute_task(
explorer_flash_mode: str | None = None,
explorer_pro_mode: str | None = None,
verification_level: str | None = None,
execution_target: str | None = None,
plugins: list[PluginSelection] | None = None,
) -> None:
"""Executes a single mobile automation task end-to-end.
@@ -138,7 +142,12 @@ async def execute_task(
target_serial = (
device_serial or settings.ADB_DEVICE_SERIAL or os.environ.get("ADB_DEVICE_SERIAL")
)
if not target_serial:
execution_target = TypeAdapter(ExecutionTarget).validate_python(
execution_target or os.getenv("ARTEMIS_EXECUTION_TARGET", "android")
)
if execution_target == "computer":
target_serial = None
if not target_serial and execution_target != "computer":
try:
from artemis.runtime import device_pool
@@ -158,6 +167,7 @@ async def execute_task(
try:
agent = Agent(config=config.build(), session_id=effective_sid)
await agent.init(
execution_target=execution_target,
retry_count=int(os.getenv("ARTEMIS_HEALTH_RETRIES", 5)),
retry_wait_seconds=int(os.getenv("ARTEMIS_HEALTH_DELAY", 2)),
)
@@ -179,7 +189,14 @@ async def execute_task(
if llm_result_path:
task.with_llm_output_saving(path=llm_result_path)
await agent.run_task(request=task.build())
request = task.build()
request.execution_target = execution_target
plugin_choices = os.getenv("ARTEMIS_TASK_PLUGINS")
if plugins is not None:
request.plugins = plugins
elif plugin_choices is not None:
request.plugins = TypeAdapter(list[PluginSelection]).validate_json(plugin_choices)
await agent.run_task(request=request)
finally:
if agent is not None:
await agent.clean()
@@ -330,6 +347,17 @@ def run_command(
help="Target specific Android device by serial number (e.g. emulator-5554).",
),
] = None,
execution_target: Annotated[
str | None,
typer.Option("--target", help="执行目标:android、computer 或 mixed。电脑任务无需 ADB。 "),
] = None,
plugins_json: Annotated[
str | None,
typer.Option(
"--plugins-json",
help='插件配置引用 JSON,如 [{"id":"open-computer-use","profile":"default"}];不能提供密钥。',
),
] = None,
session_id: Annotated[
str | None,
typer.Option(
@@ -350,6 +378,25 @@ def run_command(
check_ffmpeg_available()
console = Console()
try:
target = TypeAdapter(ExecutionTarget).validate_python(
execution_target or os.getenv("ARTEMIS_EXECUTION_TARGET", "android")
)
choices_json = (
plugins_json if plugins_json is not None else os.getenv("ARTEMIS_TASK_PLUGINS")
)
choices = (
TypeAdapter(list[PluginSelection]).validate_json(choices_json)
if choices_json is not None
else None
)
except ValueError:
raise typer.BadParameter(
"执行目标或插件配置引用 JSON 无效;只能引用已保存的插件配置。"
) from None
overrides = {"execution_target": target}
if choices is not None:
overrides["plugins"] = [p.model_dump() for p in choices]
is_worker = (
os.environ.get("ARTEMIS_TASK_WORKER") == "1"
@@ -386,6 +433,7 @@ def run_command(
session_id=target_sid,
ingress="cli",
base_url=base_url,
runtime_overrides=overrides,
)
if resp and resp.get("tasks"):
console.print(
@@ -440,9 +488,12 @@ def run_command(
f"[yellow]Daemon routing notice: {exc}. Falling back to local execution...[/yellow]"
)
if not is_worker and not is_standalone and (target != "android" or choices):
raise typer.BadParameter("插件任务需要统一队列服务,请检查服务后重试;未自动重复提交。")
adb_client = None
try:
if which("adb"):
if target != "computer" and which("adb"):
adb_client = AdbClient(
host=settings.ADB_HOST or "localhost",
port=settings.ADB_PORT or 5037,
@@ -451,7 +502,8 @@ def run_command(
# Optional cosmetic device-status display; run continues without it.
logger.debug(f"Could not create ADB client for device status display: {exc}")
display_device_status(console, adb_client=adb_client)
if target != "computer":
display_device_status(console, adb_client=adb_client)
cancelled = False
original_sigterm = None
@@ -488,6 +540,8 @@ def run_command(
explorer_flash_mode=explorer_flash_mode,
explorer_pro_mode=explorer_pro_mode,
verification_level=verification_level,
execution_target=target,
plugins=choices,
)
)
except (KeyboardInterrupt, asyncio.CancelledError):
+14 -1
View File
@@ -109,7 +109,14 @@ class McpActionExecutor:
@property
def action_tool_names(self) -> frozenset[str]:
"""Device actions plus backend extension names -- the dynamic dispatch set."""
return (REQUIRED_ACTIONS | OPTIONAL_ACTIONS) | {e.name for e in self.actuator.extensions()}
from artemis.plugins.runtime import get_plugin_runtime
plugins = get_plugin_runtime(self.ctx)
return (
(REQUIRED_ACTIONS | OPTIONAL_ACTIONS)
| {e.name for e in self.actuator.extensions()}
| (set(plugins.bindings) if plugins else set())
)
async def _session_or_start(self) -> ActionSession:
if self._session is None or not self._session.started:
@@ -232,6 +239,12 @@ class McpActionExecutor:
"""
raw_name = name.split(":")[-1] if ":" in name else name
from artemis.plugins.runtime import get_plugin_runtime
plugins = get_plugin_runtime(self.ctx)
if plugins and raw_name in plugins.tool_names:
return await plugins.execute(raw_name, args, tool_call_id)
if raw_name in AGENT_TOOL_NAMES:
return await self._execute_agent_tool(raw_name, name, args, tool_call_id, state)
+16
View File
@@ -88,6 +88,22 @@ async def observe(
device_data = await asyncio.wait_for(
controller.get_screen_data(), timeout=settings.ARTEMIS_OBSERVATION_TIMEOUT_SECONDS
)
if getattr(device_data, "platform", "android") == "computer":
screenshot = base64.b64decode(device_data.base64) if device_data.base64 else None
path = None
if screenshot and ctx.data_engine:
path = str(
ctx.data_engine.get_image_path(ctx.data_engine.get_or_create_image(screenshot))
)
return ObserveResult(
ok=True,
message="已读取电脑插件观察结果",
screenshot_path=path,
width=device_data.width,
height=device_data.height,
elements_text=device_data.observation_text,
hierarchy_ok=True,
), screenshot
screenshot_bytes = base64.b64decode(device_data.base64)
with Image.open(BytesIO(screenshot_bytes)) as image:
if min(image.size) <= 1:
+1
View File
@@ -0,0 +1 @@
"""Installed skills and external MCP capabilities for ARTEMIS tasks."""
+93
View File
@@ -0,0 +1,93 @@
"""Read-only screen adapter for plugin-controlled computers. Never fabricates images."""
import base64
from io import BytesIO
from PIL import Image
from artemis.drivers.base import BaseDeviceDriver, ScreenData
class PluginScreenDriver(BaseDeviceDriver):
def __init__(self, ctx):
self.ctx = ctx
@property
def device_id(self):
return self.ctx.device.device_id
@property
def screen_size(self):
return self.ctx.device.device_width, self.ctx.device.device_height
async def connect(self):
return None
async def disconnect(self):
await self.ctx.plugins.aclose()
async def get_screen_data(self, skip_settling=False):
runtime = self.ctx.plugins
candidates = [
(pid, package) for pid, package in runtime.packages.items() if package[0].observation
]
if not candidates:
raise ValueError("电脑任务没有可用的观察插件")
pid, (manifest, _, _) = candidates[0]
loaded = await runtime.load(pid)
if loaded.status != "success":
raise RuntimeError(loaded.text())
spec = manifest.observation
tool_name, arguments = runtime._observation_calls.get(pid, (spec.tool, spec.arguments))
name = next(
(
name
for name, (owner, tool) in runtime.bindings.items()
if owner == pid and tool.name == tool_name
),
None,
)
if not name:
raise ValueError("电脑观察工具未授权或未注册")
outcome = await runtime.call(name, arguments)
if outcome.status != "success":
raise RuntimeError(outcome.text())
image = next((b for b in outcome.content if b.get("type") == "image"), None)
raw = base64.b64decode(image["data"], validate=True) if image else b""
width, height = 0, 0
if raw:
with Image.open(BytesIO(raw)) as picture:
width, height = picture.size
picture.verify()
return ScreenData(
screenshot_bytes=raw,
screenshot_base64=base64.b64encode(raw).decode(),
width=width,
height=height,
platform="computer",
observation_text=f"来源:{manifest.name} {manifest.version} / {tool_name}\n{outcome.text()}",
)
async def _unsupported(self, *args, **kwargs):
raise RuntimeError("此任务通过已授权的电脑插件操作,请使用插件工具")
tap = _unsupported
long_press = _unsupported
swipe = _unsupported
swipe_direction = _unsupported
input_text = _unsupported
press_key = _unsupported
launch_app = _unsupported
stop_app = _unsupported
get_current_package = _unsupported
execute_shell = _unsupported
start_video_recording = _unsupported
stop_video_recording = _unsupported
class PluginReadSession:
def __init__(self, ctx):
self.driver = PluginScreenDriver(ctx)
async def screenshot_b64(self):
return (await self.driver.get_screen_data()).screenshot_base64
+159
View File
@@ -0,0 +1,159 @@
"""Install immutable, verified plugin packages without executing package hooks."""
import hashlib
import io
from pathlib import Path, PurePosixPath
import shutil
import stat
import tempfile
import time
from typing import Callable
import zipfile
import httpx
from artemis.config.settings import settings
from artemis.plugins.models import CatalogEntry, PluginCatalog, PluginManifest
from artemis.plugins.store import PluginStore, contained_path
def download(url: str, progress: Callable[[str, int], None]) -> bytes:
if httpx.URL(url).scheme != "https":
raise ValueError("插件下载必须使用 HTTPS")
deadline = time.monotonic() + settings.ARTEMIS_PLUGIN_DOWNLOAD_TIMEOUT_SECONDS
chunks: list[bytes] = []
size = 0
limit = settings.ARTEMIS_PLUGIN_DOWNLOAD_MAX_BYTES
with httpx.stream("GET", url, follow_redirects=True, timeout=30) as response:
response.raise_for_status()
if response.url.scheme != "https":
raise ValueError("插件下载不能降级为明文 HTTP")
total = int(response.headers.get("content-length", 0))
if total > limit:
raise ValueError("插件下载大小超过限制")
for chunk in response.iter_bytes():
if time.monotonic() > deadline:
raise ValueError("插件下载超时,请检查网络后重试")
size += len(chunk)
if size > limit:
raise ValueError("插件下载大小超过限制")
chunks.append(chunk)
progress("下载中", min(70, int(size / total * 70)) if total else 0)
return b"".join(chunks)
def load_catalog() -> PluginCatalog:
data = download(settings.ARTEMIS_PLUGIN_MARKETPLACE_URL, lambda *_: None)
if len(data) > 2 * 1024 * 1024:
raise ValueError("市场索引过大")
return PluginCatalog.model_validate_json(data)
def _extract(data: bytes, target: Path) -> None:
with zipfile.ZipFile(io.BytesIO(data)) as archive:
members = archive.infolist()
if (
len(members) > 20000
or sum(m.file_size for m in members) > settings.ARTEMIS_PLUGIN_EXTRACT_MAX_BYTES
):
raise ValueError("插件解压大小或文件数量超过限制")
seen: set[str] = set()
for member in members:
relative = member.filename.replace("\\", "/")
parts = PurePosixPath(relative).parts
reserved = {
"con",
"prn",
"aux",
"nul",
*(f"com{i}" for i in range(1, 10)),
*(f"lpt{i}" for i in range(1, 10)),
}
if (
not parts
or relative.startswith("/")
or any(p in {"..", "."} or ":" in p or p.endswith((".", " ")) for p in parts)
or any(p.split(".")[0].casefold() in reserved for p in parts)
or stat.S_ISLNK(member.external_attr >> 16)
):
raise ValueError("插件包包含不安全的路径或链接")
key = relative.rstrip("/").casefold()
if key in seen:
raise ValueError("插件包包含重复文件路径")
seen.add(key)
path = contained_path(target, relative)
if member.is_dir():
path.mkdir(parents=True, exist_ok=True)
else:
path.parent.mkdir(parents=True, exist_ok=True)
with archive.open(member) as source, path.open("wb") as output:
shutil.copyfileobj(source, output)
def install_archive(
store: PluginStore,
data: bytes,
*,
entry: CatalogEntry | None = None,
progress: Callable[[str, int], None] = lambda *_: None,
) -> dict:
if len(data) > settings.ARTEMIS_PLUGIN_DOWNLOAD_MAX_BYTES:
raise ValueError("插件包大小超过限制")
progress("校验安装包", 75)
if entry and hashlib.sha256(data).hexdigest() != entry.sha256:
raise ValueError("安装包 SHA-256 校验失败,原版本保持不变")
with tempfile.TemporaryDirectory(prefix="install-", dir=store.root) as temp:
staged = Path(temp) / "package"
staged.mkdir()
_extract(data, staged)
manifest = PluginManifest.model_validate_json(
(staged / "plugin.json").read_text(encoding="utf-8")
)
manifest.check_compatible()
if entry and manifest != entry.manifest:
raise ValueError("安装包清单与市场索引不一致")
if manifest.skill:
skill = contained_path(staged, manifest.skill)
if not skill.is_file() or skill.stat().st_size > 256 * 1024:
raise ValueError("Skill 文件缺失或超过大小限制")
with store.mutation_lock:
store.assert_idle(manifest.id)
destination = store.package_dir(manifest.id, manifest.version)
if destination.exists():
raise ValueError("此插件版本已经安装;请使用新版本号,不能覆盖任务已固定的文件")
progress("安装中", 90)
destination.parent.mkdir(parents=True, exist_ok=True)
staged.replace(destination)
try:
store.register(manifest, "marketplace" if entry else "local")
except Exception:
# Restore the staging directory; TemporaryDirectory removes this failed install.
destination.replace(staged)
raise
progress("安装完成,请配置并启用", 100)
return store.get(manifest.id)
def install_local(store: PluginStore, path: Path, progress=lambda *_: None) -> dict:
if path.is_file():
if path.stat().st_size > settings.ARTEMIS_PLUGIN_DOWNLOAD_MAX_BYTES:
raise ValueError("插件包大小超过限制")
return install_archive(store, path.read_bytes(), progress=progress)
if not path.is_dir():
raise ValueError("本地插件路径不存在")
output = io.BytesIO()
size = 0
count = 0
with zipfile.ZipFile(output, "w", compression=zipfile.ZIP_DEFLATED) as archive:
for source in path.rglob("*"):
if source.is_symlink():
raise ValueError("本地插件不能包含符号链接")
if source.is_file():
count += 1
size += source.stat().st_size
if count > 20000 or size > settings.ARTEMIS_PLUGIN_EXTRACT_MAX_BYTES:
raise ValueError("本地插件目录过大")
archive.write(source, source.relative_to(path).as_posix())
if output.tell() > settings.ARTEMIS_PLUGIN_DOWNLOAD_MAX_BYTES:
raise ValueError("插件包大小超过限制")
return install_archive(store, output.getvalue(), progress=progress)
+175
View File
@@ -0,0 +1,175 @@
"""Versioned plugin contracts shared by the installer, API and task workers."""
from __future__ import annotations
import re
import sys
import platform
from typing import Any, Annotated, Literal
from urllib.parse import urlsplit
from packaging.specifiers import SpecifierSet
from packaging.version import Version
from pydantic import BaseModel, ConfigDict, Field, SecretStr, field_validator, model_validator
Identifier = Annotated[str, Field(pattern=r"^[a-z][a-z0-9_-]{0,47}$")]
class PluginModel(BaseModel):
model_config = ConfigDict(extra="forbid")
class ServerConfig(PluginModel):
transport: Literal["stdio", "streamable_http"] = "stdio"
command: str = ""
args: list[str] = Field(default_factory=list)
env: dict[str, str] = Field(default_factory=dict)
url: str = ""
headers: dict[str, str] = Field(default_factory=dict)
@model_validator(mode="after")
def validate_transport(self):
if self.transport == "stdio" and not self.command.strip():
raise ValueError("stdio 插件必须提供启动命令")
if self.transport == "streamable_http":
parsed = urlsplit(self.url)
if parsed.scheme not in {"http", "https"} or not parsed.hostname:
raise ValueError("MCP 地址必须是 HTTP(S) 地址")
if parsed.username or parsed.password or parsed.fragment:
raise ValueError("MCP 地址不能包含凭据或片段;请使用密钥配置")
return self
class ConfigField(PluginModel):
label: str = Field(min_length=1, max_length=100)
description: str = Field(default="", max_length=1000)
secret: bool = False
required: bool = False
default: str = ""
ExecutionTarget = Literal["android", "computer", "mixed"]
class PluginObservation(PluginModel):
tool: str = Field(min_length=1)
arguments: dict[str, Any] = Field(default_factory=dict)
refresh_tools: list[str] = Field(default_factory=list)
class PluginManifest(PluginModel):
schema_version: Literal[1] = 1
id: Identifier
name: str = Field(min_length=1, max_length=100)
version: str
description: str = Field(default="", max_length=4000)
artemis_version: str = ">=2.0.0,<3.0.0"
platforms: list[Literal["win32", "linux", "darwin"]] = Field(default_factory=lambda: ["win32"])
architectures: list[Literal["amd64", "arm64"]] = Field(
default_factory=lambda: ["amd64", "arm64"]
)
source: str = ""
license: str = ""
server: ServerConfig | None = None
skill: str | None = None
config: dict[Identifier, ConfigField] = Field(default_factory=dict)
resources: list[str] = Field(default_factory=list)
targets: list[ExecutionTarget] = Field(default_factory=lambda: ["mixed"])
observation: PluginObservation | None = None
@field_validator("version")
@classmethod
def valid_version(cls, value: str) -> str:
Version(value)
if not re.fullmatch(r"[0-9][a-zA-Z0-9.+-]{0,63}", value):
raise ValueError("插件版本格式无效")
return value
@field_validator("artemis_version")
@classmethod
def valid_specifier(cls, value: str) -> str:
SpecifierSet(value)
return value
@model_validator(mode="after")
def has_capability(self):
if self.server is None and self.skill is None:
raise ValueError("插件至少需要 MCP 服务或 Skill")
for name, field in self.config.items():
if field.secret and field.default:
raise ValueError(f"密钥字段 {name} 不允许默认值")
return self
def check_compatible(self, artemis_version: str | None = None) -> None:
from artemis._version import __version__
artemis_version = artemis_version or __version__
if Version(artemis_version) not in SpecifierSet(self.artemis_version):
raise ValueError(f"插件不兼容 ARTEMIS {artemis_version}")
if sys.platform not in self.platforms:
raise ValueError(f"插件不支持当前平台 {sys.platform}")
architecture = {"x86_64": "amd64", "aarch64": "arm64"}.get(
platform.machine().lower(), platform.machine().lower()
)
if architecture not in self.architectures:
raise ValueError(f"插件不支持当前处理器架构 {architecture}")
class PluginProfile(PluginModel):
name: Identifier = "default"
label: str = "默认配置"
is_default: bool = False
values: dict[str, str] = Field(default_factory=dict)
# Only a write payload contains secrets; read responses expose configured names.
secrets: dict[str, SecretStr] = Field(default_factory=dict, repr=False)
allowed_tools: list[str] | None = None
argument_constraints: dict[str, dict[str, list[str]]] = Field(default_factory=dict)
resources: list[str] = Field(default_factory=list)
authorized: bool = False
class PluginSelection(PluginModel):
id: Identifier
profile: Identifier = "default"
version: str | None = None
class CatalogEntry(PluginModel):
manifest: PluginManifest
archive_url: str
sha256: str = Field(pattern=r"^[a-f0-9]{64}$")
verification: str = Field(min_length=1, max_length=4000)
@field_validator("archive_url")
@classmethod
def https_archive(cls, value: str) -> str:
parsed = urlsplit(value)
if parsed.scheme != "https" or not parsed.hostname or parsed.username or parsed.password:
raise ValueError("市场安装包必须使用不含凭据的 HTTPS 地址")
return value
class PluginCatalog(PluginModel):
schema_version: Literal[1] = 1
plugins: list[CatalogEntry] = Field(default_factory=list)
class PluginCallResult(PluginModel):
status: Literal["success", "error", "uncertain"]
content: list[dict[str, Any]] = Field(default_factory=list)
structured_content: dict[str, Any] | None = None
message: str = ""
plugin_id: str = ""
version: str = ""
tool: str = ""
image_names: list[str] = Field(default_factory=list)
def text(self) -> str:
import json
parts = [str(block["text"]) for block in self.content if block.get("type") == "text"]
if self.structured_content is not None:
parts.append(json.dumps(self.structured_content, ensure_ascii=False))
if self.message:
parts.append(self.message)
return "\n".join(parts) or "工具已返回;请根据证据判断业务结果。"
+431
View File
@@ -0,0 +1,431 @@
"""Per-task plugin loading and shared Flash/Pro tool dispatch."""
from __future__ import annotations
import asyncio
import base64
import hashlib
import json
from pathlib import Path
import re
import uuid
from typing import Any
import jsonschema
from langchain_core.messages import ToolMessage
from langchain_core.tools import StructuredTool
from artemis.config.settings import settings
from artemis.core.tool_declaration import ToolDeclaration
from artemis.plugins.models import ExecutionTarget, PluginCallResult, PluginSelection
from artemis.plugins.session import PluginSession, resolve_server
from artemis.plugins.store import PluginStore, contained_path, package_resources
def get_plugin_runtime(ctx: Any) -> PluginRuntime | None:
runtime = getattr(ctx, "plugins", None)
return runtime if isinstance(runtime, PluginRuntime) else None
class PluginRuntime:
def __init__(
self,
selections: list[PluginSelection] | None = None,
*,
store: PluginStore | None = None,
ctx: Any = None,
target: ExecutionTarget | None = None,
):
self.store = store or PluginStore()
self.ctx = ctx
self.packages = {}
target = target or getattr(ctx, "execution_target", "mixed")
self._lease_id = f"plugin-runtime-{uuid.uuid4().hex}"
with self.store.mutation_lock:
pinned = self.store.pin(selections, target)
for selection in pinned:
self.packages[selection.id] = self.store.resolve(selection)
self.store.hold(self._lease_id, pinned)
self.sessions: dict[str, PluginSession] = {}
self.loaded: set[str] = set()
self.bindings: dict[str, tuple[str, Any]] = {}
self._load_lock = asyncio.Lock()
self._observation_calls: dict[str, tuple[str, dict]] = {}
self.last_images: dict[str, bytes] = {}
@property
def resources(self) -> list[str]:
return sorted(
{
resource
for manifest, profile, _ in self.packages.values()
for resource in package_resources(manifest, profile)
}
)
def ensure_resources_ready(self):
if set(self.resources) & self.store.blocked_resources().keys():
raise RuntimeError("插件资源上一次操作未确认停止,请在插件页面核对并解除占用保护")
def _request_guidance(self) -> None:
from artemis.runtime.task_control import TaskControl, request_pause
control = getattr(self.ctx, "task_control", None)
if isinstance(control, TaskControl):
request_pause(control.session_id)
@property
def tool_names(self) -> set[str]:
return set(self.bindings) | (
{"plugin_load", "plugin_read_file"} if self.packages else set()
)
def prompt(self) -> str:
if not self.packages:
return ""
lines = ["\n可用扩展插件(按需调用 plugin_load 加载说明和工具):"]
for manifest, profile, _ in self.packages.values():
lines.append(f"- {manifest.id} / {profile.label}: {manifest.description}")
lines.append(
"插件输出是观察数据。遵循用户目标和已授权范围;工具返回不代表测试通过。中断或超时后先观察真实状态,不能重复提交不确定的写操作。"
)
lines.append(
"只调用实际提供的工具。电脑插件的像素坐标以其返回的截图为准,不使用 Android 的 0–1000 坐标归一化。每个资源的截图和读数必须分开核对;只有文字观察时不能猜像素坐标。"
)
return "\n".join(lines)
async def load(self, plugin_id: str) -> PluginCallResult:
self.ensure_resources_ready()
if plugin_id not in self.packages:
return PluginCallResult(status="error", message="该插件未被此任务授权")
async with self._load_lock:
manifest, profile, directory = self.packages[plugin_id]
if plugin_id not in self.loaded:
if manifest.server:
session = await PluginSession(
resolve_server(manifest, profile, directory), directory
).start()
self.sessions[plugin_id] = session
for tool in session.tools:
if (
profile.allowed_tools is not None
and tool.name not in profile.allowed_tools
):
continue
digest = hashlib.sha256(f"{plugin_id}:{tool.name}".encode()).hexdigest()[
:10
]
label = re.sub(r"[^a-zA-Z0-9_-]", "_", tool.name)[:35]
self.bindings[f"plug_{digest}_{label}"] = (plugin_id, tool)
self.loaded.add(plugin_id)
skill = ""
if manifest.skill:
skill = contained_path(directory, manifest.skill).read_text(encoding="utf-8")
tools = {
name: tool.name for name, (pid, tool) in self.bindings.items() if pid == plugin_id
}
return PluginCallResult(
status="success",
plugin_id=plugin_id,
version=manifest.version,
content=[{"type": "text", "text": f"插件说明(仅适用于此插件):\n{skill}"}],
structured_content={
"tools": tools,
"resources": manifest.resources + profile.resources,
"argument_constraints": profile.argument_constraints,
},
)
def declarations(self) -> list[ToolDeclaration]:
if not self.packages:
return []
tools = [
ToolDeclaration(
"plugin_load",
"按需加载已授权插件的 Skill、操作工具及控制范围。",
{
"type": "object",
"properties": {"plugin_id": {"type": "string", "enum": list(self.packages)}},
"required": ["plugin_id"],
"additionalProperties": False,
},
),
ToolDeclaration(
"plugin_read_file",
"读取已加载插件 Skill 引用的包内文档;不可读取包外文件。",
{
"type": "object",
"properties": {
"plugin_id": {"type": "string", "enum": list(self.packages)},
"path": {"type": "string"},
},
"required": ["plugin_id", "path"],
"additionalProperties": False,
},
),
]
for name, (pid, tool) in self.bindings.items():
tools.append(
ToolDeclaration(name, f"[{pid}] {tool.description or tool.name}", tool.inputSchema)
)
return tools
def langchain_tools(self) -> list[StructuredTool]:
result = []
for declaration in self.declarations():
def handler(name):
async def call(**arguments):
outcome = await self.call(name, arguments)
return ToolMessage(
content=self.content_blocks(outcome),
name=name,
tool_call_id="plugin",
status="success" if outcome.status == "success" else "error",
)
return call
result.append(
StructuredTool(
name=declaration.name,
description=declaration.description,
args_schema=declaration.parameters,
coroutine=handler(declaration.name),
)
)
return result
def _redact(self, value: str) -> str:
for _, profile, _ in self.packages.values():
for secret in profile.secrets.values():
raw = secret.get_secret_value()
if raw:
value = value.replace(raw, "[已隐藏密钥]")
return value
def _redact_payload(self, value: Any) -> Any:
if isinstance(value, list):
return [self._redact_payload(item) for item in value]
if isinstance(value, dict):
# Binary image/audio data are not text. Never corrupt base64 or JSON keys.
binary = value.get("type") in {"image", "audio"}
return {
key: item if binary and key == "data" else self._redact_payload(item)
for key, item in value.items()
}
return self._redact(value) if isinstance(value, str) else value
async def call(self, name: str, arguments: dict) -> PluginCallResult:
try:
if name == "plugin_load":
return await self.load(arguments["plugin_id"])
if name == "plugin_read_file":
plugin_id = arguments["plugin_id"]
if plugin_id not in self.loaded:
raise ValueError("请先加载插件")
path = contained_path(self.packages[plugin_id][2], arguments["path"])
if (
path.suffix.lower() not in {".md", ".txt", ".json"}
or path.stat().st_size > 256 * 1024
):
raise ValueError("仅支持读取 256 KiB 以内的插件说明文件")
return PluginCallResult(
status="success",
content=[
{"type": "text", "text": self._redact(path.read_text(encoding="utf-8"))}
],
)
pid, tool = self.bindings[name]
self.ensure_resources_ready()
manifest, profile, _ = self.packages[pid]
jsonschema.validate(arguments, tool.inputSchema)
for key, allowed in profile.argument_constraints.get(tool.name, {}).items():
value: Any = arguments
for part in key.split("."):
value = value.get(part) if isinstance(value, dict) else None
if not isinstance(value, (str, int, float, bool)) or str(value) not in allowed:
raise PermissionError(f"工具参数 {key} 超出授权范围,请用户补充指导或修改授权")
span = None
if self.ctx:
from artemis.data_engine.trace import TraceSpan
span = TraceSpan(name=name, trace_type="tool", ctx=self.ctx)
if span:
span.__enter__()
span.payload = {
"args": self._redact_payload(arguments),
"plugin_id": pid,
"plugin_version": manifest.version,
"resource_ids": sorted(package_resources(manifest, profile)),
}
try:
raw = await self.sessions[pid].call(tool.name, arguments)
payload = raw.model_dump(mode="json", by_alias=True, exclude_none=True)
encoded = json.dumps(payload, ensure_ascii=False)
if len(encoded.encode()) > settings.ARTEMIS_PLUGIN_RESULT_MAX_BYTES:
raise RuntimeError("插件结果超过大小限制,操作可能已经完成;请重新观察")
payload = self._redact_payload(payload)
outcome = PluginCallResult(
status="error" if payload.get("isError") else "success",
content=payload.get("content", []),
structured_content=payload.get("structuredContent"),
plugin_id=pid,
version=manifest.version,
tool=tool.name,
)
observation = manifest.observation
image_block = next((b for b in outcome.content if b.get("type") == "image"), None)
if image_block:
self.last_images[pid] = base64.b64decode(image_block["data"], validate=True)
elif not (tool.annotations and tool.annotations.readOnlyHint):
self.last_images.pop(pid, None)
if (
outcome.status == "success"
and observation
and tool.name in observation.refresh_tools
):
self._observation_calls[pid] = (tool.name, dict(arguments))
self._persist_images(outcome)
if span:
stored = outcome.model_dump()
stored["content"] = [b for b in stored["content"] if b.get("type") != "image"]
stored["post_image_name"] = next(iter(outcome.image_names), None)
span.payload["post_image_name"] = stored["post_image_name"]
span.payload["image_names"] = outcome.image_names
span.result = stored
if outcome.status != "success":
span.status, span.error = "failed", outcome.text()
return outcome
except (RuntimeError, OSError, ValueError, TimeoutError):
if self.sessions[pid].stop_uncertain:
self.store.block_resources(
self.resources,
"远程插件操作中断,无法确认写操作是否停止;请核对远程状态后解除保护。",
)
if span:
span.status, span.error = "failed", "插件调用失败,结果不确定"
return PluginCallResult(
status="uncertain",
plugin_id=pid,
version=manifest.version,
tool=tool.name,
message="插件调用中断或超时,结果不确定。请先观察实际状态,不能盲目重放写操作。",
)
finally:
if span:
span.__exit__(None, None, None)
except asyncio.CancelledError:
if any(s.stop_uncertain for s in self.sessions.values()):
self.store.block_resources(
self.resources, "插件操作取消后未确认停止,请核对目标状态后解除保护。"
)
raise
except PermissionError:
self._request_guidance()
return PluginCallResult(
status="error",
message="工具参数超出此任务的授权范围,已请求暂停。请提供授权范围内的指导;如需扩大范围,请结束任务后修改插件配置。",
)
except (
KeyError,
ValueError,
OSError,
jsonschema.ValidationError,
jsonschema.SchemaError,
):
# Schema errors can contain the rejected value (including secrets); keep diagnostics structural.
return PluginCallResult(
status="error",
message="插件尚未就绪、参数不符合工具定义或超出授权范围;请检查插件配置和授权。",
)
def _persist_images(self, outcome: PluginCallResult) -> None:
engine = getattr(self.ctx, "data_engine", None)
if not engine:
return
for block in outcome.content:
if block.get("type") == "image" and block.get("data"):
image = base64.b64decode(block["data"], validate=True)
# Save the text from the same tool result before a later action
# can fill this image's missing tree with a different screen.
name = engine.get_or_create_image(image, ui_tree=outcome.text())
outcome.image_names.append(name)
if engine.current_session_id:
engine.storage.reference_plugin_image(engine.current_session_id, name)
@staticmethod
def content_blocks(outcome: PluginCallResult) -> list[dict]:
blocks: list[dict] = [{"type": "text", "text": outcome.text()}]
for block in outcome.content:
if block.get("type") == "image" and block.get("mimeType") in {
"image/png",
"image/jpeg",
"image/webp",
}:
blocks.append(
{
"type": "image_url",
"image_url": {"url": f"data:{block['mimeType']};base64,{block['data']}"},
}
)
elif block.get("type") not in {"image", "text"}:
blocks.append({"type": "text", "text": json.dumps(block, ensure_ascii=False)})
return blocks
async def execute(self, name: str, arguments: dict, tool_call_id: str):
from artemis.agents.validator.tool_declarations import ToolExecutionResult
outcome = await self.call(name, arguments)
image = next((b for b in outcome.content if b.get("type") == "image"), None)
image_bytes = base64.b64decode(image["data"]) if image else None
engine = getattr(self.ctx, "data_engine", None)
path = None
if engine and image_bytes:
path = str(engine.get_image_path(engine.get_or_create_image(image_bytes)))
return ToolExecutionResult(
tool_name=name,
tool_call_id=tool_call_id,
status="success" if outcome.status == "success" else "error",
text_summary=(
f"来源:插件 {outcome.plugin_id} v{outcome.version} / {outcome.tool}\n"
if outcome.plugin_id
else ""
)
+ outcome.text(),
raw_result=self.content_blocks(outcome),
screenshot_bytes=image_bytes,
screenshot_path=path,
ui_elements_text=outcome.text(),
metadata={
"plugin_id": outcome.plugin_id,
"plugin_version": outcome.version,
"resource_ids": (
sorted(package_resources(*self.packages[outcome.plugin_id][:2]))
if outcome.plugin_id in self.packages
else []
),
"plugin_status": outcome.status,
"post_image_name": next(iter(outcome.image_names), None),
},
)
async def aclose(self, *, release: bool = True):
pending = list(self.sessions.items())
errors = await asyncio.gather(
*(session.aclose() for _, session in pending), return_exceptions=True
)
for (pid, _), error in zip(pending, errors):
if not isinstance(error, BaseException):
self.sessions.pop(pid, None)
self.bindings = {
name: binding for name, binding in self.bindings.items() if binding[0] in self.sessions
}
self.loaded.intersection_update(self.sessions)
self._observation_calls.clear()
self.last_images.clear()
if any(isinstance(error, BaseException) for error in errors):
self.store.block_resources(self.resources, "插件未确认停止,请核对目标状态后解除保护。")
raise RuntimeError("部分插件未确认停止,资源仍需保持占用")
if release:
self.store.release(self._lease_id)
+185
View File
@@ -0,0 +1,185 @@
"""Task-owned external MCP transport. A timed-out write is never replayed."""
from __future__ import annotations
import asyncio
from contextlib import AsyncExitStack
import os
from pathlib import Path
import re
from typing import Any
import httpx
from mcp import ClientSession, StdioServerParameters
from mcp.client.stdio import stdio_client
from mcp.client.streamable_http import streamable_http_client
from artemis.config.settings import settings
from artemis.plugins.models import PluginManifest, PluginProfile, ServerConfig
def resolve_server(
manifest: PluginManifest, profile: PluginProfile, directory: Path
) -> ServerConfig:
if manifest.server is None:
raise ValueError("此插件不包含 MCP 服务")
values = {name: field.default for name, field in manifest.config.items() if not field.secret}
values.update(profile.values)
def substitute(value: Any) -> Any:
if isinstance(value, dict):
return {k: substitute(v) for k, v in value.items()}
if isinstance(value, list):
return [substitute(v) for v in value]
if not isinstance(value, str):
return value
def replace(match: re.Match) -> str:
key = match.group(1)
if key == "plugin_dir":
return str(directory)
if key.startswith("config.") and key[7:] in values:
return values[key[7:]]
if key.startswith("secret.") and key[7:] in profile.secrets:
return profile.secrets[key[7:]].get_secret_value()
raise ValueError(f"插件配置引用无法解析:{key}")
return re.sub(r"\$\{([^}]+)\}", replace, value)
return ServerConfig.model_validate(substitute(manifest.server.model_dump()))
class PluginSession:
"""One owner task enters/exits anyio transport scopes, even during cancellation."""
def __init__(self, server: ServerConfig, directory: Path):
self.server = server
self.directory = directory
self.tools: list[Any] = []
self._queue: asyncio.Queue = asyncio.Queue()
self._ready = asyncio.Event()
self._owner: asyncio.Task | None = None
self._error: BaseException | None = None
self.stop_uncertain = False
async def start(self) -> PluginSession:
self._owner = asyncio.create_task(self._run(), name="artemis-plugin-session")
try:
await asyncio.wait_for(
self._ready.wait(), settings.ARTEMIS_PLUGIN_START_TIMEOUT_SECONDS
)
if self._error:
raise RuntimeError(
"插件 MCP 启动失败,请检查命令、连接地址及必填配置"
) from self._error
return self
except BaseException:
await self.aclose()
raise
async def _run(self):
active = None
try:
async with AsyncExitStack() as stack:
if self.server.transport == "stdio":
# A plugin can print credentials to stderr; never forward it to task reports.
errlog = stack.enter_context(open(os.devnull, "w"))
streams = await stack.enter_async_context(
stdio_client(
StdioServerParameters(
command=self.server.command,
args=self.server.args,
env=self.server.env,
cwd=str(self.directory),
),
errlog=errlog,
)
)
else:
client = await stack.enter_async_context(
httpx.AsyncClient(
headers=self.server.headers,
timeout=settings.ARTEMIS_PLUGIN_TOOL_TIMEOUT_SECONDS,
follow_redirects=False,
)
)
streams = await stack.enter_async_context(
streamable_http_client(self.server.url, http_client=client)
)
session = await stack.enter_async_context(ClientSession(streams[0], streams[1]))
await session.initialize()
cursor = None
for _ in range(20):
response = await session.list_tools(cursor=cursor)
self.tools.extend(response.tools)
cursor = response.nextCursor
if not cursor:
break
else:
raise ValueError("插件工具列表分页超过限制")
self._ready.set()
while True:
item = await self._queue.get()
if item is None:
return
active, name, arguments = item
if active.cancelled():
active = None
continue
# On timeout retire the whole connection: MCP may deliver a late response.
result = await asyncio.wait_for(
session.call_tool(name, arguments),
settings.ARTEMIS_PLUGIN_TOOL_TIMEOUT_SECONDS,
)
if not active.done():
active.set_result(result)
active = None
except BaseException as exc:
self._error = exc
if active is not None and self.server.transport == "streamable_http":
self.stop_uncertain = True
finally:
self._ready.set()
if active is not None and not active.done():
active.set_exception(
RuntimeError(
"插件调用中断或超时,执行结果不确定;请先观察状态,不能盲目重放操作"
)
)
while not self._queue.empty():
item = self._queue.get_nowait()
if item is not None and not item[0].done():
item[0].set_exception(RuntimeError("插件连接已关闭,此调用尚未执行"))
async def call(self, name: str, arguments: dict):
if self._owner is None or self._owner.done() or self._error is not None:
raise RuntimeError("插件连接已关闭,不能继续执行")
future = asyncio.get_running_loop().create_future()
self._queue.put_nowait((future, name, arguments))
try:
# Bound the caller as well as the protocol operation: transport context
# cleanup may itself stall after the inner request times out.
return await asyncio.wait_for(
future,
settings.ARTEMIS_PLUGIN_TOOL_TIMEOUT_SECONDS
+ settings.ARTEMIS_PLUGIN_SHUTDOWN_TIMEOUT_SECONDS,
)
except TimeoutError:
await self.aclose()
raise RuntimeError("插件响应或清理超时,执行结果不确定") from None
except asyncio.CancelledError:
# Guidance/pause must stop the transport before the task releases shared resources.
await self.aclose()
raise
async def aclose(self):
owner = self._owner
if owner is None or owner.done():
return
owner.cancel()
done, _ = await asyncio.wait(
{owner}, timeout=settings.ARTEMIS_PLUGIN_SHUTDOWN_TIMEOUT_SECONDS
)
if not done:
self.stop_uncertain = True
raise RuntimeError("插件尚未确认停止,不能释放其占用资源")
+352
View File
@@ -0,0 +1,352 @@
"""Transactional plugin registry; task state continues to live in DataEngine."""
from __future__ import annotations
from contextlib import contextmanager
from functools import wraps
import json
import os
from pathlib import Path
import sqlite3
import shutil
import uuid
from filelock import FileLock
import psutil
from typing import Any
from artemis.config.paths import get_app_dir
from artemis.plugins.models import PluginManifest, PluginProfile, PluginSelection
def serialized(method):
"""Serialize package mutation and task reservations across threads/processes."""
@wraps(method)
def locked(self, *args, **kwargs):
with self.mutation_lock:
return method(self, *args, **kwargs)
return locked
def contained_path(root: Path, relative: str) -> Path:
"""Resolve package paths without following traversal or symlinks outside the package."""
path = (root / relative).resolve()
if not path.is_relative_to(root.resolve()) or path == root.resolve():
raise ValueError("插件文件路径超出安装目录")
return path
def package_resources(manifest: PluginManifest, profile: PluginProfile) -> set[str]:
declared = [*manifest.resources, *profile.resources]
if not declared and manifest.server:
declared = [f"plugin:{manifest.id}:{profile.name}"]
return {resource.strip().casefold() for resource in declared if resource.strip()}
class PluginStore:
def __init__(self, root: Path | None = None):
self.root = root or get_app_dir() / "plugins"
self.root.mkdir(parents=True, exist_ok=True)
self.db_path = self.root / "registry.sqlite3"
self.mutation_lock = FileLock(self.root / "registry.lock", timeout=10)
with self.connect() as db:
db.executescript("""
CREATE TABLE IF NOT EXISTS plugins (
id TEXT PRIMARY KEY, version TEXT NOT NULL,
enabled INTEGER NOT NULL DEFAULT 0, manifest TEXT NOT NULL,
source TEXT NOT NULL DEFAULT 'local'
);
CREATE TABLE IF NOT EXISTS profiles (
plugin_id TEXT NOT NULL, name TEXT NOT NULL, data TEXT NOT NULL,
secrets TEXT NOT NULL DEFAULT '{}',
PRIMARY KEY (plugin_id, name)
);
CREATE TABLE IF NOT EXISTS task_pins (
task_id TEXT NOT NULL, plugin_id TEXT NOT NULL, version TEXT NOT NULL,
pid INTEGER NOT NULL, created REAL NOT NULL,
PRIMARY KEY(task_id, plugin_id)
);
CREATE TABLE IF NOT EXISTS blocked_resources (
resource TEXT PRIMARY KEY, reason TEXT NOT NULL
);
""")
os.chmod(self.db_path, 0o600)
@contextmanager
def connect(self):
db = sqlite3.connect(self.db_path, timeout=10)
db.row_factory = sqlite3.Row
try:
with db:
yield db
finally:
db.close()
def package_dir(self, plugin_id: str, version: str) -> Path:
PluginSelection(id=plugin_id)
# Validate through the manifest validator without accepting directory separators.
PluginManifest.valid_version(version)
return contained_path(self.root, f"packages/{plugin_id}/{version}")
@serialized
def register(self, manifest: PluginManifest, source: str = "local") -> None:
manifest.check_compatible()
self.assert_idle(manifest.id)
with self.connect() as db:
existing = db.execute("SELECT id FROM plugins WHERE id=?", (manifest.id,)).fetchone()
# Updating executable code always leaves new work disabled until explicitly enabled.
db.execute(
"INSERT INTO plugins (id,version,manifest,source) VALUES (?,?,?,?) "
"ON CONFLICT(id) DO UPDATE SET version=excluded.version, "
"manifest=excluded.manifest,source=excluded.source,enabled=0",
(manifest.id, manifest.version, manifest.model_dump_json(), source),
)
if not existing:
profile = PluginProfile(
is_default=True,
values={k: v.default for k, v in manifest.config.items() if not v.secret},
)
db.execute(
"INSERT INTO profiles (plugin_id,name,data) VALUES (?,?,?)",
(manifest.id, profile.name, profile.model_dump_json(exclude={"secrets"})),
)
@serialized
def get(self, plugin_id: str) -> dict[str, Any]:
with self.connect() as db:
row = db.execute("SELECT * FROM plugins WHERE id=?", (plugin_id,)).fetchone()
if row is None:
raise ValueError("插件未安装")
profiles = db.execute(
"SELECT name,data,secrets FROM profiles WHERE plugin_id=? ORDER BY name",
(plugin_id,),
).fetchall()
return {
"manifest": json.loads(row["manifest"]),
"enabled": bool(row["enabled"]),
"source": row["source"],
"profiles": [
{**json.loads(p["data"]), "configured_secrets": list(json.loads(p["secrets"]))}
for p in profiles
],
}
@serialized
def list(self) -> list[dict[str, Any]]:
with self.connect() as db:
ids = [row[0] for row in db.execute("SELECT id FROM plugins ORDER BY id")]
return [self.get(plugin_id) for plugin_id in ids]
@serialized
def save_profile(self, plugin_id: str, profile: PluginProfile) -> None:
self.assert_idle(plugin_id)
manifest = PluginManifest.model_validate(self.get(plugin_id)["manifest"])
for key in profile.values:
if key not in manifest.config or manifest.config[key].secret:
raise ValueError(f"普通配置字段无效:{key}")
for key in profile.secrets:
if key not in manifest.config or not manifest.config[key].secret:
raise ValueError(f"密钥配置字段无效:{key}")
with self.connect() as db:
old = db.execute(
"SELECT secrets FROM profiles WHERE plugin_id=? AND name=?",
(plugin_id, profile.name),
).fetchone()
secrets = json.loads(old[0]) if old else {}
for name, value in profile.secrets.items():
# Blank means keep, matching the model settings form.
if value.get_secret_value():
secrets[name] = value.get_secret_value()
db.execute(
"INSERT INTO profiles (plugin_id,name,data,secrets) VALUES (?,?,?,?) "
"ON CONFLICT(plugin_id,name) DO UPDATE SET data=excluded.data,secrets=excluded.secrets",
(
plugin_id,
profile.name,
profile.model_dump_json(exclude={"secrets"}),
json.dumps(secrets),
),
)
if profile.is_default:
for row in db.execute(
"SELECT name,data FROM profiles WHERE plugin_id=? AND name<>?",
(plugin_id, profile.name),
).fetchall():
data = json.loads(row["data"])
data["is_default"] = False
db.execute(
"UPDATE profiles SET data=? WHERE plugin_id=? AND name=?",
(json.dumps(data), plugin_id, row["name"]),
)
@serialized
def set_enabled(self, plugin_id: str, enabled: bool) -> None:
self.assert_idle(plugin_id)
info = self.get(plugin_id)
if enabled and not any(p["authorized"] for p in info["profiles"]):
raise ValueError("请先配置并授权至少一套连接配置")
if enabled:
for profile in info["profiles"]:
if profile["authorized"]:
self.resolve(
PluginSelection(id=plugin_id, profile=profile["name"]), allow_disabled=True
)
with self.connect() as db:
db.execute("UPDATE plugins SET enabled=? WHERE id=?", (enabled, plugin_id))
@serialized
def resolve(
self, selection: PluginSelection, *, allow_disabled: bool = False
) -> tuple[PluginManifest, PluginProfile, Path]:
info = self.get(selection.id)
if not info["enabled"] and not allow_disabled:
raise ValueError(f"插件 {selection.id} 未启用")
manifest = PluginManifest.model_validate(info["manifest"])
version = selection.version or manifest.version
directory = self.package_dir(selection.id, version)
manifest = PluginManifest.model_validate_json(
(directory / "plugin.json").read_text(encoding="utf-8")
)
if (manifest.id, manifest.version) != (selection.id, version):
raise ValueError("安装包标识与固定版本不一致")
manifest.check_compatible()
with self.connect() as db:
row = db.execute(
"SELECT data,secrets FROM profiles WHERE plugin_id=? AND name=?",
(selection.id, selection.profile),
).fetchone()
if row is None:
raise ValueError("插件连接配置不存在")
profile = PluginProfile.model_validate(
{**json.loads(row[0]), "secrets": json.loads(row[1])}
)
if not profile.authorized:
raise ValueError("插件连接配置尚未授权")
for name, field in manifest.config.items():
value = (
profile.secrets.get(name)
if field.secret
else profile.values.get(name, field.default)
)
if field.required and not value:
raise ValueError(f"请补齐插件配置:{field.label}")
return manifest, profile, directory
def selections(self, target: str = "mixed") -> list[PluginSelection]:
selections = []
for plugin in self.list():
if not plugin["enabled"] or target not in plugin["manifest"].get("targets", ["mixed"]):
continue
profiles = plugin["profiles"]
default = next((p for p in profiles if p.get("is_default")), None)
default = default or next((p for p in profiles if p["name"] == "default"), None)
if default and default["authorized"]:
selections.append(
PluginSelection(
id=plugin["manifest"]["id"],
profile=default["name"],
version=plugin["manifest"]["version"],
)
)
return selections
@serialized
def pin(self, selections: list[PluginSelection] | None, target: str) -> list[PluginSelection]:
pinned = []
for choice in self.selections(target) if selections is None else selections:
manifest, profile, _ = self.resolve(choice)
if target not in manifest.targets:
raise ValueError(f"插件 {manifest.name} 不支持 {target} 任务")
pinned.append(
PluginSelection(id=manifest.id, profile=profile.name, version=manifest.version)
)
if len({p.id for p in pinned}) != len(pinned):
raise ValueError("同一任务不能重复选择同一插件")
if target == "computer" and not any(self.resolve(p)[0].observation for p in pinned):
raise ValueError("纯电脑任务需要启用并选择一个提供画面观察的电脑插件")
return pinned
@serialized
def uninstall(self, plugin_id: str) -> None:
self.get(plugin_id)
self.assert_idle(plugin_id)
PluginSelection(id=plugin_id)
directory = contained_path(self.root, f"packages/{plugin_id}")
trash = contained_path(self.root, f"uninstall-{uuid.uuid4().hex}")
directory.replace(trash)
try:
with self.connect() as db:
db.execute("DELETE FROM profiles WHERE plugin_id=?", (plugin_id,))
db.execute("DELETE FROM plugins WHERE id=?", (plugin_id,))
except sqlite3.Error:
trash.replace(directory)
raise
shutil.rmtree(trash)
@serialized
def hold(self, task_id: str, selections: list[PluginSelection]) -> None:
process = psutil.Process()
resolved = [(s, self.resolve(s)[0].version) for s in selections]
with self.connect() as db:
for selection, version in resolved:
db.execute(
"INSERT OR REPLACE INTO task_pins VALUES (?,?,?,?,?)",
(
task_id,
selection.id,
version,
process.pid,
process.create_time(),
),
)
@serialized
def reserve(self, task_id: str, selections: list[PluginSelection], target: str) -> list[str]:
pinned = self.pin(selections, target)
resources = set()
for selection in pinned:
manifest, profile, _ = self.resolve(selection)
resources.update(package_resources(manifest, profile))
self.hold(task_id, pinned)
return sorted(resources)
@serialized
def release(self, task_id: str) -> None:
with self.connect() as db:
db.execute("DELETE FROM task_pins WHERE task_id=?", (task_id,))
@serialized
def assert_idle(self, plugin_id: str) -> None:
with self.connect() as db:
rows = db.execute("SELECT * FROM task_pins WHERE plugin_id=?", (plugin_id,)).fetchall()
for row in rows:
try:
alive = abs(psutil.Process(row["pid"]).create_time() - row["created"]) < 0.01
except psutil.NoSuchProcess:
alive = False
except psutil.AccessDenied:
alive = True
if alive:
raise ValueError(
"插件正被排队、暂停或执行中的任务使用,请任务结束后再更新配置、升级或卸载"
)
db.execute(
"DELETE FROM task_pins WHERE task_id=? AND plugin_id=?",
(row["task_id"], plugin_id),
)
def block_resources(self, resources: list[str], reason: str) -> None:
with self.connect() as db:
db.executemany(
"INSERT OR REPLACE INTO blocked_resources VALUES (?,?)",
[(r, reason) for r in resources],
)
def blocked_resources(self) -> dict[str, str]:
with self.connect() as db:
return dict(db.execute("SELECT resource,reason FROM blocked_resources").fetchall())
def confirm_resource_stopped(self, resource: str) -> None:
with self.connect() as db:
db.execute("DELETE FROM blocked_resources WHERE resource=?", (resource,))
+83 -2
View File
@@ -19,6 +19,7 @@ from __future__ import annotations
from dataclasses import dataclass
from datetime import UTC, datetime
import json
import hashlib
import os
from pathlib import Path
import re
@@ -52,6 +53,7 @@ class DeviceLockOwner:
session_id: str | None = None
ingress: str | None = None
lock_scope: str | None = None
resource_ids: tuple[str, ...] = ()
@classmethod
def from_dict(cls, value: dict) -> DeviceLockOwner:
@@ -65,6 +67,7 @@ class DeviceLockOwner:
session_id=(str(value["session_id"]) if value.get("session_id") is not None else None),
ingress=(str(value["ingress"]) if value.get("ingress") is not None else None),
lock_scope=(str(value["lock_scope"]) if value.get("lock_scope") is not None else None),
resource_ids=tuple(str(item) for item in value.get("resource_ids", [])),
)
@@ -116,6 +119,7 @@ class DeviceExecutionLock:
session_id: str | None = None,
ingress: str | None = None,
lock_scope: str | None = None,
resource_ids: list[str] | tuple[str, ...] = (),
):
self.device_id = device_id or "default"
self.lock_scope = lock_scope or os.getenv(self.LOCK_SCOPE_ENV) or None
@@ -140,6 +144,10 @@ class DeviceExecutionLock:
self.ingress = ingress or os.getenv("ARTEMIS_TASK_INGRESS") or "sdk"
self._queue_path: Path | None = None
self._acquired = False
self.resource_ids = tuple(
sorted(set(r.strip().casefold() for r in resource_ids if r.strip()))
)
self._resource_paths: list[Path] = []
@classmethod
def _device_lock_path(
@@ -534,6 +542,51 @@ class DeviceExecutionLock:
return removed
def _try_acquire_owner_lock(self) -> bool:
if not self.resource_ids:
return self._try_acquire_device_owner_lock()
payload = self._owner_payload(
self.token,
self.device_id,
self.description,
session_id=self.session_id,
ingress=self.ingress,
lock_scope=self.lock_scope,
)
payload["resource_ids"] = self.resource_ids
try:
for resource in self.resource_ids:
digest = hashlib.sha256(resource.encode()).hexdigest()
path = self.path.parent / f"artemis-device-resource-{digest}.lock"
# Same resource identity applies across plugins and ADB server endpoints.
owner = self._get_active_owner_for_path(path)
if owner is not None and owner.token == self.token:
continue
try:
fd = os.open(path, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600)
except FileExistsError:
self._release_resources()
return False
self._resource_paths.append(path)
try:
os.write(fd, json.dumps(payload).encode())
finally:
os.close(fd)
if self._try_acquire_device_owner_lock():
return True
self._release_resources()
return False
except BaseException:
self._release_resources()
raise
def _release_resources(self) -> None:
for path in self._resource_paths:
owner = self._read_owner(path)
if owner is not None and owner.pid == os.getpid() and owner.token == self.token:
self._safe_unlink(path)
self._resource_paths.clear()
def _try_acquire_device_owner_lock(self) -> bool:
owner_payload = self._owner_payload(
self.token,
self.device_id,
@@ -542,6 +595,7 @@ class DeviceExecutionLock:
ingress=self.ingress,
lock_scope=self.lock_scope,
)
owner_payload["resource_ids"] = self.resource_ids
try:
fd = os.open(self.path, os.O_CREAT | os.O_EXCL | os.O_WRONLY)
except FileExistsError:
@@ -609,6 +663,11 @@ class DeviceExecutionLock:
) -> None:
"""Wait in FIFO order and acquire exclusive access to the target Artemis device."""
self._queue_path = self._claim_or_create_queue_ticket()
if self.resource_ids:
payload = json.loads(self._queue_path.read_text(encoding="utf-8"))
payload["resource_ids"] = self.resource_ids
if not self._atomic_write_json(self._queue_path, payload):
raise DeviceBusyError("Could not register task resource requirements.")
started_at = time.monotonic()
effective_concurrency = self.effective_concurrency
try:
@@ -679,6 +738,14 @@ class DeviceExecutionLock:
)
is_eligible = bool(device_queue and device_queue[0] == self._queue_path)
if is_eligible and self.resource_ids:
for earlier in all_wait_files:
if earlier == self._queue_path:
break
owner = self._read_owner(earlier)
if owner and set(owner.resource_ids).intersection(self.resource_ids):
is_eligible = False
break
if is_eligible and self._try_acquire_owner_lock():
self._safe_unlink(self._queue_path)
self._queue_path = None
@@ -708,6 +775,7 @@ class DeviceExecutionLock:
raise
def release(self) -> None:
self._release_resources()
if not self._acquired:
return
try:
@@ -938,7 +1006,7 @@ class DeviceExecutionLock:
"""Attach session metadata after tracing starts inside the lease owner."""
lock_dir = get_temp_dir("device-locks")
if device_id is not None:
clean_id = cls._normalize_device_id(device_id)
clean_id = cls._normalize_lock_id(device_id, os.getenv(cls.LOCK_SCOPE_ENV) or None)
path = lock_dir / f"artemis-device-{clean_id}.lock"
if not path.exists():
path = lock_dir / "artemis-global-device.lock"
@@ -959,8 +1027,21 @@ class DeviceExecutionLock:
"acquired_at": owner.acquired_at,
"session_id": str(session_id),
"ingress": ingress or owner.ingress or "sdk",
"lock_scope": owner.lock_scope,
"resource_ids": owner.resource_ids,
}
return cls._atomic_write_json(path, payload)
if not cls._atomic_write_json(path, payload):
return False
# Device and shared-resource records describe one lease. Discovery may
# encounter either first, so attach the same session to all its records.
for related in lock_dir.glob("artemis-device-*.lock"):
if related == path:
continue
candidate = cls._read_owner(related)
if candidate and candidate.pid == owner.pid and candidate.token == owner.token:
if not cls._atomic_write_json(related, payload):
return False
return True
@classmethod
def cleanup_stale_locks(cls, device_id: str | None = None) -> int:
+15 -8
View File
@@ -190,6 +190,7 @@ class TaskControl:
session_id=self.session_id,
ingress=previous.ingress,
lock_scope=previous.lock_scope,
resource_ids=getattr(previous, "resource_ids", ()),
)
cancelled = threading.Event()
acquiring = asyncio.create_task(
@@ -220,24 +221,30 @@ class TaskControl:
"""
running = asyncio.ensure_future(operation)
pause = self.directory / "pause.request"
async def stop_operation():
running.cancel()
result = (await asyncio.gather(running, return_exceptions=True))[0]
if isinstance(result, BaseException) and not isinstance(result, asyncio.CancelledError):
raise result
try:
while True:
if running.done():
# Preserve a completed tool result (including a permission failure
# that requested pause). The next checkpoint handles the request.
return False, running.result()
if pause.exists():
running.cancel()
await asyncio.gather(running, return_exceptions=True)
await stop_operation()
await self.checkpoint(on_pause=on_pause, on_resume=on_resume)
return True, None
if has_pending_instruction(self.directory):
running.cancel()
await asyncio.gather(running, return_exceptions=True)
await stop_operation()
return True, None
if running.done():
return False, running.result()
await asyncio.wait({running}, timeout=0.2)
finally:
if not running.done():
running.cancel()
await asyncio.gather(running, return_exceptions=True)
await stop_operation()
def release(self) -> None:
self.device_lock.release()
+91 -31
View File
@@ -202,7 +202,22 @@ class Agent:
device_serial: str | None = None,
retry_count: int = 5,
retry_wait_seconds: int = 5,
execution_target: str = "android",
):
if execution_target not in {"android", "computer", "mixed"}:
raise ValueError("任务目标必须是 android、computer 或 mixed")
self._execution_target = execution_target
if execution_target == "computer":
self._device_context = DeviceContext(
device_id="computer:local",
mobile_platform=DevicePlatform.COMPUTER,
device_width=0,
device_height=0,
)
self._adb_client = None
self._ui_adb_client = None
self._initialized = True
return True
target_dev = device_serial or device_id
if target_dev:
self._config = self._config.model_copy(
@@ -366,9 +381,13 @@ class Agent:
- .with_trace_recording() to enable trace recording
- .with_name() to set a custom task name
"""
return TaskRequestBuilder[None].from_common(
goal=goal,
common=self._config.task_request_defaults,
return (
TaskRequestBuilder[None]
.from_common(
goal=goal,
common=self._config.task_request_defaults,
)
.for_target(getattr(self, "_execution_target", "android"))
)
@overload
@@ -480,6 +499,13 @@ class Agent:
if not self._initialized:
raise AgentNotInitializedError()
if request.execution_target != getattr(self, "_execution_target", "android"):
raise AgentTaskRequestError("任务目标与 Agent 初始化目标不一致")
if request.execution_target == "computer" and (
request.app_path or request.locked_app_package
):
raise AgentTaskRequestError("纯电脑任务不能使用 Android 安装包或应用锁定")
if request.profile:
agent_profile = self._config.agent_profiles.get(request.profile)
if agent_profile is None:
@@ -522,7 +548,9 @@ class Agent:
ui_adb_client=self._ui_adb_client,
llm_config=agent_profile.llm_config,
agent_config=self._config,
execution_target=request.execution_target,
)
from artemis.plugins.runtime import PluginRuntime
output_config = None
if request.output_description or request.output_format:
@@ -551,28 +579,41 @@ class Agent:
or getattr(task, "id", None)
or getattr(getattr(task, "request", None), "task_name", None)
)
active_owner = DeviceExecutionLock.get_active_owner(self._device_context.device_id)
already_held = (
active_owner is not None
and active_owner.pid == os.getpid()
and (
(sess_id and str(active_owner.session_id) == str(sess_id))
or active_owner.token == self._device_context.device_id
)
)
device_lock = (
None
if already_held
else DeviceExecutionLock(
self._device_context.device_id,
description=f"{request.goal[:120]}",
concurrency_mode=effective_mode,
max_concurrency=effective_max,
session_id=str(sess_id) if sess_id else None,
ingress=os.getenv("ARTEMIS_TASK_INGRESS") or "agent",
)
)
device_lock = None
try:
# Construct the runtime only inside the task's cleanup boundary. A
# cancelled queue wait or setup failure must not leave version pins.
context.plugins = PluginRuntime(request.plugins, ctx=context)
active_owner = DeviceExecutionLock.get_active_owner(self._device_context.device_id)
already_held = (
active_owner is not None
and active_owner.pid == os.getpid()
and (
(sess_id and str(active_owner.session_id) == str(sess_id))
or active_owner.token == self._device_context.device_id
)
)
if not already_held or context.plugins.resources:
device_lock = DeviceExecutionLock(
self._device_context.device_id,
description=f"{request.goal[:120]}",
concurrency_mode=effective_mode,
max_concurrency=effective_max,
session_id=str(sess_id) if sess_id else None,
ingress=os.getenv("ARTEMIS_TASK_INGRESS") or "agent",
resource_ids=context.plugins.resources,
)
context.plugins.ensure_resources_ready()
if context.plugins.packages:
from artemis.plugins.models import PluginSelection
context.plugins.store.hold(
str(sess_id),
[
PluginSelection(id=m.id, version=m.version, profile=p.name)
for m, p, _ in context.plugins.packages.values()
],
)
if device_lock is not None and os.environ.get("ARTEMIS_CLOUD_MODE") != "1":
from artemis.runtime.task_control import control_dir, _write_atomic
@@ -615,16 +656,23 @@ class Agent:
context.task_control = task_control
task_control._state("running")
self._prepare_output_files(task=task)
if os.environ.get("ARTEMIS_CLOUD_MODE") != "1":
if (
os.environ.get("ARTEMIS_CLOUD_MODE") != "1"
and request.execution_target != "computer"
):
if self._ui_adb_client is not None:
await self._connect_screen_client(context, str(sess_id))
await self._ensure_device_unlocked()
publish_startup_progress(
"environment", "Preparing the device environment", session_id=str(sess_id)
)
await self._prepare_app_installation(task=task)
await self._prepare_device_environment(context=context)
await self._prepare_app_lock(task=task, context=context)
if request.execution_target != "computer":
await self._prepare_app_installation(task=task)
await self._prepare_device_environment(context=context)
await self._prepare_app_lock(task=task, context=context)
elif context.execution_setup:
context.execution_setup.video_recording_tools_enabled = False
context.execution_setup.record_video_enabled = False
async with context:
recording_started = False
recording_parts = 1
@@ -637,6 +685,7 @@ class Agent:
async def pause_capture():
nonlocal recording_started
await context.plugins.aclose(release=False)
if recording_started:
try:
await controller.stop_video_recording()
@@ -648,6 +697,8 @@ class Agent:
async def resume_capture():
nonlocal recording_started, recording_parts
if request.execution_target == "computer":
return
if not (
self._config.video_recording_tools_enabled
or self._config.record_video_enabled
@@ -662,7 +713,7 @@ class Agent:
context.on_task_pause = pause_capture
context.on_task_resume = resume_capture
if (
if request.execution_target != "computer" and (
self._config.video_recording_tools_enabled
or self._config.record_video_enabled
):
@@ -897,6 +948,15 @@ class Agent:
raise
finally:
# Close plugins before releasing the whole task's device/resource lease.
plugins_stopped = False
try:
if context.plugins:
await context.plugins.aclose()
context.plugins.store.release(str(sess_id))
plugins_stopped = True
except Exception:
logger.error("插件未确认停止,执行资源将保留至工作进程退出。")
try:
# Background ADB processes (logcat, screenrecord, ...) started
# by the Operator must not outlive the automation task.
@@ -932,9 +992,9 @@ class Agent:
if self._ui_adb_client is not None:
await asyncio.to_thread(self._ui_adb_client.disconnect)
finally:
if task_control is not None:
if task_control is not None and plugins_stopped:
await asyncio.to_thread(task_control.release)
elif device_lock is not None:
elif device_lock is not None and plugins_stopped:
await asyncio.to_thread(device_lock.release)
async with self._task_lock:
@@ -28,6 +28,7 @@ except ImportError:
from artemis.constants import RECURSION_LIMIT
from artemis.sdk.types.agent import AgentProfile
from artemis.sdk.types.task import TaskRequest, TaskRequestCommon
from artemis.plugins.models import ExecutionTarget, PluginSelection
from pydantic import BaseModel
TIn = TypeVar("TIn", bound=BaseModel | None)
@@ -64,6 +65,18 @@ class TaskRequestCommonBuilder(BaseModel):
self._llm_output_path: Path | None = None
self._locked_app_package: str | None = None
self._app_path: Path | None = None
self._execution_target: ExecutionTarget = "android"
self._plugins: list[PluginSelection] | None = None
def for_target(self, target: ExecutionTarget) -> Self:
"""Select android, computer or mixed execution; match Agent.init's target."""
self._execution_target = target
return self
def with_plugins(self, plugins: list[PluginSelection] | None) -> Self:
"""Select saved plugin profiles; None uses defaults, [] disables plugins."""
self._plugins = plugins
return self
def with_max_steps(self, max_steps: int) -> Self:
"""Set the maximum number of steps the task can take.
@@ -138,6 +151,8 @@ class TaskRequestCommonBuilder(BaseModel):
llm_output_path=self._llm_output_path,
locked_app_package=self._locked_app_package,
app_path=self._app_path,
execution_target=self._execution_target,
plugins=self._plugins,
)
@@ -179,6 +194,8 @@ class TaskRequestBuilder(TaskRequestCommonBuilder, Generic[TIn]):
res._llm_output_path = common.llm_output_path
res._locked_app_package = common.locked_app_package
res._app_path = common.app_path
res._execution_target = common.execution_target
res._plugins = common.plugins
return res
def using_profile(self, profile: str | AgentProfile) -> "TaskRequestBuilder[TIn]":
@@ -255,5 +272,7 @@ class TaskRequestBuilder(TaskRequestCommonBuilder, Generic[TIn]):
llm_output_path=self._llm_output_path,
locked_app_package=self._locked_app_package,
app_path=self._app_path,
execution_target=self._execution_target,
plugins=self._plugins,
)
return task_request
+3
View File
@@ -25,6 +25,7 @@ from typing import Any, Generic, TypeVar, overload
from artemis.config import LLMConfig, get_default_llm_config
from artemis.constants import RECURSION_LIMIT
from artemis.context import DeviceContext
from artemis.plugins.models import ExecutionTarget, PluginSelection
from artemis.sdk.types.platform import TaskRunStatus
from artemis.sdk.utils import load_llm_config_override
from pydantic import BaseModel, Field
@@ -108,6 +109,8 @@ class TaskRequestCommon(TaskRequestBase):
model_config = {"ignored_types": (CyFunctionDetector,)}
max_steps: int = RECURSION_LIMIT
plugins: list[PluginSelection] | None = None
execution_target: ExecutionTarget = "android"
locked_app_package: str | None = None
app_path: Path | None = None
"""Path to an app to install before running the task.
+4
View File
@@ -55,6 +55,10 @@ def format_action_clean(action_obj) -> str:
act_type = action_obj.get("action") or action_obj.get("name")
if not act_type:
return json.dumps(action_obj, ensure_ascii=False)
if str(act_type).startswith("plug_"):
tool_name = str(act_type).split("_", 2)[-1]
source = action_obj.get("plugin_id") or "扩展插件"
return f"{source} · {tool_name}: {json.dumps(action_obj.get('args') or {}, ensure_ascii=False)}"
# Flash records the tool call verbatim: the action name at the top level
# and every argument (app_name, key, text, direction, ...) under ``args``.