Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
59f458205d | ||
|
|
4465a2e199 | ||
|
|
c818a09a49 | ||
|
|
e1e26772d9 | ||
|
|
1dc2b0c686 |
No files matched your search
@@ -6,6 +6,8 @@ OPENAI_BASE_URL=https://api-n-cd.playones.com/v1
|
||||
OPENAI_MODEL=mimo-v2.6-flash
|
||||
# 单次模型 HTTP 请求超时(秒)
|
||||
OPENAI_TIMEOUT_SECONDS=300
|
||||
# 可选的后台步骤截图摘要单次超时,独立于上面的主模型请求超时。
|
||||
ARTEMIS_STEP_SUMMARIZER_TIMEOUT_SECONDS=120
|
||||
# 失败后最多重试 5 次,由 Artemis 统一管理,SDK 不叠加重试。
|
||||
OPENAI_MAX_RETRIES=5
|
||||
OPENAI_RETRY_INTERVAL_SECONDS=10
|
||||
@@ -19,6 +21,8 @@ ARTEMIS_OUTPUTTER_ENABLED=true
|
||||
ARTEMIS_STEP_SUMMARIZER_ENABLED=true
|
||||
# Pro 连续无工具调用或参数校验失败的最大次数;耗尽后明确失败,避免空转。
|
||||
ARTEMIS_OPERATOR_MAX_NO_PROGRESS_ATTEMPTS=3
|
||||
# Flash 连续无工具调用的最大次数;无效回复不会重复送回模型。
|
||||
ARTEMIS_FLASH_MAX_NO_PROGRESS_ATTEMPTS=3
|
||||
|
||||
# 视频分段、重试与单次分析超时(秒)
|
||||
ARTEMIS_VIDEO_CHUNK_SECONDS=60
|
||||
|
||||
@@ -130,6 +130,11 @@ async def tick_batches():
|
||||
batch.update(status="paused", reason="模型请求已暂停,请先恢复模型连接再继续批次")
|
||||
store.put("batch", batch["id"], batch)
|
||||
for job in active:
|
||||
# The queue records started_at at submission, then replaces it
|
||||
# when the worker actually starts. Waiting for a device must
|
||||
# not consume this case's execution budget.
|
||||
if job["status"] != "running":
|
||||
continue
|
||||
limit = int(
|
||||
(job.get("case", {}).get("task_config") or {}).get("timeout_seconds")
|
||||
or batch.get("timeout_seconds", 1800)
|
||||
@@ -139,8 +144,7 @@ async def tick_batches():
|
||||
if task_state.get("non_execution_started_at"):
|
||||
excluded += time.time() - float(task_state["non_execution_started_at"])
|
||||
if (
|
||||
job["status"] != "finalizing"
|
||||
and not job.get("timed_out")
|
||||
not job.get("timed_out")
|
||||
and time.time() - job.get("started_at", time.time()) - excluded > limit
|
||||
):
|
||||
job.update(
|
||||
@@ -148,8 +152,8 @@ async def tick_batches():
|
||||
)
|
||||
store.save_job(job)
|
||||
task_queue_service.stop_tasks(session_id=job["session_id"])
|
||||
batch.update(status="paused", reason="任务超时,已暂停剩余任务;请确认设备状态")
|
||||
store.put("batch", batch["id"], batch)
|
||||
# on_terminal records the failed case. The next pending
|
||||
# case then runs normally unless stop_on_failure is set.
|
||||
continue
|
||||
if batch["status"] != "running":
|
||||
continue
|
||||
|
||||
@@ -64,7 +64,12 @@ def get_model_settings() -> dict:
|
||||
"max_retries": settings.OPENAI_MAX_RETRIES,
|
||||
"retry_interval_seconds": settings.OPENAI_RETRY_INTERVAL_SECONDS,
|
||||
"reasoning_effort": settings.OPENAI_REASONING_EFFORT,
|
||||
"model_presets": ["gpt-5.6-luna", "mimo-v2.6-flash"],
|
||||
"model_presets": [
|
||||
"gpt-5.6-luna",
|
||||
"gpt-6-luna",
|
||||
"mimo-v2.6-flash",
|
||||
"mimo-v2.6-pro",
|
||||
],
|
||||
"env_path": str(get_env_file()),
|
||||
}
|
||||
|
||||
|
||||
@@ -95,21 +95,46 @@ def failure_reason(directory: Path, status: str, recorded: str | None) -> str:
|
||||
state = read_json(directory / "status.json", {})
|
||||
if isinstance(state, dict) and state.get("error"):
|
||||
return redact(state["error"])
|
||||
log_lines: list[str] = []
|
||||
for name in ("stdout.log", "stderr.log"):
|
||||
path = directory / name
|
||||
try:
|
||||
with path.open("rb") as stream:
|
||||
stream.seek(0, 2)
|
||||
stream.seek(max(0, stream.tell() - 131072))
|
||||
lines = stream.read().decode("utf-8", errors="replace").splitlines()
|
||||
log_lines.extend(stream.read().decode("utf-8", errors="replace").splitlines())
|
||||
except OSError:
|
||||
continue
|
||||
for line in reversed(lines):
|
||||
match = re.search(r"Error running automation:\s*(.+)", line)
|
||||
if match:
|
||||
return redact(match.group(1))
|
||||
if re.search(r"(?i)(?:timeout|timed out|超时|deadline exceeded)", line):
|
||||
return redact("模型请求或任务执行超时:" + line.strip())
|
||||
for line in reversed(log_lines):
|
||||
match = re.search(r"Error running automation:\s*(.+)", line)
|
||||
if match:
|
||||
return redact(match.group(1))
|
||||
for line in reversed(log_lines):
|
||||
stripped = line.strip()
|
||||
if stripped.startswith(("⚠", "ℹ", "WARNING", "INFO", "DEBUG")):
|
||||
continue
|
||||
if any(
|
||||
name in line
|
||||
for name in (
|
||||
"VisualStepSummarizer:",
|
||||
"StepMemoryService:",
|
||||
"Could not clean unowned screen wake locks",
|
||||
)
|
||||
):
|
||||
continue
|
||||
exception_line = re.fullmatch(
|
||||
r"(?:[A-Za-z_]\w*\.)*[A-Za-z_]\w*(?:Error|Exception|Timeout):\s*.*",
|
||||
stripped,
|
||||
)
|
||||
if exception_line and not re.search(r"(?i)(?:timeout|timed out|超时)", stripped):
|
||||
return redact(stripped)
|
||||
if re.search(
|
||||
r"(?i)(?:\b(?:TimeoutError|APITimeoutError|ReadTimeout|ConnectTimeout)\s*:|"
|
||||
r"(?:模型请求|任务执行|model request|task execution).{0,100}"
|
||||
r"(?:超时|timed out|deadline exceeded))",
|
||||
stripped,
|
||||
):
|
||||
return redact("模型请求或任务执行超时:" + stripped)
|
||||
return "任务未完成,运行日志中没有可提取的具体异常;请查看执行步骤。"
|
||||
|
||||
|
||||
@@ -607,15 +632,17 @@ def session_videos(report: dict) -> list[tuple[Path, str]]:
|
||||
if not candidate:
|
||||
return []
|
||||
source = Path(str(candidate)).resolve()
|
||||
session_root = (TRACES_PATH / identifier(report["id"])).resolve()
|
||||
if not source.is_relative_to(session_root):
|
||||
trace_root = TRACES_PATH.resolve()
|
||||
# Workers save recordings in their finalized web_<time>_<id>_PASS/FAIL
|
||||
# folder. The recording row already ties that path to this session.
|
||||
if not source.is_relative_to(trace_root):
|
||||
return []
|
||||
# A resumed recording lives under resume_NNNN; the initial recording is
|
||||
# in its parent, with any later segments in sibling resume folders.
|
||||
directory = source.parent
|
||||
if directory.name.startswith("resume_"):
|
||||
directory = directory.parent
|
||||
if not directory.is_dir() or not directory.is_relative_to(session_root):
|
||||
if not directory.is_dir() or not directory.is_relative_to(trace_root):
|
||||
return []
|
||||
paths = [
|
||||
p
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "artemis-showcase-ui",
|
||||
"version": "1.2.0",
|
||||
"version": "1.2.3",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "artemis-showcase-ui",
|
||||
"version": "1.2.0",
|
||||
"version": "1.2.3",
|
||||
"dependencies": {
|
||||
"@angular/cdk": "^22.1.5",
|
||||
"@angular/common": "^22.1.4",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "artemis-showcase-ui",
|
||||
"version": "1.2.0",
|
||||
"version": "1.2.3",
|
||||
"scripts": {
|
||||
"ng": "ng",
|
||||
"start": "ng serve --proxy-config proxy.conf.json",
|
||||
|
||||
+13
-2
@@ -7,7 +7,8 @@ import { CapabilitySettingsComponent } from './capability-settings.component';
|
||||
describe('持久化模型与功能设置', () => {
|
||||
let http: HttpTestingController;
|
||||
const config = { base_url: 'https://example.test/v1', model: 'mimo-v2.6-flash',
|
||||
api_key_configured: true, timeout_seconds: 300, max_retries: 5, retry_interval_seconds: 10, reasoning_effort: 'high', env_path: '/app/.env' };
|
||||
api_key_configured: true, timeout_seconds: 300, max_retries: 5, retry_interval_seconds: 10, reasoning_effort: 'high',
|
||||
model_presets: ['gpt-5.6-luna', 'gpt-6-luna', 'mimo-v2.6-flash', 'mimo-v2.6-pro'], env_path: '/app/.env' };
|
||||
beforeEach(() => {
|
||||
TestBed.configureTestingModule({ providers: [provideHttpClient(), provideHttpClientTesting()] });
|
||||
http = TestBed.inject(HttpTestingController);
|
||||
@@ -44,13 +45,23 @@ describe('持久化模型与功能设置', () => {
|
||||
await fixture.whenStable();
|
||||
const root: HTMLElement = fixture.nativeElement;
|
||||
const presets = root.querySelectorAll<HTMLButtonElement>('.model-presets button');
|
||||
expect(presets.length).toBe(2);
|
||||
expect(Array.from(presets, button => button.textContent?.trim())).toEqual([
|
||||
'gpt-5.6-luna', 'gpt-6-luna', 'mimo-v2.6-flash', 'mimo-v2.6-pro'
|
||||
]);
|
||||
presets[0].click();
|
||||
fixture.detectChanges();
|
||||
await fixture.whenStable();
|
||||
const input = root.querySelector<HTMLInputElement>('#model-name')!;
|
||||
expect(input.value).toBe('gpt-5.6-luna');
|
||||
expect(presets[0].getAttribute('aria-pressed')).toBe('true');
|
||||
presets[1].click();
|
||||
fixture.detectChanges();
|
||||
await fixture.whenStable();
|
||||
expect(input.value).toBe('gpt-6-luna');
|
||||
presets[3].click();
|
||||
fixture.detectChanges();
|
||||
await fixture.whenStable();
|
||||
expect(input.value).toBe('mimo-v2.6-pro');
|
||||
expect(input.getAttribute('list')).toBeNull();
|
||||
expect(input.autocomplete).toBe('off');
|
||||
input.value = 'custom/model-v2';
|
||||
|
||||
@@ -11,6 +11,7 @@ interface ModelSettings {
|
||||
max_retries: number;
|
||||
retry_interval_seconds: number;
|
||||
reasoning_effort: string;
|
||||
model_presets: string[];
|
||||
env_path: string;
|
||||
message?: string;
|
||||
}
|
||||
@@ -90,7 +91,7 @@ interface ModelSettings {
|
||||
export class ModelSettingsComponent implements OnInit {
|
||||
private http = inject(HttpClient);
|
||||
saved = output<void>();
|
||||
readonly modelPresets = ['gpt-5.6-luna', 'mimo-v2.6-flash'];
|
||||
modelPresets: string[] = [];
|
||||
baseUrl = ''; model = ''; apiKey = ''; timeout = 300; retries = 5; retryInterval = 10; reasoning = 'high';
|
||||
busy = signal<'save' | 'test' | null>(null);
|
||||
loading = signal(true); keyConfigured = signal(false); envPath = signal('');
|
||||
@@ -112,6 +113,7 @@ export class ModelSettingsComponent implements OnInit {
|
||||
}
|
||||
private apply(config: ModelSettings): void {
|
||||
this.baseUrl = config.base_url; this.model = config.model; this.timeout = config.timeout_seconds;
|
||||
this.modelPresets = config.model_presets;
|
||||
this.retries = config.max_retries; this.retryInterval = config.retry_interval_seconds ?? 10; this.reasoning = config.reasoning_effort ?? 'high'; this.keyConfigured.set(config.api_key_configured); this.envPath.set(config.env_path);
|
||||
}
|
||||
save(): void {
|
||||
|
||||
@@ -55,5 +55,5 @@ Keep in mind that the mobile device is an asynchronous, constantly evolving envi
|
||||
---
|
||||
|
||||
# 6. TERMINATION PROTOCOL (`report_task_status`)
|
||||
- **Completion (`status="completed"`)**: ONLY call `report_task_status(status="completed", ...)` after you have **visually verified** from the latest screen that the objective is fully achieved.
|
||||
- **Completion (`status="completed"`)**: ONLY call `report_task_status(status="completed", ...)` after the latest screenshot and UI elements support the claimed result. If those sources disagree during a transition, wait and observe again before concluding. Tie each claimed page title or value to an observed screenshot or UI element; mark any missing evidence as unverified.
|
||||
- **Failure (`status="failed"`)**: If the task is genuinely impossible due to paywalls, unrecoverable crashes, or hitting an absolute dead end after sensible fallbacks, call `report_task_status(status="failed", ...)` with the exact blocking reason.
|
||||
@@ -24,6 +24,7 @@ You are the Step Summarizer for an Android UI automation agent. Your task is to
|
||||
- **Target & Action**: Identify what control/button was targeted (referencing the red visual indicator on the BEFORE screen) and the physical action performed.
|
||||
- **Objective Transition**: Describe the physical screen change (e.g., page transitioned, modal dialog opened, list scrolled revealing new items, checkbox/toggle toggled, keyboard appeared).
|
||||
- **Preserve Critical Data & Verifications**: Faithfully transcribe visible text, toast alerts, error banners, verification codes, prices, account names, or tracking numbers that appeared on the AFTER screen.
|
||||
3. **Reconcile both observation sources**: The optional AFTER ACTION UI ELEMENTS block is device hierarchy evidence from the same observation request. During a page animation its contents may be newer than the screenshot. If the image still shows the old page but the hierarchy names the new page, state both observations and that the transition is not yet visually settled; never conclude that the tap did nothing. Do not invent details absent from both sources. A controller's "Tapped" outcome only confirms input dispatch.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -78,6 +78,7 @@ from artemis.controllers.unified_controller import UnifiedMobileController
|
||||
from artemis.data_engine.trace import trace
|
||||
from artemis.graph.perception import _check_injected_instruction_file
|
||||
from artemis.graph.state import State
|
||||
from artemis.llm.router import ModelEndpoint
|
||||
from artemis.llm.structured import ParseFailure, parse_structured
|
||||
from artemis.mcp.action_executor import McpActionExecutor
|
||||
from artemis.mcp.observation import observe
|
||||
@@ -167,6 +168,7 @@ class FlashRunner:
|
||||
ctx,
|
||||
model_name=self.step_summarizer_cfg.model,
|
||||
retry_limit=self.memory_runtime_cfg.retry_limit,
|
||||
timeout_seconds=self.step_summarizer_cfg.timeout_seconds,
|
||||
max_concurrency=self.memory_runtime_cfg.max_concurrency,
|
||||
flush_timeout_s=self.memory_runtime_cfg.flush_timeout_s,
|
||||
)
|
||||
@@ -287,7 +289,9 @@ class FlashRunner:
|
||||
logger.warning(f"Failed to get operator LLM from config, using default: {e}")
|
||||
|
||||
return RobustChatModelWrapper(
|
||||
get_openai_llm(model_name=settings.OPENAI_MODEL), self.ctx
|
||||
get_openai_llm(model_name=settings.OPENAI_MODEL),
|
||||
self.ctx,
|
||||
endpoint=ModelEndpoint(model_name=settings.OPENAI_MODEL),
|
||||
)
|
||||
|
||||
def _render_system_prompt(self, tools_declaration: list) -> str:
|
||||
@@ -838,6 +842,8 @@ class FlashRunner:
|
||||
pre_img_bytes=pre_screenshot_bytes,
|
||||
post_img_bytes=post_img_bytes,
|
||||
exec_outcome=exec_result.text_summary,
|
||||
post_ui_elements_text=exec_result.ui_elements_text,
|
||||
observation_settled=(exec_result.metadata or {}).get("observation_settled"),
|
||||
action_key=str(tc_id),
|
||||
data_engine_step_id=recorded_step_id,
|
||||
# The model's own reasoning for this turn steers the lens's
|
||||
@@ -1063,6 +1069,8 @@ class FlashRunner:
|
||||
current_xml_list = xml_list
|
||||
previous_turn: _TurnRecord | None = None
|
||||
pending_notices: list[str] = []
|
||||
no_tool_attempts = 0
|
||||
no_tool_limit = settings.ARTEMIS_FLASH_MAX_NO_PROGRESS_ATTEMPTS
|
||||
|
||||
while limit is None or turns < limit:
|
||||
control = getattr(self.ctx, "task_control", None)
|
||||
@@ -1198,11 +1206,24 @@ class FlashRunner:
|
||||
if is_final:
|
||||
final_report = {"status": "failed", "explanation": raw_text}
|
||||
break
|
||||
no_tool_attempts += 1
|
||||
if no_tool_attempts >= no_tool_limit:
|
||||
final_report = {
|
||||
"status": "failed",
|
||||
"explanation": (
|
||||
f"模型连续 {no_tool_attempts} 次未调用工具,"
|
||||
"已停止任务以避免空转和重复计费;未执行新的设备动作。"
|
||||
),
|
||||
}
|
||||
break
|
||||
pending_notices.append(_NO_TOOL_CALL_NOTICE)
|
||||
ledger.stage_turn(messages[turn_base:])
|
||||
# Keep the observation and any injected user instruction, but
|
||||
# do not feed the model its own unproductive prose on retry.
|
||||
ledger.stage_turn(messages[turn_base:-1])
|
||||
previous_turn = _TurnRecord()
|
||||
continue
|
||||
|
||||
no_tool_attempts = 0
|
||||
# Process tool calls
|
||||
turn = _TurnRecord()
|
||||
(
|
||||
|
||||
@@ -36,6 +36,7 @@ from jinja2 import Template
|
||||
from langchain_core.messages import BaseMessage, HumanMessage, SystemMessage
|
||||
|
||||
from artemis.context import ArtemisContext
|
||||
from artemis.llm.messages import adapt_responses_messages
|
||||
from artemis.memory.step_memory import JobKey, StepMemoryService
|
||||
from artemis.services.llm import RobustChatModelWrapper, get_openai_llm, get_llm
|
||||
from artemis.services.token_meter import record_llm_usage
|
||||
@@ -151,6 +152,7 @@ class VisualStepSummarizer(StepMemoryService):
|
||||
ctx: ArtemisContext,
|
||||
model_name: str | None = None,
|
||||
retry_limit: int = 3,
|
||||
timeout_seconds: float = 120.0,
|
||||
*,
|
||||
max_concurrency: int = 1,
|
||||
flush_timeout_s: float = 30.0,
|
||||
@@ -159,8 +161,11 @@ class VisualStepSummarizer(StepMemoryService):
|
||||
ctx,
|
||||
max_concurrency=max_concurrency,
|
||||
retry_limit=retry_limit,
|
||||
flush_timeout_s=flush_timeout_s,
|
||||
# A pending request must be allowed to reach its own timeout when
|
||||
# the task finishes; otherwise the shorter flush cancels it first.
|
||||
flush_timeout_s=max(flush_timeout_s, timeout_seconds + 1.0),
|
||||
)
|
||||
self._timeout_seconds = timeout_seconds
|
||||
|
||||
# Initialize lightweight VLM: prioritize explicit model_name
|
||||
target_model = model_name or settings.OPENAI_MODEL
|
||||
@@ -224,6 +229,8 @@ class VisualStepSummarizer(StepMemoryService):
|
||||
action_key: str | None = None,
|
||||
data_engine_step_id: UUID | str | None = None,
|
||||
focus: dict[str, str] | None = None,
|
||||
post_ui_elements_text: str | None = None,
|
||||
observation_settled: bool | None = None,
|
||||
) -> None:
|
||||
"""Dispatches an asynchronous summarization task without blocking the caller.
|
||||
|
||||
@@ -254,6 +261,8 @@ class VisualStepSummarizer(StepMemoryService):
|
||||
"exec_outcome": exec_outcome,
|
||||
"data_engine_step_id": data_engine_step_id,
|
||||
"focus": focus,
|
||||
"post_ui_elements_text": post_ui_elements_text,
|
||||
"observation_settled": observation_settled,
|
||||
}
|
||||
self.submit(key, payload, aliases=aliases)
|
||||
|
||||
@@ -406,6 +415,10 @@ class VisualStepSummarizer(StepMemoryService):
|
||||
if verbatim and not self._phrase_carries(phrase, verbatim):
|
||||
lead_lines.append(f"Action arguments (verbatim): {action_name}({verbatim})")
|
||||
lead_lines.append(f"Controller Outcome: {exec_outcome}")
|
||||
if input_data.get("observation_settled") is False:
|
||||
lead_lines.append(
|
||||
"Observation status: screen still changing; page state is not confirmed."
|
||||
)
|
||||
focus = input_data.get("focus")
|
||||
if isinstance(focus, dict) and focus:
|
||||
lead_lines.extend(["", render_focus_block(focus, dual=dual)])
|
||||
@@ -454,12 +467,24 @@ class VisualStepSummarizer(StepMemoryService):
|
||||
}
|
||||
)
|
||||
|
||||
post_ui = input_data.get("post_ui_elements_text")
|
||||
if post_bytes and isinstance(post_ui, str) and post_ui.strip():
|
||||
content_blocks.append(
|
||||
{
|
||||
"type": "text",
|
||||
"text": "--- [3] AFTER ACTION UI ELEMENTS ---\n" + post_ui[:8000],
|
||||
}
|
||||
)
|
||||
|
||||
messages: list[BaseMessage] = [
|
||||
SystemMessage(content=rendered_prompt),
|
||||
HumanMessage(content=content_blocks),
|
||||
]
|
||||
|
||||
response = await asyncio.wait_for(self._llm.ainvoke(messages), timeout=25.0)
|
||||
response = await asyncio.wait_for(
|
||||
self._llm.ainvoke(adapt_responses_messages(messages, self._model_name)),
|
||||
timeout=self._timeout_seconds,
|
||||
)
|
||||
self._meter_lens_call(response)
|
||||
summary_raw = response.content if isinstance(response.content, str) else ""
|
||||
if isinstance(response.content, list):
|
||||
@@ -505,8 +530,14 @@ class VisualStepSummarizer(StepMemoryService):
|
||||
logger.debug(f"DataEngine step summary update skipped: {de_err}")
|
||||
return True
|
||||
|
||||
except TimeoutError:
|
||||
logger.warning(
|
||||
f"VisualStepSummarizer: Step {step_number} summary request timed out "
|
||||
f"(limit {self._timeout_seconds:.1f}s)"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"VisualStepSummarizer: Error generating summary for step {step_number}: {e}"
|
||||
f"VisualStepSummarizer: Error generating summary for step {step_number}: "
|
||||
f"{type(e).__name__}: {e}"
|
||||
)
|
||||
return False
|
||||
@@ -370,6 +370,11 @@ class StepSummarizerConfig(BaseModel):
|
||||
default_factory=lambda: settings.OPENAI_MODEL,
|
||||
description="Lightweight model used for background step state summarization.",
|
||||
)
|
||||
timeout_seconds: float = Field(
|
||||
default=120.0,
|
||||
gt=0,
|
||||
description="Timeout for one optional background visual-summary model request.",
|
||||
)
|
||||
prune_history_xml: bool = Field(
|
||||
default=True,
|
||||
description="Whether to prune outdated heavy UI XML trees from historical turns.",
|
||||
|
||||
@@ -114,6 +114,8 @@ class Settings(BaseSettings):
|
||||
ARTEMIS_STEP_SUMMARIZER_ENABLED: bool = True
|
||||
# Consecutive Pro responses without a valid tool call before failing the task.
|
||||
ARTEMIS_OPERATOR_MAX_NO_PROGRESS_ATTEMPTS: int = Field(default=3, ge=1, le=20)
|
||||
# Consecutive Flash responses without any tool call before failing the task.
|
||||
ARTEMIS_FLASH_MAX_NO_PROGRESS_ATTEMPTS: int = Field(default=3, ge=1, le=20)
|
||||
|
||||
# Android ADB Connectivity
|
||||
ADB_HOST: str | None = Field(default=DEFAULT_ADB_HOST)
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
"""Adapt LangChain messages for Responses-compatible model families."""
|
||||
|
||||
from collections.abc import Sequence
|
||||
from typing import Any
|
||||
|
||||
from langchain_core.messages import BaseMessage, HumanMessage, SystemMessage
|
||||
|
||||
|
||||
_USER_ROLE_MODELS = {"mimo-v2.6-pro", "mimo-v2.6-flash"}
|
||||
|
||||
|
||||
def adapt_responses_messages(
|
||||
messages: Sequence[BaseMessage], model_name: str | None
|
||||
) -> Sequence[BaseMessage]:
|
||||
"""Send selected models user-role instructions without changing stored history.
|
||||
|
||||
Full Artemis requests to GPT models can fail with system-role input. The
|
||||
equivalent user-role request works with GPT and the two tested Mimo models.
|
||||
Other models keep their existing message roles.
|
||||
"""
|
||||
if not model_name:
|
||||
return messages
|
||||
model = model_name.strip().lower()
|
||||
if not (model.startswith("gpt") or model in _USER_ROLE_MODELS):
|
||||
return messages
|
||||
if not any(isinstance(message, SystemMessage) for message in messages):
|
||||
return messages
|
||||
|
||||
adapted: list[BaseMessage] = []
|
||||
pending_blocks: list[dict[str, Any]] = []
|
||||
for message in messages:
|
||||
if isinstance(message, SystemMessage):
|
||||
content = message.content
|
||||
if isinstance(content, str):
|
||||
pending_blocks.append({"type": "text", "text": content})
|
||||
else:
|
||||
pending_blocks.extend(
|
||||
{"type": "text", "text": block} if isinstance(block, str) else block
|
||||
for block in content
|
||||
)
|
||||
continue
|
||||
|
||||
if pending_blocks:
|
||||
if isinstance(message, HumanMessage):
|
||||
content = message.content
|
||||
blocks = (
|
||||
[{"type": "text", "text": content}]
|
||||
if isinstance(content, str)
|
||||
else list(content)
|
||||
)
|
||||
adapted.append(
|
||||
message.model_copy(
|
||||
update={
|
||||
"content": [
|
||||
*pending_blocks,
|
||||
*blocks,
|
||||
]
|
||||
}
|
||||
)
|
||||
)
|
||||
pending_blocks = []
|
||||
continue
|
||||
adapted.append(HumanMessage(content=pending_blocks))
|
||||
pending_blocks = []
|
||||
adapted.append(message)
|
||||
|
||||
if pending_blocks:
|
||||
adapted.append(HumanMessage(content=pending_blocks))
|
||||
return adapted
|
||||
@@ -44,6 +44,7 @@ from artemis.mcp.action_manifest import OPTIONAL_ACTIONS, REQUIRED_ACTIONS
|
||||
from artemis.mcp.action_specs import exception_prefix
|
||||
from artemis.mcp.action_session import ActionSession, get_action_session
|
||||
from artemis.mcp.action_types import ActionCode, ActionResult
|
||||
from artemis.mcp.action_types import ObserveResult
|
||||
from artemis.mcp.actuators.adb import AdbActuator
|
||||
from artemis.agents.validator.tool_declarations import (
|
||||
ToolExecutionResult,
|
||||
@@ -56,6 +57,7 @@ from artemis.utils.coordinates import (
|
||||
parse_swipe_parameters,
|
||||
)
|
||||
from artemis.utils.logger import get_logger
|
||||
from artemis.utils.image_hash import dhash_hex, hamming_distance_hex
|
||||
from artemis.utils.notes import (
|
||||
format_list_notes_failure,
|
||||
format_list_notes_success,
|
||||
@@ -76,6 +78,9 @@ AGENT_TOOL_NAMES: frozenset[str] = (
|
||||
|
||||
#: Actions whose ``target`` is a single point: an element index or an [x, y] pair.
|
||||
_POINT_TARGET_ACTIONS: frozenset[str] = frozenset({"click", "long_press", "input_text"})
|
||||
_SCREEN_CHANGING_ACTIONS: frozenset[str] = frozenset(
|
||||
{"click", "click_sequence", "long_press", "input_text", "press_key", "swipe", "manage_app"}
|
||||
)
|
||||
|
||||
|
||||
class _ArgError(ValueError):
|
||||
@@ -111,6 +116,99 @@ class McpActionExecutor:
|
||||
self._session = await get_action_session(self.ctx, actuator=self.actuator)
|
||||
return self._session
|
||||
|
||||
@staticmethod
|
||||
def _image_hash(observation: ObserveResult) -> str | None:
|
||||
path = observation.screenshot_path
|
||||
if not path:
|
||||
return None
|
||||
try:
|
||||
return dhash_hex(Path(path).read_bytes())
|
||||
except OSError:
|
||||
return None
|
||||
|
||||
@classmethod
|
||||
def _same_screen(cls, first: ObserveResult, second: ObserveResult) -> bool:
|
||||
"""Compare two observations without treating a dispatched tap as navigation."""
|
||||
if not first.ok or not second.ok:
|
||||
return False
|
||||
checks: list[bool] = []
|
||||
first_hash, second_hash = cls._image_hash(first), cls._image_hash(second)
|
||||
distance = hamming_distance_hex(first_hash, second_hash)
|
||||
if distance is not None:
|
||||
checks.append(distance <= 4)
|
||||
if first.hierarchy_ok and second.hierarchy_ok:
|
||||
checks.append(first.elements_text == second.elements_text)
|
||||
return bool(checks) and all(checks)
|
||||
|
||||
@staticmethod
|
||||
def _element_signature(elements: list[dict[str, Any]]) -> list[tuple[Any, Any, Any]]:
|
||||
return [(e.get("text"), e.get("resource_id"), e.get("bounds")) for e in elements]
|
||||
|
||||
@classmethod
|
||||
def _stale_image_with_new_hierarchy(
|
||||
cls,
|
||||
observation: ObserveResult,
|
||||
previous_hash: str | None,
|
||||
previous_elements: list[dict[str, Any]] | None,
|
||||
) -> bool:
|
||||
if previous_hash is None or previous_elements is None or not observation.hierarchy_ok:
|
||||
return False
|
||||
distance = hamming_distance_hex(previous_hash, cls._image_hash(observation))
|
||||
return (
|
||||
distance is not None
|
||||
and distance <= 4
|
||||
and cls._element_signature(previous_elements)
|
||||
!= cls._element_signature(observation.elements)
|
||||
)
|
||||
|
||||
async def _observe_after_action(
|
||||
self,
|
||||
session: ActionSession,
|
||||
raw_name: str,
|
||||
previous_screenshot: str | None,
|
||||
previous_elements: list[dict[str, Any]] | None = None,
|
||||
) -> tuple[ObserveResult, bool]:
|
||||
"""Prefer a settled image/tree pair after actions that can change screens.
|
||||
|
||||
The Android helper captures its bitmap and hierarchy concurrently. During a
|
||||
transition the bitmap can still show the old page while the hierarchy already
|
||||
describes the new one. Bounded re-observation waits for adjacent stable
|
||||
frames and rejects a new hierarchy paired with the previous page's bitmap.
|
||||
"""
|
||||
first = await session.observe(settle_ms=400)
|
||||
if not first.ok or raw_name not in _SCREEN_CHANGING_ACTIONS:
|
||||
return first, first.ok
|
||||
|
||||
second = await session.observe(settle_ms=250)
|
||||
if not second.ok:
|
||||
return first, False
|
||||
previous_hash = None
|
||||
if previous_screenshot:
|
||||
try:
|
||||
previous_hash = dhash_hex(Path(previous_screenshot).read_bytes())
|
||||
except OSError:
|
||||
pass
|
||||
if self._same_screen(first, second):
|
||||
distance = hamming_distance_hex(previous_hash, self._image_hash(second))
|
||||
if (distance is None or distance > 4) and not self._stale_image_with_new_hierarchy(
|
||||
second, previous_hash, previous_elements
|
||||
):
|
||||
return second, True
|
||||
|
||||
third = await session.observe(settle_ms=250)
|
||||
if not third.ok:
|
||||
return second, False
|
||||
if self._same_screen(second, third) and not self._stale_image_with_new_hierarchy(
|
||||
third, previous_hash, previous_elements
|
||||
):
|
||||
return third, True
|
||||
fourth = await session.observe(settle_ms=250)
|
||||
if not fourth.ok:
|
||||
return third, False
|
||||
return fourth, self._same_screen(
|
||||
third, fourth
|
||||
) and not self._stale_image_with_new_hierarchy(fourth, previous_hash, previous_elements)
|
||||
|
||||
# --- Public entry ----------------------------------------------------------------
|
||||
|
||||
async def execute(
|
||||
@@ -171,6 +269,7 @@ class McpActionExecutor:
|
||||
xml_list: str | None = None
|
||||
target_semantics: dict[str, Any] = {}
|
||||
target_coordinates: list[int] | None = None
|
||||
observation_settled: bool | None = None
|
||||
with span:
|
||||
try:
|
||||
session = await self._session_or_start()
|
||||
@@ -196,7 +295,12 @@ class McpActionExecutor:
|
||||
message = finalize(res) if finalize else res.message
|
||||
|
||||
if res.ok or self._observe_despite_failure(raw_name, res):
|
||||
obs = await session.observe(settle_ms=400)
|
||||
obs, observation_settled = await self._observe_after_action(
|
||||
session,
|
||||
raw_name,
|
||||
getattr(state, "latest_screenshot", None),
|
||||
getattr(state, "indexed_elements", None),
|
||||
)
|
||||
if obs.ok:
|
||||
shot_path = obs.screenshot_path
|
||||
xml_list = obs.elements_text
|
||||
@@ -217,6 +321,8 @@ class McpActionExecutor:
|
||||
|
||||
if shot_path:
|
||||
state.latest_screenshot = shot_path
|
||||
if observation_settled is False:
|
||||
message = f"{message} Post-action screen is still changing; observe again before concluding."
|
||||
|
||||
status = "success" if res.ok else "error"
|
||||
post_image_name = None
|
||||
@@ -247,6 +353,7 @@ class McpActionExecutor:
|
||||
"normalized_coordinates": res.normalized_coordinates,
|
||||
"target_semantics": target_semantics,
|
||||
"target_coordinates": target_coordinates,
|
||||
"observation_settled": observation_settled,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@@ -58,6 +58,7 @@ def ensure_step_memory(ctx):
|
||||
"retry_limit": cfg.memory.runtime.retry_limit,
|
||||
"max_concurrency": cfg.memory.runtime.max_concurrency,
|
||||
"flush_timeout_s": cfg.memory.runtime.flush_timeout_s,
|
||||
"timeout_seconds": cfg.flash.step_summarizer.timeout_seconds,
|
||||
}
|
||||
except Exception as exc:
|
||||
logger.debug(
|
||||
|
||||
@@ -46,6 +46,7 @@ from uuid import uuid4
|
||||
|
||||
from langchain_core.messages import BaseMessage, HumanMessage, SystemMessage
|
||||
|
||||
from artemis.llm.messages import adapt_responses_messages
|
||||
from artemis.memory.step_memory import JobKey, StepLens, StepMemoryService
|
||||
from artemis.memory.transcript import format_session_offset
|
||||
from artemis.utils.logger import get_logger
|
||||
@@ -503,7 +504,8 @@ class StepCapsuleLens(StepLens):
|
||||
return parsed
|
||||
|
||||
async def _invoke(self, llm: Any, messages: list[BaseMessage], model_label: str):
|
||||
response = await asyncio.wait_for(llm.ainvoke(messages), timeout=90.0)
|
||||
request_messages = adapt_responses_messages(messages, model_label)
|
||||
response = await asyncio.wait_for(llm.ainvoke(request_messages), timeout=90.0)
|
||||
# Raw-model bypass metering (gateway-wrapped models meter themselves);
|
||||
# capsule prompts are small and are kept out of the session meter's
|
||||
# last_prompt_tokens (the compaction base itself is operator-owned on
|
||||
|
||||
@@ -33,6 +33,7 @@
|
||||
"step_summarizer": {
|
||||
"enabled": "${env:ARTEMIS_STEP_SUMMARIZER_ENABLED:-true}",
|
||||
"model": "${env:OPENAI_MODEL:-mimo-v2.6-flash}",
|
||||
"timeout_seconds": "${env:ARTEMIS_STEP_SUMMARIZER_TIMEOUT_SECONDS:-120}",
|
||||
"prune_history_xml": true
|
||||
}
|
||||
},
|
||||
|
||||
@@ -113,22 +113,28 @@ class ScreenAwakeLease:
|
||||
}
|
||||
|
||||
@contextmanager
|
||||
def _mutex(self):
|
||||
def _mutex(self, *, wait_timeout_seconds: float | None = None):
|
||||
self.mutex_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
started_at = time.monotonic()
|
||||
timeout = (
|
||||
self._MUTEX_TIMEOUT_SECONDS
|
||||
if wait_timeout_seconds is None
|
||||
else max(0.0, wait_timeout_seconds)
|
||||
)
|
||||
acquired = False
|
||||
while not acquired:
|
||||
try:
|
||||
fd = os.open(self.mutex_path, os.O_CREAT | os.O_EXCL | os.O_WRONLY)
|
||||
except FileExistsError:
|
||||
if time.monotonic() - started_at >= timeout:
|
||||
raise TimeoutError("Timed out acquiring the Artemis awake-lease mutex.")
|
||||
if self._path_is_stale(self.mutex_path):
|
||||
try:
|
||||
self.mutex_path.unlink(missing_ok=True)
|
||||
except OSError:
|
||||
pass
|
||||
continue
|
||||
if time.monotonic() - started_at >= self._MUTEX_TIMEOUT_SECONDS:
|
||||
raise TimeoutError("Timed out acquiring the Artemis awake-lease mutex.")
|
||||
else:
|
||||
continue
|
||||
time.sleep(self._MUTEX_POLL_SECONDS)
|
||||
else:
|
||||
try:
|
||||
@@ -274,10 +280,15 @@ class ScreenAwakeLease:
|
||||
def cleanup_unowned_references(self) -> bool:
|
||||
"""Drain legacy shell wake locks only when no live old host owns them."""
|
||||
try:
|
||||
with self._mutex():
|
||||
# Migration cleanup is optional. A busy owner must not delay every
|
||||
# queued task by the normal 10-second lease-acquisition deadline.
|
||||
with self._mutex(wait_timeout_seconds=1.0):
|
||||
if self._live_leases():
|
||||
return False
|
||||
return self._drain_references()
|
||||
except TimeoutError:
|
||||
logger.debug(f"Skipped optional wake-lock cleanup on {self.device_id}: mutex busy")
|
||||
return False
|
||||
except Exception as exc:
|
||||
logger.warning(f"Could not clean unowned screen wake locks on {self.device_id}: {exc}")
|
||||
return False
|
||||
@@ -95,9 +95,10 @@ def _configure_usb_stay_awake(device_id: str) -> str | None:
|
||||
if not _awake_enabled() or os.environ.get("ARTEMIS_CLOUD_MODE") == "1":
|
||||
return None
|
||||
|
||||
# Migration cleanup: previous Artemis versions used a persistent shell
|
||||
# wake lock. Drain it only after every live legacy owner has exited.
|
||||
ScreenAwakeLease(device_id).cleanup_unowned_references()
|
||||
# Migration cleanup belongs to the long-lived host. A queued task worker
|
||||
# must not wait on another process's optional legacy wake-lock cleanup.
|
||||
if os.environ.get("ARTEMIS_TASK_WORKER") != "1":
|
||||
ScreenAwakeLease(device_id).cleanup_unowned_references()
|
||||
|
||||
commands = (
|
||||
(
|
||||
|
||||
@@ -54,6 +54,7 @@ from artemis.llm.reliability import (
|
||||
classify_failure,
|
||||
retry_policy_for,
|
||||
)
|
||||
from artemis.llm.messages import adapt_responses_messages
|
||||
from artemis.llm.router import ModelEndpoint, ModelFactory, ModelProvider
|
||||
from artemis.services.token_meter import record_llm_usage
|
||||
from artemis.llm.structured import (
|
||||
@@ -568,6 +569,25 @@ class RobustChatModelWrapper:
|
||||
args, kwargs = _inject_parent_trace_id(trace_id, *args, **kwargs)
|
||||
return args, kwargs, trace_id
|
||||
|
||||
def _prepare_model_call(self, args: tuple, kwargs: dict) -> tuple[tuple, dict]:
|
||||
model_name = self.endpoint.model_name if self.endpoint else None
|
||||
if (
|
||||
args
|
||||
and isinstance(args[0], (list, tuple))
|
||||
and all(isinstance(message, BaseMessage) for message in args[0])
|
||||
):
|
||||
return (adapt_responses_messages(args[0], model_name), *args[1:]), kwargs
|
||||
if (
|
||||
"input" in kwargs
|
||||
and isinstance(kwargs["input"], (list, tuple))
|
||||
and all(isinstance(message, BaseMessage) for message in kwargs["input"])
|
||||
):
|
||||
return args, {
|
||||
**kwargs,
|
||||
"input": adapt_responses_messages(kwargs["input"], model_name),
|
||||
}
|
||||
return args, kwargs
|
||||
|
||||
def _meter_usage(self, response) -> None:
|
||||
"""Record measured token usage for this call (M0 metering, best-effort)."""
|
||||
engine = self.ctx.data_engine if self.ctx else None
|
||||
@@ -576,6 +596,7 @@ class RobustChatModelWrapper:
|
||||
record_llm_usage(engine, response, source=self._endpoint_key())
|
||||
|
||||
async def ainvoke(self, *args, **kwargs):
|
||||
args, kwargs = self._prepare_model_call(args, kwargs)
|
||||
args, kwargs, _ = self._traced_call(args, kwargs)
|
||||
response = await _run_with_recovery(
|
||||
functools.partial(self.base_model.ainvoke, *args, **kwargs),
|
||||
@@ -597,6 +618,7 @@ class RobustChatModelWrapper:
|
||||
signals the UI to drop it, and retries the whole call per policy;
|
||||
partial or duplicated chunks can never reach message history.
|
||||
"""
|
||||
args, kwargs = self._prepare_model_call(args, kwargs)
|
||||
args, kwargs, _ = self._traced_call(args, kwargs)
|
||||
emit_deltas = bool(self.ctx and self.ctx.data_engine)
|
||||
response = await _run_with_recovery(
|
||||
|
||||
@@ -33,6 +33,7 @@
|
||||
"step_summarizer": {
|
||||
"enabled": "${env:ARTEMIS_STEP_SUMMARIZER_ENABLED:-true}",
|
||||
"model": "${env:OPENAI_MODEL:-mimo-v2.6-flash}",
|
||||
"timeout_seconds": "${env:ARTEMIS_STEP_SUMMARIZER_TIMEOUT_SECONDS:-120}",
|
||||
"prune_history_xml": true
|
||||
}
|
||||
},
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
# ARTEMIS-Eapil 1.2.1
|
||||
|
||||
## 修复
|
||||
|
||||
- 批量任务达到执行超时后,该用例记录为失败,不再自动暂停整批。当前用例结算后继续执行下一项;开启“失败即停止”的批次仍会跳过剩余项。排队等待不计入执行超时。
|
||||
- Flash 连续收到无工具调用的回复时,最多尝试 3 次并给出明确失败原因,避免持续空转和重复计费。重试会保留设备观察与用户指导,但不把模型的无效回复送回上下文。可通过 `ARTEMIS_FLASH_MAX_NO_PROGRESS_ATTEMPTS` 调整上限。
|
||||
|
||||
模型请求中的相同缓存 token 数量通常是提示词前缀缓存命中,不能据此判断缓存造成循环。本版针对无工具调用的执行循环建立退出条件。
|
||||
|
||||
## 安装与验证
|
||||
|
||||
请下载附件 `artemis-eapil-1.2.1-source.zip`,解压后运行 `start.bat`;国内网络使用 `start-cn.bat`。附件包含精简 Git 仓库,可供后续启动脚本检查更新。Gitea 自动生成的源码 ZIP 不含 `.git`,请使用发布附件。升级后重启 ARTEMIS 服务、刷新网页并重新加载 AI 客户端的 MCP。
|
||||
|
||||
验证:Python 默认测试 2182 项通过、6 项跳过;Ruff、质量基线和核心类型检查通过。
|
||||
@@ -0,0 +1,13 @@
|
||||
# ARTEMIS-Eapil 1.2.2
|
||||
|
||||
## 修复
|
||||
|
||||
- 任务报告区分实际执行失败与可恢复的诊断警告。屏幕唤醒锁清理超时、步骤视觉摘要重试不再被误写为任务或模型请求超时;真实终止异常仍会显示。
|
||||
- 旧屏幕唤醒锁清理遇到设备锁繁忙时快速跳过,避免队列任务因清理动作等待或连续失败。长驻服务负责后续清理。
|
||||
- Flash 步骤视觉摘要使用独立超时,默认 25 秒,可通过 `ARTEMIS_STEP_SUMMARIZER_TIMEOUT_SECONDS` 设置,不再占用主模型请求的 300 秒超时额度。
|
||||
|
||||
## 安装与验证
|
||||
|
||||
下载附件 `artemis-eapil-1.2.2-source.zip`,解压后运行 `start.bat`;国内网络使用 `start-cn.bat`。附件含精简 Git 仓库,后续可由启动脚本检查更新。升级后重启 ARTEMIS 服务、刷新网页并重新加载 AI 客户端的 MCP。
|
||||
|
||||
验证:Python 默认测试 2191 项通过、2 项跳过、8 项按标记排除;Ruff、质量基线和核心类型检查通过。两项真实模拟器 Flash 批量任务均完成;干净环境未复现原先的锁争用,锁繁忙与报告归因通过回归测试覆盖。
|
||||
@@ -0,0 +1,13 @@
|
||||
# ARTEMIS-Eapil 1.2.3
|
||||
|
||||
## 更新
|
||||
|
||||
- 视觉步骤摘要的单次模型请求默认超时从 25 秒延长至 120 秒;任务收尾不会再在 30 秒时提前取消仍在进行的摘要。可通过 `ARTEMIS_STEP_SUMMARIZER_TIMEOUT_SECONDS` 调整。
|
||||
- Pro 与 Flash 共用的步骤摘要服务现在都会读取该超时配置。
|
||||
- 本版本还包含尚未发布的 Responses 消息与视频导出修复,以及设备观察稳定性改进。
|
||||
|
||||
## 安装
|
||||
|
||||
下载 `artemis-eapil-1.2.3-source.zip`,解压后运行 `start.bat`,国内网络使用 `start-cn.bat`。附件包含可继续更新的 Git 仓库。已有安装可按原方式运行启动脚本更新;升级后重启 ARTEMIS 服务,并重新加载 MCP。
|
||||
|
||||
验证:Python 默认测试 2207 项通过、2 项跳过、8 项按标记排除;Ruff、质量基线、核心类型检查、前端 149 项测试及构建均通过。
|
||||
+1
-1
@@ -14,7 +14,7 @@
|
||||
|
||||
[project]
|
||||
name = "artemis"
|
||||
version = "1.2.0"
|
||||
version = "1.2.3"
|
||||
description = "Autonomous AI Assistant for Mobile Automation"
|
||||
|
||||
authors = [
|
||||
|
||||
@@ -4,7 +4,7 @@ from io import BytesIO
|
||||
import json
|
||||
import sqlite3
|
||||
import time
|
||||
from unittest.mock import AsyncMock
|
||||
from unittest.mock import AsyncMock, Mock
|
||||
from uuid import UUID
|
||||
import zipfile
|
||||
|
||||
@@ -347,6 +347,61 @@ async def test_batch_timeout_stays_visible_in_job_report_and_history(client, iso
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("stop_on_failure", [False, True])
|
||||
async def test_timed_out_case_releases_next_batch_case(isolated, monkeypatch, stop_on_failure):
|
||||
from apps.admin_console import server
|
||||
from artemis.runtime.device_pool import DeviceStatus
|
||||
|
||||
batch = service.create_batch(payload(timeout_seconds=1, stop_on_failure=stop_on_failure))
|
||||
service.batch_action(batch["id"], "resume")
|
||||
first = isolated.jobs(batch["id"])[0]
|
||||
isolated.save_job({**first, "status": "running", "started_at": time.time() - 2})
|
||||
monkeypatch.setattr(
|
||||
service, "state", type("State", (), {"is_paused": False, "active_runs": {}})()
|
||||
)
|
||||
stop = Mock(return_value=True)
|
||||
enqueue = AsyncMock(return_value={"tasks": [{"device_serial": "test-device"}]})
|
||||
monkeypatch.setattr(server.task_queue_service, "stop_tasks", stop)
|
||||
monkeypatch.setattr(server.task_queue_service, "enqueue_tasks", enqueue)
|
||||
monkeypatch.setattr(
|
||||
"artemis.runtime.device_pool.try_list_devices_async",
|
||||
AsyncMock(return_value=[DeviceStatus(serial="test-device", state="device")]),
|
||||
)
|
||||
monkeypatch.setattr(reports, "export_batch", Mock())
|
||||
|
||||
await service.tick_batches()
|
||||
assert isolated.get("batch", batch["id"])["status"] == "running"
|
||||
assert "超时" in isolated.job(first["session_id"])["error"]
|
||||
stop.assert_called_once_with(session_id=first["session_id"])
|
||||
await service.tick_batches()
|
||||
stop.assert_called_once()
|
||||
|
||||
await service.on_terminal({"session_id": first["session_id"]}, "cancelled")
|
||||
await service.tick_batches()
|
||||
statuses = [job["status"] for job in isolated.jobs(batch["id"])]
|
||||
assert statuses == (["failed", "skipped"] if stop_on_failure else ["failed", "queued"])
|
||||
assert enqueue.await_count == (0 if stop_on_failure else 1)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_queued_case_does_not_consume_execution_timeout(isolated, monkeypatch):
|
||||
from apps.admin_console import server
|
||||
|
||||
batch = service.create_batch(payload(timeout_seconds=1))
|
||||
service.batch_action(batch["id"], "resume")
|
||||
first = isolated.jobs(batch["id"])[0]
|
||||
isolated.save_job({**first, "status": "queued", "started_at": time.time() - 30})
|
||||
monkeypatch.setattr(service, "state", type("State", (), {"is_paused": False})())
|
||||
stop = Mock()
|
||||
monkeypatch.setattr(server.task_queue_service, "stop_tasks", stop)
|
||||
|
||||
await service.tick_batches()
|
||||
stop.assert_not_called()
|
||||
assert isolated.job(first["session_id"])["status"] == "queued"
|
||||
assert isolated.get("batch", batch["id"])["status"] == "running"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_model_timeout_from_runner_log_reaches_failed_task_and_report(isolated):
|
||||
job = isolated.jobs(service.create_batch(payload())["id"])[0]
|
||||
@@ -365,6 +420,33 @@ async def test_model_timeout_from_runner_log_reaches_failed_task_and_report(isol
|
||||
assert "超时" in reports.session_report(job["session_id"])["error"]
|
||||
|
||||
|
||||
def test_optional_timeout_warnings_are_not_reported_as_task_failure(tmp_path):
|
||||
(tmp_path / "stdout.log").write_text(
|
||||
"⚠ Could not clean unowned screen wake locks on emulator-5554: "
|
||||
"Timed out acquiring the Artemis awake-lease mutex.\n"
|
||||
"⚠ VisualStepSummarizer: Error generating summary for step 3: \n"
|
||||
"ℹ StepMemoryService: Retrying Step 3 (attempt 4) after 1.0s\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
assert reports.failure_reason(tmp_path, "failed", None) == (
|
||||
"任务未完成,运行日志中没有可提取的具体异常;请查看执行步骤。"
|
||||
)
|
||||
|
||||
(tmp_path / "stderr.log").write_text(
|
||||
"TimeoutError: model request timed out after 300 seconds\n", encoding="utf-8"
|
||||
)
|
||||
reason = reports.failure_reason(tmp_path, "failed", None)
|
||||
assert "model request timed out" in reason
|
||||
assert "awake-lease" not in reason
|
||||
|
||||
(tmp_path / "stderr.log").write_text(
|
||||
"DeviceBusyError: device queue reservation was cancelled\n", encoding="utf-8"
|
||||
)
|
||||
assert reports.failure_reason(tmp_path, "failed", None) == (
|
||||
"DeviceBusyError: device queue reservation was cancelled"
|
||||
)
|
||||
|
||||
|
||||
def test_reports_do_not_execute_formulas_or_invent_pass(isolated):
|
||||
batch = service.create_batch(payload())
|
||||
job = isolated.jobs(batch["id"])[0]
|
||||
@@ -462,6 +544,43 @@ def test_task_material_zip_contains_ordered_evidence_and_reports(
|
||||
assert "1.1-all.zip" in response.headers["content-disposition"]
|
||||
|
||||
|
||||
def test_video_export_accepts_finalized_worker_trace_and_rejects_external_path(
|
||||
isolated, tmp_path, monkeypatch
|
||||
):
|
||||
job = isolated.jobs(service.create_batch(payload())["id"])[0]
|
||||
session_id = job["session_id"]
|
||||
recording_dir = reports.TRACES_PATH / f"web_123_{session_id[:8]}_PASS_2026-09-28"
|
||||
recording_dir.mkdir(parents=True)
|
||||
first = recording_dir / "recording.mp4"
|
||||
first.write_bytes(b"first segment")
|
||||
resumed = recording_dir / "resume_0001"
|
||||
resumed.mkdir()
|
||||
second = resumed / "recording.mp4"
|
||||
second.write_bytes(b"second segment")
|
||||
|
||||
repository = Mock()
|
||||
repository.get_video_recording_for_session.return_value = {"local_video_path": str(first)}
|
||||
repository.get_session_by_id.return_value = {"video_filepath": str(first)}
|
||||
monkeypatch.setattr(reports, "SessionRepository", lambda: repository)
|
||||
|
||||
archive_path = reports.export_session_media(session_id, "video")
|
||||
with zipfile.ZipFile(archive_path) as archive:
|
||||
names = archive.namelist()
|
||||
assert names == [
|
||||
"任务录屏/0001_web_123_" + session_id[:8] + "_PASS_2026-09-28_recording.mp4",
|
||||
"任务录屏/0002_resume_0001_recording.mp4",
|
||||
"文件清单.json",
|
||||
]
|
||||
assert archive.read(names[0]) == b"first segment"
|
||||
assert archive.read(names[1]) == b"second segment"
|
||||
|
||||
external = tmp_path / "outside" / "recording.mp4"
|
||||
external.parent.mkdir()
|
||||
external.write_bytes(b"unrelated")
|
||||
repository.get_video_recording_for_session.return_value = {"local_video_path": str(external)}
|
||||
assert reports.session_videos({"id": session_id}) == []
|
||||
|
||||
|
||||
def test_history_filters_all_persisted_rows_and_escapes_like(client, isolated):
|
||||
batch = service.create_batch(payload())
|
||||
first, second = isolated.jobs(batch["id"])
|
||||
|
||||
@@ -70,6 +70,12 @@ def test_new_install_uses_playones_endpoint_without_overriding_saved_url(monkeyp
|
||||
assert service.get_model_settings()["base_url"] == "https://saved.example/v1"
|
||||
|
||||
|
||||
def test_model_presets_include_flash_pro_and_luna_without_restricting_custom_names():
|
||||
presets = service.get_model_settings()["model_presets"]
|
||||
assert presets == ["gpt-5.6-luna", "gpt-6-luna", "mimo-v2.6-flash", "mimo-v2.6-pro"]
|
||||
assert service.ModelSettingsUpdate(model="custom/model-v2").model == "custom/model-v2"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"url",
|
||||
[
|
||||
|
||||
@@ -60,20 +60,21 @@ REFRESHED = [_el(1, "Back", 100, 100), _el(2, "Wi-Fi toggle", 900, 300)]
|
||||
|
||||
|
||||
class _FakeSession:
|
||||
"""An action session whose observations replay a scripted list sequence."""
|
||||
"""An action session whose screen advances after each device action."""
|
||||
|
||||
started = True
|
||||
|
||||
def __init__(self, observations: list[list[dict]]):
|
||||
self.calls: list[tuple[str, dict]] = []
|
||||
self._observations = list(observations)
|
||||
self._observations = observations
|
||||
|
||||
async def call(self, name, args):
|
||||
self.calls.append((name, dict(args)))
|
||||
return ActionResult.success(name, f"{name} dispatched")
|
||||
|
||||
async def observe(self, settle_ms=0):
|
||||
elements = self._observations.pop(0) if self._observations else []
|
||||
index = min(len(self.calls) - 1, len(self._observations) - 1)
|
||||
elements = self._observations[index] if self._observations else []
|
||||
return ObserveResult(
|
||||
ok=True,
|
||||
elements_text="\n".join(f"[{e['index']}] {e['text']}" for e in elements),
|
||||
|
||||
@@ -636,6 +636,32 @@ async def test_run_no_tool_call_turn_is_nudged_not_terminated(mock_context):
|
||||
and any(EXECUTION_RESULT_MARKER in b.get("text", "") for b in m.content)
|
||||
for m in turn2_messages
|
||||
)
|
||||
assert not any(
|
||||
isinstance(m, AIMessage) and m.content == "thinking only" for m in turn2_messages
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("attempts", [1, 3])
|
||||
async def test_flash_stops_repeated_no_tool_responses(mock_context, monkeypatch, attempts):
|
||||
from artemis.config.settings import settings
|
||||
|
||||
monkeypatch.setattr(settings, "ARTEMIS_FLASH_MAX_NO_PROGRESS_ATTEMPTS", attempts)
|
||||
responses = [AIMessage(content="I will click now.", tool_calls=[]) for _ in range(attempts)]
|
||||
with (
|
||||
patch("artemis.controllers.unified_controller.get_driver"),
|
||||
patch(
|
||||
"artemis.agents.flash.runner.capture_screenshot_and_parse_ui",
|
||||
AsyncMock(return_value=_INITIAL_OBSERVATION),
|
||||
),
|
||||
):
|
||||
runner = _make_runner(mock_context, responses, max_turns=0)
|
||||
result = await runner.run(State(initial_goal="Open Settings"))
|
||||
|
||||
assert result["status"] == "failed"
|
||||
assert f"连续 {attempts} 次未调用工具" in result["explanation"]
|
||||
assert runner._invoke_model.await_count == attempts
|
||||
runner.executor.execute.assert_not_awaited()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
|
||||
"""Unit tests for VisualStepSummarizer and Context Compressor in Flash profile."""
|
||||
|
||||
import asyncio
|
||||
from unittest.mock import ANY, AsyncMock, Mock, call
|
||||
from uuid import uuid4
|
||||
import pytest
|
||||
@@ -38,10 +39,20 @@ def mock_context():
|
||||
return ctx
|
||||
|
||||
|
||||
def _user_role_prompt(messages):
|
||||
"""Return the instruction text and observation blocks sent to the model."""
|
||||
assert len(messages) == 1
|
||||
assert isinstance(messages[0], HumanMessage)
|
||||
blocks = messages[0].content
|
||||
return blocks[0]["text"], blocks[1:]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_summarizer_dispatch_and_caching(mock_context):
|
||||
"""Verify non-blocking dispatch and summary generation."""
|
||||
summarizer = VisualStepSummarizer(mock_context)
|
||||
assert summarizer._timeout_seconds == 120.0
|
||||
assert summarizer._flush_timeout_s > summarizer._timeout_seconds
|
||||
|
||||
mock_llm_response = AIMessage(
|
||||
content="[Step 1 State: Tapped 'Login' button. Home view opened.]"
|
||||
@@ -106,6 +117,29 @@ async def test_summarizer_retries_independently(mock_context):
|
||||
assert attempt_count == 2
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_visual_summary_uses_its_own_configured_timeout(mock_context):
|
||||
summarizer = VisualStepSummarizer(mock_context, retry_limit=0, timeout_seconds=0.01)
|
||||
|
||||
async def slow_response(_messages):
|
||||
await asyncio.sleep(1)
|
||||
|
||||
summarizer._llm = Mock()
|
||||
summarizer._llm.ainvoke = AsyncMock(side_effect=slow_response)
|
||||
summarizer.dispatch(
|
||||
step_number=1,
|
||||
action_name="click",
|
||||
action_args={"target": [100, 100]},
|
||||
pre_img_bytes=b"pre",
|
||||
post_img_bytes=b"post",
|
||||
exec_outcome="Clicked",
|
||||
)
|
||||
|
||||
await summarizer.flush()
|
||||
assert summarizer.has_failed(1)
|
||||
assert summarizer._llm.ainvoke.await_count == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_summarizer_bounded_retry_marks_failed(mock_context):
|
||||
"""Persistent failures exit after 1 + retry_limit attempts with an explicit failed state."""
|
||||
@@ -401,8 +435,8 @@ async def test_single_frame_dispatch_uses_single_image_prompt_variant(mock_conte
|
||||
await summarizer.flush()
|
||||
|
||||
assert summarizer.has_summary(1)
|
||||
system_text = str(captured[0][0].content)
|
||||
human_text = str(captured[0][1].content)
|
||||
system_text, human_blocks = _user_role_prompt(captured[0])
|
||||
human_text = str(human_blocks)
|
||||
assert "Exactly ONE screenshot" in system_text
|
||||
assert "no independent post-action evidence" in system_text
|
||||
assert "DECISION FRAME" in human_text
|
||||
@@ -436,15 +470,21 @@ async def test_dual_frame_dispatch_keeps_transition_prompt(mock_context):
|
||||
pre_img_bytes=b"pre",
|
||||
post_img_bytes=b"post",
|
||||
exec_outcome="executed",
|
||||
post_ui_elements_text="[1] Text: '存储空间' | Bounds: [0,0][100,100]",
|
||||
observation_settled=False,
|
||||
)
|
||||
await summarizer.flush()
|
||||
|
||||
assert summarizer.has_summary(1)
|
||||
system_text = str(captured[0][0].content)
|
||||
human_text = str(captured[0][1].content)
|
||||
system_text, human_blocks = _user_role_prompt(captured[0])
|
||||
human_text = str(human_blocks)
|
||||
assert "Before/After screenshots" in system_text
|
||||
assert "--- [1] BEFORE ACTION SCREEN (Action Marked Visually in Red) ---" in human_text
|
||||
assert "--- [2] AFTER ACTION SCREEN ---" in human_text
|
||||
assert "--- [3] AFTER ACTION UI ELEMENTS ---" in human_text
|
||||
assert "Text: '存储空间'" in human_text
|
||||
assert "screen still changing" in human_text
|
||||
assert "hierarchy names the new page" in system_text
|
||||
assert "DECISION FRAME" not in human_text
|
||||
|
||||
|
||||
@@ -604,8 +644,8 @@ async def test_focus_block_and_provenance_marked_action_phrase_reach_the_lens(mo
|
||||
await summarizer.flush()
|
||||
|
||||
assert summarizer.has_summary(3)
|
||||
system_text = str(captured[0][0].content)
|
||||
lead_text = captured[0][1].content[0]["text"]
|
||||
system_text, human_blocks = _user_role_prompt(captured[0])
|
||||
lead_text = human_blocks[0]["text"]
|
||||
assert "# OPERATOR FOCUS" in system_text
|
||||
assert "Absence is a fact" in system_text
|
||||
assert lead_text.startswith(
|
||||
@@ -619,7 +659,7 @@ async def test_focus_block_and_provenance_marked_action_phrase_reach_the_lens(mo
|
||||
assert "User instruction at this step: Do not join any network yet." in lead_text
|
||||
assert "expect it to read On." in lead_text
|
||||
# The focus precedes the frames: context first, evidence second.
|
||||
assert captured[0][1].content[1]["text"].startswith("--- [1] BEFORE ACTION SCREEN")
|
||||
assert human_blocks[1]["text"].startswith("--- [1] BEFORE ACTION SCREEN")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -648,7 +688,8 @@ async def test_dispatch_without_focus_carries_no_focus_block(mock_context):
|
||||
)
|
||||
await summarizer.flush()
|
||||
|
||||
lead_text = captured[0][1].content[0]["text"]
|
||||
_, human_blocks = _user_role_prompt(captured[0])
|
||||
lead_text = human_blocks[0]["text"]
|
||||
assert lead_text.startswith("Step 1 Physical Action: Tapped 'Search' at [500, 600]\n")
|
||||
assert "(self-described)" not in lead_text
|
||||
assert FOCUS_BLOCK_HEADER not in lead_text
|
||||
@@ -682,7 +723,8 @@ async def test_single_frame_focus_label_forbids_describing_the_outcome(mock_cont
|
||||
)
|
||||
await summarizer.flush()
|
||||
|
||||
lead_text = captured[0][1].content[0]["text"]
|
||||
_, human_blocks = _user_role_prompt(captured[0])
|
||||
lead_text = human_blocks[0]["text"]
|
||||
assert "the expected outcome is NOT in this frame and must not be described" in lead_text
|
||||
assert "what screen change it expected" not in lead_text
|
||||
# The focus text is released once the summary landed, like the frames.
|
||||
@@ -774,7 +816,8 @@ async def test_verbatim_argument_line_appears_only_when_the_phrase_folds_argumen
|
||||
|
||||
lead_by_step = {}
|
||||
for messages in captured:
|
||||
text = messages[1].content[0]["text"]
|
||||
_, human_blocks = _user_role_prompt(messages)
|
||||
text = human_blocks[0]["text"]
|
||||
lead_by_step[text.split(" ")[1]] = text
|
||||
launch = lead_by_step["1"]
|
||||
assert launch.startswith("Step 1 Physical Action: Launched app 'Settings'\nController Outcome:")
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
"""Page transitions are recaptured until screenshot and hierarchy agree."""
|
||||
|
||||
from unittest.mock import AsyncMock, Mock
|
||||
|
||||
import pytest
|
||||
from PIL import Image
|
||||
|
||||
from artemis.mcp.action_executor import McpActionExecutor
|
||||
from artemis.mcp.action_types import ObserveResult
|
||||
|
||||
|
||||
def _screen(tmp_path, name: str, *, reverse: bool = False) -> str:
|
||||
image = Image.new("L", (64, 64))
|
||||
image.putdata([255 - x * 4 if reverse else x * 4 for _y in range(64) for x in range(64)])
|
||||
path = tmp_path / name
|
||||
image.save(path, format="JPEG")
|
||||
return str(path)
|
||||
|
||||
|
||||
def _observation(path: str, page: str) -> ObserveResult:
|
||||
return ObserveResult(
|
||||
ok=True,
|
||||
screenshot_path=path,
|
||||
elements_text=f"[1] {page}",
|
||||
elements=[{"text": page, "resource_id": "page/title", "bounds": [0, 0, 64, 64]}],
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_navigation_recaptures_when_image_lags_behind_hierarchy(tmp_path):
|
||||
home = _screen(tmp_path, "home.jpg")
|
||||
detail = _screen(tmp_path, "detail.jpg", reverse=True)
|
||||
session = Mock()
|
||||
session.observe = AsyncMock(
|
||||
side_effect=[
|
||||
_observation(home, "存储空间"),
|
||||
_observation(detail, "存储空间"),
|
||||
_observation(detail, "存储空间"),
|
||||
]
|
||||
)
|
||||
executor = McpActionExecutor(Mock(), actuator=Mock())
|
||||
|
||||
observed, settled = await executor._observe_after_action(
|
||||
session, "click", home, _observation(home, "设置首页").elements
|
||||
)
|
||||
|
||||
assert observed.screenshot_path == detail
|
||||
assert observed.elements_text == "[1] 存储空间"
|
||||
assert settled is True
|
||||
assert session.observe.await_count == 3
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_navigation_waits_until_new_page_has_two_stable_frames(tmp_path):
|
||||
home = _screen(tmp_path, "home.jpg")
|
||||
detail = _screen(tmp_path, "detail.jpg", reverse=True)
|
||||
session = Mock()
|
||||
session.observe = AsyncMock(
|
||||
side_effect=[
|
||||
_observation(home, "设置首页"),
|
||||
_observation(home, "设置首页"),
|
||||
_observation(detail, "存储空间"),
|
||||
_observation(detail, "存储空间"),
|
||||
]
|
||||
)
|
||||
executor = McpActionExecutor(Mock(), actuator=Mock())
|
||||
|
||||
observed, settled = await executor._observe_after_action(
|
||||
session, "click", home, _observation(home, "设置首页").elements
|
||||
)
|
||||
|
||||
assert observed.screenshot_path == detail
|
||||
assert settled is True
|
||||
assert session.observe.await_count == 4
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_old_bitmap_with_new_hierarchy_is_never_marked_settled(tmp_path):
|
||||
home = _screen(tmp_path, "home.jpg")
|
||||
session = Mock()
|
||||
session.observe = AsyncMock(return_value=_observation(home, "存储空间"))
|
||||
executor = McpActionExecutor(Mock(), actuator=Mock())
|
||||
|
||||
observed, settled = await executor._observe_after_action(
|
||||
session, "click", home, _observation(home, "设置首页").elements
|
||||
)
|
||||
|
||||
assert observed.elements_text == "[1] 存储空间"
|
||||
assert settled is False
|
||||
assert session.observe.await_count == 4
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_read_only_action_keeps_single_observation(tmp_path):
|
||||
home = _screen(tmp_path, "home.jpg")
|
||||
session = Mock()
|
||||
session.observe = AsyncMock(return_value=_observation(home, "设置首页"))
|
||||
executor = McpActionExecutor(Mock(), actuator=Mock())
|
||||
|
||||
observed, settled = await executor._observe_after_action(session, "wait_for_delay", home)
|
||||
|
||||
assert observed.screenshot_path == home
|
||||
assert settled is True
|
||||
session.observe.assert_awaited_once_with(settle_ms=400)
|
||||
@@ -13,6 +13,8 @@
|
||||
# limitations under the License.
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
import time
|
||||
|
||||
import pytest
|
||||
|
||||
@@ -126,3 +128,30 @@ def test_migration_cleanup_never_drains_a_live_legacy_owner(monkeypatch):
|
||||
cleanup._drain_references = lambda: pytest.fail("must not drain a live owner")
|
||||
|
||||
assert cleanup.cleanup_unowned_references() is False
|
||||
|
||||
|
||||
def test_optional_cleanup_skips_a_busy_mutex_without_delaying_task_start(monkeypatch):
|
||||
owner = ScreenAwakeLease("device-123")
|
||||
owner.mutex_path.write_text(json.dumps(owner._payload()), encoding="utf-8")
|
||||
cleanup = ScreenAwakeLease("device-123")
|
||||
monkeypatch.setattr(cleanup, "_drain_references", lambda: pytest.fail("must not drain"))
|
||||
|
||||
started_at = time.monotonic()
|
||||
assert cleanup.cleanup_unowned_references() is False
|
||||
assert time.monotonic() - started_at < 2.0
|
||||
|
||||
|
||||
def test_unremovable_stale_mutex_still_obeys_deadline(monkeypatch):
|
||||
lease = ScreenAwakeLease("device-123")
|
||||
lease.mutex_path.write_text(json.dumps({"pid": 999999}), encoding="utf-8")
|
||||
original_unlink = Path.unlink
|
||||
|
||||
def refuse_mutex_unlink(path, *args, **kwargs):
|
||||
if path == lease.mutex_path:
|
||||
raise PermissionError("file is in use")
|
||||
return original_unlink(path, *args, **kwargs)
|
||||
|
||||
monkeypatch.setattr(Path, "unlink", refuse_mutex_unlink)
|
||||
with pytest.raises(TimeoutError, match="awake-lease mutex"):
|
||||
with lease._mutex(wait_timeout_seconds=0.1):
|
||||
pytest.fail("stale mutex must not be acquired")
|
||||
@@ -1,3 +1,4 @@
|
||||
import os
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from artemis.runtime.awake_service import (
|
||||
@@ -37,6 +38,22 @@ def test_usb_policy_is_primary_when_android_reports_it_active(mock_run, lease_ty
|
||||
assert not any("KEYCODE_UNKNOWN" in command for command in commands)
|
||||
|
||||
|
||||
@patch.dict(os.environ, {"ARTEMIS_TASK_WORKER": "1"})
|
||||
@patch("artemis.runtime.awake_service.ScreenAwakeLease")
|
||||
@patch("artemis.runtime.awake_service.subprocess.run")
|
||||
def test_queued_worker_does_not_run_optional_legacy_cleanup(mock_run, lease_type):
|
||||
mock_run.side_effect = [
|
||||
completed(),
|
||||
completed(),
|
||||
completed(),
|
||||
completed("2\n"),
|
||||
completed(" mStayOn=true\n"),
|
||||
]
|
||||
|
||||
assert _configure_usb_stay_awake("device-123") == AWAKE_STRATEGY_USB
|
||||
lease_type.assert_not_called()
|
||||
|
||||
|
||||
@patch("artemis.runtime.awake_service.ScreenAwakeLease")
|
||||
@patch("artemis.runtime.awake_service.subprocess.run")
|
||||
def test_inactive_usb_policy_falls_back_to_effective_host_heartbeat(mock_run, _lease_type):
|
||||
|
||||
@@ -2,10 +2,11 @@
|
||||
|
||||
from types import SimpleNamespace
|
||||
|
||||
from langchain_core.messages import AIMessage, AIMessageChunk
|
||||
from langchain_core.messages import AIMessage, AIMessageChunk, HumanMessage, SystemMessage
|
||||
import pytest
|
||||
|
||||
from artemis.llm.reliability import LLMExhaustedError, LLMPermanentError
|
||||
from artemis.llm.router import ModelEndpoint
|
||||
from artemis.services import llm as llm_service
|
||||
from artemis.services.llm import (
|
||||
RobustChatModelWrapper,
|
||||
@@ -54,6 +55,30 @@ async def test_mid_stream_failure_retries_without_duplicating_chunks():
|
||||
assert result.content == "Hello world"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_gpt_stream_uses_adapted_messages_without_mutating_history():
|
||||
class RecordingStream:
|
||||
messages = None
|
||||
|
||||
async def astream(self, messages, **kwargs):
|
||||
self.messages = messages
|
||||
yield AIMessageChunk(content="OK")
|
||||
|
||||
base = RecordingStream()
|
||||
wrapper = RobustChatModelWrapper(
|
||||
base, endpoint=ModelEndpoint(model_name="gpt-6-luna", max_retries=0)
|
||||
)
|
||||
original = [SystemMessage(content="Use tools"), HumanMessage(content="Inspect screen")]
|
||||
result = await wrapper.complete(original)
|
||||
|
||||
assert result.content == "OK"
|
||||
assert len(base.messages) == 1
|
||||
assert isinstance(base.messages[0], HumanMessage)
|
||||
assert base.messages[0].content[0]["text"] == "Use tools"
|
||||
assert base.messages[0].content[1]["text"] == "Inspect screen"
|
||||
assert isinstance(original[0], SystemMessage)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_auth_error_fails_fast_without_retry_or_pause(tmp_path):
|
||||
class AuthFails:
|
||||
|
||||
@@ -16,11 +16,12 @@
|
||||
|
||||
import pytest
|
||||
|
||||
from artemis.config import AgentGlobalConfig
|
||||
from artemis.config import AgentGlobalConfig, load_agent_config
|
||||
|
||||
|
||||
def test_memory_defaults():
|
||||
cfg = AgentGlobalConfig.model_validate({})
|
||||
assert cfg.flash.step_summarizer.timeout_seconds == 120.0
|
||||
# 2 per the 2026-09-01 on-device baseline (serial queue at 1 pushed
|
||||
# summary-ready P90 past the scrub-edge grace window).
|
||||
assert cfg.memory.runtime.max_concurrency == 2
|
||||
@@ -41,6 +42,14 @@ def test_memory_defaults():
|
||||
assert cfg.memory.transcript.hard_ratio == 0.9
|
||||
|
||||
|
||||
def test_visual_summary_timeout_config(monkeypatch):
|
||||
monkeypatch.delenv("ARTEMIS_STEP_SUMMARIZER_TIMEOUT_SECONDS", raising=False)
|
||||
assert load_agent_config().flash.step_summarizer.timeout_seconds == 120.0
|
||||
|
||||
monkeypatch.setenv("ARTEMIS_STEP_SUMMARIZER_TIMEOUT_SECONDS", "75")
|
||||
assert load_agent_config().flash.step_summarizer.timeout_seconds == 75.0
|
||||
|
||||
|
||||
def test_transcript_ratio_ladder_is_validated():
|
||||
"""start <= soft <= hard, all within [0, 1]: the defaults pass, a
|
||||
crossed pair is rejected with a clear message."""
|
||||
|
||||
@@ -5,10 +5,11 @@ from unittest.mock import MagicMock
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from langchain_core.messages import HumanMessage, ToolMessage
|
||||
from langchain_core.messages import HumanMessage, SystemMessage, ToolMessage
|
||||
from pydantic import BaseModel, SecretStr, ValidationError
|
||||
|
||||
from artemis.config.settings import settings
|
||||
from artemis.llm.messages import adapt_responses_messages
|
||||
from artemis.llm.router import ModelEndpoint, ModelFactory, ModelProvider
|
||||
from artemis.services.llm import RobustChatModelWrapper
|
||||
|
||||
@@ -85,6 +86,91 @@ async def test_text_and_image_use_responses_transport(transport_model):
|
||||
assert result.usage_metadata["total_tokens"] == 13
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
("model_name", "reasoning_effort"),
|
||||
[
|
||||
("mimo-v2.6-pro", "high"),
|
||||
("mimo-v2.6-flash", "high"),
|
||||
("gpt-6-luna", "max"),
|
||||
("gpt-6-luna", "high"),
|
||||
],
|
||||
)
|
||||
async def test_selected_models_put_instructions_in_user_input(
|
||||
transport_model, model_name, reasoning_effort
|
||||
):
|
||||
_, requests, _ = transport_model
|
||||
endpoint = ModelEndpoint(
|
||||
model_name=model_name,
|
||||
reasoning_effort=reasoning_effort,
|
||||
timeout_seconds=23,
|
||||
max_retries=0,
|
||||
)
|
||||
model = ModelFactory.create_model(endpoint)
|
||||
wrapper = RobustChatModelWrapper(model, endpoint=endpoint)
|
||||
tool = {
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "inspect_screen",
|
||||
"description": "Inspect the current screen",
|
||||
"parameters": {"type": "object", "properties": {}},
|
||||
},
|
||||
}
|
||||
await wrapper.bind_tools([tool]).ainvoke(
|
||||
[
|
||||
SystemMessage(content="Use the available tools."),
|
||||
HumanMessage(
|
||||
content=[
|
||||
{"type": "text", "text": "Inspect this screen."},
|
||||
{"type": "image_url", "image_url": {"url": "data:image/png;base64,AA=="}},
|
||||
]
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
payload = json.loads(requests[-1].content)
|
||||
assert payload["model"] == model_name
|
||||
assert payload["reasoning"]["effort"] == reasoning_effort
|
||||
assert payload["input"][0]["role"] == "user"
|
||||
assert payload["input"][0]["content"][0]["text"] == "Use the available tools."
|
||||
assert payload["input"][0]["content"][2]["type"] == "input_image"
|
||||
assert payload["tools"][0]["name"] == "inspect_screen"
|
||||
assert all(item.get("role") != "system" for item in payload["input"])
|
||||
|
||||
|
||||
@pytest.mark.parametrize("model_name", ["gpt-6-luna", "GPT-5.6-luna"])
|
||||
def test_gpt_message_adaptation_keeps_history_and_images(model_name):
|
||||
image = {"type": "image_url", "image_url": {"url": "data:image/png;base64,AA=="}}
|
||||
original = [
|
||||
SystemMessage(content="First instruction"),
|
||||
SystemMessage(content="Second instruction"),
|
||||
HumanMessage(content=[{"type": "text", "text": "Observation"}, image]),
|
||||
SystemMessage(content="Follow-up instruction"),
|
||||
]
|
||||
adapted = adapt_responses_messages(original, model_name)
|
||||
|
||||
assert len(adapted) == 2
|
||||
assert isinstance(adapted[0], HumanMessage)
|
||||
assert adapted[0].content[0]["text"] == "First instruction"
|
||||
assert adapted[0].content[1]["text"] == "Second instruction"
|
||||
assert adapted[0].content[3] == image
|
||||
assert isinstance(adapted[1], HumanMessage)
|
||||
assert adapted[1].content == [{"type": "text", "text": "Follow-up instruction"}]
|
||||
assert isinstance(original[0], SystemMessage)
|
||||
assert len(original[2].content) == 2
|
||||
|
||||
|
||||
@pytest.mark.parametrize("model_name", ["mimo-v2.6-pro", "mimo-v2.6-flash"])
|
||||
def test_mimo_messages_use_same_user_role_format(model_name):
|
||||
messages = [SystemMessage(content="Instructions"), HumanMessage(content="Task")]
|
||||
adapted = adapt_responses_messages(messages, model_name)
|
||||
assert len(adapted) == 1
|
||||
assert isinstance(adapted[0], HumanMessage)
|
||||
assert adapted[0].content[0]["text"] == "Instructions"
|
||||
assert adapted[0].content[1]["text"] == "Task"
|
||||
assert isinstance(messages[0], SystemMessage)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_max_reasoning_is_sent_unchanged_to_responses(transport_model):
|
||||
model, requests, _ = transport_model
|
||||
|
||||
Reference in new issue
Block a user