feat: add prompt-first batches and pausable task guidance
This commit is contained in:
1 parent
cec9898e4c
commit
057f0c3050
44 files changed
+1080
-191
No files matched your search
@@ -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 []
|
||||
|
||||
@@ -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 ''} ---")
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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()
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user