feat: add prompt-first batches and pausable task guidance

This commit is contained in:
chenmingxuan committed 2026-09-23 22:59:48 +08:00
1 parent cec9898e4c
commit 057f0c3050
44 files changed
+1080 -191

No files matched your search

+4 -1
View File
@@ -37,6 +37,7 @@ from artemis.agents.explorer.tiers import SUBMIT_TOOL
from artemis.agents.explorer.tool_declarations import UNIVERSAL_EXPLORER_TOOLS
from artemis.graph.state import State
from artemis.services.llm import get_llm
from artemis.config.settings import settings
from artemis.utils.logger import get_logger
logger = get_logger(__name__)
@@ -120,7 +121,9 @@ class UniversalRunnerMixin:
else:
current_llm = bound_llm
response = await asyncio.wait_for(current_llm.ainvoke(messages), timeout=180)
response = await asyncio.wait_for(
current_llm.ainvoke(messages), timeout=settings.OPENAI_TIMEOUT_SECONDS
)
messages.append(response)
tool_calls = getattr(response, "tool_calls", None) or []
+22 -1
View File
@@ -37,7 +37,6 @@ active window until its turn is chunked, whose user lines then carry it on.
"""
import asyncio
from artemis.config.settings import settings
import base64
from dataclasses import dataclass, field
import json
@@ -45,6 +44,9 @@ from pathlib import Path
import re
import uuid
from artemis.config.settings import settings
from artemis.runtime.task_control import TaskControl
from jinja2 import Template
from langchain_core.messages import (
BaseMessage,
@@ -1060,6 +1062,25 @@ class FlashRunner:
pending_notices: list[str] = []
while limit is None or turns < limit:
control = getattr(self.ctx, "task_control", None)
if isinstance(control, TaskControl):
resumed = await control.checkpoint(
on_pause=getattr(self.ctx, "on_task_pause", None),
on_resume=getattr(self.ctx, "on_task_resume", None),
)
if resumed:
(
shot_path,
current_pre_screenshot_bytes,
current_xml_list,
) = await capture_screenshot_and_parse_ui(
self.ctx, state, self.controller, skip_settling=False
)
state.latest_screenshot = shot_path
pending_notices.append(
"设备在暂停期间可能被其他任务操作过。已重新观察画面;"
"请根据当前状态和新指导重新判断操作,勿沿用旧坐标。"
)
turns += 1
logger.info(f"--- Artemis Flash Turn {turns}{f'/{limit}' if limit else ''} ---")
+1
View File
@@ -109,6 +109,7 @@ class Settings(BaseSettings):
ARTEMIS_CHECKPOINT_TIMEOUT_SECONDS: float = Field(default=180.0, gt=0)
ARTEMIS_SETTLEMENT_TIMEOUT_SECONDS: float = Field(default=120.0, gt=0)
ARTEMIS_VIDEO_ENABLED: bool = False
ARTEMIS_RECORDING_ENABLED: bool = False
ARTEMIS_OUTPUTTER_ENABLED: bool = True
ARTEMIS_STEP_SUMMARIZER_ENABLED: bool = True
# Consecutive Pro responses without a valid tool call before failing the task.
+2
View File
@@ -46,6 +46,7 @@ except ImportError:
from pydantic import BaseModel, ConfigDict, Field, PrivateAttr
from artemis.utils.video import detect_video_tools_enabled
from artemis.config.settings import settings
from artemis.config import ExplorerConfig, LLMConfig, OutputterConfig
@@ -108,6 +109,7 @@ class ExecutionSetup(BaseModel):
enable_remote_tracing: bool = False
app_lock_status: AppLaunchResult | None = None
video_recording_tools_enabled: bool = Field(default_factory=detect_video_tools_enabled)
record_video_enabled: bool = Field(default_factory=lambda: settings.ARTEMIS_RECORDING_ENABLED)
disable_checker: bool = False
"""Legacy master switch (compat alias): ``True`` disables BOTH the midway
checkpoints and the final check, regardless of the individual gates below.
+14
View File
@@ -372,6 +372,20 @@ class StorageManager:
)
conn.commit()
def discard_session_recordings(self, session_id: str) -> None:
"""Remove references to temporary analysis-only captures after the run."""
with self._get_connection() as conn:
conn.execute(
"UPDATE video_recordings SET local_video_path = NULL, status = 'unavailable' "
"WHERE session_id = ?",
(session_id,),
)
conn.execute(
"UPDATE sessions SET video_filepath = NULL WHERE session_id = ?",
(session_id,),
)
conn.commit()
def update_session_video_path(self, session_id: UUID, video_path: str):
"""Update video filepath across session and video_recordings tables."""
with self._get_connection() as conn:
+10 -12
View File
@@ -23,6 +23,7 @@ from artemis.controllers.unified_controller import UnifiedMobileController
from artemis.data_engine.trace import trace
from artemis.graph.state import State
from artemis.graph.visibility import strict_state
from artemis.runtime.task_control import TaskControl
from artemis.utils.logger import get_logger
from artemis.utils.ocr_api import is_ocr_configured, perform_ocr
from artemis.utils.ocr_xml_fusion import (
@@ -78,18 +79,9 @@ def _check_injected_instruction_file(base_dir: str) -> dict | None:
Returns the payload dict: `instruction` (str | None) plus the explicit
`release_loop` stop signal (bool) declared by the injecting caller.
"""
instruction_path = Path(base_dir) / "injected_instruction.json"
if instruction_path.exists():
with open(instruction_path, encoding="utf-8") as f:
data = json.load(f)
instruction_path.unlink()
payload = {
"instruction": data.get("instruction"),
"release_loop": bool(data.get("release_loop")),
}
logger.info(f"Read and deleted injected instruction payload: {payload}")
return payload
return None
from artemis.runtime.task_control import consume_instructions
return consume_instructions(base_dir)
def _unprotect_current_check_lines(ctx: ArtemisContext) -> None:
@@ -122,6 +114,12 @@ async def perception_node(state: State, ctx: ArtemisContext) -> dict:
"""
state = strict_state(state, "perception")
logger.info("Entering Perception Node...")
control = getattr(ctx, "task_control", None)
if isinstance(control, TaskControl):
await control.checkpoint(
on_pause=getattr(ctx, "on_task_pause", None),
on_resume=getattr(ctx, "on_task_resume", None),
)
if not ctx.data_engine.current_step_id:
ctx.data_engine.allocate_step_id()
+156
View File
@@ -0,0 +1,156 @@
"""Cooperative, session-scoped task guidance and device lease parking."""
import asyncio
import json
import logging
import os
from pathlib import Path
import threading
import time
import uuid
from artemis.config import TRACES_PATH
from artemis.runtime.device_lock import DeviceExecutionLock
logger = logging.getLogger(__name__)
def control_dir(session_id: str) -> Path:
if not session_id or not all(c.isalnum() or c in "-_" for c in session_id):
raise ValueError("无效的任务编号")
return TRACES_PATH / session_id
def _write_atomic(path: Path, data: dict) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
temporary = path.with_name(f".{path.name}.{uuid.uuid4().hex}.tmp")
temporary.write_text(json.dumps(data, ensure_ascii=False), encoding="utf-8")
os.replace(temporary, path)
def read_state(session_id: str) -> dict:
try:
return json.loads(
(control_dir(session_id) / "task_control_state.json").read_text(encoding="utf-8")
)
except (OSError, ValueError):
return {}
def request_pause(session_id: str) -> None:
(control_dir(session_id) / "resume.request").unlink(missing_ok=True)
_write_atomic(control_dir(session_id) / "pause.request", {"created_at": time.time()})
def request_resume(session_id: str) -> None:
_write_atomic(control_dir(session_id) / "resume.request", {"created_at": time.time()})
def add_instruction(
session_id: str, instruction: str, *, release_loop: bool = False, source: str = "user"
) -> None:
if not instruction.strip() or len(instruction) > 10000:
raise ValueError("指导内容必须在 1–10000 字之间")
directory = control_dir(session_id) / "instructions"
directory.mkdir(parents=True, exist_ok=True)
# Unique names make simultaneous web and MCP submissions additive, never last-write-wins.
path = directory / f"{time.time_ns():020d}-{uuid.uuid4().hex}.json"
_write_atomic(
path,
{
"instruction": instruction.strip(),
"release_loop": bool(release_loop),
"source": source,
"timestamp": time.time(),
},
)
def consume_instructions(base_dir: str | Path) -> dict | None:
directory = Path(base_dir) / "instructions"
found = []
for path in sorted(directory.glob("*.json")) if directory.exists() else []:
try:
claimed = path.with_suffix(".reading")
path.rename(claimed)
found.append(json.loads(claimed.read_text(encoding="utf-8")))
claimed.unlink()
except (OSError, ValueError):
continue
legacy = Path(base_dir) / "injected_instruction.json"
if legacy.exists():
try:
claimed = legacy.with_suffix(".reading")
legacy.rename(claimed)
found.append(json.loads(claimed.read_text(encoding="utf-8")))
claimed.unlink()
except (OSError, ValueError):
pass
if not found:
return None
return {
"instruction": "\n\n".join(str(item.get("instruction") or "") for item in found).strip(),
"release_loop": any(bool(item.get("release_loop")) for item in found),
}
class TaskControl:
"""Yield a device lease at the next safe turn and reacquire at the FIFO tail."""
def __init__(self, session_id: str, device_lock: DeviceExecutionLock):
self.session_id = session_id
self.device_lock = device_lock
self.directory = control_dir(session_id)
def _state(self, status: str) -> None:
_write_atomic(
self.directory / "task_control_state.json",
{"status": status, "updated_at": time.time(), "pid": os.getpid()},
)
async def checkpoint(self, *, on_pause=None, on_resume=None) -> bool:
pause = self.directory / "pause.request"
if not pause.exists():
return False
pause.unlink(missing_ok=True)
if on_pause is not None:
await on_pause()
self.device_lock.release()
self._state("task_paused")
resume = self.directory / "resume.request"
while not resume.exists():
await asyncio.sleep(0.2)
resume.unlink(missing_ok=True)
self._state("resuming")
previous = self.device_lock
self.device_lock = DeviceExecutionLock(
previous.device_id,
description=previous.description,
max_concurrency=previous.max_concurrency,
concurrency_mode=previous.concurrency_mode,
session_id=self.session_id,
ingress=previous.ingress,
lock_scope=previous.lock_scope,
)
cancelled = threading.Event()
acquiring = asyncio.create_task(
asyncio.to_thread(self.device_lock.acquire, cancel_event=cancelled)
)
try:
await asyncio.shield(acquiring)
except asyncio.CancelledError:
cancelled.set()
try:
await asyncio.shield(acquiring)
except Exception:
logger.warning("Resume acquisition ended during cancellation", exc_info=True)
self.device_lock.release()
raise
if on_resume is not None:
await on_resume()
self._state("running")
return True
def release(self) -> None:
self.device_lock.release()
(self.directory / "task_control_state.json").unlink(missing_ok=True)
+68 -10
View File
@@ -540,6 +540,7 @@ class Agent:
last_state: State | None = None
last_state_snapshot: dict | None = None
output = None
task_control = None
effective_mode = getattr(self._config, "concurrency_mode", "per_device")
effective_max = getattr(self._config, "max_concurrency", None)
sess_id = (
@@ -599,6 +600,11 @@ class Agent:
# publish a new active session while the current task is still
# finishing.
self._prepare_tracing(task=task, context=context)
if device_lock is not None and os.environ.get("ARTEMIS_TASK_WORKER") == "1":
from artemis.runtime.task_control import TaskControl
task_control = TaskControl(str(sess_id), device_lock)
context.task_control = task_control
self._prepare_output_files(task=task)
if os.environ.get("ARTEMIS_CLOUD_MODE") != "1":
if self._ui_adb_client is not None:
@@ -612,16 +618,46 @@ class Agent:
await self._prepare_app_lock(task=task, context=context)
async with context:
recording_started = False
if self._config.video_recording_tools_enabled:
recording_parts = 1
controller = get_controller(context)
output_dir = None
if context.execution_setup and context.execution_setup.traces_path:
output_dir = (
self._tmp_traces_dir / context.execution_setup.trace_name
).resolve()
async def pause_capture():
nonlocal recording_started
if recording_started:
try:
await controller.stop_video_recording()
finally:
recording_started = False
from artemis.tools.command_tool import shutdown_adb_background_tasks
await shutdown_adb_background_tasks(context)
async def resume_capture():
nonlocal recording_started, recording_parts
if not (
self._config.video_recording_tools_enabled
or self._config.record_video_enabled
):
return
recording_parts += 1
target = (
output_dir / f"resume_{recording_parts:04d}" if output_dir else None
)
result = await controller.start_video_recording(output_dir=target)
recording_started = bool(result and result.success)
context.on_task_pause = pause_capture
context.on_task_resume = resume_capture
if (
self._config.video_recording_tools_enabled
or self._config.record_video_enabled
):
try:
controller = get_controller(context)
output_dir = None
if context.execution_setup and context.execution_setup.traces_path:
output_dir = (
self._tmp_traces_dir / context.execution_setup.trace_name
).resolve()
logger.info(f"[{task_name}] Starting automated screen recording...")
start_res = await controller.start_video_recording(
output_dir=output_dir
@@ -840,6 +876,25 @@ class Agent:
except Exception as e:
logger.warning(f"[{task_name}] Failed to stop background ADB tasks: {e}")
try:
if (
self._config.video_recording_tools_enabled
and not self._config.record_video_enabled
):
# Video analysis used a temporary capture during execution.
# Keep no recording after the final model/report step.
trace_dir = (self._tmp_traces_dir / task_name).resolve()
root = self._tmp_traces_dir.resolve()
if trace_dir.is_relative_to(root) and trace_dir.is_dir():
for recording in trace_dir.rglob("*"):
if recording.is_file() and (
recording.suffix.lower() in {".mkv", ".mp4", ".webm"}
or recording.name == "recording.json"
):
recording.unlink(missing_ok=True)
if context.data_engine and context.data_engine.current_session_id:
context.data_engine.storage.discard_session_recordings(
str(context.data_engine.current_session_id)
)
await self._finalize_tracing_safely(task=task, context=context)
finally:
if os.environ.get("ARTEMIS_CLOUD_MODE") != "1":
@@ -847,7 +902,9 @@ class Agent:
if self._ui_adb_client is not None:
await asyncio.to_thread(self._ui_adb_client.disconnect)
finally:
if device_lock is not None:
if task_control is not None:
await asyncio.to_thread(task_control.release)
elif device_lock is not None:
await asyncio.to_thread(device_lock.release)
async with self._task_lock:
@@ -1074,6 +1131,7 @@ class Agent:
if task.request.record_trace
else False,
video_recording_tools_enabled=self._config.video_recording_tools_enabled,
record_video_enabled=self._config.record_video_enabled,
disable_checker=self._config.disable_checker,
disable_midway_checks=self._config.disable_midway_checks,
disable_final_check=self._config.disable_final_check,
@@ -609,6 +609,7 @@ class AgentConfigBuilder:
servers=self._servers,
graph_config_callbacks=self._graph_config_callbacks,
video_recording_tools_enabled=self._video_recording_tools_enabled,
record_video_enabled=settings.ARTEMIS_RECORDING_ENABLED,
force_web_accessibility=self._force_web_accessibility,
disable_checker=self._disable_checker,
disable_midway_checks=self._disable_midway_checks,
+2
View File
@@ -29,6 +29,7 @@ from artemis.config import (
from artemis.context import DevicePlatform
from artemis.sdk.types.task import AgentProfile, TaskRequestCommon
from artemis.utils.video import detect_video_tools_enabled
from artemis.config.settings import settings
from pydantic import BaseModel, Field
@@ -113,6 +114,7 @@ class AgentConfig(BaseModel):
servers: ServerConfig
graph_config_callbacks: Callbacks = None
video_recording_tools_enabled: bool = Field(default_factory=detect_video_tools_enabled)
record_video_enabled: bool = Field(default_factory=lambda: settings.ARTEMIS_RECORDING_ENABLED)
force_web_accessibility: bool = False
disable_checker: bool = False
disable_midway_checks: bool = True