Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
59f458205d | ||
|
|
4465a2e199 | ||
|
|
c818a09a49 | ||
|
|
e1e26772d9 |
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
|
OPENAI_MODEL=mimo-v2.6-flash
|
||||||
# 单次模型 HTTP 请求超时(秒)
|
# 单次模型 HTTP 请求超时(秒)
|
||||||
OPENAI_TIMEOUT_SECONDS=300
|
OPENAI_TIMEOUT_SECONDS=300
|
||||||
|
# 可选的后台步骤截图摘要单次超时,独立于上面的主模型请求超时。
|
||||||
|
ARTEMIS_STEP_SUMMARIZER_TIMEOUT_SECONDS=120
|
||||||
# 失败后最多重试 5 次,由 Artemis 统一管理,SDK 不叠加重试。
|
# 失败后最多重试 5 次,由 Artemis 统一管理,SDK 不叠加重试。
|
||||||
OPENAI_MAX_RETRIES=5
|
OPENAI_MAX_RETRIES=5
|
||||||
OPENAI_RETRY_INTERVAL_SECONDS=10
|
OPENAI_RETRY_INTERVAL_SECONDS=10
|
||||||
|
|||||||
@@ -64,7 +64,12 @@ def get_model_settings() -> dict:
|
|||||||
"max_retries": settings.OPENAI_MAX_RETRIES,
|
"max_retries": settings.OPENAI_MAX_RETRIES,
|
||||||
"retry_interval_seconds": settings.OPENAI_RETRY_INTERVAL_SECONDS,
|
"retry_interval_seconds": settings.OPENAI_RETRY_INTERVAL_SECONDS,
|
||||||
"reasoning_effort": settings.OPENAI_REASONING_EFFORT,
|
"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()),
|
"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", {})
|
state = read_json(directory / "status.json", {})
|
||||||
if isinstance(state, dict) and state.get("error"):
|
if isinstance(state, dict) and state.get("error"):
|
||||||
return redact(state["error"])
|
return redact(state["error"])
|
||||||
|
log_lines: list[str] = []
|
||||||
for name in ("stdout.log", "stderr.log"):
|
for name in ("stdout.log", "stderr.log"):
|
||||||
path = directory / name
|
path = directory / name
|
||||||
try:
|
try:
|
||||||
with path.open("rb") as stream:
|
with path.open("rb") as stream:
|
||||||
stream.seek(0, 2)
|
stream.seek(0, 2)
|
||||||
stream.seek(max(0, stream.tell() - 131072))
|
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:
|
except OSError:
|
||||||
continue
|
continue
|
||||||
for line in reversed(lines):
|
for line in reversed(log_lines):
|
||||||
match = re.search(r"Error running automation:\s*(.+)", line)
|
match = re.search(r"Error running automation:\s*(.+)", line)
|
||||||
if match:
|
if match:
|
||||||
return redact(match.group(1))
|
return redact(match.group(1))
|
||||||
if re.search(r"(?i)(?:timeout|timed out|超时|deadline exceeded)", line):
|
for line in reversed(log_lines):
|
||||||
return redact("模型请求或任务执行超时:" + line.strip())
|
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 "任务未完成,运行日志中没有可提取的具体异常;请查看执行步骤。"
|
return "任务未完成,运行日志中没有可提取的具体异常;请查看执行步骤。"
|
||||||
|
|
||||||
|
|
||||||
@@ -607,15 +632,17 @@ def session_videos(report: dict) -> list[tuple[Path, str]]:
|
|||||||
if not candidate:
|
if not candidate:
|
||||||
return []
|
return []
|
||||||
source = Path(str(candidate)).resolve()
|
source = Path(str(candidate)).resolve()
|
||||||
session_root = (TRACES_PATH / identifier(report["id"])).resolve()
|
trace_root = TRACES_PATH.resolve()
|
||||||
if not source.is_relative_to(session_root):
|
# 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 []
|
return []
|
||||||
# A resumed recording lives under resume_NNNN; the initial recording is
|
# A resumed recording lives under resume_NNNN; the initial recording is
|
||||||
# in its parent, with any later segments in sibling resume folders.
|
# in its parent, with any later segments in sibling resume folders.
|
||||||
directory = source.parent
|
directory = source.parent
|
||||||
if directory.name.startswith("resume_"):
|
if directory.name.startswith("resume_"):
|
||||||
directory = directory.parent
|
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 []
|
return []
|
||||||
paths = [
|
paths = [
|
||||||
p
|
p
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "artemis-showcase-ui",
|
"name": "artemis-showcase-ui",
|
||||||
"version": "1.2.1",
|
"version": "1.2.3",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "artemis-showcase-ui",
|
"name": "artemis-showcase-ui",
|
||||||
"version": "1.2.1",
|
"version": "1.2.3",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@angular/cdk": "^22.1.5",
|
"@angular/cdk": "^22.1.5",
|
||||||
"@angular/common": "^22.1.4",
|
"@angular/common": "^22.1.4",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "artemis-showcase-ui",
|
"name": "artemis-showcase-ui",
|
||||||
"version": "1.2.1",
|
"version": "1.2.3",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"ng": "ng",
|
"ng": "ng",
|
||||||
"start": "ng serve --proxy-config proxy.conf.json",
|
"start": "ng serve --proxy-config proxy.conf.json",
|
||||||
|
|||||||
+13
-2
@@ -7,7 +7,8 @@ import { CapabilitySettingsComponent } from './capability-settings.component';
|
|||||||
describe('持久化模型与功能设置', () => {
|
describe('持久化模型与功能设置', () => {
|
||||||
let http: HttpTestingController;
|
let http: HttpTestingController;
|
||||||
const config = { base_url: 'https://example.test/v1', model: 'mimo-v2.6-flash',
|
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(() => {
|
beforeEach(() => {
|
||||||
TestBed.configureTestingModule({ providers: [provideHttpClient(), provideHttpClientTesting()] });
|
TestBed.configureTestingModule({ providers: [provideHttpClient(), provideHttpClientTesting()] });
|
||||||
http = TestBed.inject(HttpTestingController);
|
http = TestBed.inject(HttpTestingController);
|
||||||
@@ -44,13 +45,23 @@ describe('持久化模型与功能设置', () => {
|
|||||||
await fixture.whenStable();
|
await fixture.whenStable();
|
||||||
const root: HTMLElement = fixture.nativeElement;
|
const root: HTMLElement = fixture.nativeElement;
|
||||||
const presets = root.querySelectorAll<HTMLButtonElement>('.model-presets button');
|
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();
|
presets[0].click();
|
||||||
fixture.detectChanges();
|
fixture.detectChanges();
|
||||||
await fixture.whenStable();
|
await fixture.whenStable();
|
||||||
const input = root.querySelector<HTMLInputElement>('#model-name')!;
|
const input = root.querySelector<HTMLInputElement>('#model-name')!;
|
||||||
expect(input.value).toBe('gpt-5.6-luna');
|
expect(input.value).toBe('gpt-5.6-luna');
|
||||||
expect(presets[0].getAttribute('aria-pressed')).toBe('true');
|
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.getAttribute('list')).toBeNull();
|
||||||
expect(input.autocomplete).toBe('off');
|
expect(input.autocomplete).toBe('off');
|
||||||
input.value = 'custom/model-v2';
|
input.value = 'custom/model-v2';
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ interface ModelSettings {
|
|||||||
max_retries: number;
|
max_retries: number;
|
||||||
retry_interval_seconds: number;
|
retry_interval_seconds: number;
|
||||||
reasoning_effort: string;
|
reasoning_effort: string;
|
||||||
|
model_presets: string[];
|
||||||
env_path: string;
|
env_path: string;
|
||||||
message?: string;
|
message?: string;
|
||||||
}
|
}
|
||||||
@@ -90,7 +91,7 @@ interface ModelSettings {
|
|||||||
export class ModelSettingsComponent implements OnInit {
|
export class ModelSettingsComponent implements OnInit {
|
||||||
private http = inject(HttpClient);
|
private http = inject(HttpClient);
|
||||||
saved = output<void>();
|
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';
|
baseUrl = ''; model = ''; apiKey = ''; timeout = 300; retries = 5; retryInterval = 10; reasoning = 'high';
|
||||||
busy = signal<'save' | 'test' | null>(null);
|
busy = signal<'save' | 'test' | null>(null);
|
||||||
loading = signal(true); keyConfigured = signal(false); envPath = signal('');
|
loading = signal(true); keyConfigured = signal(false); envPath = signal('');
|
||||||
@@ -112,6 +113,7 @@ export class ModelSettingsComponent implements OnInit {
|
|||||||
}
|
}
|
||||||
private apply(config: ModelSettings): void {
|
private apply(config: ModelSettings): void {
|
||||||
this.baseUrl = config.base_url; this.model = config.model; this.timeout = config.timeout_seconds;
|
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);
|
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 {
|
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`)
|
# 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.
|
- **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.
|
- **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).
|
- **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.
|
- **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.data_engine.trace import trace
|
||||||
from artemis.graph.perception import _check_injected_instruction_file
|
from artemis.graph.perception import _check_injected_instruction_file
|
||||||
from artemis.graph.state import State
|
from artemis.graph.state import State
|
||||||
|
from artemis.llm.router import ModelEndpoint
|
||||||
from artemis.llm.structured import ParseFailure, parse_structured
|
from artemis.llm.structured import ParseFailure, parse_structured
|
||||||
from artemis.mcp.action_executor import McpActionExecutor
|
from artemis.mcp.action_executor import McpActionExecutor
|
||||||
from artemis.mcp.observation import observe
|
from artemis.mcp.observation import observe
|
||||||
@@ -167,6 +168,7 @@ class FlashRunner:
|
|||||||
ctx,
|
ctx,
|
||||||
model_name=self.step_summarizer_cfg.model,
|
model_name=self.step_summarizer_cfg.model,
|
||||||
retry_limit=self.memory_runtime_cfg.retry_limit,
|
retry_limit=self.memory_runtime_cfg.retry_limit,
|
||||||
|
timeout_seconds=self.step_summarizer_cfg.timeout_seconds,
|
||||||
max_concurrency=self.memory_runtime_cfg.max_concurrency,
|
max_concurrency=self.memory_runtime_cfg.max_concurrency,
|
||||||
flush_timeout_s=self.memory_runtime_cfg.flush_timeout_s,
|
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}")
|
logger.warning(f"Failed to get operator LLM from config, using default: {e}")
|
||||||
|
|
||||||
return RobustChatModelWrapper(
|
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:
|
def _render_system_prompt(self, tools_declaration: list) -> str:
|
||||||
@@ -838,6 +842,8 @@ class FlashRunner:
|
|||||||
pre_img_bytes=pre_screenshot_bytes,
|
pre_img_bytes=pre_screenshot_bytes,
|
||||||
post_img_bytes=post_img_bytes,
|
post_img_bytes=post_img_bytes,
|
||||||
exec_outcome=exec_result.text_summary,
|
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),
|
action_key=str(tc_id),
|
||||||
data_engine_step_id=recorded_step_id,
|
data_engine_step_id=recorded_step_id,
|
||||||
# The model's own reasoning for this turn steers the lens's
|
# The model's own reasoning for this turn steers the lens's
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ from jinja2 import Template
|
|||||||
from langchain_core.messages import BaseMessage, HumanMessage, SystemMessage
|
from langchain_core.messages import BaseMessage, HumanMessage, SystemMessage
|
||||||
|
|
||||||
from artemis.context import ArtemisContext
|
from artemis.context import ArtemisContext
|
||||||
|
from artemis.llm.messages import adapt_responses_messages
|
||||||
from artemis.memory.step_memory import JobKey, StepMemoryService
|
from artemis.memory.step_memory import JobKey, StepMemoryService
|
||||||
from artemis.services.llm import RobustChatModelWrapper, get_openai_llm, get_llm
|
from artemis.services.llm import RobustChatModelWrapper, get_openai_llm, get_llm
|
||||||
from artemis.services.token_meter import record_llm_usage
|
from artemis.services.token_meter import record_llm_usage
|
||||||
@@ -151,6 +152,7 @@ class VisualStepSummarizer(StepMemoryService):
|
|||||||
ctx: ArtemisContext,
|
ctx: ArtemisContext,
|
||||||
model_name: str | None = None,
|
model_name: str | None = None,
|
||||||
retry_limit: int = 3,
|
retry_limit: int = 3,
|
||||||
|
timeout_seconds: float = 120.0,
|
||||||
*,
|
*,
|
||||||
max_concurrency: int = 1,
|
max_concurrency: int = 1,
|
||||||
flush_timeout_s: float = 30.0,
|
flush_timeout_s: float = 30.0,
|
||||||
@@ -159,8 +161,11 @@ class VisualStepSummarizer(StepMemoryService):
|
|||||||
ctx,
|
ctx,
|
||||||
max_concurrency=max_concurrency,
|
max_concurrency=max_concurrency,
|
||||||
retry_limit=retry_limit,
|
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
|
# Initialize lightweight VLM: prioritize explicit model_name
|
||||||
target_model = model_name or settings.OPENAI_MODEL
|
target_model = model_name or settings.OPENAI_MODEL
|
||||||
@@ -224,6 +229,8 @@ class VisualStepSummarizer(StepMemoryService):
|
|||||||
action_key: str | None = None,
|
action_key: str | None = None,
|
||||||
data_engine_step_id: UUID | str | None = None,
|
data_engine_step_id: UUID | str | None = None,
|
||||||
focus: dict[str, str] | None = None,
|
focus: dict[str, str] | None = None,
|
||||||
|
post_ui_elements_text: str | None = None,
|
||||||
|
observation_settled: bool | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Dispatches an asynchronous summarization task without blocking the caller.
|
"""Dispatches an asynchronous summarization task without blocking the caller.
|
||||||
|
|
||||||
@@ -254,6 +261,8 @@ class VisualStepSummarizer(StepMemoryService):
|
|||||||
"exec_outcome": exec_outcome,
|
"exec_outcome": exec_outcome,
|
||||||
"data_engine_step_id": data_engine_step_id,
|
"data_engine_step_id": data_engine_step_id,
|
||||||
"focus": focus,
|
"focus": focus,
|
||||||
|
"post_ui_elements_text": post_ui_elements_text,
|
||||||
|
"observation_settled": observation_settled,
|
||||||
}
|
}
|
||||||
self.submit(key, payload, aliases=aliases)
|
self.submit(key, payload, aliases=aliases)
|
||||||
|
|
||||||
@@ -406,6 +415,10 @@ class VisualStepSummarizer(StepMemoryService):
|
|||||||
if verbatim and not self._phrase_carries(phrase, verbatim):
|
if verbatim and not self._phrase_carries(phrase, verbatim):
|
||||||
lead_lines.append(f"Action arguments (verbatim): {action_name}({verbatim})")
|
lead_lines.append(f"Action arguments (verbatim): {action_name}({verbatim})")
|
||||||
lead_lines.append(f"Controller Outcome: {exec_outcome}")
|
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")
|
focus = input_data.get("focus")
|
||||||
if isinstance(focus, dict) and focus:
|
if isinstance(focus, dict) and focus:
|
||||||
lead_lines.extend(["", render_focus_block(focus, dual=dual)])
|
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] = [
|
messages: list[BaseMessage] = [
|
||||||
SystemMessage(content=rendered_prompt),
|
SystemMessage(content=rendered_prompt),
|
||||||
HumanMessage(content=content_blocks),
|
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)
|
self._meter_lens_call(response)
|
||||||
summary_raw = response.content if isinstance(response.content, str) else ""
|
summary_raw = response.content if isinstance(response.content, str) else ""
|
||||||
if isinstance(response.content, list):
|
if isinstance(response.content, list):
|
||||||
@@ -505,8 +530,14 @@ class VisualStepSummarizer(StepMemoryService):
|
|||||||
logger.debug(f"DataEngine step summary update skipped: {de_err}")
|
logger.debug(f"DataEngine step summary update skipped: {de_err}")
|
||||||
return True
|
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:
|
except Exception as e:
|
||||||
logger.warning(
|
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
|
return False
|
||||||
@@ -370,6 +370,11 @@ class StepSummarizerConfig(BaseModel):
|
|||||||
default_factory=lambda: settings.OPENAI_MODEL,
|
default_factory=lambda: settings.OPENAI_MODEL,
|
||||||
description="Lightweight model used for background step state summarization.",
|
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(
|
prune_history_xml: bool = Field(
|
||||||
default=True,
|
default=True,
|
||||||
description="Whether to prune outdated heavy UI XML trees from historical turns.",
|
description="Whether to prune outdated heavy UI XML trees from historical turns.",
|
||||||
|
|||||||
@@ -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_specs import exception_prefix
|
||||||
from artemis.mcp.action_session import ActionSession, get_action_session
|
from artemis.mcp.action_session import ActionSession, get_action_session
|
||||||
from artemis.mcp.action_types import ActionCode, ActionResult
|
from artemis.mcp.action_types import ActionCode, ActionResult
|
||||||
|
from artemis.mcp.action_types import ObserveResult
|
||||||
from artemis.mcp.actuators.adb import AdbActuator
|
from artemis.mcp.actuators.adb import AdbActuator
|
||||||
from artemis.agents.validator.tool_declarations import (
|
from artemis.agents.validator.tool_declarations import (
|
||||||
ToolExecutionResult,
|
ToolExecutionResult,
|
||||||
@@ -56,6 +57,7 @@ from artemis.utils.coordinates import (
|
|||||||
parse_swipe_parameters,
|
parse_swipe_parameters,
|
||||||
)
|
)
|
||||||
from artemis.utils.logger import get_logger
|
from artemis.utils.logger import get_logger
|
||||||
|
from artemis.utils.image_hash import dhash_hex, hamming_distance_hex
|
||||||
from artemis.utils.notes import (
|
from artemis.utils.notes import (
|
||||||
format_list_notes_failure,
|
format_list_notes_failure,
|
||||||
format_list_notes_success,
|
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.
|
#: 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"})
|
_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):
|
class _ArgError(ValueError):
|
||||||
@@ -111,6 +116,99 @@ class McpActionExecutor:
|
|||||||
self._session = await get_action_session(self.ctx, actuator=self.actuator)
|
self._session = await get_action_session(self.ctx, actuator=self.actuator)
|
||||||
return self._session
|
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 ----------------------------------------------------------------
|
# --- Public entry ----------------------------------------------------------------
|
||||||
|
|
||||||
async def execute(
|
async def execute(
|
||||||
@@ -171,6 +269,7 @@ class McpActionExecutor:
|
|||||||
xml_list: str | None = None
|
xml_list: str | None = None
|
||||||
target_semantics: dict[str, Any] = {}
|
target_semantics: dict[str, Any] = {}
|
||||||
target_coordinates: list[int] | None = None
|
target_coordinates: list[int] | None = None
|
||||||
|
observation_settled: bool | None = None
|
||||||
with span:
|
with span:
|
||||||
try:
|
try:
|
||||||
session = await self._session_or_start()
|
session = await self._session_or_start()
|
||||||
@@ -196,7 +295,12 @@ class McpActionExecutor:
|
|||||||
message = finalize(res) if finalize else res.message
|
message = finalize(res) if finalize else res.message
|
||||||
|
|
||||||
if res.ok or self._observe_despite_failure(raw_name, res):
|
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:
|
if obs.ok:
|
||||||
shot_path = obs.screenshot_path
|
shot_path = obs.screenshot_path
|
||||||
xml_list = obs.elements_text
|
xml_list = obs.elements_text
|
||||||
@@ -217,6 +321,8 @@ class McpActionExecutor:
|
|||||||
|
|
||||||
if shot_path:
|
if shot_path:
|
||||||
state.latest_screenshot = 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"
|
status = "success" if res.ok else "error"
|
||||||
post_image_name = None
|
post_image_name = None
|
||||||
@@ -247,6 +353,7 @@ class McpActionExecutor:
|
|||||||
"normalized_coordinates": res.normalized_coordinates,
|
"normalized_coordinates": res.normalized_coordinates,
|
||||||
"target_semantics": target_semantics,
|
"target_semantics": target_semantics,
|
||||||
"target_coordinates": target_coordinates,
|
"target_coordinates": target_coordinates,
|
||||||
|
"observation_settled": observation_settled,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -58,6 +58,7 @@ def ensure_step_memory(ctx):
|
|||||||
"retry_limit": cfg.memory.runtime.retry_limit,
|
"retry_limit": cfg.memory.runtime.retry_limit,
|
||||||
"max_concurrency": cfg.memory.runtime.max_concurrency,
|
"max_concurrency": cfg.memory.runtime.max_concurrency,
|
||||||
"flush_timeout_s": cfg.memory.runtime.flush_timeout_s,
|
"flush_timeout_s": cfg.memory.runtime.flush_timeout_s,
|
||||||
|
"timeout_seconds": cfg.flash.step_summarizer.timeout_seconds,
|
||||||
}
|
}
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.debug(
|
logger.debug(
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ from uuid import uuid4
|
|||||||
|
|
||||||
from langchain_core.messages import BaseMessage, HumanMessage, SystemMessage
|
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.step_memory import JobKey, StepLens, StepMemoryService
|
||||||
from artemis.memory.transcript import format_session_offset
|
from artemis.memory.transcript import format_session_offset
|
||||||
from artemis.utils.logger import get_logger
|
from artemis.utils.logger import get_logger
|
||||||
@@ -503,7 +504,8 @@ class StepCapsuleLens(StepLens):
|
|||||||
return parsed
|
return parsed
|
||||||
|
|
||||||
async def _invoke(self, llm: Any, messages: list[BaseMessage], model_label: str):
|
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);
|
# Raw-model bypass metering (gateway-wrapped models meter themselves);
|
||||||
# capsule prompts are small and are kept out of the session meter's
|
# capsule prompts are small and are kept out of the session meter's
|
||||||
# last_prompt_tokens (the compaction base itself is operator-owned on
|
# last_prompt_tokens (the compaction base itself is operator-owned on
|
||||||
|
|||||||
@@ -33,6 +33,7 @@
|
|||||||
"step_summarizer": {
|
"step_summarizer": {
|
||||||
"enabled": "${env:ARTEMIS_STEP_SUMMARIZER_ENABLED:-true}",
|
"enabled": "${env:ARTEMIS_STEP_SUMMARIZER_ENABLED:-true}",
|
||||||
"model": "${env:OPENAI_MODEL:-mimo-v2.6-flash}",
|
"model": "${env:OPENAI_MODEL:-mimo-v2.6-flash}",
|
||||||
|
"timeout_seconds": "${env:ARTEMIS_STEP_SUMMARIZER_TIMEOUT_SECONDS:-120}",
|
||||||
"prune_history_xml": true
|
"prune_history_xml": true
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -113,22 +113,28 @@ class ScreenAwakeLease:
|
|||||||
}
|
}
|
||||||
|
|
||||||
@contextmanager
|
@contextmanager
|
||||||
def _mutex(self):
|
def _mutex(self, *, wait_timeout_seconds: float | None = None):
|
||||||
self.mutex_path.parent.mkdir(parents=True, exist_ok=True)
|
self.mutex_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
started_at = time.monotonic()
|
started_at = time.monotonic()
|
||||||
|
timeout = (
|
||||||
|
self._MUTEX_TIMEOUT_SECONDS
|
||||||
|
if wait_timeout_seconds is None
|
||||||
|
else max(0.0, wait_timeout_seconds)
|
||||||
|
)
|
||||||
acquired = False
|
acquired = False
|
||||||
while not acquired:
|
while not acquired:
|
||||||
try:
|
try:
|
||||||
fd = os.open(self.mutex_path, os.O_CREAT | os.O_EXCL | os.O_WRONLY)
|
fd = os.open(self.mutex_path, os.O_CREAT | os.O_EXCL | os.O_WRONLY)
|
||||||
except FileExistsError:
|
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):
|
if self._path_is_stale(self.mutex_path):
|
||||||
try:
|
try:
|
||||||
self.mutex_path.unlink(missing_ok=True)
|
self.mutex_path.unlink(missing_ok=True)
|
||||||
except OSError:
|
except OSError:
|
||||||
pass
|
pass
|
||||||
continue
|
else:
|
||||||
if time.monotonic() - started_at >= self._MUTEX_TIMEOUT_SECONDS:
|
continue
|
||||||
raise TimeoutError("Timed out acquiring the Artemis awake-lease mutex.")
|
|
||||||
time.sleep(self._MUTEX_POLL_SECONDS)
|
time.sleep(self._MUTEX_POLL_SECONDS)
|
||||||
else:
|
else:
|
||||||
try:
|
try:
|
||||||
@@ -274,10 +280,15 @@ class ScreenAwakeLease:
|
|||||||
def cleanup_unowned_references(self) -> bool:
|
def cleanup_unowned_references(self) -> bool:
|
||||||
"""Drain legacy shell wake locks only when no live old host owns them."""
|
"""Drain legacy shell wake locks only when no live old host owns them."""
|
||||||
try:
|
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():
|
if self._live_leases():
|
||||||
return False
|
return False
|
||||||
return self._drain_references()
|
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:
|
except Exception as exc:
|
||||||
logger.warning(f"Could not clean unowned screen wake locks on {self.device_id}: {exc}")
|
logger.warning(f"Could not clean unowned screen wake locks on {self.device_id}: {exc}")
|
||||||
return False
|
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":
|
if not _awake_enabled() or os.environ.get("ARTEMIS_CLOUD_MODE") == "1":
|
||||||
return None
|
return None
|
||||||
|
|
||||||
# Migration cleanup: previous Artemis versions used a persistent shell
|
# Migration cleanup belongs to the long-lived host. A queued task worker
|
||||||
# wake lock. Drain it only after every live legacy owner has exited.
|
# must not wait on another process's optional legacy wake-lock cleanup.
|
||||||
ScreenAwakeLease(device_id).cleanup_unowned_references()
|
if os.environ.get("ARTEMIS_TASK_WORKER") != "1":
|
||||||
|
ScreenAwakeLease(device_id).cleanup_unowned_references()
|
||||||
|
|
||||||
commands = (
|
commands = (
|
||||||
(
|
(
|
||||||
|
|||||||
@@ -54,6 +54,7 @@ from artemis.llm.reliability import (
|
|||||||
classify_failure,
|
classify_failure,
|
||||||
retry_policy_for,
|
retry_policy_for,
|
||||||
)
|
)
|
||||||
|
from artemis.llm.messages import adapt_responses_messages
|
||||||
from artemis.llm.router import ModelEndpoint, ModelFactory, ModelProvider
|
from artemis.llm.router import ModelEndpoint, ModelFactory, ModelProvider
|
||||||
from artemis.services.token_meter import record_llm_usage
|
from artemis.services.token_meter import record_llm_usage
|
||||||
from artemis.llm.structured import (
|
from artemis.llm.structured import (
|
||||||
@@ -568,6 +569,25 @@ class RobustChatModelWrapper:
|
|||||||
args, kwargs = _inject_parent_trace_id(trace_id, *args, **kwargs)
|
args, kwargs = _inject_parent_trace_id(trace_id, *args, **kwargs)
|
||||||
return args, kwargs, trace_id
|
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:
|
def _meter_usage(self, response) -> None:
|
||||||
"""Record measured token usage for this call (M0 metering, best-effort)."""
|
"""Record measured token usage for this call (M0 metering, best-effort)."""
|
||||||
engine = self.ctx.data_engine if self.ctx else None
|
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())
|
record_llm_usage(engine, response, source=self._endpoint_key())
|
||||||
|
|
||||||
async def ainvoke(self, *args, **kwargs):
|
async def ainvoke(self, *args, **kwargs):
|
||||||
|
args, kwargs = self._prepare_model_call(args, kwargs)
|
||||||
args, kwargs, _ = self._traced_call(args, kwargs)
|
args, kwargs, _ = self._traced_call(args, kwargs)
|
||||||
response = await _run_with_recovery(
|
response = await _run_with_recovery(
|
||||||
functools.partial(self.base_model.ainvoke, *args, **kwargs),
|
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;
|
signals the UI to drop it, and retries the whole call per policy;
|
||||||
partial or duplicated chunks can never reach message history.
|
partial or duplicated chunks can never reach message history.
|
||||||
"""
|
"""
|
||||||
|
args, kwargs = self._prepare_model_call(args, kwargs)
|
||||||
args, kwargs, _ = self._traced_call(args, kwargs)
|
args, kwargs, _ = self._traced_call(args, kwargs)
|
||||||
emit_deltas = bool(self.ctx and self.ctx.data_engine)
|
emit_deltas = bool(self.ctx and self.ctx.data_engine)
|
||||||
response = await _run_with_recovery(
|
response = await _run_with_recovery(
|
||||||
|
|||||||
@@ -33,6 +33,7 @@
|
|||||||
"step_summarizer": {
|
"step_summarizer": {
|
||||||
"enabled": "${env:ARTEMIS_STEP_SUMMARIZER_ENABLED:-true}",
|
"enabled": "${env:ARTEMIS_STEP_SUMMARIZER_ENABLED:-true}",
|
||||||
"model": "${env:OPENAI_MODEL:-mimo-v2.6-flash}",
|
"model": "${env:OPENAI_MODEL:-mimo-v2.6-flash}",
|
||||||
|
"timeout_seconds": "${env:ARTEMIS_STEP_SUMMARIZER_TIMEOUT_SECONDS:-120}",
|
||||||
"prune_history_xml": true
|
"prune_history_xml": true
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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]
|
[project]
|
||||||
name = "artemis"
|
name = "artemis"
|
||||||
version = "1.2.1"
|
version = "1.2.3"
|
||||||
description = "Autonomous AI Assistant for Mobile Automation"
|
description = "Autonomous AI Assistant for Mobile Automation"
|
||||||
|
|
||||||
authors = [
|
authors = [
|
||||||
|
|||||||
@@ -420,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"]
|
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):
|
def test_reports_do_not_execute_formulas_or_invent_pass(isolated):
|
||||||
batch = service.create_batch(payload())
|
batch = service.create_batch(payload())
|
||||||
job = isolated.jobs(batch["id"])[0]
|
job = isolated.jobs(batch["id"])[0]
|
||||||
@@ -517,6 +544,43 @@ def test_task_material_zip_contains_ordered_evidence_and_reports(
|
|||||||
assert "1.1-all.zip" in response.headers["content-disposition"]
|
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):
|
def test_history_filters_all_persisted_rows_and_escapes_like(client, isolated):
|
||||||
batch = service.create_batch(payload())
|
batch = service.create_batch(payload())
|
||||||
first, second = isolated.jobs(batch["id"])
|
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"
|
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(
|
@pytest.mark.parametrize(
|
||||||
"url",
|
"url",
|
||||||
[
|
[
|
||||||
|
|||||||
@@ -60,20 +60,21 @@ REFRESHED = [_el(1, "Back", 100, 100), _el(2, "Wi-Fi toggle", 900, 300)]
|
|||||||
|
|
||||||
|
|
||||||
class _FakeSession:
|
class _FakeSession:
|
||||||
"""An action session whose observations replay a scripted list sequence."""
|
"""An action session whose screen advances after each device action."""
|
||||||
|
|
||||||
started = True
|
started = True
|
||||||
|
|
||||||
def __init__(self, observations: list[list[dict]]):
|
def __init__(self, observations: list[list[dict]]):
|
||||||
self.calls: list[tuple[str, dict]] = []
|
self.calls: list[tuple[str, dict]] = []
|
||||||
self._observations = list(observations)
|
self._observations = observations
|
||||||
|
|
||||||
async def call(self, name, args):
|
async def call(self, name, args):
|
||||||
self.calls.append((name, dict(args)))
|
self.calls.append((name, dict(args)))
|
||||||
return ActionResult.success(name, f"{name} dispatched")
|
return ActionResult.success(name, f"{name} dispatched")
|
||||||
|
|
||||||
async def observe(self, settle_ms=0):
|
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(
|
return ObserveResult(
|
||||||
ok=True,
|
ok=True,
|
||||||
elements_text="\n".join(f"[{e['index']}] {e['text']}" for e in elements),
|
elements_text="\n".join(f"[{e['index']}] {e['text']}" for e in elements),
|
||||||
|
|||||||
@@ -14,6 +14,7 @@
|
|||||||
|
|
||||||
"""Unit tests for VisualStepSummarizer and Context Compressor in Flash profile."""
|
"""Unit tests for VisualStepSummarizer and Context Compressor in Flash profile."""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
from unittest.mock import ANY, AsyncMock, Mock, call
|
from unittest.mock import ANY, AsyncMock, Mock, call
|
||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
import pytest
|
import pytest
|
||||||
@@ -38,10 +39,20 @@ def mock_context():
|
|||||||
return ctx
|
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
|
@pytest.mark.asyncio
|
||||||
async def test_summarizer_dispatch_and_caching(mock_context):
|
async def test_summarizer_dispatch_and_caching(mock_context):
|
||||||
"""Verify non-blocking dispatch and summary generation."""
|
"""Verify non-blocking dispatch and summary generation."""
|
||||||
summarizer = VisualStepSummarizer(mock_context)
|
summarizer = VisualStepSummarizer(mock_context)
|
||||||
|
assert summarizer._timeout_seconds == 120.0
|
||||||
|
assert summarizer._flush_timeout_s > summarizer._timeout_seconds
|
||||||
|
|
||||||
mock_llm_response = AIMessage(
|
mock_llm_response = AIMessage(
|
||||||
content="[Step 1 State: Tapped 'Login' button. Home view opened.]"
|
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
|
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
|
@pytest.mark.asyncio
|
||||||
async def test_summarizer_bounded_retry_marks_failed(mock_context):
|
async def test_summarizer_bounded_retry_marks_failed(mock_context):
|
||||||
"""Persistent failures exit after 1 + retry_limit attempts with an explicit failed state."""
|
"""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()
|
await summarizer.flush()
|
||||||
|
|
||||||
assert summarizer.has_summary(1)
|
assert summarizer.has_summary(1)
|
||||||
system_text = str(captured[0][0].content)
|
system_text, human_blocks = _user_role_prompt(captured[0])
|
||||||
human_text = str(captured[0][1].content)
|
human_text = str(human_blocks)
|
||||||
assert "Exactly ONE screenshot" in system_text
|
assert "Exactly ONE screenshot" in system_text
|
||||||
assert "no independent post-action evidence" in system_text
|
assert "no independent post-action evidence" in system_text
|
||||||
assert "DECISION FRAME" in human_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",
|
pre_img_bytes=b"pre",
|
||||||
post_img_bytes=b"post",
|
post_img_bytes=b"post",
|
||||||
exec_outcome="executed",
|
exec_outcome="executed",
|
||||||
|
post_ui_elements_text="[1] Text: '存储空间' | Bounds: [0,0][100,100]",
|
||||||
|
observation_settled=False,
|
||||||
)
|
)
|
||||||
await summarizer.flush()
|
await summarizer.flush()
|
||||||
|
|
||||||
assert summarizer.has_summary(1)
|
assert summarizer.has_summary(1)
|
||||||
system_text = str(captured[0][0].content)
|
system_text, human_blocks = _user_role_prompt(captured[0])
|
||||||
human_text = str(captured[0][1].content)
|
human_text = str(human_blocks)
|
||||||
assert "Before/After screenshots" in system_text
|
assert "Before/After screenshots" in system_text
|
||||||
assert "--- [1] BEFORE ACTION SCREEN (Action Marked Visually in Red) ---" in human_text
|
assert "--- [1] BEFORE ACTION SCREEN (Action Marked Visually in Red) ---" in human_text
|
||||||
assert "--- [2] AFTER ACTION SCREEN ---" 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
|
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()
|
await summarizer.flush()
|
||||||
|
|
||||||
assert summarizer.has_summary(3)
|
assert summarizer.has_summary(3)
|
||||||
system_text = str(captured[0][0].content)
|
system_text, human_blocks = _user_role_prompt(captured[0])
|
||||||
lead_text = captured[0][1].content[0]["text"]
|
lead_text = human_blocks[0]["text"]
|
||||||
assert "# OPERATOR FOCUS" in system_text
|
assert "# OPERATOR FOCUS" in system_text
|
||||||
assert "Absence is a fact" in system_text
|
assert "Absence is a fact" in system_text
|
||||||
assert lead_text.startswith(
|
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 "User instruction at this step: Do not join any network yet." in lead_text
|
||||||
assert "expect it to read On." in lead_text
|
assert "expect it to read On." in lead_text
|
||||||
# The focus precedes the frames: context first, evidence second.
|
# 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
|
@pytest.mark.asyncio
|
||||||
@@ -648,7 +688,8 @@ async def test_dispatch_without_focus_carries_no_focus_block(mock_context):
|
|||||||
)
|
)
|
||||||
await summarizer.flush()
|
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 lead_text.startswith("Step 1 Physical Action: Tapped 'Search' at [500, 600]\n")
|
||||||
assert "(self-described)" not in lead_text
|
assert "(self-described)" not in lead_text
|
||||||
assert FOCUS_BLOCK_HEADER 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()
|
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 "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
|
assert "what screen change it expected" not in lead_text
|
||||||
# The focus text is released once the summary landed, like the frames.
|
# 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 = {}
|
lead_by_step = {}
|
||||||
for messages in captured:
|
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
|
lead_by_step[text.split(" ")[1]] = text
|
||||||
launch = lead_by_step["1"]
|
launch = lead_by_step["1"]
|
||||||
assert launch.startswith("Step 1 Physical Action: Launched app 'Settings'\nController Outcome:")
|
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.
|
# limitations under the License.
|
||||||
|
|
||||||
import json
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
import time
|
||||||
|
|
||||||
import pytest
|
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")
|
cleanup._drain_references = lambda: pytest.fail("must not drain a live owner")
|
||||||
|
|
||||||
assert cleanup.cleanup_unowned_references() is False
|
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 unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
from artemis.runtime.awake_service import (
|
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)
|
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.ScreenAwakeLease")
|
||||||
@patch("artemis.runtime.awake_service.subprocess.run")
|
@patch("artemis.runtime.awake_service.subprocess.run")
|
||||||
def test_inactive_usb_policy_falls_back_to_effective_host_heartbeat(mock_run, _lease_type):
|
def test_inactive_usb_policy_falls_back_to_effective_host_heartbeat(mock_run, _lease_type):
|
||||||
|
|||||||
@@ -2,10 +2,11 @@
|
|||||||
|
|
||||||
from types import SimpleNamespace
|
from types import SimpleNamespace
|
||||||
|
|
||||||
from langchain_core.messages import AIMessage, AIMessageChunk
|
from langchain_core.messages import AIMessage, AIMessageChunk, HumanMessage, SystemMessage
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from artemis.llm.reliability import LLMExhaustedError, LLMPermanentError
|
from artemis.llm.reliability import LLMExhaustedError, LLMPermanentError
|
||||||
|
from artemis.llm.router import ModelEndpoint
|
||||||
from artemis.services import llm as llm_service
|
from artemis.services import llm as llm_service
|
||||||
from artemis.services.llm import (
|
from artemis.services.llm import (
|
||||||
RobustChatModelWrapper,
|
RobustChatModelWrapper,
|
||||||
@@ -54,6 +55,30 @@ async def test_mid_stream_failure_retries_without_duplicating_chunks():
|
|||||||
assert result.content == "Hello world"
|
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
|
@pytest.mark.asyncio
|
||||||
async def test_auth_error_fails_fast_without_retry_or_pause(tmp_path):
|
async def test_auth_error_fails_fast_without_retry_or_pause(tmp_path):
|
||||||
class AuthFails:
|
class AuthFails:
|
||||||
|
|||||||
@@ -16,11 +16,12 @@
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from artemis.config import AgentGlobalConfig
|
from artemis.config import AgentGlobalConfig, load_agent_config
|
||||||
|
|
||||||
|
|
||||||
def test_memory_defaults():
|
def test_memory_defaults():
|
||||||
cfg = AgentGlobalConfig.model_validate({})
|
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
|
# 2 per the 2026-09-01 on-device baseline (serial queue at 1 pushed
|
||||||
# summary-ready P90 past the scrub-edge grace window).
|
# summary-ready P90 past the scrub-edge grace window).
|
||||||
assert cfg.memory.runtime.max_concurrency == 2
|
assert cfg.memory.runtime.max_concurrency == 2
|
||||||
@@ -41,6 +42,14 @@ def test_memory_defaults():
|
|||||||
assert cfg.memory.transcript.hard_ratio == 0.9
|
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():
|
def test_transcript_ratio_ladder_is_validated():
|
||||||
"""start <= soft <= hard, all within [0, 1]: the defaults pass, a
|
"""start <= soft <= hard, all within [0, 1]: the defaults pass, a
|
||||||
crossed pair is rejected with a clear message."""
|
crossed pair is rejected with a clear message."""
|
||||||
|
|||||||
@@ -5,10 +5,11 @@ from unittest.mock import MagicMock
|
|||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
import pytest
|
import pytest
|
||||||
from langchain_core.messages import HumanMessage, ToolMessage
|
from langchain_core.messages import HumanMessage, SystemMessage, ToolMessage
|
||||||
from pydantic import BaseModel, SecretStr, ValidationError
|
from pydantic import BaseModel, SecretStr, ValidationError
|
||||||
|
|
||||||
from artemis.config.settings import settings
|
from artemis.config.settings import settings
|
||||||
|
from artemis.llm.messages import adapt_responses_messages
|
||||||
from artemis.llm.router import ModelEndpoint, ModelFactory, ModelProvider
|
from artemis.llm.router import ModelEndpoint, ModelFactory, ModelProvider
|
||||||
from artemis.services.llm import RobustChatModelWrapper
|
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
|
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
|
@pytest.mark.asyncio
|
||||||
async def test_max_reasoning_is_sent_unchanged_to_responses(transport_model):
|
async def test_max_reasoning_is_sent_unchanged_to_responses(transport_model):
|
||||||
model, requests, _ = transport_model
|
model, requests, _ = transport_model
|
||||||
|
|||||||
Reference in new issue
Block a user