feat: improve task execution, verification and packaging

Add execution incidents, action bursts and shared Flash history.

Expose verification and Explorer settings with streamed check results.

Support graceful cancellation, recording recovery and bundled UI resources.

Add CI quality checks and clean up comments and documentation.
This commit is contained in:
somew1nd committed 2026-09-02 21:45:51 -07:00
1 parent 5ccf06d677
commit 67248c887e
288 files changed
+15584 -6620

No files matched your search

+15
View File
@@ -0,0 +1,15 @@
.git
.github
.venv
.pytest_cache
.ruff_cache
**/__pycache__
**/*.pyc
apps/showcase_ui/.angular
apps/showcase_ui/dist
apps/showcase_ui/node_modules
build
dist
traces
.env
*.log
+175
View File
@@ -0,0 +1,175 @@
name: CI
on:
push:
pull_request:
workflow_dispatch:
permissions:
contents: read
concurrency:
group: ci-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
env:
PYTHONUTF8: "1"
UV_NO_PROGRESS: "1"
jobs:
python-quality:
name: Python 3.12 / ${{ matrix.os }}
runs-on: ${{ matrix.os }}
timeout-minutes: 30
strategy:
fail-fast: false
matrix:
os:
- ubuntu-latest
- windows-latest
steps:
- name: Check out repository
uses: actions/checkout@v4
- name: Set up Python 3.12
uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Set up uv
uses: astral-sh/setup-uv@v6
with:
enable-cache: true
cache-dependency-glob: uv.lock
- name: Install locked development environment
run: uv sync --dev --frozen
- name: Check Python formatting
run: uv run ruff format --check .
- name: Lint Python
run: uv run ruff check .
- name: Enforce quality ratchets
run: uv run python scripts/quality_ratchet.py
- name: Type-check protected core modules
run: uv run pyright --project pyright-core.json
# Device, cloud, manual, and end-to-end suites require dedicated
# infrastructure and intentionally do not run on GitHub-hosted runners.
- name: Run deterministic Python tests
run: >-
uv run pytest
tests/unit
tests/tools
packages/artemis-client/tests
-q
--cov=artemis
--cov=mcp_server
--cov=apps/admin_console
--cov-fail-under=60
frontend:
name: Frontend tests and production build
runs-on: ubuntu-latest
timeout-minutes: 20
defaults:
run:
working-directory: apps/showcase_ui
steps:
- name: Check out repository
uses: actions/checkout@v4
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: "22.22.3"
cache: npm
cache-dependency-path: apps/showcase_ui/package-lock.json
- name: Install locked frontend dependencies
run: npm ci
- name: Run frontend tests
run: npm test -- --watch=false --browsers=ChromeHeadless
- name: Build frontend for production
run: npm run build
package:
name: Build and smoke-test Python packages
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- name: Check out repository
uses: actions/checkout@v4
- name: Set up Python 3.12
uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Set up uv
uses: astral-sh/setup-uv@v6
with:
enable-cache: true
cache-dependency-glob: uv.lock
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: "22.22.3"
cache: npm
cache-dependency-path: apps/showcase_ui/package-lock.json
- name: Build frontend assets for the wheel
run: |
npm ci --prefix apps/showcase_ui
npm run build --prefix apps/showcase_ui
- name: Build client and Artemis distributions
run: |
uv build --package artemis-client --wheel --out-dir dist
uv build --package artemis --wheel --sdist --out-dir dist
- name: Install wheels in a clean environment
run: |
uv venv .smoke-venv --python 3.12
uv pip install --python .smoke-venv/bin/python dist/artemis_client-*.whl dist/artemis-*.whl
- name: Smoke-test installed package and CLI
shell: bash
run: |
smoke_dir="$RUNNER_TEMP/artemis-wheel-smoke"
mkdir -p "$smoke_dir"
cd "$smoke_dir"
source "$GITHUB_WORKSPACE/.smoke-venv/bin/activate"
python - <<'PY'
from importlib.metadata import files, version
import artemis
from artemis.resources import get_bundled_config_path, get_bundled_showcase_dist
bundled_config = get_bundled_config_path("artemis.jsonc")
bundled_ui = get_bundled_showcase_dist()
assert artemis.__version__ == version("artemis")
assert bundled_config is not None and bundled_config.is_file()
assert bundled_ui is not None and (bundled_ui / "index.html").is_file()
assert not any("node_modules" in str(path) for path in (files("artemis") or ()))
print(f"Installed Artemis {artemis.__version__} with bundled runtime resources")
PY
artemis --version
artemis --help
- name: Upload distributions
uses: actions/upload-artifact@v4
with:
name: python-distributions
path: dist/*
if-no-files-found: error
retention-days: 7
+20
View File
@@ -0,0 +1,20 @@
repos:
- repo: local
hooks:
- id: ruff-check
name: Ruff lint
entry: uv run ruff check
language: system
types: [python]
- id: ruff-format
name: Ruff format
entry: uv run ruff format --check
language: system
types: [python]
- id: quality-ratchet
name: Quality debt ratchet
entry: uv run python scripts/quality_ratchet.py
language: system
pass_filenames: false
+5
View File
@@ -0,0 +1,5 @@
{
"broad_exception_handlers": 754,
"silent_broad_exception_handlers": 0,
"type_ignore_comments": 18
}
+18 -1
View File
@@ -9,10 +9,27 @@ Thank you for your interest in contributing to **ARTEMIS**! We welcome and appre
- **🐛 Report Bugs & Suggest Features**: If you find an issue or have an idea to improve ARTEMIS, please open an [Issue](https://github.com/google/artemis/issues).
- **💡 Submit Pull Requests**:
1. Fork the repository and create your feature/fix branch (`git checkout -b feat/my-feature`).
2. Make your changes and verify with `make test` and `make lint`.
2. Make your changes and verify with `make test`, `make lint`, and `make typecheck`.
3. Open a Pull Request with a brief summary of your work.
- **💬 Community & Discussion**: Join our [Discord Community](https://discord.gg/wF2FN4WHGY) to chat with maintainers, ask questions, or share feedback.
---
Every contribution—big or small—helps make ARTEMIS better. Thank you for building with us!
## Test layers
`make test` is the required, deterministic suite. It does not need an Android
device, model credentials, or the optional private cloud service. This is the
same suite used by pull-request CI.
- `make test-integration` runs cross-component tests that may need configured
model credentials.
- `make test-device` runs Android and end-to-end tests and therefore requires
an attached, authorized device or emulator.
- `make test-all` collects every test tree and is intended for a fully
provisioned maintainer environment.
Tests that require external state must carry the appropriate `android`,
`cloud`, `manual`, or `e2e` marker. They must remain safely collectable when
that dependency is unavailable.
+32 -13
View File
@@ -12,30 +12,49 @@
# See the License for the specific language governing permissions and
# limitations under the License.
FROM node:22-bookworm-slim AS frontend-builder
WORKDIR /build/apps/showcase_ui
COPY apps/showcase_ui/package.json apps/showcase_ui/package-lock.json ./
RUN npm ci
COPY apps/showcase_ui/angular.json apps/showcase_ui/tsconfig*.json ./
COPY apps/showcase_ui/public/ ./public/
COPY apps/showcase_ui/src/ ./src/
RUN npm run build
FROM python:3.12-slim AS wheel-builder
WORKDIR /build
COPY artemis/ ./artemis/
COPY apps/ ./apps/
COPY mcp_server/ ./mcp_server/
COPY packages/artemis-client/ ./packages/artemis-client/
COPY config/ ./config/
COPY pyproject.toml setup.py README.md LICENSE ./
COPY --from=frontend-builder /build/apps/showcase_ui/dist/ ./apps/showcase_ui/dist/
# Build wheels for the runtime image.
RUN python -m pip wheel --no-cache-dir --no-deps --wheel-dir /wheels ./packages/artemis-client \
&& python -m pip wheel --no-cache-dir --no-deps --wheel-dir /wheels .
FROM python:3.12-slim
WORKDIR /app
# Install necessary build tools
RUN apt-get update && apt-get install -y --no-install-recommends \
build-essential \
adb \
curl \
git \
adb \
&& rm -rf /var/lib/apt/lists/*
# Copy application source code and configuration
COPY artemis/ /app/artemis/
COPY apps/ /app/apps/
COPY mcp_server/ /app/mcp_server/
COPY config/ /app/config/
COPY pyproject.toml setup.py README.md LICENSE /app/
# Install dependencies and artemis package
RUN pip install --no-cache-dir -e /app
COPY --from=wheel-builder /wheels/ /wheels/
RUN pip install --no-cache-dir /wheels/artemis_client-*.whl \
&& pip install --no-cache-dir /wheels/artemis-*.whl \
&& rm -rf /wheels
ENV PYTHONUNBUFFERED=1
ENV PYTHONPATH=/app
ENV ARTEMIS_APP_DIR=/app
ENV PORT=8080
+21 -5
View File
@@ -12,7 +12,7 @@
# See the License for the specific language governing permissions and
# limitations under the License.
.PHONY: help test install install-deps setup start ui restart stop status build-ui doctor clean precommit-install precommit lint format typecheck
.PHONY: help test test-integration test-device test-all install install-deps setup start ui restart stop status build-ui doctor clean precommit-install precommit lint format typecheck quality-ratchet
help: ## Show this help message
@echo 'Usage: make [target]'
@@ -42,9 +42,21 @@ build-ui: ## Build the Showcase UI Angular frontend
doctor: ## Run system, device, and toolchain diagnostics
@uv run artemis doctor
test: ## Run all tests
@echo "🧪 Running all tests..."
@uv run pytest -v
test: ## Run deterministic tests that need no device, credentials, or private services
@echo "🧪 Running deterministic tests..."
@uv run pytest
test-integration: ## Run non-device integration tests (may require configured model credentials)
@echo "🧪 Running integration tests..."
@uv run pytest tests/integration tests/tools -m "integration and not android and not cloud and not manual"
test-device: ## Run Android and end-to-end tests explicitly
@echo "📱 Running device and end-to-end tests..."
@uv run pytest tests/integration tests/e2e -m "android or e2e"
test-all: ## Run every test tree; external prerequisites must be available
@echo "🧪 Running the complete test tree..."
@uv run pytest tests packages/artemis-client/tests -m "integration or not integration"
install: ## Install python dependencies via uv
@echo "📦 Installing python dependencies..."
@@ -66,6 +78,7 @@ lint: ## Run linting checks
@echo "🔍 Running linting checks..."
@uv run ruff format --check
@uv run ruff check
@uv run python scripts/quality_ratchet.py
format: ## Format code
@echo "✨ Formatting code..."
@@ -74,7 +87,10 @@ format: ## Format code
typecheck: ## Run type checking
@echo "🔍 Running type checks..."
@uv run pyright
@uv run pyright --project pyright-core.json
quality-ratchet: ## Prevent broad exception and type-ignore debt from increasing
@uv run python scripts/quality_ratchet.py
precommit-install: ## Install pre-commit hooks
@echo "🔧 Installing pre-commit hooks..."
+2 -2
View File
@@ -273,8 +273,8 @@ Evaluated on [AndroidWorld](https://github.com/google-research/android_world)
ARTEMIS supports two execution profiles tailored for different automation requirements:
* **Flash Profile (`--profile flash`)**: Fast and token-efficient reactive loop (~3–5s per step). Ideal for routine, deterministic UI tasks within 25–30 steps. *Limitations*: Does not support long-term state monitoring, video stream analysis, or multi-step failure self-healing.
* **Pro Profile (`--profile pro`)**: Our most capable model architecture (~15–40s per step). Powered by a multi-agent graph with planning, visual verification, and automated recovery. Capable of handling 100+ step long-horizon workflows, continuous device state monitoring, and multimedia video analysis.
* **Flash Profile (`--profile flash`)**: Fast and token-efficient reactive loop (~3–5s per step): one model observes the live screen, thinks, and acts, with no graph orchestration. Ideal for routine, deterministic UI tasks. The loop is unbounded by default (`agent.flash.max_turns`, 0 = unlimited) because history is compressed rather than capped: Flash shares the Pro session transcript ledger (session-relative `T+mm:ss` clock, screenshots folded into visual summaries, older steps chunked into eras and recallable on demand via `recall_history`) and can query the session recording through `video_analyzer`. Transient UI (auto-fading control bars, toasts) is handled by chaining taps into one `click_sequence`. *Limitations*: No task plan or notes, no pre-execution safety net, no checkpoint verification or final report, and no ADB shell.
* **Pro Profile (`--profile pro`)**: Our most capable architecture (~15–40s per step), built as a multi-agent graph. A **Planner** maintains a living Markdown task plan with milestones and `verify` / `assert` check items; the **Operator** executes it with the full toolset (Explorer grounding whose `flash` / `pro` / `ultra` tier is a user setting per profile — `pro.explorer.mode` / `flash.explorer_mode` in `config/artemis.jsonc` or `--explorer-pro-mode` — never chosen by the agent; notes, history recall, video analysis, ADB diagnostics). Every single action passes a pre-execution **Safety Net** (XML-first, pixel fallback), while multi-action **fast-action bursts** fire back to back to beat turn latency on transient UI. A blocked or failed action opens an **execution incident** that stays in the Operator's context until a later action succeeds, so recovery is handled by the Operator itself with no separate repair agent. A read-only **Checker** verifies plan checkpoints and runs an exit final review against the original goal (`--verification-level`: `off` / `final` (default) / `checkpoints` / `strict`), and plan milestone edits get an advisory review. Handles 100+ step long-horizon workflows, `[Loop:continuous]` monitoring, and an optional written report.
## Roadmap
+2 -2
View File
@@ -275,8 +275,8 @@ if __name__ == "__main__":
ARTEMIS 提供两种运行模式以适应不同的自动化需求:
* **Flash 模式 (`--profile flash`)**:轻量极速且省 Token 的响应式循环(单步约 3–5 秒),适合 25–30 步以内的常规确定性 UI 操作。*局限性*:不支持长时间状态监控、视频流分析以及复杂的多步自愈恢复。
* **Pro 模式 (`--profile pro`)**:最强大的模型架构(单步约 15–40 秒),基于多智能体图编排,具备自主规划、视觉校验与闭环自愈能力,支持 100+ 步的长程复杂业务流、设备持续状态监控与多媒体视频分析。
* **Flash 模式 (`--profile flash`)**:轻量极速且省 Token 的响应式循环(单步约 3–5 秒):单个模型观察实时屏幕、思考、执行,不经过图编排,适合常规确定性 UI 操作。默认不限步数(`agent.flash.max_turns`,0 表示不限),因为历史是被压缩而不是被截断:Flash 与 Pro 共用同一套会话记录账本(相对测试时间 `T+mm:ss`、截图折叠为视觉摘要、更早的步骤分块归档并可通过 `recall_history` 按需召回),并可调用 `video_analyzer` 分析整段会话录屏;对自动消失的控制栏、toast 等瞬态控件,用 `click_sequence` 把多次点击串成一个原子序列。*局限性*:没有任务计划与笔记、没有执行前安全网、没有检查点校验与最终报告、不能执行 ADB 命令。
* **Pro 模式 (`--profile pro`)**:最强大的架构(单步约 15–40 秒),由多智能体图编排:**Planner** 维护一份带里程碑和 `verify` / `assert` 检查项的 Markdown 任务计划;**Operator** 按计划执行,拥有全套工具(Explorer 元素定位,其 `flash` / `pro` / `ultra` 三档感知深度是按运行模式设置的用户配置——`config/artemis.jsonc` 里的 `pro.explorer.mode` / `flash.explorer_mode` 或 `--explorer-pro-mode`——智能体自身不会选择档位;笔记、历史召回、视频分析、ADB 诊断)。单个动作执行前都经过 **Safety Net** 校验(XML 优先、像素兜底),而一轮里的多个动作则作为**快速动作序列 (fast-action burst)** 连发,赶在瞬态控件消失前完成操作。动作被拦截或失败时会打开一条**执行事故 (execution incident)**,持续留在 Operator 的上下文里直到后续动作执行成功,恢复由 Operator 自己完成,不再有独立的修复智能体。只读的 **Checker** 校验计划中的检查点,并在退出前对照原始目标做终审(`--verification-level`:`off` / `final`(默认)/ `checkpoints` / `strict`),计划里程碑的改动会得到建议式复核。支持 100+ 步的长程复杂业务流、`[Loop:continuous]` 持续监控以及可选的书面报告。
## 路线图
+1
View File
@@ -236,6 +236,7 @@ class ServerState:
state = ServerState()
import sys
if __name__ == "admin_console.core.state":
sys.modules["apps.admin_console.core.state"] = sys.modules[__name__]
elif __name__ == "apps.admin_console.core.state":
@@ -13,6 +13,8 @@
# limitations under the License.
import logging
import os
import sqlite3
import time
from typing import Any
@@ -74,10 +76,12 @@ class SessionRepository:
)
if cursor.fetchone() is None:
return {}
columns = {
str(row[1]) for row in cursor.execute("PRAGMA table_info(video_recordings)")
}
ready_clause = "status = 'ready'" if "status" in columns else "end_time IS NOT NULL"
columns = {str(row[1]) for row in cursor.execute("PRAGMA table_info(video_recordings)")}
# Failed recordings may still be recoverable from disk.
# Callers exclude in-progress sessions before resolving paths.
ready_clause = (
"status IN ('ready', 'failed')" if "status" in columns else "end_time IS NOT NULL"
)
cursor.execute(
"SELECT session_id, local_video_path FROM video_recordings "
"WHERE session_id IS NOT NULL AND local_video_path IS NOT NULL "
@@ -104,7 +108,9 @@ class SessionRepository:
return None
result = dict(row)
if "status" not in result:
result["status"] = "ready" if result.get("end_time") is not None else "recording"
result["status"] = (
"ready" if result.get("end_time") is not None else "recording"
)
result.setdefault("error", None)
return result
except Exception:
@@ -147,6 +153,40 @@ class SessionRepository:
except Exception:
return {}
def get_unfinalized_video_recordings(self) -> list[dict[str, Any]]:
"""Recordings whose worker never finalized them, oldest first.
Rows of sessions that are still ``running`` are skipped: their worker
(possibly owned by another daemon) may still be writing the file.
"""
try:
with db_session(self.db_path) as conn:
cursor = conn.cursor()
cursor.execute(
"SELECT name FROM sqlite_master WHERE type='table' AND name='video_recordings'"
)
if cursor.fetchone() is None:
return []
columns = {
str(row[1]) for row in cursor.execute("PRAGMA table_info(video_recordings)")
}
pending_clause = (
"v.status IN ('recording', 'finalizing', 'failed')"
if "status" in columns
else "v.end_time IS NULL"
)
cursor.execute(
"SELECT v.session_id, v.local_video_path, v.start_time, "
"s.status AS session_status, s.start_time AS session_start_time "
"FROM video_recordings v LEFT JOIN sessions s ON s.session_id = v.session_id "
f"WHERE v.session_id IS NOT NULL AND v.local_video_path IS NOT NULL "
f"AND {pending_clause} ORDER BY v.start_time ASC"
)
rows = [dict(r) for r in cursor.fetchall()]
except (OSError, sqlite3.Error):
return []
return [row for row in rows if str(row.get("session_status") or "").lower() != "running"]
def mark_recording_failed_if_pending(self, session_id: str, error: str) -> bool:
"""Close a recording lifecycle when its worker exits before finalization."""
try:
@@ -174,8 +214,16 @@ class SessionRepository:
"WHERE session_id = ?",
(str(local_video_path), time.time(), str(session_id)),
)
updated = cursor.rowcount > 0
if os.path.exists(str(local_video_path)):
# Mirror the worker's own finalization so the session row
# and trace tooling see the recovered file too.
cursor.execute(
"UPDATE sessions SET video_filepath = ? WHERE session_id = ?",
(str(local_video_path), str(session_id)),
)
conn.commit()
return cursor.rowcount > 0
return updated
except Exception:
return False
@@ -324,8 +372,7 @@ class SessionRepository:
# read_status itself never raises; this guards the
# lock/write side of update_trace_status.
logger.warning(
"Could not mark trace %s failed during orphan "
"reconciliation: %s",
"Could not mark trace %s failed during orphan reconciliation: %s",
row["session_id"],
exc,
)
+9 -1
View File
@@ -149,7 +149,9 @@ def _get_session_video_sync(session_id: str):
v_url = media_service.resolve_video_url(row_dict, video_rec_map, video_idx)
video_segments = media_service.resolve_video_segments(v_url)
if v_url:
version = int(float((recording or {}).get("end_time") or row_dict.get("end_time") or 0) * 1000)
version = int(
float((recording or {}).get("end_time") or row_dict.get("end_time") or 0) * 1000
)
separator = "&" if "?" in v_url else "?"
versioned_url = f"{v_url}{separator}v={version}" if version else v_url
for segment in video_segments:
@@ -188,3 +190,9 @@ async def get_task_plan(session_id: str):
@router.get("/api/sessions/{session_id}/notes")
async def get_all_notes(session_id: str):
return {"notes": media_service.get_session_notes_content(session_id)}
@router.get("/api/sessions/{session_id}/checks")
async def get_session_checks(session_id: str):
"""Checker verdict ledger + run outcome (backfill for the Checker panel)."""
return media_service.get_session_checks(session_id)
+2 -3
View File
@@ -64,11 +64,10 @@ def _list_sessions_sync():
try:
from artemis.runtime import DeviceExecutionLock
active_owners = DeviceExecutionLock.get_active_owners()
active_owner_sids = {
str(owner.session_id)
for owner in active_owners.values()
if owner.session_id
str(owner.session_id) for owner in active_owners.values() if owner.session_id
}
except Exception:
active_owner_sids = set()
+7 -3
View File
@@ -297,7 +297,11 @@ async def get_credentials():
providers = ("google", "openai", "anthropic", "openrouter", "ocr")
status = {name: bool(settings.get_api_key(name)) for name in providers}
status["gemini"] = status["google"]
return {"providers": [{"name": name, "configured": configured} for name, configured in status.items()]}
return {
"providers": [
{"name": name, "configured": configured} for name, configured in status.items()
]
}
@router.post("/credentials/test")
@@ -370,7 +374,7 @@ async def update_credentials(request: UpdateCredentialsRequest):
async def get_model_config_and_env():
"""Retrieve the current active artemis.jsonc configuration and .env status for custom setup."""
import os
from artemis.config.paths import ROOT_DIR, get_config_path
from artemis.config.paths import get_config_path, get_env_file
from artemis.config import settings
from artemis.utils.file import load_jsonc
@@ -390,7 +394,7 @@ async def get_model_config_and_env():
config_content = f"// Error reading config: {e}"
# 2. Env file resolution
env_path = ROOT_DIR / ".env"
env_path = get_env_file()
# 3. Relevant Env Variables status
def get_real_env_value(key_name: str, provider: str) -> str | None:
+45 -25
View File
@@ -90,8 +90,7 @@ async def run_task(request: RunRequest):
(
item
for item in state.queue_items
if isinstance(item, dict)
and str(item.get("session_id")) == requested_sid
if isinstance(item, dict) and str(item.get("session_id")) == requested_sid
),
None,
)
@@ -122,9 +121,7 @@ async def run_task(request: RunRequest):
# fail downstream with a clear error instead.
if request.device_serial:
try:
rejection = await device_pool.validate_explicit_serial_async(
request.device_serial
)
rejection = await device_pool.validate_explicit_serial_async(request.device_serial)
except Exception:
rejection = None
if rejection:
@@ -144,19 +141,19 @@ async def run_task(request: RunRequest):
# prefers the diagnostics target preference, then any unlocked ready
# device); the verified serial is bound below.
target_serial = request.device_serial
device_probe = await readiness_engine.run_device_submission_probe(
target_serial=target_serial
)
device_probe = await readiness_engine.run_device_submission_probe(target_serial=target_serial)
if device_probe and device_probe.summary in {"Device Locked", "Lock State Unknown"}:
locked_serial = (
device_probe.metadata.get("active_device", {}).get("serial")
or target_serial
or ""
device_probe.metadata.get("active_device", {}).get("serial") or target_serial or ""
)
detail = (
f"Android device {locked_serial} is locked. Unlock it and enter the home screen before running a task.".replace(" ", " ").strip()
f"Android device {locked_serial} is locked. Unlock it and enter the home screen before running a task.".replace(
" ", " "
).strip()
if device_probe.summary == "Device Locked"
else f"Android device {locked_serial} lock state could not be verified. Keep it unlocked on the home screen and try again.".replace(" ", " ").strip()
else f"Android device {locked_serial} lock state could not be verified. Keep it unlocked on the home screen and try again.".replace(
" ", " "
).strip()
)
raise HTTPException(status_code=409, detail=detail)
@@ -173,6 +170,8 @@ async def run_task(request: RunRequest):
profile=request.profile or "flash",
expected_output=request.expected_output,
enable_outputter=request.enable_outputter,
verification_level=request.verification_level,
explorer_mode=request.explorer_mode,
locked_app_package=request.locked_app_package,
app_path=request.app_path,
device_serial=target_serial,
@@ -182,6 +181,22 @@ async def run_task(request: RunRequest):
)
@router.get("/api/run/defaults")
async def get_run_defaults():
"""Effective Pro-profile tuning defaults from the agent config.
The launcher's sliders start here so they reflect ``artemis.jsonc`` (and the
``ARTEMIS_EXPLORER_VERSION`` override) instead of a hard-coded guess.
"""
from artemis.config import load_agent_config, verification_level_for_checker
agent_cfg = load_agent_config()
return {
"verification_level": verification_level_for_checker(agent_cfg.checker),
"explorer_mode": agent_cfg.explorer.resolve(profile="pro"),
}
@router.get("/api/devices")
async def list_devices():
"""List all connected Android devices with their busy / idle status."""
@@ -223,7 +238,6 @@ async def stop_task(
return {"status": "no_running_task"}
@router.post("/api/resume")
async def resume_task():
resumed = task_queue_service.resume_task()
@@ -269,12 +283,14 @@ async def get_status():
(global_owner.session_id if global_owner else None)
or state.active_session_id
or (running_task.get("session_id") if running_task else None)
or (active_tasks[0]["session_id"] if active_tasks and active_tasks[0].get("session_id") else None)
or (
active_tasks[0]["session_id"]
if active_tasks and active_tasks[0].get("session_id")
else None
)
or (session_repo.get_running_session_id() if is_running else None)
)
owner_connection = (
state.active_connections.get(str(running_sid), {}) if running_sid else {}
)
owner_connection = state.active_connections.get(str(running_sid), {}) if running_sid else {}
running_goal = (
owner_connection.get("goal")
or (global_owner.description if global_owner else None)
@@ -283,8 +299,10 @@ async def get_status():
or (active_tasks[0]["goal"] if active_tasks else None)
)
active_profile = owner_connection.get("profile") or state.current_profile or (
running_task.get("profile") if running_task and not global_owner else None
active_profile = (
owner_connection.get("profile")
or state.current_profile
or (running_task.get("profile") if running_task and not global_owner else None)
)
if not active_profile and (running_sid or latest_session_id):
check_sid = running_sid or latest_session_id
@@ -407,7 +425,11 @@ async def stream_events(session_id: str = "active", client: str | None = None):
active_sid = state.active_session_id
if not active_sid:
running_item = next(
(t for t in state.queue_items if isinstance(t, dict) and t.get("status") == "running"),
(
t
for t in state.queue_items
if isinstance(t, dict) and t.get("status") == "running"
),
None,
)
if running_item:
@@ -435,16 +457,14 @@ async def stream_events(session_id: str = "active", client: str | None = None):
recorded_steps = step_repo.get_session_steps(str(active_sid))
for step_dict in recorded_steps:
yield (
"event: step_recorded\n"
f"data: {json.dumps(step_dict, default=str)}\n\n"
f"event: step_recorded\ndata: {json.dumps(step_dict, default=str)}\n\n"
)
except Exception as exc:
print(f"[Stream] Could not replay active steps: {exc}")
else:
for progress_event in state.get_startup_progress(session_id):
yield (
"event: startup_progress\n"
f"data: {json.dumps(progress_event, default=str)}\n\n"
f"event: startup_progress\ndata: {json.dumps(progress_event, default=str)}\n\n"
)
shutdown_waiter = asyncio.create_task(state.shutdown_event.wait())
+5 -2
View File
@@ -21,6 +21,11 @@ class RunRequest(BaseModel):
profile: str | None = "flash"
expected_output: str | None = None
enable_outputter: bool | None = None
# Pro-profile tuning (ignored by the Flash profile): a coarse Checker preset
# ('off' | 'final' | 'checkpoints' | 'strict') and the Explorer perception
# version used by the Operator ('flash' | 'pro' | 'ultra').
verification_level: str | None = None
explorer_mode: str | None = None
locked_app_package: str | None = None
app_path: str | None = None
device_serial: str | None = None
@@ -29,7 +34,6 @@ class RunRequest(BaseModel):
conversation_id: str | None = None
class ReplayRequest(BaseModel):
device_id: str
user_submits: dict
@@ -41,4 +45,3 @@ class StopRequest(BaseModel):
session_id: str | None = None
device_id: str | None = None
all: bool = False
+9 -4
View File
@@ -67,6 +67,7 @@ from artemis.config import (
WORKSPACE_ROOT,
init_ls_address,
)
from artemis.resources import get_bundled_showcase_dist
try:
from admin_console.core.security import SameOriginBoundaryMiddleware
@@ -141,6 +142,9 @@ async def on_startup():
cleaned = session_repo.cleanup_orphans_on_startup()
if cleaned > 0:
print(f"[ServerStartup] Marked {cleaned} orphaned running session(s) as failed.")
# Workers killed together with a previous daemon never remuxed their
# recordings; publish whatever raw files they left behind.
asyncio.create_task(asyncio.to_thread(task_queue_service.recover_orphaned_recordings_on_launch))
await ipc_service.start_server()
state.worker_task = asyncio.create_task(task_queue_service.queue_worker())
@@ -174,9 +178,7 @@ async def on_shutdown():
run_task.cancel()
if run_tasks:
try:
await asyncio.wait_for(
asyncio.gather(*run_tasks, return_exceptions=True), timeout=5.0
)
await asyncio.wait_for(asyncio.gather(*run_tasks, return_exceptions=True), timeout=5.0)
except (TimeoutError, asyncio.CancelledError):
pass
for run in list(state.active_runs.values()):
@@ -231,7 +233,7 @@ except ImportError:
# Showcase UI (Angular 19) Unified Single-Port SPA Static Hosting
# ------------------------------------------------------------------------------
def _get_showcase_dist() -> Path:
"""Return the Showcase UI Angular build directory."""
"""Return the source build, or the immutable build bundled in a wheel."""
base_dist = _workspace_root / "apps" / "showcase_ui" / "dist"
candidates = [
base_dist / "frontend" / "browser",
@@ -242,6 +244,9 @@ def _get_showcase_dist() -> Path:
for candidate in candidates:
if candidate.is_dir() and (candidate / "index.html").exists():
return candidate
bundled_dist = get_bundled_showcase_dist()
if bundled_dist is not None:
return bundled_dist
return base_dist / "frontend" / "browser"
+176 -4
View File
@@ -13,6 +13,7 @@
# limitations under the License.
import base64
from datetime import datetime
import hashlib
import json
import logging
@@ -20,6 +21,7 @@ import os
from pathlib import Path
import subprocess
import threading
import time
from typing import Any
import urllib.parse
from fastapi import HTTPException
@@ -51,13 +53,23 @@ class MediaService:
if suffix == ".mp4":
return p
if suffix in (".mkv", ".webm"):
if cls.is_live_recording(p):
# scrcpy is still appending to this file: converting now
# would freeze a few-seconds partial as recording.mp4.
return p
src_stat = p.stat()
cache_key = str(p)
cached = cls._playable_cache.get(cache_key)
if cached and cached[0] == src_stat.st_mtime and cached[1] == src_stat.st_size:
return Path(cached[2])
mp4_cand = p.with_suffix(".mp4")
if mp4_cand.exists() and mp4_cand.stat().st_size > 0:
if (
mp4_cand.exists()
and mp4_cand.stat().st_size > 0
and mp4_cand.stat().st_mtime >= src_stat.st_mtime
):
# An MP4 older than its source is a stale partial left by a
# mid-run scan; fall through and convert again.
cls._playable_cache[cache_key] = (
src_stat.st_mtime,
src_stat.st_size,
@@ -88,6 +100,7 @@ class MediaService:
@staticmethod
def _convert_to_mp4(p: Path, mp4_cand: Path) -> Path:
from artemis.utils.video import get_ffmpeg_path
ffmpeg = get_ffmpeg_path()
# 1. Attempt fast copy remux
subprocess.run(
@@ -135,6 +148,115 @@ class MediaService:
return mp4_cand
return p
LIVE_RECORDING_GRACE_SECONDS = 10.0
TRACE_STATUS_SUFFIXES = ("_PASS_", "_FAIL_", "_TESTFAIL_")
@classmethod
def is_live_recording(cls, p: Path) -> bool:
"""Whether ``p`` is a raw .mkv that scrcpy is still appending to.
scrcpy touches the file continuously while recording; a raw file that
has not changed for a few seconds belongs to a finished (or killed)
recording and is safe to convert.
"""
if p.suffix.lower() != ".mkv":
return False
try:
return (time.time() - p.stat().st_mtime) < cls.LIVE_RECORDING_GRACE_SECONDS
except OSError:
return False
@staticmethod
def is_browser_playable(p: Path) -> bool:
return p.suffix.lower() in (".mp4", ".webm")
@classmethod
def strip_trace_status_suffix(cls, folder_name: str) -> str:
for marker in cls.TRACE_STATUS_SUFFIXES:
folder_name = folder_name.split(marker)[0]
return folder_name
@classmethod
def _finalize_trace_folder_name(cls, folder: Path, session_start_time: float | None) -> Path:
"""Give an unfinished temp trace folder the terminal name the worker uses."""
try:
if folder.resolve().parent != Path(TRACES_PATH).resolve():
return folder
except OSError:
return folder
if any(marker in folder.name for marker in cls.TRACE_STATUS_SUFFIXES):
return folder
try:
stamp = datetime.fromtimestamp(float(session_start_time or time.time())).strftime(
"%Y-%m-%dT%H-%M-%S"
)
except (TypeError, ValueError, OSError, OverflowError):
stamp = datetime.now().strftime("%Y-%m-%dT%H-%M-%S")
target = folder.with_name(f"{folder.name}_FAIL_{stamp}")
if target.exists():
return folder
try:
folder.rename(target)
except OSError as exc:
logger.warning("Could not rename orphaned trace folder %s: %s", folder, exc)
return folder
return target
@classmethod
def recover_orphaned_recording(
cls,
local_video_path: str | Path | None,
session_start_time: float | None = None,
) -> Path | None:
"""Finalize the recording of a worker that died before its own stop path ran.
A hard-killed worker leaves the raw scrcpy ``recording.mkv`` behind
(playable, but never remuxed) plus, possibly, a few-seconds partial
``recording.mp4`` that a session-list scan converted mid-run. The raw
file is remuxed over that partial and removed on success, and the temp
trace folder is renamed the way the worker would have done it.
Returns the final browser-playable path, or None when nothing usable
exists (the caller then records a terminal recording failure).
"""
if not local_video_path:
return None
source = Path(str(local_video_path))
folder = source.parent
if not folder.is_dir():
return None
raw = source if source.suffix.lower() == ".mkv" else folder / "recording.mkv"
mp4 = folder / "recording.mp4"
def _non_empty(path: Path) -> bool:
try:
return path.is_file() and path.stat().st_size > 0
except OSError:
return False
if _non_empty(raw):
if cls.is_live_recording(raw):
return None
with cls._playable_cache_lock:
converted = cls._convert_to_mp4(raw, mp4)
cls._playable_cache.pop(str(raw), None)
if converted != mp4 or not _non_empty(mp4):
return None
try:
raw.unlink()
except OSError:
pass
final = mp4
elif _non_empty(mp4):
final = mp4
elif _non_empty(source) and cls.is_browser_playable(source):
final = source
else:
return None
folder = cls._finalize_trace_folder_name(folder, session_start_time)
return folder / final.name
@staticmethod
def path_to_video_url(p: Path) -> str:
resolved = p.resolve()
@@ -168,13 +290,18 @@ class MediaService:
vfile = item / f"recording{ext}"
if vfile.exists():
vfile = cls.ensure_browser_playable_video(vfile)
if not cls.is_browser_playable(vfile):
# Live (still recording) or unconvertible raw file.
break
url = cls.path_to_video_url(vfile)
idx[item.name] = url
prefix = item.name.split("_PASS_")[0].split("_FAIL_")[0]
prefix = cls.strip_trace_status_suffix(item.name)
idx[prefix] = url
break
elif item.suffix in [".mp4", ".mkv", ".webm"]:
item = cls.ensure_browser_playable_video(item)
if not cls.is_browser_playable(item):
continue
url = cls.path_to_video_url(item)
idx[item.stem] = url
idx[item.name] = url
@@ -199,6 +326,7 @@ class MediaService:
v_fp = row_dict.get("video_filepath")
if v_fp and os.path.exists(v_fp):
p = cls.ensure_browser_playable_video(Path(v_fp))
if cls.is_browser_playable(p):
v_url = cls.path_to_video_url(p)
orig_rec = video_rec_map.get(s_id)
@@ -206,8 +334,9 @@ class MediaService:
p = Path(orig_rec)
if p.exists():
p = cls.ensure_browser_playable_video(p)
if cls.is_browser_playable(p):
v_url = cls.path_to_video_url(p)
else:
if not v_url:
folder_name = p.parent.name
v_url = video_idx.get(folder_name) or video_idx.get(folder_name.split("_")[0])
@@ -221,6 +350,7 @@ class MediaService:
vfile = sess_dir / f"recording{ext}"
if vfile.exists():
vfile = cls.ensure_browser_playable_video(vfile)
if cls.is_browser_playable(vfile):
v_url = cls.path_to_video_url(vfile)
break
if not v_url:
@@ -228,6 +358,8 @@ class MediaService:
for item in sess_dir.iterdir():
if item.is_file() and item.suffix.lower() in [".mp4", ".webm", ".mkv"]:
item = cls.ensure_browser_playable_video(item)
if not cls.is_browser_playable(item):
continue
v_url = cls.path_to_video_url(item)
break
except OSError:
@@ -260,7 +392,10 @@ class MediaService:
segments = []
for item in payload.get("segments", []):
segment_path = (manifest_path.parent / str(item["file"])).resolve()
if segment_path.parent != manifest_path.parent.resolve() or not segment_path.is_file():
if (
segment_path.parent != manifest_path.parent.resolve()
or not segment_path.is_file()
):
continue
segments.append(
{
@@ -400,6 +535,43 @@ class MediaService:
return f"Error reading task plan: {e}"
return "No task plan created yet."
@staticmethod
def get_session_checks(session_id: str) -> dict[str, Any]:
"""Checker material of one session for UI backfill: the append-only
verdict ledger (``check_ledger.jsonl``) and the machine-readable run
outcome (``run_outcome.json``), both written by
``artemis.graph.checkpoints``. Missing files mean "no checks ran".
"""
session_dir = TRACES_PATH / session_id
records: list[dict[str, Any]] = []
ledger_path = session_dir / "check_ledger.jsonl"
if ledger_path.is_file():
try:
for line in ledger_path.read_text(encoding="utf-8").splitlines():
line = line.strip()
if not line:
continue
try:
rec = json.loads(line)
except ValueError:
continue
if isinstance(rec, dict):
records.append(rec)
except (OSError, ValueError) as e:
logger.warning(f"Failed to read check ledger for {session_id}: {e}")
run_outcome: dict[str, Any] | None = None
outcome_path = session_dir / "run_outcome.json"
if outcome_path.is_file():
try:
loaded = json.loads(outcome_path.read_text(encoding="utf-8"))
if isinstance(loaded, dict):
run_outcome = loaded
except (OSError, ValueError) as e:
logger.warning(f"Failed to read run outcome for {session_id}: {e}")
return {"records": records, "run_outcome": run_outcome}
@staticmethod
def get_session_notes_content(session_id: str) -> dict[str, str]:
notes_dir = TRACES_PATH / session_id / "notes"
+307 -73
View File
@@ -16,8 +16,10 @@ import asyncio
from datetime import datetime
import logging
import os
from pathlib import Path
import shutil
import sys
import threading
import time
from typing import Any
import uuid
@@ -43,8 +45,11 @@ from artemis.runtime import (
AdbEndpoint,
AdbTarget,
DeviceExecutionLock,
clear_cancel_request,
current_adb_endpoint,
pid_is_alive,
process_supervisor,
request_cancel,
trace_store,
)
@@ -59,6 +64,156 @@ class TaskQueueService:
# Strong references to in-flight _execute_task_item tasks (asyncio itself only
# keeps weak references to running tasks).
_run_tasks: set[asyncio.Task] = set()
# Deadline enforcers for graceful stops (see _stop_worker_gracefully).
_forced_stop_tasks: set[asyncio.Task] = set()
DEFAULT_CANCEL_GRACE_SECONDS = 45.0
@classmethod
def _cancel_grace_seconds(cls) -> float:
"""How long a worker may finalize itself before it is killed.
``ARTEMIS_CANCEL_GRACE_SECONDS=0`` restores the legacy immediate kill.
"""
raw = os.getenv("ARTEMIS_CANCEL_GRACE_SECONDS")
if raw is None or not raw.strip():
return cls.DEFAULT_CANCEL_GRACE_SECONDS
try:
return max(0.0, float(raw))
except ValueError:
return cls.DEFAULT_CANCEL_GRACE_SECONDS
@staticmethod
def _hard_kill(pid: int, process_created_at: float = 0.0) -> bool:
if not pid:
return False
if process_created_at and process_created_at > 0:
return process_supervisor.terminate_tree_verified(pid, process_created_at)
try:
return process_supervisor.terminate_tree(pid)
except Exception:
return False
@classmethod
def _stop_worker_gracefully(
cls,
pid: Any,
process_created_at: float = 0.0,
session_id: str | None = None,
reason: str = "Task stopped from the Artemis frontend.",
) -> tuple[bool, bool]:
"""Ask a worker to cancel itself; hard-kill it once the grace period lapses.
Workers are isolated from the daemon's console (and may belong to
another ingress process), so instead of a signal the daemon drops a
cancel marker the worker polls for. Honouring it runs the worker's
normal cancellation path: the screen recording is stopped and remuxed,
the trace folder is compiled, and the device lease is released. A
worker that never picks the marker up is killed after the grace period.
Returns ``(stopped, deferred)``: ``stopped`` mirrors the legacy kill
result, ``deferred`` is True when the kill was handed to the deadline
enforcer instead of happening now.
"""
try:
pid_int = int(pid) if pid else 0
except (TypeError, ValueError):
pid_int = 0
grace = cls._cancel_grace_seconds()
if not pid_int or grace <= 0:
return cls._hard_kill(pid_int, process_created_at), False
created_at = float(process_created_at or 0.0)
if created_at <= 0:
# PID markers carry the creation time so a leftover marker can never
# cancel a future process that reuses this PID.
try:
import psutil
created_at = float(psutil.Process(pid_int).create_time())
except Exception:
created_at = 0.0
if not pid_is_alive(pid_int, created_at or None):
return True, False
written = request_cancel(
session_id=str(session_id) if session_id else None,
pid=pid_int,
process_created_at=created_at,
reason=reason,
)
if not written:
return cls._hard_kill(pid_int, created_at), False
print(
f"[stop_tasks] Cancel requested for worker {pid_int}"
f" (session {session_id or 'n/a'}); forcing termination after {grace:.0f}s"
)
cls._schedule_forced_stop(pid_int, created_at, grace, session_id)
return True, True
@classmethod
def _stop_proc_gracefully(cls, proc: Any, session_id: Any) -> bool:
"""Graceful variant for a locally spawned process; True when deferred."""
pid = getattr(proc, "pid", None)
if not pid or cls._cancel_grace_seconds() <= 0:
return False
try:
_stopped, deferred = cls._stop_worker_gracefully(
pid, session_id=str(session_id) if session_id else None
)
except Exception:
return False
return deferred
@classmethod
def _schedule_forced_stop(
cls, pid: int, process_created_at: float, grace: float, session_id: Any
) -> None:
"""Kill ``pid`` if it is still alive once ``grace`` seconds have passed."""
def _still_alive() -> bool:
return pid_is_alive(pid, process_created_at or None)
def _force() -> None:
print(
f"[stop_tasks] Worker {pid} (session {session_id or 'n/a'}) did not exit"
f" within {grace:.0f}s of the cancel request; forcing termination."
)
cls._hard_kill(pid, process_created_at)
async def _enforce_async() -> None:
deadline = time.monotonic() + grace
while time.monotonic() < deadline:
if not _still_alive():
break
await asyncio.sleep(0.5)
else:
_force()
clear_cancel_request(session_id=str(session_id) if session_id else None, pid=pid)
def _enforce_sync() -> None:
deadline = time.monotonic() + grace
while time.monotonic() < deadline:
if not _still_alive():
break
time.sleep(0.5)
else:
_force()
clear_cancel_request(session_id=str(session_id) if session_id else None, pid=pid)
try:
loop = asyncio.get_running_loop()
except RuntimeError:
loop = None
if loop is not None:
task = loop.create_task(_enforce_async())
cls._forced_stop_tasks.add(task)
task.add_done_callback(cls._forced_stop_tasks.discard)
return
threading.Thread(
target=_enforce_sync, name=f"artemis-forced-stop-{pid}", daemon=True
).start()
@staticmethod
def _task_target(task_item: dict[str, Any]) -> AdbTarget:
@@ -88,9 +243,7 @@ class TaskQueueService:
)
@classmethod
def _broadcast_startup_progress(
cls, session_id: str | None, stage: str, message: str
) -> None:
def _broadcast_startup_progress(cls, session_id: str | None, stage: str, message: str) -> None:
if not session_id:
return
data = {
@@ -223,8 +376,7 @@ class TaskQueueService:
inside each worker process.
"""
print(
"[QueueWorker] Dispatcher initialized "
f"(concurrency mode: {cls._concurrency_mode()})."
f"[QueueWorker] Dispatcher initialized (concurrency mode: {cls._concurrency_mode()})."
)
try:
DeviceExecutionLock.cleanup_stale_locks()
@@ -262,9 +414,7 @@ class TaskQueueService:
# "running" state, so admission must count those too -- active_runs alone
# lags behind by the subprocess startup latency.
in_flight = [
i
for i in state.queue_items
if isinstance(i, dict) and i.get("status") == "running"
i for i in state.queue_items if isinstance(i, dict) and i.get("status") == "running"
]
busy_devices = state.busy_device_ids | {
cls._task_target(i).lock_key for i in in_flight if i.get("device_serial")
@@ -325,9 +475,7 @@ class TaskQueueService:
state.current_profile = profile
state.active_session_id = sess_id
cls._broadcast_startup_progress(
sess_id, "launching", "Starting the execution process"
)
cls._broadcast_startup_progress(sess_id, "launching", "Starting the execution process")
# Broadcast session_started so all connected clients know the task has started
cls._broadcast_event(
@@ -353,6 +501,8 @@ class TaskQueueService:
"""Assemble the worker subprocess command line and environment."""
expected_output = task_item.get("expected_output")
enable_outputter = task_item.get("enable_outputter")
verification_level = task_item.get("verification_level")
explorer_mode = task_item.get("explorer_mode")
locked_app = task_item.get("locked_app_package") or task_item.get("locked_app")
app_path = task_item.get("app_path")
@@ -395,6 +545,10 @@ class TaskQueueService:
cmd.extend(["--output-description", str(expected_output)])
if enable_outputter is not None:
cmd.append("--enable-outputter" if enable_outputter else "--disable-outputter")
if verification_level:
cmd.extend(["--verification-level", str(verification_level)])
if explorer_mode:
cmd.extend(["--explorer-pro-mode", str(explorer_mode)])
if locked_app:
cmd.extend(["--locked-app", str(locked_app)])
if app_path:
@@ -439,9 +593,7 @@ class TaskQueueService:
print(
f"[QueueWorker] Could not record worker pid in status.json for {sess_id}: {exc}"
)
cls._broadcast_startup_progress(
sess_id, "process_ready", "Execution process started"
)
cls._broadcast_startup_progress(sess_id, "process_ready", "Execution process started")
ingress_type = str(task_item.get("ingress", "frontend"))
DeviceExecutionLock.transfer_reservation(
str(task_item.get("queue_ticket")),
@@ -467,9 +619,7 @@ class TaskQueueService:
except Exception:
log_path = None
if isinstance(proc.stdout, asyncio.StreamReader):
return asyncio.create_task(
cls._forward_worker_output(proc.stdout, log_path)
)
return asyncio.create_task(cls._forward_worker_output(proc.stdout, log_path))
return None
@classmethod
@@ -481,9 +631,7 @@ class TaskQueueService:
str(sess_id) in getattr(state, "cancelled_session_ids", set())
or run_key in state.manually_stopped_run_ids
):
print(
f"[QueueWorker] Task [{sess_id}] was cancelled during launch. Terminating."
)
print(f"[QueueWorker] Task [{sess_id}] was cancelled during launch. Terminating.")
await cls._terminate_worker_process(proc)
@classmethod
@@ -505,23 +653,21 @@ class TaskQueueService:
# against when status.json is stale, so a failed DB
# write must not pass silently.
logger.error(
"[QueueWorker] Could not persist terminal DB status "
"'%s' for session %s",
"[QueueWorker] Could not persist terminal DB status '%s' for session %s",
new_status,
sess_id,
)
else:
print(
f"[QueueWorker] Preserved authoritative session {sess_id} "
f"status '{new_status}'"
)
print(f"[QueueWorker] Preserved authoritative session {sess_id} status '{new_status}'")
# A stale status.json would leave MCP pollers seeing "running"
# until their next DB reconcile, so retry transient write
# failures before giving up.
for attempt in range(3):
try:
if trace_store.read_status(str(sess_id)):
canonical_mcp_status = "completed" if new_status in ("completed", "success") else new_status
canonical_mcp_status = (
"completed" if new_status in ("completed", "success") else new_status
)
trace_store.update_trace_status(
str(sess_id),
canonical_mcp_status,
@@ -542,31 +688,62 @@ class TaskQueueService:
@classmethod
async def _recover_or_fail_recording(cls, sess_id: Any) -> None:
"""Attempt recovery of a not-yet-ready recording, else mark it failed."""
"""Finalize a recording whose worker died before it could, else mark it failed.
A worker that stops gracefully finalizes its own recording and this is
a no-op. A hard-killed (or crashed) worker leaves the raw scrcpy file it
registered at recording start; remuxing that file is all that is
needed to publish the full video.
"""
rec_info = session_repo.get_video_recording_for_session(sess_id)
rec_status = (rec_info or {}).get("status")
recovered_video_path = None
if rec_status != "ready":
if rec_status == "ready":
return
recovered_url = None
# 1. Direct recovery from the recording row written at recording start.
try:
local_video_path = (rec_info or {}).get("local_video_path")
if local_video_path:
start_time = (rec_info or {}).get("start_time")
if not start_time:
session_row = session_repo.get_session_by_id(sess_id)
start_time = dict(session_row).get("start_time") if session_row else None
final_path = await asyncio.to_thread(
media_service.recover_orphaned_recording,
local_video_path,
start_time,
)
if final_path:
session_repo.mark_recording_ready(sess_id, str(final_path))
recovered_url = media_service.path_to_video_url(Path(final_path))
except Exception as rec_err:
print(f"[QueueWorker] Error finalizing orphaned recording: {rec_err}")
# 2. Fallback: locate an already finalized file by folder / session naming.
if not recovered_url:
try:
video_rec_map = session_repo.get_video_recordings_map()
video_idx = await asyncio.to_thread(media_service.build_video_index)
recovered_url = await asyncio.to_thread(
fallback_url = await asyncio.to_thread(
media_service.resolve_video_url,
{"session_id": sess_id},
video_rec_map,
video_idx,
)
if fallback_url:
session_repo.mark_recording_ready(sess_id, fallback_url)
recovered_url = fallback_url
except Exception as rec_err:
print(f"[QueueWorker] Error attempting recording recovery: {rec_err}")
if recovered_url:
session_repo.mark_recording_ready(sess_id, recovered_url)
cls._broadcast_event(
"recording_ready",
{"session_id": sess_id, "video_url": recovered_url},
)
recovered_video_path = recovered_url
except Exception as rec_err:
print(f"[QueueWorker] Error attempting recording recovery: {rec_err}")
return
if not recovered_video_path and rec_status != "ready":
recording_error = "Task worker exited before recording finalization completed"
if session_repo.mark_recording_failed_if_pending(sess_id, recording_error):
cls._broadcast_event(
@@ -624,6 +801,15 @@ class TaskQueueService:
state.cancelled_session_ids.discard(str(sess_id))
state.cancelled_session_ids.discard(run_key)
state.manually_stopped_run_ids.discard(run_key)
try:
clear_cancel_request(
session_id=str(sess_id) if sess_id else None,
pid=getattr(proc, "pid", None),
)
except (OSError, TypeError, ValueError):
# Marker cleanup is best-effort: an unwritable temp dir or an odd
# pid value must not block releasing the run slot.
pass
state.active_runs.pop(run_key, None)
if proc is not None and state.current_process is proc:
state.current_process = None
@@ -665,9 +851,7 @@ class TaskQueueService:
env=env,
**cls._subprocess_creation_kwargs(),
)
cls._register_worker_run(
task_item, run_key, sess_id, goal, profile, target, proc
)
cls._register_worker_run(task_item, run_key, sess_id, goal, profile, target, proc)
output_task = cls._start_output_forwarder(sess_id, proc)
await cls._terminate_if_cancelled_during_launch(run_key, sess_id, proc)
@@ -685,9 +869,7 @@ class TaskQueueService:
sess_id, returncode, manual_stop
)
await cls._recover_or_fail_recording(sess_id)
cls._announce_session_end(
task_item, sess_id, goal, new_status, manual_stop
)
cls._announce_session_end(task_item, sess_id, goal, new_status, manual_stop)
except asyncio.CancelledError:
print(f"[QueueWorker] Task [{sess_id}] received cancellation signal.")
@@ -763,9 +945,7 @@ class TaskQueueService:
return None
@classmethod
async def _reject_unavailable_device(
cls, device_serial: str | None
) -> dict[str, Any] | None:
async def _reject_unavailable_device(cls, device_serial: str | None) -> dict[str, Any] | None:
"""Return the rejection response for an unattached explicit serial, if any."""
# Strict device binding: reject an explicitly requested serial that is not
# attached and authorized, instead of silently running on another device.
@@ -804,6 +984,8 @@ class TaskQueueService:
device_serial: str | None,
ingress: str,
conversation_id: str | None,
verification_level: str | None = None,
explorer_mode: str | None = None,
) -> dict[str, Any]:
"""Reserve a device slot and build one pending queue item for a goal."""
sess_id = single_session_id if single_session_id else str(uuid.uuid4())
@@ -829,6 +1011,8 @@ class TaskQueueService:
"profile": profile or "flash",
"expected_output": expected_output,
"enable_outputter": enable_outputter,
"verification_level": verification_level,
"explorer_mode": explorer_mode,
"locked_app_package": locked_app_package,
"app_path": app_path,
"device_serial": assigned_serial,
@@ -854,8 +1038,19 @@ class TaskQueueService:
ingress: str = "frontend",
session_id: str | None = None,
conversation_id: str | None = None,
verification_level: str | None = None,
explorer_mode: str | None = None,
) -> dict[str, Any]:
"""Enqueues one or more goals and wakes up the background worker."""
"""Enqueues one or more goals and wakes up the background worker.
``verification_level`` and ``explorer_mode`` are Pro-profile tuning knobs
forwarded to the worker as ``--verification-level`` / ``--explorer-pro-mode``;
they are normalised here so the queue item and the CLI see one spelling.
"""
verification_level = (
str(verification_level).strip().lower() or None if verification_level else None
)
explorer_mode = str(explorer_mode).strip().lower() or None if explorer_mode else None
cls.ensure_worker_running()
enqueued_tasks = []
@@ -888,6 +1083,8 @@ class TaskQueueService:
device_serial,
ingress,
conversation_id,
verification_level=verification_level,
explorer_mode=explorer_mode,
)
state.queue_items.append(task_item)
enqueued_tasks.append(task_item)
@@ -931,9 +1128,10 @@ class TaskQueueService:
for dev_owner in list(active_owners.values()):
if dev_owner and DeviceExecutionLock.is_active_owner(dev_owner):
process_supervisor.terminate_tree_verified(
cls._stop_worker_gracefully(
dev_owner.pid,
dev_owner.process_created_at,
session_id=dev_owner.session_id,
)
DeviceExecutionLock.cleanup_stale_locks(dev_owner.device_id)
sid = dev_owner.session_id
@@ -973,6 +1171,7 @@ class TaskQueueService:
state.cancelled_session_ids.add(str(run_key))
run_proc = run.get("process")
if run_proc is not None and run_proc.returncode is None:
if not cls._stop_proc_gracefully(run_proc, run.get("session_id") or run_key):
try:
run_proc.kill()
except (ProcessLookupError, OSError):
@@ -982,6 +1181,7 @@ class TaskQueueService:
# process exit is observed; clearing them here would free the device
# slots before the processes are actually gone.
if state.current_process:
if not cls._stop_proc_gracefully(state.current_process, state.active_session_id):
try:
state.current_process.kill()
except (ProcessLookupError, OSError):
@@ -1167,19 +1367,24 @@ class TaskQueueService:
stopped = False
reservation_cancelled = False
if owner and DeviceExecutionLock.is_active_owner(owner):
stopped = process_supervisor.terminate_tree_verified(
stopped, _deferred = cls._stop_worker_gracefully(
owner.pid,
owner.process_created_at,
session_id=owner.session_id or target_sid,
)
DeviceExecutionLock.cleanup_stale_locks(owner.device_id)
elif owner is None and owner_record_exists and not target_sid:
# Never fall back to a frontend PID while another process has an
# owner record that is still being published or cannot be parsed.
return None
elif owner is None and local_proc is not None and (
elif (
owner is None
and local_proc is not None
and (
local_run is not None
or not target_sid
or str(state.active_session_id) == target_sid
)
):
# The locally-managed worker can be stopped during its short
# initialization window before Agent acquires the device lease.
@@ -1187,21 +1392,28 @@ class TaskQueueService:
state.cancelled_session_ids.add(target_sid)
elif local_run_key is not None:
state.manually_stopped_run_ids.add(str(local_run_key))
deferred = False
if local_pid:
try:
stopped = process_supervisor.terminate_tree(local_pid)
stopped, deferred = cls._stop_worker_gracefully(
local_pid,
session_id=target_sid or (local_run or {}).get("session_id"),
)
except Exception:
stopped = False
if not deferred:
try:
local_proc.kill()
stopped = True
except (ProcessLookupError, OSError):
# Process already exited; terminate_tree above may have got it.
# Process already exited; the stop above may have got it.
pass
stopped = True
is_local_owner = True
elif owner is None and local_item and (
not target_sid or str(local_item.get("session_id")) == target_sid
elif (
owner is None
and local_item
and (not target_sid or str(local_item.get("session_id")) == target_sid)
):
# Cancel a frontend submission before its worker has started. This
# does not touch pending reservations created by other ingresses.
@@ -1226,12 +1438,10 @@ class TaskQueueService:
row_pid = row.get("pid")
if row_pid and session_repo.process_is_alive(row_pid):
try:
process_supervisor.terminate_tree(int(row_pid))
cls._stop_worker_gracefully(int(row_pid), session_id=target_sid)
except Exception as exc:
# Report it: a surviving worker keeps the device busy.
print(
f"[stop_tasks] Could not terminate worker pid {row_pid}: {exc}"
)
print(f"[stop_tasks] Could not terminate worker pid {row_pid}: {exc}")
DeviceExecutionLock.cleanup_stale_locks()
stopped = True
return stopped, is_local_owner, reservation_cancelled
@@ -1264,9 +1474,7 @@ class TaskQueueService:
state.active_connections.pop(sid, None)
if stopped_session_id:
session_repo.update_session_status(
str(stopped_session_id), "cancelled", time.time()
)
session_repo.update_session_status(str(stopped_session_id), "cancelled", time.time())
try:
is_mcp = bool(owner and owner.ingress == "mcp")
if is_mcp or trace_store.read_status(str(stopped_session_id)):
@@ -1287,8 +1495,7 @@ class TaskQueueService:
)
if state.active_session_id and (
not stopped_session_id
or str(state.active_session_id) == str(stopped_session_id)
not stopped_session_id or str(state.active_session_id) == str(stopped_session_id)
):
state.active_session_id = None
state.current_goal = None
@@ -1305,8 +1512,7 @@ class TaskQueueService:
(
item
for item in state.queue_items
if isinstance(item, dict)
and str(item.get("session_id")) == str(stopped_session_id)
if isinstance(item, dict) and str(item.get("session_id")) == str(stopped_session_id)
),
None,
)
@@ -1316,15 +1522,12 @@ class TaskQueueService:
item
for item in state.queue_items
if not (
isinstance(item, dict)
and str(item.get("session_id")) == str(stopped_session_id)
isinstance(item, dict) and str(item.get("session_id")) == str(stopped_session_id)
)
]
@classmethod
def _stop_targeted_task(
cls, target_sid: str | None, target_device: str | None
) -> bool:
def _stop_targeted_task(cls, target_sid: str | None, target_device: str | None) -> bool:
"""Stop a specific task (or default single-device active task)."""
active_owners = {}
try:
@@ -1338,9 +1541,7 @@ class TaskQueueService:
owner_record_exists = DeviceExecutionLock.has_owner_record(target_device)
owner_pid = owner.pid if owner else None
local_run_key, local_run, local_proc = cls._resolve_local_run(
target_sid, target_device
)
local_run_key, local_run, local_proc = cls._resolve_local_run(target_sid, target_device)
local_pid = getattr(local_proc, "pid", None)
is_local_owner = bool(owner_pid and local_pid and owner_pid == local_pid)
@@ -1409,7 +1610,6 @@ class TaskQueueService:
return cls._stop_targeted_task(target_sid, target_device)
@classmethod
def resume_task(cls) -> bool:
if PAUSE_FILE.exists():
@@ -1417,6 +1617,40 @@ class TaskQueueService:
return True
return False
@classmethod
def recover_orphaned_recordings_on_launch(cls) -> int:
"""Finalize recordings left behind by workers that died with the daemon.
The per-run finalizer handles workers that exit while the daemon is up;
this sweep covers everything else (daemon crash, machine reboot, tasks
cancelled before this recovery path existed). Returns the number of
recordings published.
"""
recovered = 0
try:
pending = session_repo.get_unfinalized_video_recordings()
except Exception as exc:
print(f"[ServerStartup] Could not enumerate unfinalized recordings: {exc}")
return 0
for row in pending:
sess_id = row.get("session_id")
try:
final_path = media_service.recover_orphaned_recording(
row.get("local_video_path"),
row.get("start_time") or row.get("session_start_time"),
)
except Exception as exc:
print(f"[ServerStartup] Recording recovery failed for {sess_id}: {exc}")
continue
if not final_path:
continue
if session_repo.mark_recording_ready(str(sess_id), str(final_path)):
recovered += 1
print(f"[ServerStartup] Recovered recording for session {sess_id}: {final_path}")
if recovered:
print(f"[ServerStartup] Recovered {recovered} orphaned recording(s).")
return recovered
@staticmethod
def archive_older_replays_on_launch():
"""Archives all existing step replay output folders on server launch."""
@@ -47,9 +47,7 @@ def subprocess_creation_kwargs() -> dict[str, Any]:
"stderr": asyncio.subprocess.STDOUT,
}
if sys.platform == "win32":
kwargs["creationflags"] = (
subprocess.CREATE_NEW_PROCESS_GROUP | subprocess.CREATE_NO_WINDOW
)
kwargs["creationflags"] = subprocess.CREATE_NEW_PROCESS_GROUP | subprocess.CREATE_NO_WINDOW
return kwargs
@@ -277,7 +277,7 @@ limitations under the License.
}
</div>
}
<!-- Verification Box (Pulled out into a minimalist framed card) -->
<!-- Verification result -->
@if (milestone.checks.length > 0) {
<div class="milestone-checks-container">
@for (check of milestone.checks; track trackMarkdownLine($index, check)) {
@@ -505,7 +505,7 @@ limitations under the License.
class="phase-header"
[class.first-phase]="first && startupWorkItems().length === 0"
>
<span class="phase-worked-time">Worked for {{ phase.durationSeconds }}s</span>
<span class="phase-worked-time">{{ isCheckerPhase(phase) ? 'Checked' : 'Worked' }} for {{ phase.durationSeconds }}s</span>
@if (phase.tokens) {
<span class="phase-token-usage" [title]="getTokenTooltip(phase)">
<span class="token-dot">·</span>
@@ -524,12 +524,68 @@ limitations under the License.
>
<!-- Unified Step / Stream Body -->
<div class="step-body">
<!-- Check block (verifier lane): header, then the declared check items
and how each is judged; the chronological stream follows, and the
result rows close the block (see check-result below). -->
@if (isCheckerBlock(block)) {
<div class="check-lane" [ngClass]="'tone-' + getCheckerTone(block)">
<div class="check-header">
<span class="stream-status-dot" [class.streaming]="isBlockRunning(block)"></span>
<span class="check-title">{{ getCheckerPhaseLabel(block) }}</span>
@if (block.data.phase !== 'outcome' && block.data.subgoal_text) {
<span class="check-subject" [title]="block.data.subgoal_text">{{ block.data.subgoal_text }}</span>
}
<span class="check-status">{{ getCheckerStatusLabel(block) }}</span>
</div>
@if (!isCheckerMuted(block) && getCheckerItems(block).length > 0) {
<ol class="check-items">
@for (item of getCheckerItems(block); track $index) {
<li class="check-item">
<span class="check-index">{{ $index + 1 }}</span>
<span class="check-text">{{ item.text }}</span>
<span class="check-method">{{ getCheckMethodLabel(item, block) }}</span>
</li>
}
</ol>
}
</div>
}
<!-- Text, tool calls, and actions share one chronological timeline. -->
@if (getSortedStepEvents(block.data).length > 0) {
<div class="step-actions-container">
@for (item of getSortedStepEvents(block.data); track trackStepEvent(itemIdx, item); let itemIdx = $index) {
<!-- Check block: one Thought / Work entry per streamed turn, in time order with its tool calls -->
@if (isCheckerBlock(block) && item.type === 'thinking') {
<div
class="thinking-stream-details native-thinking-details"
[class.is-running]="isCheckerSegmentRunning(block, itemIdx)"
[class.collapsed]="isCheckerSegmentCollapsed(block, itemIdx)">
<div class="details-summary" (click)="toggleCheckerSegment(block, itemIdx)">
<span class="stream-status-dot" [class.streaming]="isCheckerSegmentRunning(block, itemIdx)"></span>
Thought
</div>
<div class="details-content-wrapper">
<div class="details-content stream-box">
<div class="stream-text" [innerHTML]="renderMarkdown(item.data.text, block.id + '-seg-' + itemIdx)"></div>
</div>
</div>
</div>
}
@if (isCheckerBlock(block) && item.type === 'text' && isCheckerTextVisible(item)) {
<div
class="step-raw-thinking-message"
[class.is-running]="isCheckerSegmentRunning(block, itemIdx)">
<div class="report-header">
<span class="stream-status-dot" [class.streaming]="isCheckerSegmentRunning(block, itemIdx)"></span>
Work
</div>
<div class="report-content stream-box">
<div class="stream-text" [innerHTML]="renderMarkdown(item.data.text, block.id + '-seg-' + itemIdx)"></div>
</div>
</div>
}
<!-- Native Thinking (Thought) -->
@if (item.type === 'thinking') {
@if (!isCheckerBlock(block) && item.type === 'thinking') {
<div
class="thinking-stream-details native-thinking-details"
[class.is-running]="isBlockRunning(block)"
@@ -546,7 +602,7 @@ limitations under the License.
</div>
}
<!-- Raw Thinking / Plan / Message (Work) -->
@if (item.type === 'text' && getRawThinking(block)) {
@if (!isCheckerBlock(block) && item.type === 'text' && getRawThinking(block)) {
<div
class="step-raw-thinking-message"
[class.is-running]="isBlockRunning(block)">
@@ -1093,6 +1149,26 @@ limitations under the License.
}
</div>
}
<!-- Check block result: one row per check item, keyed by its number above -->
@if (hasCheckerResult(block)) {
<div class="check-result" [ngClass]="'tone-' + getCheckerTone(block)">
@for (verdict of getCheckerVerdicts(block); track $index) {
<div class="check-verdict" [ngClass]="verdict.status">
<span class="check-index">{{ getVerdictItemNumber(block, verdict, $index) }}</span>
<span class="material-symbols-outlined verdict-icon">{{ getVerdictIcon(verdict.status) }}</span>
<span class="verdict-body">
<span class="verdict-evidence">{{ verdict.evidence || getVerdictStatusText(verdict.status) }}</span>
@if (verdict.suggestion) {
<span class="verdict-suggestion">{{ verdict.suggestion }}</span>
}
</span>
</div>
}
@for (note of getCheckerNotes(block); track note) {
<div class="check-note">{{ note }}</div>
}
</div>
}
</div>
</div>
}
@@ -3558,5 +3558,162 @@ $stream-row-height: 25px;
}
}
/* Checker blocks share the step timeline, marked by a violet left border. */
.agent-stream-container .stream-logs-content .phase-body .block-item.type-checker {
border-left: 2px solid #ddd6fe;
padding-left: 12px;
margin-left: -14px;
font-family: 'Google Sans', 'Google Sans Text', 'Inter', Arial, sans-serif;
.check-index {
flex: 0 0 14px;
text-align: right;
color: #a1a1aa;
font-variant-numeric: tabular-nums;
user-select: none;
}
.check-lane {
display: flex;
flex-direction: column;
gap: $uniform-gap;
margin-bottom: $uniform-gap;
.check-header {
display: flex;
align-items: center;
gap: 8px;
min-height: 20px;
font-size: 12px;
line-height: 1.5;
letter-spacing: 0.2px;
color: #71717a;
user-select: none;
.stream-status-dot {
flex: 0 0 auto;
width: 8px;
height: 8px;
border-radius: 50%;
display: inline-block;
background: #d4d4d8;
&.streaming {
background: #7c3aed;
box-shadow: 0 0 0 0 rgba(124, 58, 237, 0.45);
animation: pulse-stream-dot 1.8s cubic-bezier(0.4, 0, 0.6, 1) infinite;
}
}
.check-title {
flex: 0 0 auto;
font-weight: 500;
color: #52525b;
}
.check-subject {
flex: 1 1 auto;
min-width: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.check-status {
flex: 0 0 auto;
margin-left: auto;
font-weight: 500;
color: #52525b;
white-space: nowrap;
}
}
&.tone-passed .check-status { color: #15803d; }
&.tone-failed .check-status { color: #b91c1c; }
&.tone-inconclusive .check-status { color: #b45309; }
&.tone-running .check-status { color: #6d28d9; }
&.tone-muted { opacity: 0.6; }
.check-items {
list-style: none;
margin: 0;
padding: 0 0 0 16px;
display: flex;
flex-direction: column;
gap: 2px;
.check-item {
display: flex;
align-items: baseline;
gap: 8px;
font-size: 12px;
line-height: 1.5;
color: #3f3f46;
.check-text {
flex: 1 1 auto;
min-width: 0;
word-break: break-word;
}
.check-method {
flex: 0 0 auto;
color: #a1a1aa;
white-space: nowrap;
}
}
}
}
.check-result {
display: flex;
flex-direction: column;
gap: 4px;
margin-top: $uniform-gap;
padding-left: 16px;
.check-verdict {
display: flex;
align-items: flex-start;
gap: 8px;
font-size: 12px;
line-height: 1.5;
color: #52525b;
.check-index {
line-height: 18px;
}
.verdict-icon {
flex: 0 0 auto;
font-size: 15px;
line-height: 18px;
color: #a1a1aa;
}
&.passed .verdict-icon { color: #16a34a; }
&.failed .verdict-icon { color: #dc2626; }
&.inconclusive .verdict-icon { color: #d97706; }
&.superseded, &.unchecked { color: #a1a1aa; }
.verdict-body {
flex: 1 1 auto;
min-width: 0;
word-break: break-word;
}
.verdict-suggestion {
display: block;
color: #1d4ed8;
}
}
.check-note {
font-size: 12px;
line-height: 1.5;
color: #71717a;
padding-left: 22px;
word-break: break-word;
}
}
}
@@ -470,6 +470,8 @@ export class AgentStreamComponent implements AfterViewInit {
effect(() => {
const blocks = this.consolidatedBlocks();
blocks.forEach(block => {
// Check blocks render their per-turn segments directly (no typewriter).
if (block.type === 'checker') return;
const blockId = block.id;
const rawText = this.getRawThinking(block) || '';
const nativeText = this.getNativeThinking(block) || '';
@@ -1162,8 +1164,187 @@ export class AgentStreamComponent implements AfterViewInit {
return this.getThinkingTexts(block.data).raw;
}
// --- Check blocks (verifier lane) ----------------------------------------------
public isCheckerBlock(block: any): boolean {
return block?.type === 'checker';
}
public isCheckerPhase(phase: any): boolean {
const blocks: any[] = Array.isArray(phase?.blocks) ? phase.blocks : [];
return blocks.length > 0 && blocks.every((b) => this.isCheckerBlock(b));
}
public getCheckerPhaseLabel(block: any): string {
switch (block?.data?.phase) {
case 'final':
return 'Final check';
case 'outcome':
return 'Result';
default:
return 'Check';
}
}
public getCheckerVerdicts(block: any): any[] {
return Array.isArray(block?.data?.verdicts) ? block.data.verdicts : [];
}
/** Declared check items; persisted attempts only carry verdicts, so derive them. */
public getCheckerItems(block: any): any[] {
const d = block?.data || {};
if (Array.isArray(d.items) && d.items.length > 0) return d.items;
return this.getCheckerVerdicts(block).map((v) => ({ kind: v.kind, text: v.item_text, when: v.when }));
}
/** How the item is judged, in plain words (verify = must pass, assert = recorded test result). */
public getCheckMethodLabel(item: any, block: any): string {
const parts: string[] = [item?.kind === 'assert' ? 'Test assertion' : 'Must pass'];
if (item?.when === 'at_end' && block?.data?.phase !== 'final') parts.push('at the end');
return parts.join(' · ');
}
/** Superseded / unchecked attempts collapse to their header line. */
public isCheckerMuted(block: any): boolean {
const status = block?.data?.status;
return status === 'superseded' || status === 'unchecked';
}
/** Number of the check item a verdict belongs to (1-based, matches the list above the stream). */
public getVerdictItemNumber(block: any, verdict: any, fallbackIndex: number): number {
const idx = this.getCheckerItems(block).findIndex((i) => i.text === verdict?.item_text);
return (idx > -1 ? idx : fallbackIndex) + 1;
}
public getVerdictStatusText(status: string): string {
switch (status) {
case 'passed':
return 'Passed';
case 'failed':
return 'Failed';
case 'inconclusive':
return 'Could not be confirmed';
case 'superseded':
return 'Superseded by a newer check';
case 'unchecked':
return 'Not checked';
default:
return status || '';
}
}
public hasCheckerResult(block: any): boolean {
if (!this.isCheckerBlock(block) || this.isBlockRunning(block) || this.isCheckerMuted(block)) return false;
if (block.data?.phase === 'outcome') return this.getCheckerNotes(block).length > 0;
return this.getCheckerVerdicts(block).length > 0 || this.getCheckerNotes(block).length > 0;
}
/** Follow-ups not already visible in the verdict rows (the findings text repeats them). */
public getCheckerNotes(block: any): string[] {
const d = block?.data || {};
if (d.phase === 'outcome') return Array.isArray(d.last_findings) ? d.last_findings : [];
const notes: string[] = [];
if (Array.isArray(d.unmet_subgoals)) {
for (const text of d.unmet_subgoals) notes.push(`Not finished yet: ${text}`);
}
if (d.reverted) notes.push('The step was sent back to be redone.');
if (d.error) notes.push(String(d.error));
return notes;
}
// Streamed turns of a check: the last one is live while the block runs;
// finished Thought turns fold unless the reader opens them.
public openCheckSegments = signal<Set<string>>(new Set<string>());
public isCheckerSegmentRunning(block: any, itemIdx: number): boolean {
if (!this.isBlockRunning(block)) return false;
return itemIdx === this.getSortedStepEvents(block.data).length - 1;
}
public isCheckerSegmentCollapsed(block: any, itemIdx: number): boolean {
if (this.isCheckerSegmentRunning(block, itemIdx)) return false;
return !this.openCheckSegments().has(`${block.id}-${itemIdx}`);
}
public toggleCheckerSegment(block: any, itemIdx: number): void {
const key = `${block.id}-${itemIdx}`;
this.openCheckSegments.update((set) => {
const next = new Set(set);
if (next.has(key)) next.delete(key); else next.add(key);
return next;
});
}
public isCheckerTextVisible(item: any): boolean {
const text = item?.data?.text;
return typeof text === 'string' && text.trim().length > 0 && this.isHumanThinking(text);
}
public getCheckerTone(block: any): string {
const d = block?.data || {};
if (d.phase === 'outcome') {
return d.task_status === 'completed' ? 'passed' : (d.task_status === 'blocked' ? 'failed' : 'inconclusive');
}
if (d.isCompleted === false) return 'running';
if (d.status === 'superseded' || d.status === 'unchecked') return 'muted';
if (d.status === 'error') return 'inconclusive';
const verdicts = this.getCheckerVerdicts(block);
if (verdicts.some((v) => v.status === 'failed')) return 'failed';
if (verdicts.some((v) => v.status === 'inconclusive')) return 'inconclusive';
return 'passed';
}
public getCheckerStatusLabel(block: any): string {
const d = block?.data || {};
if (d.phase === 'outcome') {
const t = d.tests || {};
const label = d.task_status === 'completed' ? 'Goal completed'
: (d.task_status === 'blocked' ? 'Blocked' : 'Partially completed');
const counts = [
[t.passed, 'passed'],
[t.failed, 'failed'],
[t.inconclusive, 'inconclusive'],
[t.unchecked, 'not checked']
]
.filter(([n]) => Number(n) > 0)
.map(([n, word]) => `${n} ${word}`);
return counts.length > 0 ? `${label} · ${counts.join(' · ')}` : label;
}
if (d.isCompleted === false) return 'Checking…';
switch (d.status) {
case 'superseded':
return 'Superseded';
case 'unchecked':
return 'Not checked';
case 'error':
return 'No verdict';
}
const verdicts = this.getCheckerVerdicts(block);
const failed = verdicts.filter((v) => v.status === 'failed').length;
if (failed > 0) return `${failed} failed`;
if (verdicts.some((v) => v.status === 'inconclusive')) return 'Inconclusive';
return verdicts.length > 0 ? 'Passed' : 'Done';
}
public getVerdictIcon(status: string): string {
switch (status) {
case 'passed':
return 'check_circle';
case 'failed':
return 'cancel';
case 'inconclusive':
return 'help';
case 'superseded':
return 'history';
case 'unchecked':
return 'radio_button_unchecked';
default:
return 'pending';
}
}
public hasVisibleContent(block: any): boolean {
if (!block) return false;
if (block.type === 'checker') return true;
const hasNative = Boolean(this.getNativeThinking(block));
const hasRaw = Boolean(this.getRawThinking(block));
const hasVisibleTools = Boolean(block.data?.generic_tools) && block.data.generic_tools.some((t: any) =>
@@ -1569,6 +1750,7 @@ export class AgentStreamComponent implements AfterViewInit {
public isBlockRunning(block: any): boolean {
if (!block || !block.data) return false;
if (!this.isCurrentSessionActive()) return false;
if (block.type === 'checker') return block.data.isCompleted === false;
if (this.isReportStatusAction(block.data.action_taken) || block.data?.status === 'completed' || block.data?.status === 'failed' || block.data?.status === 'cancelled') return false;
if (block.data.isCompleted === false) return true;
const blocks = this.consolidatedBlocks();
@@ -290,7 +290,7 @@ limitations under the License.
}
</div>
}
<!-- Verification Box (Pulled out into a minimalist framed card) -->
<!-- Verification result -->
@if (milestone.checks.length > 0) {
<div class="milestone-checks-container">
@for (check of milestone.checks; track trackMarkdownLine($index, check)) {
@@ -0,0 +1,47 @@
import {
DEFAULT_EXPLORER_MODE,
DEFAULT_VERIFICATION_LEVEL,
EXPLORER_MODES,
VERIFICATION_LEVELS,
levelIndex,
notchPercent
} from './pro-tuning.model';
describe('pro tuning ladders', () => {
it('keeps every notch id and label unique within its ladder', () => {
for (const ladder of [VERIFICATION_LEVELS, EXPLORER_MODES]) {
expect(new Set(ladder.map((l) => l.id)).size).toBe(ladder.length);
expect(new Set(ladder.map((l) => l.label)).size).toBe(ladder.length);
}
});
it('mirrors the backend wire values', () => {
expect(VERIFICATION_LEVELS.map((l) => l.id)).toEqual(['off', 'final', 'checkpoints', 'strict']);
expect(EXPLORER_MODES.map((l) => l.id)).toEqual(['flash', 'pro', 'ultra']);
expect(VERIFICATION_LEVELS.map((l) => l.id)).toContain(DEFAULT_VERIFICATION_LEVEL);
expect(EXPLORER_MODES.map((l) => l.id)).toContain(DEFAULT_EXPLORER_MODE);
});
it('every level explains itself', () => {
for (const level of [...VERIFICATION_LEVELS, ...EXPLORER_MODES]) {
expect(level.tagline.length).toBeGreaterThan(10);
expect(level.latency.length).toBeGreaterThan(0);
expect(level.runs.length).toBeGreaterThan(0);
expect(level.bestFor.length).toBeGreaterThan(10);
}
});
it('resolves ids case-insensitively and falls back to the default', () => {
expect(levelIndex(VERIFICATION_LEVELS, ' STRICT ', DEFAULT_VERIFICATION_LEVEL)).toBe(3);
expect(levelIndex(VERIFICATION_LEVELS, 'nope', DEFAULT_VERIFICATION_LEVEL)).toBe(1);
expect(levelIndex(EXPLORER_MODES, null, DEFAULT_EXPLORER_MODE)).toBe(0);
});
it('maps notches onto the track', () => {
expect(notchPercent(0, 4)).toBe(0);
expect(notchPercent(3, 4)).toBe(100);
expect(notchPercent(1, 3)).toBe(50);
expect(notchPercent(9, 3)).toBe(100);
expect(notchPercent(0, 1)).toBe(0);
});
});
@@ -0,0 +1,165 @@
/**
* Copyright 2026 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/**
* Launcher options for `/api/run` (`verification_level`, `explorer_mode`).
* Keep ids in sync with `VERIFICATION_LEVEL_PRESETS` in `artemis/config/agent.py`
* and `EXPLORER_TIERS` in `artemis/agents/explorer/tiers.py`.
*/
export type VerificationLevelId = 'off' | 'final' | 'checkpoints' | 'strict';
export type ExplorerModeId = 'flash' | 'pro' | 'ultra';
/** One notch on a tuning slider. */
export interface TuningLevel<TId extends string = string> {
/** Wire value sent to the backend. */
id: TId;
/** Short name shown next to the slider title and as the hover card heading. */
label: string;
/** One-sentence summary shown in the hover card. */
tagline: string;
/** Plain-language time cost, e.g. "no extra time". */
latency: string;
/** Checks or searches performed at this level. */
runs: string[];
/** Checks or searches omitted at this level. */
skips?: string[];
/** When to pick this level. */
bestFor: string;
}
export const VERIFICATION_LEVELS: readonly TuningLevel<VerificationLevelId>[] = [
{
id: 'off',
label: 'Off',
tagline: 'No checking. The run ends as soon as the task looks done.',
latency: 'no extra time',
runs: [
'Each step is treated as finished the moment it is carried out.',
'You get the full action trace, but no pass / fail verdict.'
],
skips: ['Nothing is double-checked and nothing is retried.'],
bestFor: 'Quick tries and demos, when you only want to watch what happens.'
},
{
id: 'final',
label: 'At the end',
tagline: 'One check of the finished result against your goal. This is the default.',
latency: 'adds about 20–60 s at the end',
runs: [
'When the task finishes, the final screen, the step history and the device state are compared with what you asked for.',
'If the result does not match, the task goes back and tries to fix it, up to 3 times.'
],
skips: ['Nothing is checked while the task is still running.'],
bestFor: 'Everyday tasks: an honest pass / fail without slowing the run down.'
},
{
id: 'checkpoints',
label: 'Every step',
tagline: 'Each step is checked as soon as it is done, plus the final check.',
latency: 'a short check after each step, done in the background',
runs: [
'Every step is checked right after it completes, using the screenshots from that moment.',
'If a step went wrong, it gets fixed before moving on (up to 2 tries per step).',
'A failed test condition is written down and the task keeps going.',
'The final check still runs at the end.'
],
bestFor: 'Long tasks where one early mistake would spoil everything after it.'
},
{
id: 'strict',
label: 'Strict',
tagline: 'Every step is checked, with more retries. The first failed test stops the run.',
latency: 'slowest: more checks and more retries',
runs: [
'Each check takes longer and gets more attempts: 4 fixes per step and 5 at the end.',
'The first failed test condition stops the run immediately, with the evidence attached.'
],
bestFor: 'Release checks and regression runs, where a wrong pass is never acceptable.'
}
];
export const EXPLORER_MODES: readonly TuningLevel<ExplorerModeId>[] = [
{
id: 'flash',
label: 'Quick glance',
tagline: 'Finds buttons and text on the screen in a single look.',
latency: '1 look per search',
runs: [
'Something on screen is asked for by name, icon or colour and its position comes back straight away.',
'Several things can be looked up at once.'
],
skips: ['No zooming in and no second try.'],
bestFor: 'Ordinary apps with clearly labelled buttons, icons and text.'
},
{
id: 'pro',
label: 'Second look',
tagline: 'Takes up to 3 looks, thinking in between, before answering.',
latency: 'up to 3 looks per search',
runs: [
'The screen layout is read first, then the picture is searched.',
'If the first try misses, a different approach is tried within the 3 looks.'
],
skips: ['Still no zooming into small areas, to keep searches short.'],
bestFor: 'Things described by where they are ("the switch next to Wi-Fi") or with unclear labels.'
},
{
id: 'ultra',
label: 'Close-up',
tagline: 'Zooms into parts of the screen and takes up to 8 looks.',
latency: 'up to 8 looks per search (slowest)',
runs: [
'Parts of the screen can be cropped and magnified to read tiny text and crowded layouts piece by piece.',
'Later looks reuse the earlier ones, so they cost less time than they sound.'
],
bestFor: 'Crowded screens, tiny targets, charts and drawings, and checks where exact placement matters.'
}
];
/** Per-run tuning sent with `/api/run` for the Pro profile. */
export interface ProTuningOptions {
verificationLevel?: VerificationLevelId | string;
explorerMode?: ExplorerModeId | string;
}
/** Effective defaults reported by `GET /api/run/defaults`. */
export interface ProTuningDefaults {
verification_level?: string | null;
explorer_mode?: string | null;
}
export const DEFAULT_VERIFICATION_LEVEL: VerificationLevelId = 'final';
export const DEFAULT_EXPLORER_MODE: ExplorerModeId = 'flash';
/** Index of a level id within its ladder; falls back to the default when unknown. */
export function levelIndex<TId extends string>(
ladder: readonly TuningLevel<TId>[],
id: string | null | undefined,
fallback: TId
): number {
const wanted = String(id ?? '').trim().toLowerCase();
const idx = ladder.findIndex((l) => l.id === wanted);
if (idx >= 0) return idx;
return Math.max(0, ladder.findIndex((l) => l.id === fallback));
}
/** Slider fill percentage for a notch index on a ladder of `count` notches. */
export function notchPercent(index: number, count: number): number {
if (count <= 1) return 0;
const clamped = Math.min(Math.max(index, 0), count - 1);
return (clamped / (count - 1)) * 100;
}
@@ -79,7 +79,7 @@ export interface StepItemData {
export interface StepBlock {
id: string;
type: 'llm_stream' | 'step';
type: 'llm_stream' | 'step' | 'checker';
timestamp: string;
data: any;
}
@@ -93,6 +93,75 @@ export interface PhaseBlock {
blocks: StepBlock[];
}
/** One check item's verdict as booked by the Checker (ledger record shape). */
export interface CheckerVerdict {
item_text: string;
kind: 'verify' | 'assert' | string;
status: 'passed' | 'failed' | 'inconclusive' | 'superseded' | 'unchecked' | string;
evidence: string;
suggestion?: string;
}
export interface CheckerCheckItem {
kind: string;
text: string;
when?: string;
}
/**
* One streamed LLM turn of a multi-turn agent (the Checker's tool loop):
* the text of one execution id with the time its first chunk arrived, so the
* timeline can interleave it with the tool calls that followed.
*/
export interface StreamSegment {
execution_id: string;
stream_type: 'thinking' | 'text';
text: string;
timestamp: string;
isCompleted?: boolean;
}
/**
* Data of a `checker` timeline block: one Checker attempt (a midway checkpoint
* of a completed subgoal or the exit final review), or the run outcome
* (`phase: 'outcome'`). Streamed reasoning and tool traces land in the same
* `operator_*_thinking` / `generic_tools` fields as an Operator step so the
* timeline renders them with one code path.
*/
export interface CheckerBlockData {
event?: string;
attempt_id?: string;
checkpoint_id?: string;
phase: 'checkpoint' | 'final' | 'outcome' | string;
subgoal_text?: string;
status?: 'running' | 'done' | 'superseded' | 'unchecked' | 'error' | string;
trace_id?: string | null;
anchor_step_id?: string | null;
items?: CheckerCheckItem[];
verdicts?: CheckerVerdict[];
findings?: string[];
unmet_subgoals?: string[];
reverted?: boolean;
applicable?: boolean;
repairs_used?: number;
route?: string;
error?: string;
task_status?: 'completed' | 'partial' | 'blocked' | string;
tests?: { passed: number; failed: number; inconclusive: number; unchecked: number };
last_findings?: string[];
started_at?: number;
finished_at?: number;
duration?: number;
isCompleted?: boolean;
generic_tools?: any[];
/** Per-turn stream segments (live sessions only); the flat fields below hold the joined text. */
stream_segments?: StreamSegment[];
operator_native_thinking?: string;
operator_raw_thinking?: string;
[key: string]: any;
}
/** @deprecated legacy JSON-in-stream checker verdict (pre-ledger); kept for the parser. */
export interface CheckerResult {
success: boolean;
reason: string;
@@ -580,7 +580,7 @@
<span class="provider-dot"></span>
<span class="provider-name">{{ modelConfigEnv()?.default_model?.provider || 'google' }}</span>
<span class="model-divider">/</span>
<span class="model-name">{{ modelConfigEnv()?.default_model?.model || 'gemini-3.7-flash' }}</span>
<span class="model-name">{{ modelConfigEnv()?.default_model?.model || 'gemini-3.8-flash' }}</span>
</div>
</div>
@@ -1761,14 +1761,14 @@
<p class="desc-line">
<span class="mode-name">Flash</span>
<span class="bullet">·</span>
<span>Token-efficient, rapid <strong>3–5s/step</strong> reactive decisions. Best for routine daily tasks within <strong>25 steps</strong>.</span>
<span class="limitation-tag">(No long-term monitoring or video analysis)</span>
<span>Token-efficient, rapid <strong>3–5s/step</strong> decisions straight from the live screen. Best for everyday tasks with a clear path, however long they run.</span>
<span class="limitation-tag">(No plan, no result check, no ADB commands)</span>
</p>
} @else {
<p class="desc-line">
<span class="mode-name">Pro</span>
<span class="bullet">·</span>
<span>Our most powerful agent architecture. Handles <strong>100+ step</strong> workflows and direct phone state monitoring (<strong>20–40s</strong> / step).</span>
<span>Our most powerful mode. Plans first, checks its own results and recovers when a tap is blocked. Handles <strong>100+ step</strong> workflows and live phone monitoring (<strong>15–40s</strong> / step).</span>
</p>
}
</div>
@@ -1792,7 +1792,7 @@
class="model-toggle-btn btn-flash"
[class.active]="selectedProfile() === 'flash'"
(click)="setProfile('flash')"
title="Flash: Reactive fast-loop architecture for standard app workflows (3–5s/step)"
title="Flash: fast single-look loop for everyday app tasks (3–5s/step)"
>
<span class="material-symbols-outlined btn-icon">bolt</span>
<span class="btn-text">Flash</span>
@@ -1802,7 +1802,7 @@
class="model-toggle-btn btn-pro"
[class.active]="selectedProfile() === 'pro'"
(click)="setProfile('pro')"
title="Pro: Multi-agent graph architecture with planner & closed-loop verification for complex tasks"
title="Pro: plans first, checks results and recovers from blocked steps. For long or complex tasks (15–40s/step)"
>
<span class="material-symbols-outlined btn-icon">psychology</span>
<span class="btn-text">Pro</span>
@@ -1863,6 +1863,120 @@
}
</div>
</div>
<!-- Pro Mode Tuning Card: verification depth & explorer perception sliders -->
<div class="pro-tuning-card" [class.tip-open]="activeTuningTip() !== null">
<div class="tuning-card-header">
<span class="tuning-title">Pro settings</span>
@if (!isTuningDefault()) {
<button type="button" class="btn-tuning-reset" (click)="resetTuning()" title="Back to the defaults">Reset</button>
}
</div>
<div class="tuning-sliders">
@for (slider of tuningSliders(); track slider.kind) {
<div
class="tuning-slider"
[class.verify]="slider.kind === 'verify'"
[class.explore]="slider.kind === 'explore'"
[class.dragging]="activeTuningTip() === slider.kind"
[style.--pct]="slider.fraction"
>
<div class="slider-head">
<span class="slider-name">{{ slider.name }}</span>
<span class="slider-value">{{ slider.level.label }}</span>
</div>
<div class="slider-ends" aria-hidden="true">
<span>{{ slider.ends[0] }}</span>
<span>{{ slider.ends[1] }}</span>
</div>
<div
class="slider-track-wrap"
(pointerenter)="showTuningTip(slider.kind)"
(pointerleave)="hideTuningTip(slider.kind)"
>
<div class="slider-rail" aria-hidden="true">
<div class="slider-fill"></div>
@for (lvl of slider.ladder; track lvl.id; let i = $index) {
<span
class="slider-notch"
[class.reached]="i <= slider.index"
[style.--n]="notchFraction(i, slider.ladder.length)"
></span>
}
@if (slider.index === slider.ladder.length - 1) {
<!-- Re-created each time the thumb reaches the end, so the spread replays. -->
<div class="slider-solid"></div>
<svg class="slider-dither" width="100%" height="6" aria-hidden="true">
@for (px of ditherPixels; track $index) {
<rect
[attr.x]="px.x + '%'"
[attr.y]="px.y"
width="2"
height="2"
[style.opacity]="px.opacity"
[style.animation-delay.ms]="px.delay"
/>
}
</svg>
}
</div>
<div class="slider-thumb" aria-hidden="true"></div>
@if (slider.index === slider.ladder.length - 1) {
<span class="thumb-ripple" aria-hidden="true"></span>
}
<input
type="range"
class="slider-input"
min="0"
[max]="slider.ladder.length - 1"
step="1"
[value]="slider.index"
(input)="setTuningIndex(slider.kind, $any($event.target).value)"
(focus)="showTuningTip(slider.kind)"
(blur)="hideTuningTip(slider.kind)"
[attr.aria-label]="slider.name"
[attr.aria-valuetext]="slider.level.label + ': ' + slider.level.tagline"
/>
@if (activeTuningTip() === slider.kind) {
<div class="tuning-tip-arrow" aria-hidden="true"></div>
<div class="tuning-tip" role="tooltip">
<div class="tip-heading">
<span class="tip-title">{{ slider.level.label }}</span>
<span class="tip-latency">{{ slider.level.latency }}</span>
</div>
<p class="tip-tagline">{{ slider.level.tagline }}</p>
<div class="tip-section">
<span class="tip-section-title">What happens</span>
<ul>
@for (line of slider.level.runs; track $index) {
<li>{{ line }}</li>
}
</ul>
</div>
@if (slider.level.skips?.length) {
<div class="tip-section skips">
<span class="tip-section-title">Not included</span>
<ul>
@for (line of slider.level.skips; track $index) {
<li>{{ line }}</li>
}
</ul>
</div>
}
<div class="tip-best">
<span class="tip-section-title">Use it for</span>
<span>{{ slider.level.bestFor }}</span>
</div>
</div>
}
</div>
</div>
}
</div>
</div>
}
<!-- Recommended Tasks Hub (Clean & Direct) -->
@@ -4272,6 +4272,381 @@ $text-light: #94a3b8;
}
}
/* Pro tuning sliders and hover cards */
.pro-tuning-card {
--tip-w: 320px;
--thumb-w: 22px;
--thumb-h: 14px;
--col-gap: 28px;
--accent: #6366f1;
position: relative;
background: rgba(255, 255, 255, 0.88);
backdrop-filter: blur(20px) saturate(180%);
-webkit-backdrop-filter: blur(20px) saturate(180%);
border: 1px solid rgba(226, 232, 240, 0.95);
border-radius: 16px;
padding: 12px 18px 14px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.02), 0 8px 24px -6px rgba(15, 23, 42, 0.04);
display: flex;
flex-direction: column;
gap: 10px;
animation: slideUpFade 0.2s cubic-bezier(0.4, 0, 0.2, 1);
&.tip-open {
z-index: 30;
}
.tuning-card-header {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 10px;
min-height: 16px;
.tuning-title {
font-size: 13px;
font-weight: 700;
color: #0f172a;
letter-spacing: -0.01em;
}
.btn-tuning-reset {
border: none;
background: transparent;
padding: 0;
font-family: inherit;
font-size: 12px;
font-weight: 500;
color: #94a3b8;
cursor: pointer;
transition: color 0.15s ease;
&:hover {
color: #475569;
}
&:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 3px;
border-radius: 3px;
}
}
}
.tuning-sliders {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 4px var(--col-gap);
}
.tuning-slider {
display: flex;
flex-direction: column;
gap: 4px;
min-width: 0;
/* Hover card clamp range, relative to this slider's track. The left
column may spill right across the gap; the right column may spill
left. Overridden below when the columns stack. */
--tip-min: 0px;
--tip-max: calc(100% - var(--tip-w));
&.verify {
--tip-max: calc(200% + var(--col-gap) - var(--tip-w));
}
&.explore {
--accent: #0ea5e9;
--tip-min: calc(-100% - var(--col-gap));
}
.slider-head {
display: flex;
align-items: baseline;
gap: 8px;
font-size: 13px;
line-height: 18px;
color: #0f172a;
.slider-name {
font-weight: 400;
color: #334155;
}
.slider-value {
font-weight: 600;
}
}
.slider-ends {
display: flex;
justify-content: space-between;
font-size: 11.5px;
line-height: 14px;
color: #94a3b8;
}
.slider-track-wrap {
position: relative;
height: 26px;
display: flex;
align-items: center;
}
.slider-rail {
position: relative;
width: 100%;
height: 6px;
border-radius: 3px;
background: #e2e8f0;
}
.slider-fill {
position: absolute;
top: 0;
bottom: 0;
left: 0;
width: calc(var(--pct) * (100% - var(--thumb-w)) + var(--thumb-w) / 2);
border-radius: 3px;
background: color-mix(in srgb, var(--accent) 24%, #e2e8f0);
transition: width 0.15s ease;
}
.slider-notch {
position: absolute;
top: 50%;
left: calc(var(--n) * (100% - var(--thumb-w)) + var(--thumb-w) / 2);
width: 4px;
height: 4px;
border-radius: 50%;
background: #94a3b8;
transform: translate(-50%, -50%);
transition: background 0.15s ease;
z-index: 1;
&.reached {
background: rgba(255, 255, 255, 0.95);
}
}
.slider-thumb {
position: absolute;
top: 50%;
left: calc(var(--pct) * (100% - var(--thumb-w)) + var(--thumb-w) / 2);
width: var(--thumb-w);
height: var(--thumb-h);
border-radius: 5px;
background: #ffffff;
border: 1px solid rgba(15, 23, 42, 0.1);
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.12), 0 2px 6px rgba(15, 23, 42, 0.08);
transform: translate(-50%, -50%);
pointer-events: none;
transition: left 0.15s ease, box-shadow 0.15s ease, border-color 0.15s ease;
}
/* End of track: a pixel front sweeps from the thumb to the left end,
a solid fill follows it until the whole rail has changed colour,
and the thumb emits one ripple. */
.slider-solid {
position: absolute;
inset: 0;
border-radius: 3px;
background: var(--accent);
transform-origin: right center;
transform: scaleX(1);
animation: solidSweep 1s cubic-bezier(0.4, 0, 0.2, 1) 0.2s both;
pointer-events: none;
}
.slider-dither {
position: absolute;
inset: 0;
display: block;
overflow: hidden;
border-radius: 3px;
pointer-events: none;
rect {
fill: var(--accent);
animation: ditherIn 0.3s ease-out both;
}
}
.thumb-ripple {
position: absolute;
top: 50%;
left: calc(var(--pct) * (100% - var(--thumb-w)) + var(--thumb-w) / 2);
width: var(--thumb-w);
height: var(--thumb-w);
border-radius: 50%;
border: 1.5px solid var(--accent);
transform: translate(-50%, -50%) scale(0.6);
opacity: 0;
pointer-events: none;
animation: thumbRipple 0.6s ease-out forwards;
}
&.dragging .slider-thumb {
border-color: color-mix(in srgb, var(--accent) 45%, transparent);
box-shadow:
0 1px 2px rgba(15, 23, 42, 0.12),
0 2px 8px rgba(15, 23, 42, 0.1),
0 0 0 4px color-mix(in srgb, var(--accent) 14%, transparent);
}
/* Native range input does drag, keyboard, and a11y; it is invisible
but its thumb geometry matches the drawn thumb so hits line up. */
.slider-input {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
margin: 0;
opacity: 0;
cursor: pointer;
-webkit-appearance: none;
appearance: none;
background: transparent;
&::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: var(--thumb-w);
height: 26px;
}
&::-moz-range-thumb {
width: var(--thumb-w);
height: 26px;
border: 0;
}
}
.slider-input:focus-visible ~ .slider-thumb,
&:has(.slider-input:focus-visible) .slider-thumb {
box-shadow:
0 1px 2px rgba(15, 23, 42, 0.12),
0 0 0 2px #ffffff,
0 0 0 4px var(--accent);
}
.tuning-tip-arrow {
position: absolute;
bottom: calc(100% + 2px);
left: calc(var(--pct) * (100% - var(--thumb-w)) + var(--thumb-w) / 2);
width: 10px;
height: 10px;
background: #ffffff;
border-right: 1px solid rgba(226, 232, 240, 1);
border-bottom: 1px solid rgba(226, 232, 240, 1);
transform: translate(-50%, 50%) rotate(45deg);
z-index: 41;
pointer-events: none;
}
.tuning-tip {
position: absolute;
bottom: calc(100% + 7px);
left: clamp(
var(--tip-min),
calc(var(--pct) * (100% - var(--thumb-w)) + var(--thumb-w) / 2 - var(--tip-w) / 2),
var(--tip-max)
);
width: var(--tip-w);
max-width: calc(100vw - 32px);
padding: 12px 14px;
background: #ffffff;
border: 1px solid rgba(226, 232, 240, 1);
border-radius: 12px;
box-shadow: 0 12px 32px -8px rgba(15, 23, 42, 0.18), 0 2px 6px rgba(15, 23, 42, 0.06);
z-index: 40;
pointer-events: none;
animation: tipPop 0.16s ease-out;
font-size: 12px;
line-height: 1.5;
color: #334155;
display: flex;
flex-direction: column;
gap: 8px;
.tip-heading {
display: flex;
flex-direction: column;
gap: 1px;
.tip-title {
font-size: 13.5px;
font-weight: 600;
color: #0f172a;
letter-spacing: -0.01em;
}
.tip-latency {
font-size: 11.5px;
color: #94a3b8;
}
}
.tip-tagline {
margin: 0;
color: #0f172a;
}
.tip-section {
display: flex;
flex-direction: column;
gap: 2px;
ul {
margin: 0;
padding-left: 16px;
display: flex;
flex-direction: column;
gap: 2px;
}
li::marker {
color: #cbd5e1;
}
&.skips li {
color: #64748b;
}
}
.tip-section-title {
font-size: 10.5px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.06em;
color: #94a3b8;
}
.tip-best {
display: flex;
flex-direction: column;
gap: 2px;
color: #475569;
}
}
}
@media (max-width: 760px) {
.tuning-sliders {
grid-template-columns: minmax(0, 1fr);
gap: 10px;
}
.tuning-slider {
&.verify,
&.explore {
--tip-min: 0px;
--tip-max: calc(100% - var(--tip-w));
}
}
}
}
/* Smart Suggestions Hub (Device-Aware, Flash vs Pro Tailored) */
.smart-suggestions-container {
display: flex;
@@ -4733,7 +5108,47 @@ $text-light: #94a3b8;
@media (prefers-reduced-motion: reduce) {
.home-container .aurora-background .glow,
.sparkle-icon,
.status-dot {
.status-dot,
.slider-dither rect,
.slider-solid,
.thumb-ripple {
animation: none !important;
}
}
@keyframes ditherIn {
from {
opacity: 0;
}
}
@keyframes solidSweep {
from {
transform: scaleX(0);
}
to {
transform: scaleX(1);
}
}
@keyframes thumbRipple {
0% {
opacity: 0.7;
transform: translate(-50%, -50%) scale(0.6);
}
100% {
opacity: 0;
transform: translate(-50%, -50%) scale(2.6);
}
}
@keyframes tipPop {
from {
opacity: 0;
transform: translateY(6px) scale(0.96);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
@@ -15,7 +15,6 @@
*/
import { Component, signal, computed, effect, inject, OnInit, OnDestroy, ChangeDetectionStrategy } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { Router, RouterLink } from '@angular/router';
import { AgentService } from '../../services/agent.service';
@@ -32,6 +31,69 @@ import {
SuggestionCategory
} from '../../core/data/smart-tasks.data';
import { TaskRecommendationService } from '../../core/services/task-recommendation.service';
import {
DEFAULT_EXPLORER_MODE,
DEFAULT_VERIFICATION_LEVEL,
EXPLORER_MODES,
ExplorerModeId,
TuningLevel,
VERIFICATION_LEVELS,
VerificationLevelId,
levelIndex,
notchPercent
} from '../../core/models/pro-tuning.model';
export type TuningKind = 'verify' | 'explore';
/** One 2x2 px square of the "maxed out" dither texture drawn over a slider rail. */
export interface DitherPixel {
/** Horizontal position as a percentage of the rail width. */
x: number;
/** Row offset in px (rail is 6 px tall, three 2 px rows). */
y: number;
/** Resting opacity; squares near the thumb are stronger. */
opacity: number;
/** Animation delay in ms so the texture spreads leftwards from the thumb. */
delay: number;
}
/**
* Seeded pixels keep the slider texture stable across renders.
* Delays increase with distance from the thumb to animate from right to left.
*/
function buildDitherPixels(count = 260, seed = 7): DitherPixel[] {
let state = seed >>> 0;
const rand = (): number => {
state = (Math.imul(state, 1664525) + 1013904223) >>> 0;
return state / 0x100000000;
};
const pixels: DitherPixel[] = [];
for (let i = 0; i < count; i++) {
const x = rand() * 100;
const y = Math.floor(rand() * 3) * 2;
pixels.push({
x: Math.round(x * 10) / 10,
y,
opacity: Math.round((0.35 + 0.4 * rand()) * 100) / 100,
// 8 ms per percent: the front takes ~0.8 s to reach the left end.
delay: Math.round((100 - x) * 8)
});
}
return pixels;
}
/** Shared view model for the verification and screen-reading sliders. */
export interface TuningSliderVm {
kind: TuningKind;
name: string;
/** One-word meaning of each end of the track, e.g. ["Off", "Strict"]. */
ends: readonly [string, string];
ladder: readonly TuningLevel[];
index: number;
level: TuningLevel;
/** 0..1 position of the thumb along the track. */
fraction: number;
}
export type { AppReference, SmartSuggestion, SuggestionCategory };
@@ -139,6 +201,118 @@ export class HomeComponent implements OnInit, OnDestroy {
public enableOutputter = signal<boolean>(true);
public showOutputterDrawer = signal<boolean>(false);
// Slider indexes map to the API ids in VERIFICATION_LEVELS / EXPLORER_MODES.
public readonly verificationLevels = VERIFICATION_LEVELS;
public readonly explorerModes = EXPLORER_MODES;
public verificationIndex = signal<number>(
levelIndex(VERIFICATION_LEVELS, DEFAULT_VERIFICATION_LEVEL, DEFAULT_VERIFICATION_LEVEL)
);
public explorerIndex = signal<number>(
levelIndex(EXPLORER_MODES, DEFAULT_EXPLORER_MODE, DEFAULT_EXPLORER_MODE)
);
/** Effective defaults from the backend config (`GET /api/run/defaults`). */
private tuningDefaults = signal<{ verification: VerificationLevelId; explorer: ExplorerModeId }>({
verification: DEFAULT_VERIFICATION_LEVEL,
explorer: DEFAULT_EXPLORER_MODE
});
/** True once the user moved a slider; backend defaults then stop overriding it. */
private tuningTouched = signal<boolean>(false);
/** Which slider's hover card is open (while hovering, dragging, or focused). */
public activeTuningTip = signal<TuningKind | null>(null);
/** Pixel cloud drawn over a rail once its slider reaches the last notch. */
public readonly ditherPixels: readonly DitherPixel[] = buildDitherPixels();
public verificationLevel = computed<TuningLevel<VerificationLevelId>>(
() => VERIFICATION_LEVELS[this.verificationIndex()]
);
public explorerMode = computed<TuningLevel<ExplorerModeId>>(
() => EXPLORER_MODES[this.explorerIndex()]
);
public isTuningDefault = computed<boolean>(() => {
const d = this.tuningDefaults();
return this.verificationLevel().id === d.verification && this.explorerMode().id === d.explorer;
});
public tuningSliders = computed<TuningSliderVm[]>(() => {
const vi = this.verificationIndex();
const ei = this.explorerIndex();
return [
{
kind: 'verify',
name: 'Result check',
ends: ['Off', 'Strict'],
ladder: VERIFICATION_LEVELS,
index: vi,
level: VERIFICATION_LEVELS[vi],
fraction: notchPercent(vi, VERIFICATION_LEVELS.length) / 100
},
{
kind: 'explore',
name: 'Screen reading',
ends: ['Faster', 'Sharper'],
ladder: EXPLORER_MODES,
index: ei,
level: EXPLORER_MODES[ei],
fraction: notchPercent(ei, EXPLORER_MODES.length) / 100
}
];
});
public notchFraction(index: number, count: number): number {
return notchPercent(index, count) / 100;
}
public setTuningIndex(kind: TuningKind, raw: number | string): void {
const idx = Math.round(Number(raw));
if (!Number.isFinite(idx)) return;
const ladder = kind === 'verify' ? VERIFICATION_LEVELS : EXPLORER_MODES;
const clamped = Math.min(Math.max(idx, 0), ladder.length - 1);
this.tuningTouched.set(true);
if (kind === 'verify') {
this.verificationIndex.set(clamped);
} else {
this.explorerIndex.set(clamped);
}
// Keyboard nudges and drags should keep the explanation visible.
this.activeTuningTip.set(kind);
}
public showTuningTip(kind: TuningKind): void {
this.activeTuningTip.set(kind);
}
public hideTuningTip(kind: TuningKind): void {
if (this.activeTuningTip() === kind) {
this.activeTuningTip.set(null);
}
}
public resetTuning(): void {
const d = this.tuningDefaults();
this.verificationIndex.set(levelIndex(VERIFICATION_LEVELS, d.verification, DEFAULT_VERIFICATION_LEVEL));
this.explorerIndex.set(levelIndex(EXPLORER_MODES, d.explorer, DEFAULT_EXPLORER_MODE));
this.tuningTouched.set(false);
}
/** Pull the effective config defaults so the sliders start where artemis.jsonc is. */
private loadProTuningDefaults(): void {
this.agentService.getProTuningDefaults().subscribe({
next: (res) => {
const vIdx = levelIndex(VERIFICATION_LEVELS, res?.verification_level, DEFAULT_VERIFICATION_LEVEL);
const eIdx = levelIndex(EXPLORER_MODES, res?.explorer_mode, DEFAULT_EXPLORER_MODE);
this.tuningDefaults.set({
verification: VERIFICATION_LEVELS[vIdx].id,
explorer: EXPLORER_MODES[eIdx].id
});
if (!this.tuningTouched()) {
this.verificationIndex.set(vIdx);
this.explorerIndex.set(eIdx);
}
},
// Defaults are a convenience; the built-in ladder defaults already apply.
error: () => undefined
});
}
public toggleOutputterDrawer(): void {
this.showOutputterDrawer.update((v) => !v);
}
@@ -362,6 +536,8 @@ export class HomeComponent implements OnInit, OnDestroy {
}
ngOnInit(): void {
this.loadProTuningDefaults();
// Initial fetch of system readiness & model configuration
this.systemService.fetchReadiness().subscribe();
this.systemService.fetchModelConfigEnv().subscribe();
@@ -913,7 +1089,10 @@ export class HomeComponent implements OnInit, OnDestroy {
this.selectedProfile() === 'pro' && this.expectedOutput().trim()
? this.expectedOutput().trim()
: undefined,
this.selectedProfile() === 'pro' ? this.enableOutputter() : undefined
this.selectedProfile() === 'pro' ? this.enableOutputter() : undefined,
this.selectedProfile() === 'pro'
? { verificationLevel: this.verificationLevel().id, explorerMode: this.explorerMode().id }
: undefined
)
.subscribe({
next: () => {
@@ -105,8 +105,8 @@ limitations under the License.
rows="1"
[disabled]="isSubmitting()"
[placeholder]="selectedProfile() === 'pro'
? 'Pro: Multi-agent cognitive graph architecture for complex multi-step tasks'
: 'Flash: Reactive fast loop for standard app workflows within 25 steps'"
? 'Pro: plans first, checks results and recovers from blocked steps. For long or complex tasks'
: 'Flash: fast single-look loop for everyday app tasks'"
class="dock-textarea"
spellcheck="false"
></textarea>
@@ -155,13 +155,13 @@ limitations under the License.
<div class="model-profile-toggle" role="group" aria-label="Select Agent Architecture">
<button type="button" class="profile-toggle-pill" [class.active]="selectedProfile() === 'flash'"
(mousedown)="$event.preventDefault()" (click)="setProfile('flash', $event)"
title="Flash: Reactive fast-loop architecture for standard app workflows (3–5s/step)">
title="Flash: fast single-look loop for everyday app tasks (3–5s/step)">
<span class="material-symbols-outlined pill-icon icon-flash">bolt</span>
<span class="pill-label">Flash</span>
</button>
<button type="button" class="profile-toggle-pill" [class.active]="selectedProfile() === 'pro'"
(mousedown)="$event.preventDefault()" (click)="setProfile('pro', $event)"
title="Pro: Multi-agent cognitive graph architecture with planner & closed-loop verification for complex tasks">
title="Pro: plans first, checks results and recovers from blocked steps. For long or complex tasks (15–40s/step)">
<span class="material-symbols-outlined pill-icon icon-pro">psychology</span>
<span class="pill-label">Pro</span>
</button>
@@ -19,6 +19,7 @@ import { HttpClient } from '@angular/common/http';
import { Observable } from 'rxjs';
import { Session, ModelInfo, TaskQueueItem, AgentStatusResponse } from '../core/models/session.model';
import { ProTuningDefaults, ProTuningOptions } from '../core/models/pro-tuning.model';
import { StepItemData, StepReplayFrame } from '../core/models/stream.model';
import { extractStepReplayFrames } from '../utils/action-formatter.util';
export type { Session, ModelInfo, TaskQueueItem, AgentStatusResponse, StepItemData, StepReplayFrame };
@@ -347,7 +348,7 @@ export class AgentService {
// Streaming chunks are coalesced into one signal update per short window so a
// chatty LLM stream cannot force a full recompute pass per SSE chunk.
private pendingStreamChunks = new Map<string, { execId: any; stepId: any; streamType: string; chunk: string }>();
private pendingStreamChunks = new Map<string, { execId: any; stepId: any; parentTraceId?: any; streamType: string; chunk: string }>();
private streamFlushTimer: ReturnType<typeof setTimeout> | null = null;
private lastQueueSignature: string | null = null;
private lastActiveTasksSignature: string | null = null;
@@ -378,6 +379,14 @@ export class AgentService {
return this.sessions().some(s => s.status === 'running' || s.status === 'paused');
});
/**
* Effective Pro tuning defaults (verification level, explorer mode) from the
* backend agent config, so the launcher sliders start where the config is.
*/
public getProTuningDefaults(): Observable<ProTuningDefaults> {
return this.http.get<ProTuningDefaults>('/api/run/defaults');
}
/**
* Run a new task by submitting to backend queue
*/
@@ -385,7 +394,8 @@ export class AgentService {
goal: string,
profile: string = 'flash',
expectedOutput?: string,
enableOutputter?: boolean
enableOutputter?: boolean,
proTuning?: ProTuningOptions
): Observable<any> {
return new Observable((obs) => {
const submittedEvent: StartupProgressEvent = {
@@ -401,6 +411,12 @@ export class AgentService {
if (enableOutputter !== undefined) {
payload.enable_outputter = enableOutputter;
}
if (proTuning?.verificationLevel) {
payload.verification_level = proTuning.verificationLevel;
}
if (proTuning?.explorerMode) {
payload.explorer_mode = proTuning.explorerMode;
}
this.clearUserPinnedSession();
this.http.post<any>('/api/run', payload).subscribe({
next: (res) => {
@@ -702,6 +718,7 @@ export class AgentService {
this.isPaused.set(false);
this.pausedError.set(null);
this.fetchNotes(sessionId);
this.fetchChecks(sessionId);
this.updateModelForCurrentView();
// If video window is currently open, dynamically sync/refresh video for new session
@@ -709,7 +726,7 @@ export class AgentService {
this.openVideoPlayer(sessionId);
}
console.log(`Selecting session: ${sessionId}`);
console.debug(`Selecting session: ${sessionId}`);
// Start reading the persisted snapshot immediately
this.backfillSessionSteps(sessionId, loadGeneration);
this.ensureLiveStream();
@@ -724,7 +741,7 @@ export class AgentService {
return;
}
console.log('Establishing persistent unified live stream via /api/stream');
console.debug('Establishing persistent unified live stream via /api/stream');
// The stream is registered outside the Angular zone: high-frequency SSE
// callbacks must not schedule a change-detection pass each. Signal writes
// still notify the render scheduler, so the UI stays live.
@@ -756,7 +773,8 @@ export class AgentService {
'session_started',
'session_ended',
'recording_ready',
'recording_failed'
'recording_failed',
'checker_event'
];
eventTypes.forEach((eventType) => {
@@ -843,6 +861,11 @@ export class AgentService {
this.fetchStatus();
if (endedId) {
this.fetchNotes(endedId);
if (String(endedId) === String(this.currentSessionId() || '')) {
// The exit final review / run outcome may have landed while the
// stream was reconnecting: reconcile from the persisted ledger.
this.fetchChecks(endedId);
}
if (
this.isVideoWindowOpen()
&& this.activeVideoSessionId === endedId
@@ -1010,14 +1033,19 @@ export class AgentService {
private queueStreamChunk(parsedData: any): void {
const execId = parsedData.execution_id;
const stepId = parsedData.step_id;
// The emitting agent's trace: lets the aggregator route a concurrent
// agent's stream (e.g. the Checker) to its own block instead of the
// Operator step that happens to be current.
const parentTraceId = parsedData.parent_trace_id || null;
const streamType = parsedData.stream_type || 'text';
const key = `${execId}|${streamType}`;
const pending = this.pendingStreamChunks.get(key);
if (pending) {
pending.chunk += parsedData.chunk;
if (stepId) pending.stepId = stepId;
if (parentTraceId) pending.parentTraceId = parentTraceId;
} else {
this.pendingStreamChunks.set(key, { execId, stepId, streamType, chunk: parsedData.chunk });
this.pendingStreamChunks.set(key, { execId, stepId, parentTraceId, streamType, chunk: parsedData.chunk });
}
if (!this.streamFlushTimer) {
const delay = typeof document !== 'undefined' && document.hidden ? 500 : 80;
@@ -1058,14 +1086,18 @@ export class AgentService {
data: {
...existingLog.data,
text: existingLog.data.text + batch.chunk,
step_id: batch.stepId || existingLog.data.step_id
step_id: batch.stepId || existingLog.data.step_id,
parent_trace_id: batch.parentTraceId || existingLog.data.parent_trace_id || null
}
};
} else {
// A new execution closes the previous open stream of the same agent
// lane only; concurrent agents (Operator vs. Checker) keep theirs.
next = next.map((l) => {
if (l.type === 'llm_stream' &&
(l.data.stream_type || 'text') === batch.streamType &&
!l.data.isCompleted) {
!l.data.isCompleted &&
(l.data.parent_trace_id || null) === (batch.parentTraceId || null)) {
return { ...l, data: { ...l.data, isCompleted: true } };
}
return l;
@@ -1076,6 +1108,7 @@ export class AgentService {
data: {
execution_id: batch.execId,
step_id: batch.stepId,
parent_trace_id: batch.parentTraceId || null,
text: batch.chunk,
stream_type: batch.streamType,
isCompleted: false
@@ -1511,6 +1544,105 @@ export class AgentService {
/**
* Fetch notes for a specific session
*/
/**
* Backfill the Checker's attempts and run outcome from the persisted verdict
* ledger (`/api/sessions/{id}/checks`) as synthetic `checker_event` logs, so
* historical sessions show the same timeline blocks as live ones. Live
* events for attempts already known are merged by attempt id in the
* aggregator, so re-fetching is idempotent.
*/
public fetchChecks(sessionId: string): void {
if (!sessionId) return;
this.http.get<{ records?: any[]; run_outcome?: any }>(`/api/sessions/${sessionId}/checks`).subscribe({
next: (res) => {
if (this.currentSessionId() !== sessionId) return;
const snapshot = this.buildCheckerSnapshotLogs(sessionId, res?.records || [], res?.run_outcome || null);
this.sessionLogs.update((logs) => [
...logs.filter((log) => !log.checks_snapshot),
...snapshot
]);
},
error: (err) => {
console.error(`Failed to fetch checks for session ${sessionId}:`, err);
}
});
}
private buildCheckerSnapshotLogs(sessionId: string, records: any[], runOutcome: any): any[] {
const byAttempt = new Map<string, any>();
for (const rec of records) {
if (!rec || !rec.attempt_id) continue;
const attemptId = String(rec.attempt_id);
const checkpointId = String(rec.checkpoint_id || '');
let attempt = byAttempt.get(attemptId);
if (!attempt) {
attempt = {
event: 'attempt_finished',
phase: checkpointId === 'final' ? 'final' : 'checkpoint',
attempt_id: attemptId,
checkpoint_id: checkpointId,
subgoal_text: String(rec.subgoal_text || (checkpointId === 'final'
? "Final review against the user's original goal"
: `Subgoal ${checkpointId.slice(0, 8)}`)),
anchor_step_id: rec.anchor_step_id ?? null,
trace_id: rec.trace_id ?? null,
status: 'done',
verdicts: [],
findings: [],
ts: typeof rec.ts === 'number' ? rec.ts : undefined,
session_id: sessionId
};
byAttempt.set(attemptId, attempt);
}
if (typeof rec.ts === 'number') {
attempt.ts = attempt.ts === undefined ? rec.ts : Math.min(attempt.ts, rec.ts);
}
if (!attempt.trace_id && rec.trace_id) {
attempt.trace_id = rec.trace_id;
}
attempt.verdicts.push({
item_text: String(rec.item_text || ''),
kind: String(rec.kind || ''),
status: String(rec.status || ''),
evidence: String(rec.evidence || ''),
suggestion: rec.suggestion ? String(rec.suggestion) : '',
when: rec.when ? String(rec.when) : undefined
});
}
const logs: any[] = [];
for (const attempt of byAttempt.values()) {
const statuses = new Set(attempt.verdicts.map((v: any) => v.status));
if (statuses.size === 1) {
const only = [...statuses][0];
if (only === 'superseded' || only === 'unchecked') attempt.status = only;
}
const ts = attempt.ts ?? Date.now() / 1000;
attempt.timestamp = ts;
logs.push({
type: 'checker_event',
session_id: sessionId,
timestamp: new Date(ts * 1000).toISOString(),
checks_snapshot: true,
data: attempt
});
}
if (runOutcome && typeof runOutcome === 'object') {
const lastTs = logs.length > 0
? Math.max(...logs.map((l) => new Date(l.timestamp).getTime() / 1000))
: Date.now() / 1000;
const ts = lastTs + 0.001;
logs.push({
type: 'checker_event',
session_id: sessionId,
timestamp: new Date(ts * 1000).toISOString(),
checks_snapshot: true,
data: { event: 'run_outcome', phase: 'outcome', ...runOutcome, ts, timestamp: ts, session_id: sessionId }
});
}
return logs;
}
public fetchNotes(sessionId: string): void {
if (!sessionId) {
this.currentNotes.set({});
@@ -91,3 +91,261 @@ describe('stream aggregator timeline ordering', () => {
.toEqual(['text', 'tool']);
});
});
describe('stream aggregator checker lane', () => {
const started = {
type: 'checker_event',
timestamp: '2026-08-25T20:00:05.000Z',
data: {
event: 'attempt_started',
phase: 'checkpoint',
attempt_id: 'abc#1',
checkpoint_id: 'abc',
subgoal_text: 'Create the alarm',
trace_id: 'trace-checker-1',
items: [{ kind: 'verify', text: 'alarm exists' }],
ts: 1756152005
}
};
it('creates a running checker block from attempt_started and finishes it by attempt id', () => {
const blocks = consolidateLogsToBlocks([
started,
{
type: 'checker_event',
timestamp: '2026-08-25T20:00:09.000Z',
data: {
event: 'attempt_finished',
attempt_id: 'abc#1',
status: 'done',
verdicts: [{ item_text: 'alarm exists', kind: 'verify', status: 'passed', evidence: 'seen' }],
findings: [],
ts: 1756152009
}
}
]);
expect(blocks.length).toBe(1);
expect(blocks[0].type).toBe('checker');
expect(blocks[0].id).toBe('checker-abc#1');
expect(blocks[0].data.isCompleted).toBeTrue();
expect(blocks[0].data.status).toBe('done');
expect(blocks[0].data.trace_id).toBe('trace-checker-1');
expect(blocks[0].data.verdicts.length).toBe(1);
expect(blocks[0].data.duration).toBe(4);
});
it("routes the checker's stream and tool traces by parent trace id, not by the current step", () => {
const blocks = consolidateLogsToBlocks([
{
type: 'step_recorded',
timestamp: '2026-08-25T20:00:01.000Z',
data: { step_id: 'step-1', step_number: 1, timestamp: 1756152001 }
},
started,
{
type: 'llm_stream',
timestamp: '2026-08-25T20:00:06.000Z',
data: {
execution_id: 'exec-checker',
step_id: 'step-1',
parent_trace_id: 'trace-checker-1',
stream_type: 'thinking',
text: 'Inspecting the alarm list',
isCompleted: false
}
},
{
type: 'trace_recorded',
timestamp: '2026-08-25T20:00:07.000Z',
data: {
trace_id: 'tool-probe',
parent_trace_id: 'trace-checker-1',
agent_name: 'checker',
type: 'tool',
name: 'probe_device',
step_id: 'step-1',
status: 'success'
}
}
]);
const step = blocks.find((b) => b.id === 'step-step-1')!;
const checker = blocks.find((b) => b.id === 'checker-abc#1')!;
expect(step.data.operator_native_thinking).toBeUndefined();
expect(step.data.generic_tools || []).toEqual([]);
expect(checker.data.operator_native_thinking).toBe('Inspecting the alarm list');
expect(checker.data.generic_tools.map((t: any) => t.trace_id)).toEqual(['tool-probe']);
expect(checker.data.step_id).toBeUndefined();
expect(blocks.map((b) => b.id)).toEqual(['step-step-1', 'checker-abc#1']);
});
it('keeps one stream segment per checker turn so text and tool calls interleave by time', () => {
const turn = (execId: string, timestamp: string, text: string) => ({
type: 'llm_stream',
timestamp,
data: {
execution_id: execId,
parent_trace_id: 'trace-checker-1',
stream_type: 'text',
text,
isCompleted: false
}
});
const blocks = consolidateLogsToBlocks([
started,
turn('exec-1', '2026-08-25T20:00:06.000Z', 'Let me look at step 2.'),
{
type: 'trace_recorded',
timestamp: '2026-08-25T20:00:07.000Z',
data: {
trace_id: 'tool-detail',
parent_trace_id: 'trace-checker-1',
agent_name: 'checker',
type: 'tool',
name: 'get_step_detail',
status: 'success',
timestamp: '2026-08-25T20:00:07.000Z'
}
},
turn('exec-2', '2026-08-25T20:00:08.000Z', 'The alarm is there.'),
turn('exec-1', '2026-08-25T20:00:08.500Z', 'Let me look at step 2. Done.')
]);
const checker = blocks.find((b) => b.id === 'checker-abc#1')!;
expect(checker.data.stream_segments.map((s: any) => s.execution_id)).toEqual(['exec-1', 'exec-2']);
// A later chunk of an earlier turn keeps that turn's first-seen time.
expect(checker.data.stream_segments[0].text).toBe('Let me look at step 2. Done.');
expect(checker.data.stream_segments[0].timestamp).toBe('2026-08-25T20:00:06.000Z');
const events = getSortedStepEvents(checker.data);
expect(events.map((e) => e.type)).toEqual(['text', 'tool', 'text']);
expect(events[0].data.text).toBe('Let me look at step 2. Done.');
expect(events[2].data.text).toBe('The alarm is there.');
});
it('builds the run outcome block once and keeps it idempotent', () => {
const outcome = {
type: 'checker_event',
timestamp: '2026-08-25T20:00:20.000Z',
data: { event: 'run_outcome', task_status: 'completed', tests: { passed: 1, failed: 0, inconclusive: 0, unchecked: 0 } }
};
const blocks = consolidateLogsToBlocks([outcome, outcome]);
expect(blocks.length).toBe(1);
expect(blocks[0].id).toBe('checker-outcome');
expect(blocks[0].data.phase).toBe('outcome');
expect(blocks[0].data.task_status).toBe('completed');
});
});
describe('stream aggregator step ownership', () => {
const plannerStream = {
type: 'llm_stream',
timestamp: '2026-09-02T03:57:25.000Z',
data: { execution_id: 'exec-planner', stream_type: 'text', text: 'I have formulated the task plan.', isCompleted: true }
};
const tap = { trace_id: 'trace-tap-3', type: 'action', name: 'tap', timestamp: 1788374289.37, payload: { args: { action: 'tap' } } };
it('does not fold a step without streamed text into an earlier untagged stream block', () => {
const blocks = consolidateLogsToBlocks([
plannerStream,
{
type: 'step_recorded',
timestamp: '2026-09-02T03:57:47.000Z',
data: { step_id: 's1', step_number: 1, operator_raw_thinking: 'Launching Maps', generic_tools: [] }
},
{
type: 'step_recorded',
timestamp: '2026-09-02T03:58:09.000Z',
data: { step_id: 's3', step_number: 3, generic_tools: [tap] }
}
]);
expect(blocks.map((b) => b.id)).toEqual(['stream-exec-planner', 'step-s1', 'step-s3']);
expect(blocks[0].type).toBe('llm_stream');
expect(blocks[0].data.generic_tools).toEqual([]);
expect(blocks[0].data.operator_raw_thinking).toBe('I have formulated the task plan.');
expect(blocks[2].data.generic_tools.map((t: any) => t.trace_id)).toEqual(['trace-tap-3']);
});
it('creates the step block for a tagged trace that arrives before its step is recorded', () => {
const blocks = consolidateLogsToBlocks([
{
type: 'step_recorded',
timestamp: '2026-09-02T03:58:03.000Z',
data: { step_id: 's2', step_number: 2, operator_raw_thinking: 'Dismissing the dialog', generic_tools: [] }
},
{
type: 'trace_recorded',
timestamp: '2026-09-02T03:58:09.300Z',
data: { ...tap, step_id: 's3', status: 'running' }
},
{
type: 'step_recorded',
timestamp: '2026-09-02T03:58:09.400Z',
data: { step_id: 's3', step_number: 3, generic_tools: [{ ...tap, status: 'success' }] }
}
]);
expect(blocks.map((b) => b.id)).toEqual(['step-s2', 'step-s3']);
expect(blocks[0].data.generic_tools).toEqual([]);
expect(blocks[1].data.generic_tools.length).toBe(1);
expect(blocks[1].data.generic_tools[0].status).toBe('success');
});
it('still attaches untagged traces to the latest preceding block', () => {
const blocks = consolidateLogsToBlocks([
plannerStream,
{
type: 'trace_recorded',
timestamp: '2026-09-02T03:57:26.000Z',
data: { trace_id: 'trace-note', type: 'tool', name: 'save_note', payload: { args: {} } }
}
]);
expect(blocks.length).toBe(1);
expect(blocks[0].data.generic_tools.map((t: any) => t.trace_id)).toEqual(['trace-note']);
});
});
describe('stream aggregator checker history relocation', () => {
it('moves persisted checker tool traces from the operator step to the attempt block', () => {
const blocks = consolidateLogsToBlocks([
{
type: 'step_updated',
timestamp: '2026-08-25T20:00:01.000Z',
history_snapshot: true,
data: {
step_id: 'step-1',
step_number: 1,
timestamp: 1756152001,
generic_tools: [
{ trace_id: 'trace-checker-1', type: 'agent', name: 'checker', status: 'success' },
{ trace_id: 'tool-probe', parent_trace_id: 'trace-checker-1', type: 'tool', name: 'probe_device', status: 'success' },
{ trace_id: 'tool-note', parent_trace_id: 'operator-trace', type: 'tool', name: 'save_note', status: 'success' }
]
}
},
{
type: 'checker_event',
timestamp: '2026-08-25T20:00:09.000Z',
checks_snapshot: true,
data: {
event: 'attempt_finished',
phase: 'checkpoint',
attempt_id: 'abc#1',
checkpoint_id: 'abc',
subgoal_text: 'Create the alarm',
trace_id: 'trace-checker-1',
status: 'done',
verdicts: [{ item_text: 'alarm exists', kind: 'verify', status: 'passed', evidence: 'seen' }],
ts: 1756152009
}
}
]);
const step = blocks.find((b) => b.id === 'step-step-1')!;
const checker = blocks.find((b) => b.id === 'checker-abc#1')!;
expect(step.data.generic_tools.map((t: any) => t.trace_id)).toEqual(['tool-note']);
expect(checker.data.generic_tools.map((t: any) => t.trace_id)).toEqual(['tool-probe']);
});
});
@@ -14,7 +14,7 @@
* limitations under the License.
*/
import { StepBlock, PhaseBlock, StepEvent } from '../core/models/stream.model';
import { StepBlock, PhaseBlock, StepEvent, StreamSegment } from '../core/models/stream.model';
import { isAndroidAction, isReportStatusAction, getReportStatusExplanation, getReportStatusValue } from './action-formatter.util';
import { getUniqueGenericTools } from './tool-formatter.util';
@@ -32,6 +32,145 @@ export function getItemTimestamp(ts: any, fallback: number = 0): number {
return fallback;
}
/**
* Index of the `checker` block that owns a trace id (the Checker's own agent
* trace). The Checker runs concurrently with Operator steps, so its streamed
* reasoning and tool traces are routed by `parent_trace_id`, never by the
* step that happens to be current.
*/
function findCheckerBlockByTrace(blocks: StepBlock[], parentTraceId: any): number {
if (!parentTraceId) return -1;
const key = String(parentTraceId);
return blocks.findIndex(b => b.type === 'checker' && b.data?.trace_id && String(b.data.trace_id) === key);
}
/** Latest still-running checker block (fallback when only the agent name is known). */
function findRunningCheckerBlock(blocks: StepBlock[]): number {
for (let i = blocks.length - 1; i >= 0; i--) {
if (blocks[i].type === 'checker' && blocks[i].data?.isCompleted === false) return i;
}
return -1;
}
/**
* Apply one `checker_event` (attempt_started / attempt_finished / run_outcome)
* to the block list. Attempts are keyed by attempt id so live events and the
* ledger backfill merge instead of duplicating.
*/
function applyCheckerLog(blocks: StepBlock[], log: any): void {
const d = log.data || {};
if (!d.event) return;
if (d.event === 'run_outcome') {
const id = 'checker-outcome';
const idx = blocks.findIndex(b => b.id === id);
const data = {
...(idx > -1 ? blocks[idx].data : {}),
...d,
phase: 'outcome',
status: 'done',
isCompleted: true,
generic_tools: []
};
const block: StepBlock = { id, type: 'checker', timestamp: log.timestamp, data };
if (idx > -1) blocks[idx] = block; else blocks.push(block);
return;
}
if (!d.attempt_id) return;
const id = `checker-${d.attempt_id}`;
const idx = blocks.findIndex(b => b.id === id);
const existing = idx > -1 ? blocks[idx] : null;
const ts = typeof d.ts === 'number' ? d.ts : (log.timestamp ? new Date(log.timestamp).getTime() / 1000 : undefined);
if (d.event === 'attempt_started') {
const data = {
...(existing?.data || {}),
...d,
// A late "started" must not resurrect a finished attempt.
status: existing?.data?.isCompleted ? existing.data.status : 'running',
isCompleted: existing?.data?.isCompleted ?? false,
started_at: existing?.data?.started_at ?? ts,
verdicts: existing?.data?.verdicts || [],
findings: existing?.data?.findings || [],
generic_tools: existing?.data?.generic_tools || []
};
const block: StepBlock = { id, type: 'checker', timestamp: existing?.timestamp || log.timestamp, data };
if (existing) blocks[idx] = block; else blocks.push(block);
return;
}
if (d.event === 'attempt_finished') {
const startedAt = existing?.data?.started_at ?? ts;
const verdicts = Array.isArray(d.verdicts) ? d.verdicts : (existing?.data?.verdicts || []);
const items = existing?.data?.items?.length
? existing.data.items
: verdicts.map((v: any) => ({ kind: v.kind, text: v.item_text, when: v.when }));
const data = {
...(existing?.data || {}),
...d,
status: d.status || 'done',
verdicts,
items,
findings: Array.isArray(d.findings) ? d.findings : (existing?.data?.findings || []),
generic_tools: existing?.data?.generic_tools || [],
started_at: startedAt,
finished_at: ts,
duration: startedAt !== undefined && ts !== undefined ? Math.max(0, ts - startedAt) : undefined,
isCompleted: true
};
const block: StepBlock = { id, type: 'checker', timestamp: existing?.timestamp || log.timestamp, data };
if (existing) blocks[idx] = block; else blocks.push(block);
}
}
/**
* Persisted steps list the Checker's tool traces (and its agent trace) under
* the Operator step that was current while it ran, because traces are stored
* by step id. Once the checker blocks exist (ledger backfill carries the
* attempt's trace id), move those traces to the attempt they belong to and
* drop the agent trace itself.
*/
function relocateCheckerTools(blocks: StepBlock[]): void {
const checkerByTrace = new Map<string, number>();
blocks.forEach((b, i) => {
if (b.type === 'checker' && b.data?.trace_id) {
checkerByTrace.set(String(b.data.trace_id), i);
}
});
if (checkerByTrace.size === 0) return;
blocks.forEach((block, i) => {
if (block.type === 'checker') return;
const tools: any[] = block.data?.generic_tools || [];
if (tools.length === 0) return;
const keep: any[] = [];
let moved = false;
for (const tool of tools) {
const ownTrace = tool?.trace_id ? String(tool.trace_id) : '';
const parent = tool?.parent_trace_id ? String(tool.parent_trace_id) : '';
if (ownTrace && checkerByTrace.has(ownTrace) && tool?.type === 'agent') {
moved = true; // the Checker's own agent trace: not a tool of the step
continue;
}
const target = parent ? checkerByTrace.get(parent) : undefined;
if (target === undefined) {
keep.push(tool);
continue;
}
const checker = blocks[target];
const existing: any[] = checker.data.generic_tools || [];
if (!existing.some((t) => t.trace_id && t.trace_id === tool.trace_id)) {
blocks[target] = { ...checker, data: { ...checker.data, generic_tools: [...existing, tool] } };
}
moved = true;
}
if (moved) {
blocks[i] = { ...block, data: { ...block.data, generic_tools: keep } };
}
});
}
/**
* Consolidate raw SSE logs into deduplicated and ordered StepBlocks
*/
@@ -43,6 +182,11 @@ export function consolidateLogsToBlocks(rawLogs: any[]): StepBlock[] {
rawLogs.forEach(log => {
if (!log) return;
if (log.type === 'checker_event') {
applyCheckerLog(blocks, log);
return;
}
if (log.type === 'llm_stream') {
const execId = log.data?.execution_id || log.id;
const stepId = log.data?.step_id;
@@ -50,6 +194,52 @@ export function consolidateLogsToBlocks(rawLogs: any[]): StepBlock[] {
const text = log.data?.text || '';
const isCompleted = log.data?.isCompleted ?? false;
// The Checker's own reasoning streams under its agent trace: route it to
// the attempt block, never to the Operator step that is current.
const checkerIndex = findCheckerBlockByTrace(blocks, log.data?.parent_trace_id);
if (checkerIndex > -1) {
const checkerBlock = blocks[checkerIndex];
const checkerData: any = { ...checkerBlock.data };
// The Checker is a multi-turn tool loop: every LLM turn streams under
// its own execution id. Keep one segment per turn (with its first-seen
// time) so the timeline can interleave the text with the tool calls it
// triggered, instead of collapsing all turns into one early block.
const segments: StreamSegment[] = Array.isArray(checkerData.stream_segments)
? [...checkerData.stream_segments]
: [];
const segmentIndex = segments.findIndex(
(s) => s.execution_id === execId && s.stream_type === streamType
);
const segment: StreamSegment = {
execution_id: execId,
stream_type: streamType === 'thinking' ? 'thinking' : 'text',
text,
timestamp: segmentIndex > -1 ? segments[segmentIndex].timestamp : log.timestamp,
isCompleted
};
if (segmentIndex > -1) segments[segmentIndex] = segment; else segments.push(segment);
checkerData.stream_segments = segments;
// Flat fields stay as the joined text for consumers that only know steps.
const joined = (type: string) => segments
.filter((s) => s.stream_type === type && s.text.trim())
.map((s) => s.text)
.join('\n\n');
checkerData.operator_native_thinking = joined('thinking');
checkerData.operator_raw_thinking = joined('text');
if (streamType === 'thinking') {
checkerData.operator_native_thinking_timestamp =
checkerData.operator_native_thinking_timestamp || log.timestamp;
} else {
checkerData.operator_raw_thinking_timestamp =
checkerData.operator_raw_thinking_timestamp || log.timestamp;
}
if (execId && !checkerData.execution_id) {
checkerData.execution_id = execId;
}
blocks[checkerIndex] = { ...checkerBlock, data: checkerData };
return;
}
// Find existing block: match by stepId if available, or by execId, or active incomplete block
let existingIndex = -1;
if (stepId) {
@@ -111,15 +301,12 @@ export function consolidateLogsToBlocks(rawLogs: any[]): StepBlock[] {
// Find if there is an existing block for this step or an unattached stream block from this turn
let existingIndex = blocks.findIndex(b => b.id === `step-${stepId}` || b.data?.step_id === stepId);
// If not found by step_id, check if there is an open stream block from the same execution turn
if (existingIndex === -1) {
for (let i = blocks.length - 1; i >= 0; i--) {
if (blocks[i].type === 'llm_stream' && (!blocks[i].data?.step_id || blocks[i].data?.step_id === stepId)) {
existingIndex = i;
break;
}
}
}
// No adoption of untagged stream blocks here: the Operator's streams
// always carry the step id (Perception / the Flash turn pre-allocate it
// before the LLM call), so a stream without one belongs to another agent
// (Planner, Checker...). A step whose Operator emitted only tool calls
// and no text used to steal the latest such block -- typically the
// Planner's -- and render its action inside it at the top of the timeline.
if (existingIndex > -1) {
const existingBlock = blocks[existingIndex];
@@ -185,8 +372,18 @@ export function consolidateLogsToBlocks(rawLogs: any[]): StepBlock[] {
let stepId = log.data.step_id;
let existingIndex = -1;
// 0. Tools invoked by the Checker belong to its attempt block (routed by
// the parent trace, falling back to the running attempt by agent name).
let checkerIndex = findCheckerBlockByTrace(blocks, log.data.parent_trace_id);
if (checkerIndex === -1 && String(log.data.agent_name || '').toLowerCase() === 'checker') {
checkerIndex = findRunningCheckerBlock(blocks);
}
if (checkerIndex > -1) {
existingIndex = checkerIndex;
}
// 1. If this trace_id already exists in some block, update that block directly
if (log.data.trace_id) {
if (existingIndex === -1 && log.data.trace_id) {
existingIndex = blocks.findIndex(b =>
b.data?.generic_tools?.some((t: any) => t.trace_id === log.data.trace_id)
);
@@ -202,8 +399,13 @@ export function consolidateLogsToBlocks(rawLogs: any[]): StepBlock[] {
existingIndex = blocks.findIndex(b => b.id === 'step-pre-planning' || b.data?.step_id === 'pre-planning');
}
// 4. Otherwise find by timestamp among existing step blocks
if (existingIndex === -1) {
// 4. Untagged traces only: attach to the latest block that precedes
// them. A trace that names a step whose block does not exist yet
// (tool call before any streamed text, or an action recorded before
// the step in Flash) creates that step's block below instead of being
// glued to an earlier step, where the later `step_recorded` (which
// carries the same trace) would leave a duplicate card.
if (existingIndex === -1 && !stepId) {
const logTime = log.timestamp ? new Date(log.timestamp).getTime() : 0;
let maxStepTime = -1;
for (let i = 0; i < blocks.length; i++) {
@@ -255,6 +457,8 @@ export function consolidateLogsToBlocks(rawLogs: any[]): StepBlock[] {
}
});
relocateCheckerTools(blocks);
blocks.sort((a, b) => {
const timeA = a.timestamp ? new Date(a.timestamp).getTime() : 0;
const timeB = b.timestamp ? new Date(b.timestamp).getTime() : 0;
@@ -470,13 +674,17 @@ export function getSortedStepEvents(
const rawText = typeof stepData.operator_raw_thinking === 'string'
? stepData.operator_raw_thinking.trim()
: '';
const segments: StreamSegment[] | null = Array.isArray(stepData.stream_segments) && stepData.stream_segments.length > 0
? stepData.stream_segments
: null;
const signature = [
toolsLen,
actionTs ?? '',
stepData.operator_native_thinking_timestamp ?? '',
stepData.operator_raw_thinking_timestamp ?? '',
nativeText.length,
rawText.length
rawText.length,
segments ? segments.map((s) => `${s.execution_id}:${s.stream_type}:${s.text.length}`).join(',') : ''
].join('|');
if (cache) {
@@ -492,6 +700,19 @@ export function getSortedStepEvents(
// Text and tools share one timeline. Live streams carry their first-seen
// timestamps; persisted steps use the step timestamp as a stable fallback.
if (segments) {
// Multi-turn agent (Checker): one event per streamed turn, each at the
// time its first chunk arrived, so turns sort between the tool calls.
segments.forEach((segment) => {
if (!segment.text || !segment.text.trim()) return;
events.push({
type: segment.stream_type === 'thinking' ? 'thinking' : 'text',
data: { text: segment.text, execution_id: segment.execution_id, segment: true },
timestamp: getItemTimestamp(segment.timestamp, fallbackTime),
sequence: sequence++
});
});
} else {
if (nativeText) {
events.push({
type: 'thinking',
@@ -509,6 +730,7 @@ export function getSortedStepEvents(
sequence: sequence++
});
}
}
// Add action_taken if present and valid.
if (stepData.action_taken && (isAndroidAction(stepData.action_taken) || isReportStatusAction(stepData.action_taken))) {
@@ -675,10 +675,20 @@ export function getToolDisplayLabel(tool: any, isFirstSaveNote: boolean = false)
const q = args.specific_query || args.query || '';
return q ? `Analyzing audio track: "${q}"` : 'Analyzing recording audio track';
}
case 'get_step_details':
return 'Retrieving step execution details';
case 'get_step_screenshot':
return 'Inspecting screenshot evidence';
case 'get_step_detail':
case 'get_step_details': {
const n = args.step_no ?? args.step_number ?? args.step;
return n !== undefined && n !== '' ? `Reviewing step ${n}` : 'Reviewing step details';
}
case 'get_step_screenshot': {
const n = args.step_no ?? args.step_number ?? args.step;
const which = String(args.which || '').toLowerCase() === 'post' ? 'after' : 'before';
return n !== undefined && n !== '' ? `Looking at the screen ${which} step ${n}` : 'Looking at a step screenshot';
}
case 'probe_device': {
const kind = args.kind ? String(args.kind).replace(/_/g, ' ') : '';
return kind ? `Reading ${kind} from the device` : 'Reading device state';
}
case 'search_history_for_text': {
const q = args.text || args.query || '';
return q ? `Searching execution trace for "${q}"` : 'Searching execution trace';
@@ -754,10 +764,13 @@ export function getToolIcon(tool: any): string {
return 'smart_toy';
case 'analyze_audio_only':
return 'graphic_eq';
case 'get_step_detail':
case 'get_step_details':
return 'manage_search';
case 'get_step_screenshot':
return 'image_search';
case 'probe_device':
return 'sensors';
case 'search_history_for_text':
return 'find_in_page';
case 'outputter':
+1 -1
View File
@@ -14,7 +14,7 @@
"""☕ ARTEMIS: Autonomous Multimodal Android Agent & Testing Framework."""
__version__ = "0.2.0"
from artemis._version import __version__
from artemis.interfaces.sdk.client import ArtemisClient, ConcurrencyMode
from artemis.interfaces.sdk.task import StreamEvent, StreamEventType, Task
+30
View File
@@ -0,0 +1,30 @@
# Copyright 2026 Google LLC
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Runtime package version sourced exclusively from distribution metadata."""
from importlib.metadata import PackageNotFoundError, version
def get_version() -> str:
"""Return the installed Artemis version, or an honest source-tree sentinel."""
try:
return version("artemis")
except PackageNotFoundError:
return "0+unknown"
__version__ = get_version()
__all__ = ["__version__", "get_version"]
+54 -4
View File
@@ -39,10 +39,10 @@ from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, Literal
from langchain_core.messages import HumanMessage, SystemMessage, ToolMessage
from langchain_core.messages import AIMessage, HumanMessage, SystemMessage, ToolMessage
from langchain_core.tools import BaseTool, tool
from pydantic import BaseModel, Field
from pydantic import BaseModel, Field, ValidationError
from artemis.constants import CHECKER_MAX_ITERATIONS
from artemis.context import ArtemisContext
@@ -416,14 +416,23 @@ def _format_check_items(check_items: list) -> str:
async def _structured_report(llm, messages) -> CheckReport:
structured_llm = llm.with_structured_output(CheckReport)
# A conversation must end with a user turn: Gemini rejects requests whose
# last message is the model's own answer ("Requests ending with a model
# turn are not supported"), which is exactly the state after the loop's
# final tool-free reply. Nudge with an explicit verdict request instead.
if messages and isinstance(messages[-1], AIMessage):
messages = [
*messages,
HumanMessage(content="Now provide your structured verdict report."),
]
result = await invoke_llm_with_timeout_message(structured_llm.ainvoke(messages))
if isinstance(result, CheckReport):
return result
if isinstance(result, dict):
try:
return CheckReport.model_validate(result)
except Exception:
pass
except ValidationError as exc:
logger.debug("Structured verdict dict failed CheckReport validation: %s", exc)
return CheckReport(verdicts=[])
@@ -502,6 +511,17 @@ async def _run_check_loop(
shot = _load_step_screenshot(
ctx, int(args.get("step_no", -1)), str(args.get("which", "pre"))
)
# The image is spliced in below; invoking the (traced) tool as
# well records the lookup as a tool trace so the timeline shows
# it in order with the rest of the check.
screenshot_tool = next((t for t in traced_tools if t.name == tool_name), None)
if screenshot_tool is not None:
try:
await invoke_tool_with_injection(
tool=screenshot_tool, args=args, tool_call_id=tc["id"]
)
except Exception as e:
logger.debug(f"Screenshot lookup trace not recorded: {e}")
messages.append(ToolMessage(tool_call_id=tc["id"], content=shot.description))
if shot.image_b64:
messages.append(
@@ -547,6 +567,16 @@ async def _run_check_loop(
# --- Entry points --------------------------------------------------------------------
def _announce(ctx: ArtemisContext, **kwargs) -> None:
"""Lazy bridge to the checkpoint bus (avoids an import cycle at module load)."""
try:
from artemis.graph.checkpoints import announce_checker_attempt
announce_checker_attempt(ctx, **kwargs)
except Exception as e:
logger.debug(f"Checker attempt announcement skipped: {e}")
@trace(type="agent", name="checker")
async def run_checkpoint_check(
ctx: ArtemisContext,
@@ -554,6 +584,7 @@ async def run_checkpoint_check(
anchor,
goal: str,
subgoal_text: str,
attempt_id: str | None = None,
) -> CheckReport:
"""Audits one completed subgoal's on_complete check items.
@@ -563,6 +594,15 @@ async def run_checkpoint_check(
"""
items = list(check_items)
logger.info(f"Starting checkpoint check for '{subgoal_text}' ({len(items)} item(s))")
_announce(
ctx,
attempt_id=attempt_id,
phase="checkpoint",
checkpoint_id=attempt_id.split("#", 1)[0] if attempt_id else "",
subgoal_text=subgoal_text,
anchor_step_id=getattr(anchor, "anchor_step_id", None),
check_items=items,
)
prompts = _load_prompts()
tools = build_checker_tools(ctx, "checkpoint")
probe_registered = any(t.name == "probe_device" for t in tools)
@@ -655,11 +695,21 @@ async def run_final_check(
plan_text: str,
ledger: list[dict],
check_items,
attempt_id: str | None = None,
) -> CheckReport:
"""Final review at task exit: audits the user's original goal and all
declared check items, with the final screen state available."""
items = list(check_items)
logger.info(f"Starting final check ({len(items)} declared item(s))")
_announce(
ctx,
attempt_id=attempt_id,
phase="final",
checkpoint_id="final",
subgoal_text="Final review against the user's original goal",
anchor_step_id=None,
check_items=items,
)
prompts = _load_prompts()
tools = build_checker_tools(ctx, "final")
probe_registered = any(t.name == "probe_device" for t in tools)
+1 -1
View File
@@ -17,7 +17,7 @@
The Explorer implementation is split across several modules, but external
callers (and ``unittest.mock.patch`` targets in the test suite) address
collaborators such as ``settings``, ``StorageManager``, ``draw_dots``,
``search_ui_func``, ``get_llm`` etc. through the historical module path
``get_llm`` etc. through the historical module path
``artemis.agents.explorer.explorer``. Split-out code therefore resolves those
names at call time via :func:`facade` instead of importing them directly, so
patches applied to the facade module keep affecting the split-out code.
+34 -57
View File
@@ -12,61 +12,38 @@
# See the License for the specific language governing permissions and
# limitations under the License.
"""Constants and prompt templates for the UI Explorer agent."""
"""The ``ask_explorer`` tool contract as seen by calling agents.
EXPLORE_DESCRIPTIONS = {
"flash": {
"description": (
"[EXPLORER] Call this to ask the UI Explorer agent to "
"locate coordinates on the screen layout by object detection."
),
"query_description": (
"Pipe-separated list of target element descriptions, semantic"
" action terms, or color descriptors to search for concurrently"
" (e.g., 'red settings button | gear icon')."
),
"rule_prompt": (
"Use the explorer tool to quickly check for visual icons, buttons, or text by name."
),
},
"pro": {
"description": (
"[EXPLORER] Call this to ask the UI Explorer agent to locate"
" coordinates on the screen layout by executing UI search,"
" coordinate search, and object detection."
),
"query_description": (
"The target element or information to search for, including descriptions."
),
"rule_prompt": (
"Call the explorer tool to search or inspect it (maximum {max_iterations} tries)."
),
"version_prompt": (
"You are running with a maximum of {max_iterations} turns: on your"
" final turn, you will receive a warning reminding you to call"
" submit_answer."
),
},
"ultra": {
"description": (
"[EXPLORER] Full visual reasoning agent. Deeply inspects screen"
" regions and utilizes all perception/pixel-level tools with a high"
" execution limit (up to {max_iterations} turns) for"
" high-difficulty or layout-critical tasks."
),
"query_description": (
"The target element or information to search for, including descriptions."
),
"rule_prompt": (
"For highly complex visual searches, details validation, or layout"
" verification, invoke the explorer tool to perform multi-turn"
" visual reasoning and detailed coordinate checks (maximum"
" {max_iterations} tries)."
),
"version_prompt": (
"You are running with a maximum of {max_iterations} turns: on your"
" final turn, you will receive a warning reminding you to call"
" submit_answer."
),
},
}
The contract is deliberately tier-agnostic: the Operator, Validator and Flash
runner receive the same tool name, description and argument schema whichever
Explorer tier the user configured. Tier behavior lives in
:mod:`artemis.agents.explorer.tiers`.
"""
ASK_EXPLORER_TOOL_NAME = "ask_explorer"
ASK_EXPLORER_DESCRIPTION = (
"[EXPLORER] Ask the UI Explorer to locate elements on the current screen"
" that are missing from the indexed element list or whose listed"
" coordinates look wrong. Every element it finds is appended to the"
" indexed list with a new index and returned with its normalized [x, y]"
" coordinate, so you can act on it right away."
)
ASK_EXPLORER_QUERY_DESCRIPTION = (
"What to find, described the way you see it on the screenshot: visible"
" text, icon shape, color, position, or nearby landmarks (e.g. 'gear icon"
" top-right', 'blue Send button below the message box'). Several targets"
" may be listed separated by ' | '."
)
ASK_EXPLORER_CONTEXT_FEEDBACK_DESCRIPTION = (
"Optional. What went wrong with earlier attempts or extra hints, e.g."
" which returned candidates were wrong and why."
)
#: Fed to the Explorer's own system prompt for loop engines.
EXECUTION_CONSTRAINT_TEMPLATE = (
"You are running with a maximum of {max_turns} turns: on your final turn"
" you will receive a warning and only `submit_answer` will be available."
)
+49 -19
View File
@@ -1,27 +1,57 @@
// Explorer system prompt. Each section renders as "# SECTION" followed by
// "- bullet" lines. A bullet is either a plain string or an object
// {"text": "...", "requires": ["tool_a", ...]} that is emitted only when
// every listed tool is exposed for the active tier (see tiers.py).
// Tools by tier: pro = ask_perception_tool; ultra = ask_perception_tool,
// detect_objects, get_ocr_list, inspect_region, ask_image_processor.
// submit_answer is always available.
{
"IDENTITY": "You are the agentic UI Explorer designed to identify objects, scan text, and extract UI attributes on a phone screen.",
"IDENTITY": "You are the agentic UI Explorer: given a phone screenshot and a query, you locate the matching UI element(s) and return their normalized [nx, ny] coordinates on a 0-1000 scale.",
"OPERATING PRINCIPLES": [
"**Confidence-Driven Cost-Benefit Strategy**: Maximize localization accuracy while minimizing execution latency and tool calls:",
" - **Direct Submit (High Confidence)**: If you are highly confident in the target coordinates from the initial annotated screenshot and minimal list, call `submit_answer` immediately in Turn 1 to save time.",
" - **Cost-Benefit Turn Planning (Low Confidence / Ambiguity)**: If you are not highly confident. Consider structuring your exploration turns by weighing the latency/cost of each tool against the expected accuracy payoff:",
" - **Parallel Context Gathering**: MUST parallel calling all perception tools in the first turn to gather broad screen context.",
" - **Targeted Verification**: If micro-details or coordinates are still ambiguous, zoom in on promising candidate areas (parallel tool calls). Note that a single zoom-in or multi-zoom-in operation takes one turn, plan your verification steps accordingly.",
" - **Finalization**: Submit coordinates using `submit_answer`. Stop exploring as soon as further tool calls would yield negligible accuracy gains relative to their latency.",
"**Tool Capabilities**: You have three classes of tools: `Perception` tools (to extract and locate elements, coordinates, or inspect specific regions), `Transformation` tools (for pixel-level image editing), and the `Finalization` tool (to submit final answers and terminate). Note that in your final turn, all other tools are stripped away, leaving only the finalization tool, forcing you to submit your final answer.",
"**Parallel vs Sequential Invocations**: You are fully supported to call perception tools in parallel (within a single turn) to expedite your search. However, transformation tools are expected to be called sequentially (since they transform the image for subsequent perception steps).",
"**Multimodal Perception**: You have access to images marked with bounding boxes and labels. Use both the visual information in the images and the structured text lists to make decisions.",
"**Image Pool Tracking**: Images are managed in an Image Pool (starting with 'img_0'). Transformations via transformation tools will register new image IDs (e.g., 'img_1').",
"**Prefix System**: Elements in the screenshots are labeled with prefixes returned by your tools (e.g., Numbers for initial UI, letters for tool outputs). Always reference these precise labels (e.g., \"I choose S2\") when making decisions.",
"**Contextual Awareness**: Always prioritize checking the context feedback to learn from previous mistakes.",
"**Ask Perception Tool**: Call the unified `ask_perception_tool` specifying `search_query`, `nx`, `ny`, and `detect_queries` to perform text lookups, coordinate queries, and visual detection concurrently.",
"**Locating Components**: Locate target components on screen by matching the X-labels (from text/coordinate searches) and O-labels (from object detection) in the text response with their corresponding green, blue, or red indicator dots on the output images."
"**Confidence-Driven Cost-Benefit Strategy**: Maximize localization accuracy while minimizing turns and tool calls. Every turn costs latency; stop exploring as soon as further calls would yield negligible accuracy gains.",
"**Direct Submit (High Confidence)**: If the initial annotated screenshot and the marked UI element list already make the target coordinates obvious, call `submit_answer` immediately in the first turn.",
{
"text": "**Parallel Context Gathering (Low Confidence / Ambiguity)**: If you are not confident, call `ask_perception_tool` once in the first turn with `search_query` (the visible text or name of the target), `nx`/`ny` (the most plausible spot for the target, to audit which element actually sits there) and `detect_queries` (short visual descriptions of the target). It runs the UI-tree text search, the coordinate audit and visual object detection concurrently and returns all three results with annotated screenshots. Reconcile the results, then call `submit_answer`.",
"requires": ["ask_perception_tool"]
},
{
"text": "**Targeted Verification**: If micro-details or exact coordinates are still ambiguous after context gathering, zoom in on the promising candidate areas with `inspect_region` (several regions may be inspected in parallel within one turn). Each zoom-in turn costs one of your turns, so plan verification steps accordingly.",
"requires": ["inspect_region"]
},
{
"text": "**Focused Detection**: Use `detect_objects` on its own when you only need visual detection (for example after a transformation, on a derived image ID), instead of re-running the full `ask_perception_tool` bundle.",
"requires": ["detect_objects"]
},
{
"text": "**Text Inventory**: Use `get_ocr_list` when the target is text that the UI tree does not expose (canvas-rendered or image text); it lists every OCR-detected text with coordinates.",
"requires": ["get_ocr_list"]
},
"**Finalization**: Submit coordinates using `submit_answer`. In your final turn all other tools are stripped away, leaving only `submit_answer`, so never postpone the submission past the point where you are reasonably confident.",
"**Tool Classes**: `Perception` tools extract and locate elements, coordinates and text on the screen; the `Finalization` tool (`submit_answer`) submits your ranked candidates and terminates the search.",
{
"text": "**Transformation Tool**: `ask_image_processor` performs pixel-level image editing (cropping, upscaling, masking, contrast changes) and registers the resulting images in the Image Pool. It is slow and expensive; use it only when perception on the original screenshot is insufficient, and call it sequentially because later perception steps depend on its output.",
"requires": ["ask_image_processor"]
},
{
"text": "**Parallel vs Sequential Invocations**: Perception tools may be called in parallel within a single turn to expedite the search; transformation tools must be called sequentially.",
"requires": ["inspect_region", "ask_image_processor"]
},
"**Multimodal Perception**: Tool results come as text lists plus annotated screenshots with labeled dots. Use both the visual evidence and the structured text to decide.",
{
"text": "**Image Pool Tracking**: Images are managed in an Image Pool starting with `img_0` (the original screenshot). Transformations register new IDs (`img_1`, `img_2`, ...) that can be passed as `target_image_id` to `detect_objects`. Coordinates returned for derived images are mapped back to the original screen automatically.",
"requires": ["ask_image_processor", "detect_objects"]
},
"**Label Prefixes**: Plain numbers label the elements of the initial marked UI list. Labels starting with `X` are UI-tree matches from the text search (green dots) and the coordinate audit (blue dot). Labels starting with `O` are OCR text matches (green dots). Labels starting with `D` are visually detected objects (red dots). Always reference these exact labels when reasoning (e.g. \"I choose X2\").",
"**Label Inheritance**: A candidate you submit under a plain-number, `X` or `O` label inherits that element's exact bounds, which lets the caller verify the tap target. When a detection point (`D`) and such a label describe the same element, submit the `X`/`O`/number label rather than the raw detection point.",
"**Locating Components**: Match each label in a tool's text response with its dot of the corresponding color on the returned annotated screenshot to confirm where the element really is before trusting its coordinates.",
"**Contextual Awareness**: Always check the context feedback first to learn from previous mistakes (for example candidates that were already rejected)."
],
"BEHAVIORAL GUIDELINES & SOFT CONSTRAINTS": [
"**Avoid Hallucination**: Avoid guessing coordinates. If you are unable to locate the target element on the screen after trying all perception options, submit `[]` in `candidates` and document your observations as specified in the `submit_answer` schema.",
"**Ambiguity Resolution**: If the screen contains multiple identical elements or several candidates that match the query (e.g., three \"Add\" buttons), you are encouraged to return a ranked list of all viable candidates (up to 10 candidates) via `submit_answer`, ordered by your confidence, with brief descriptions to distinguish them.",
"**Semantic & Icon Synonym Fallback**: Literal exact matches are ideal, but semantic equivalents and synonyms are fully acceptable. If a text element is requested but only a corresponding icon is available, you should attempt to map the synonym and locate the icon (e.g., map \"Search\" to a magnifying glass icon, map \"Add\" to \"+\" symbol)."
"**Avoid Hallucination**: Never guess coordinates. If the target cannot be located after using the available perception options, submit an empty `candidates` list and document your observations in `fallback_message`.",
"**Ambiguity Resolution**: If several elements match the query (e.g. three \"Add\" buttons), return a ranked list of all viable candidates (up to 10) via `submit_answer`, ordered by confidence, with brief descriptions that distinguish them.",
"**Semantic & Icon Synonym Fallback**: Literal matches are ideal, but semantic equivalents and synonyms are acceptable. If text is requested but only a corresponding icon exists, map the synonym and locate the icon (e.g. \"Search\" to a magnifying glass, \"Add\" to a \"+\" symbol)."
],
"STRICT CONSTRAINTS": [
"**Validation Standards**: All submitted coordinates `[nx, ny]` for candidates in `submit_answer` must strictly be integers in the `[0-1000]` range inclusive. If your parameters fail business validation rules, you will receive dynamic error responses in your execution loop, allowing you to self-correct and re-submit."
"**Validation Standards**: Every submitted `coords` value must be `[nx, ny]` with both integers in the inclusive `[0-1000]` range. If a submission fails validation you receive the errors as a tool response so you can correct and re-submit."
]
}
+379 -126
View File
@@ -17,6 +17,7 @@
This module is the stable entry point (and ``mock.patch`` target namespace)
for the Explorer agent. The implementation is split across sibling modules:
- ``tiers``: the flash / pro / ultra tier table (single source of truth).
- ``tool_declarations``: static tool schemas (universal + native).
- ``perception_tools``: the ``exec_*`` perception/vision tool method group.
- ``universal_runner``: the LangChain-based ``_run_universal`` loop.
@@ -24,42 +25,41 @@ for the Explorer agent. The implementation is split across sibling modules:
- ``native_runner``: the native Gemini reasoning loop of ``run``.
All historical module-level names (``settings``, ``StorageManager``,
``genai``, ``search_ui_func``, ``search_by_coordinates_func``,
``_run_object_detection``, ``draw_dots``, ``perform_ocr``,
``genai``, ``_run_object_detection``, ``draw_dots``, ``perform_ocr``,
``is_ocr_configured``, ``get_llm``, ``UNIVERSAL_EXPLORER_TOOLS``, ...) remain
importable and patchable here; split-out code resolves them through this
module at call time (see ``artemis.agents.explorer._facade``).
"""
import asyncio
import base64
import glob
import hashlib
import json
import os
from pathlib import Path
import re
import time
from typing import Any, Literal
import cv2
from google import genai
from google.genai import types
import httpx
from langchain_core.messages import (
BaseMessage,
HumanMessage,
SystemMessage,
ToolMessage,
)
from artemis.agents.image_processor.image_processor import ImageProcessor
from artemis.agents.explorer.geometry import (
FALLBACK_SCREEN_SIZE,
is_valid_norm_point,
norm_to_pixel,
pixel_to_norm,
)
from artemis.agents.explorer.native_runner import NativeRunnerMixin
from artemis.agents.explorer.perception_tools import PerceptionToolsMixin
from artemis.agents.explorer.run_setup import RunSetupMixin
from artemis.agents.explorer.screen_index import normalize_text, ScreenElement, ScreenIndex
from artemis.agents.explorer.tiers import ExplorerTier, get_tier
from artemis.agents.explorer.tool_declarations import (
NATIVE_EXPLORER_TOOL_DECLARATIONS,
UNIVERSAL_EXPLORER_TOOLS,
)
from artemis.agents.explorer.universal_runner import UniversalRunnerMixin
from artemis.agents.object_detector.object_detector import _run_object_detection
from artemis.config import settings
from artemis.constants import SAFETY_SETTINGS_BLOCK_NONE
from artemis.context import ArtemisContext
from artemis.data_engine.storage import StorageManager
from artemis.data_engine.trace import TraceSpan, trace
from artemis.data_engine.trace import trace
from artemis.graph.state import State
from artemis.llm.reliability import (
LLMExhaustedError,
@@ -68,28 +68,41 @@ from artemis.llm.reliability import (
retry_policy_for,
)
from artemis.services.llm import _record_llm_event, _record_llm_retry, get_llm
# Import diagnostic functions directly
from artemis.agents.explorer.constants import EXPLORE_DESCRIPTIONS
from artemis.agents.explorer.native_runner import NativeRunnerMixin
from artemis.agents.explorer.perception_tools import PerceptionToolsMixin
from artemis.agents.explorer.run_setup import RunSetupMixin
from artemis.agents.explorer.tool_declarations import (
NATIVE_EXPLORER_TOOL_DECLARATIONS,
UNIVERSAL_EXPLORER_TOOLS,
)
from artemis.agents.explorer.universal_runner import UniversalRunnerMixin
from artemis.mcp.xml_search_server import (
search_by_coordinates as search_by_coordinates_func,
search_ui as search_ui_func,
)
from artemis.utils.logger import get_logger
from artemis.utils.ocr_api import is_ocr_configured, perform_ocr
from artemis.utils.ocr_xml_fusion import fuse_ocr_with_xml
from artemis.utils.visualization import draw_dots, format_minimal_list_with_points
from artemis.utils.visualization import draw_dots
logger = get_logger(__name__)
#: Public surface plus the late-bound collaborators the split-out modules
#: resolve through this facade (``mock.patch`` targets for the test suite).
__all__ = [
"DEFAULT_EXPLORER_MODEL",
"Explorer",
"NATIVE_EXPLORER_TOOL_DECLARATIONS",
"StorageManager",
"UNIVERSAL_EXPLORER_TOOLS",
"_generate_content_with_reliability",
"_run_object_detection",
"draw_dots",
"genai",
"get_llm",
"is_ocr_configured",
"logger",
"perform_ocr",
"settings",
]
#: Model used when the LLM configuration carries no Explorer entry.
DEFAULT_EXPLORER_MODEL = "gemini-3.8-flash"
#: Environment override for Gemini explicit context caching ("true" / "false").
CACHING_ENV_VAR = "ARTEMIS_EXPLORER_CACHING"
#: How far outside its labeled element (as a fraction of the screen, per side)
#: a candidate's point may fall and still inherit that element's bounds.
LABEL_TOLERANCE_RATIO = 0.02
async def _generate_content_with_reliability(operation, *, label: str = "Explorer model call"):
"""Run one native google-genai model call under the shared reliability layer.
@@ -111,9 +124,7 @@ async def _generate_content_with_reliability(operation, *, label: str = "Explore
attempt += 1
failure = classify_failure(call_err)
if not failure.retryable:
logger.error(
f"{label} permanently failed [{failure.category.value}]: {call_err}"
)
logger.error(f"{label} permanently failed [{failure.category.value}]: {call_err}")
_record_llm_event(
"llm_gave_up",
{
@@ -132,8 +143,7 @@ async def _generate_content_with_reliability(operation, *, label: str = "Explore
policy = retry_policy_for(failure.category)
if attempt >= policy.max_attempts:
logger.error(
f"{label} exhausted {attempt} attempt(s)"
f" [{failure.category.value}]: {call_err}"
f"{label} exhausted {attempt} attempt(s) [{failure.category.value}]: {call_err}"
)
_record_llm_event(
"llm_gave_up",
@@ -169,6 +179,15 @@ async def _generate_content_with_reliability(operation, *, label: str = "Explore
class Explorer(PerceptionToolsMixin, UniversalRunnerMixin, RunSetupMixin, NativeRunnerMixin):
"""Locates UI elements on a screenshot for the ``ask_explorer`` tool.
The tier (flash / pro / ultra) is applied per ``run`` call from
:data:`artemis.agents.explorer.tiers.EXPLORER_TIERS`; it decides the
engine, the turn budget, the exposed perception tools and the caching
default. Construction is side-effect free: no network client is created
until the native Gemini branch actually needs one.
"""
DENYLIST_TEMPLATE = (
"\n# TOOL DENYLIST\n- The following tools are denylisted and cannot be used: {tools}\n"
)
@@ -177,61 +196,150 @@ class Explorer(PerceptionToolsMixin, UniversalRunnerMixin, RunSetupMixin, Native
def __init__(self, ctx: ArtemisContext):
self.ctx = ctx
self.global_label_idx = 1
self.width = 1080
self.height = 2400
self.image_name = None
self.screenshot_path = None
self.image_pool = {}
self.width, self.height = FALLBACK_SCREEN_SIZE
self.image_name: str | None = None
self.screenshot_path: str | None = None
self.image_pool: dict[str, dict[str, Any]] = {}
self.next_img_id = 1
self.http_client = None
self.client = None
self.turn_latencies: list[float] = []
self.turn_cached_tokens: list[int] = []
self.trace_history: list[dict[str, Any]] = []
# Rebuilt per ``run``: the queryable snapshot of the screen, and the
# element behind every label the model has been shown so far.
self.screen_index: ScreenIndex = ScreenIndex.empty(self.width, self.height)
self.label_registry: dict[str, ScreenElement] = {}
# No tier is applied until ``run``; until then only the user's own
# denylist (plus unconfigured OCR) restricts the tool set.
self.tier: ExplorerTier | None = None
self.max_turns = 0
self._user_denylist = self._load_user_denylist()
self.denylisted_tools = self._compute_denylist(None)
self.model_name = self._resolve_model_name()
self.use_native_gemini = self._detect_native_engine()
# ------------------------------------------------------------------ #
# Engine selection (no side effects)
# ------------------------------------------------------------------ #
def _resolve_model_name(self) -> str:
"""Returns the configured Explorer model name without a provider prefix."""
llm_config = getattr(self.ctx, "llm_config", None)
llm_cfg = getattr(llm_config, "explorer", None) if llm_config else None
model = getattr(llm_cfg, "model", None) if llm_cfg else None
model_name = model if isinstance(model, str) and model else DEFAULT_EXPLORER_MODEL
if "/" in model_name:
model_name = model_name.split("/")[-1]
return model_name
def _detect_native_engine(self) -> bool:
"""True when the native google-genai SDK should drive the reasoning loop.
A Gemini model alone is not enough: without a Google API key (or a
client already shared on the context) the SDK cannot be constructed,
so such configurations run through the universal LangChain engine.
"""
if "gemini" not in self.model_name.lower():
return False
if getattr(self.ctx, "_genai_client", None) is not None:
return True
api_key = getattr(settings, "GOOGLE_API_KEY", None)
return bool(api_key and api_key.get_secret_value())
def _ensure_genai_client(self):
"""Returns the context-level GenAI client, creating it on first use.
Only the native branch calls this, so universal configurations never
touch ``genai`` (and never fail on a missing Google key). The client
is stored on the context for connection pooling across agents.
"""
client = getattr(self.ctx, "_genai_client", None)
if client is None:
logger.info("Initializing GenAI client on context for connection pooling (Explorer)")
api_key = getattr(settings, "GOOGLE_API_KEY", None)
client = genai.Client(api_key=api_key.get_secret_value() if api_key else None)
self.ctx._genai_client = client
self.client = client
return client
# ------------------------------------------------------------------ #
# Tier application
# ------------------------------------------------------------------ #
def _load_user_denylist(self) -> frozenset[str]:
"""Reads the user-configured Explorer denylist from the agent config."""
try:
agent_cfg = getattr(self.ctx, "agent_config", None)
denylisted_config = (
getattr(agent_cfg, "denylisted_tools", {}).get("explorer", []) if agent_cfg else []
)
self.denylisted_tools = set(denylisted_config)
denylists = getattr(agent_cfg, "denylisted_tools", None) if agent_cfg else None
configured = denylists.get("explorer", []) if isinstance(denylists, dict) else []
return frozenset(str(name) for name in configured)
except (TypeError, AttributeError):
self.denylisted_tools = set()
self.http_client = None
self.turn_latencies = []
self.turn_cached_tokens = []
self.trace_history = []
self._init_engine()
return frozenset()
def _init_engine(self) -> None:
"""Initializes model engine and decides whether to use native Gemini or Universal path."""
ctx = self.ctx
llm_config = getattr(ctx, "llm_config", None)
llm_cfg = getattr(llm_config, "explorer", None) if llm_config else None
model_str = (
llm_cfg.model if (llm_cfg and hasattr(llm_cfg, "model")) else "gemini-3.7-flash"
).lower()
self.model_name = (
llm_cfg.model if (llm_cfg and hasattr(llm_cfg, "model")) else "gemini-3.7-flash"
)
if "/" in self.model_name:
self.model_name = self.model_name.split("/")[-1]
def _compute_denylist(self, tier: ExplorerTier | None) -> set[str]:
"""Tools the model must not see: tier-hidden, user-denylisted, unconfigured OCR."""
denylist = set(self._user_denylist)
if tier is not None:
denylist |= tier.hidden_tools
if not is_ocr_configured():
denylist.add("get_ocr_list")
return denylist
has_google_key = bool(
settings.GOOGLE_API_KEY and settings.GOOGLE_API_KEY.get_secret_value()
)
is_gemini_model = "gemini" in model_str
def _apply_tier(self, tier: ExplorerTier) -> None:
"""Adopts the tier's turn budget and tool exposure for this run."""
self.tier = tier
self.max_turns = tier.max_turns
self.denylisted_tools = self._compute_denylist(tier)
self.client = getattr(ctx, "_genai_client", None)
if self.client is not None:
self.use_native_gemini = True
elif has_google_key and is_gemini_model:
try:
self.client = genai.Client(api_key=settings.GOOGLE_API_KEY.get_secret_value())
ctx._genai_client = self.client
self.use_native_gemini = True
except Exception as e:
logger.warning(
f"Failed to initialize native Gemini client for Explorer: {e}."
" Using universal engine."
)
self.use_native_gemini = False
else:
self.use_native_gemini = False
def _resolve_caching(self, tier: ExplorerTier, enable_caching: bool | None) -> bool:
"""Resolves Gemini explicit context caching for this run.
Precedence: explicit argument, ``ARTEMIS_EXPLORER_CACHING`` env
override, the agent configuration, the execution setup, the global
settings, then the tier default. Configuration values are only
trusted when they are real booleans so a mocked or malformed config
(``None``, sentinel objects) falls through instead of forcing a value.
"""
if isinstance(enable_caching, bool):
return enable_caching
env_value = os.getenv(CACHING_ENV_VAR, "").strip().lower()
if env_value in ("true", "false"):
return env_value == "true"
for source in (
getattr(self.ctx, "agent_config", None),
getattr(self.ctx, "execution_setup", None),
):
value = getattr(getattr(source, "explorer", None), "caching", None)
if isinstance(value, bool):
return value
value = getattr(settings, "EXPLORER_CACHING", None)
if isinstance(value, bool):
return value
return tier.caching
def _hidden_tool_names(self) -> set[str]:
"""Denylisted tools plus OCR when it is unconfigured, evaluated at call time.
OCR availability is re-checked here (not only in ``_apply_tier``) so
callers that reset ``denylisted_tools`` still never see ``get_ocr_list``
without a configured OCR backend.
"""
hidden = set(self.denylisted_tools)
if not is_ocr_configured():
hidden.add("get_ocr_list")
return hidden
def get_exposed_tools(self, only_submit: bool = False) -> list[types.FunctionDeclaration]:
"""Native tool declarations the model may see for the current tier."""
hidden = self._hidden_tool_names()
tools = [tool for tool in self.TOOLS if tool.name not in hidden]
if only_submit:
tools = [tool for tool in tools if tool.name == "submit_answer"]
return tools
def _prune_historical_images(self, contents, keep_last=1):
image_parts = []
@@ -253,15 +361,117 @@ class Explorer(PerceptionToolsMixin, UniversalRunnerMixin, RunSetupMixin, Native
for p in content.parts
]
def get_exposed_tools(self, only_submit: bool = False) -> list[types.FunctionDeclaration]:
"""Returns the list of tools, filtering out denylisted and unconfigured ones."""
denylisted = set(self.denylisted_tools)
if not is_ocr_configured():
denylisted.add("get_ocr_list")
tools = [tool for tool in self.TOOLS if tool.name not in denylisted]
if only_submit:
tools = [tool for tool in tools if tool.name == "submit_answer"]
return tools
# ------------------------------------------------------------------ #
# Label bounds and the structural pre-pass
# ------------------------------------------------------------------ #
def _norm_bounds(self, element: ScreenElement) -> list[int] | None:
"""The element's bounds on the 0-1000 grid, or None when they collapse.
A box that rounds to zero width or height is useless to the caller's
hit test, so it is withheld rather than shipped as a degenerate rect.
"""
left, top, right, bottom = element.bounds
n_left, n_top = pixel_to_norm(left, top, self.width, self.height)
n_right, n_bottom = pixel_to_norm(right, bottom, self.width, self.height)
if n_right <= n_left or n_bottom <= n_top:
return None
return [n_left, n_top, n_right, n_bottom]
def _enrich_candidates(self, candidates: list[Any]) -> list[Any]:
"""Attaches the registered element's bounds to candidates that point at it.
The label alone is not trusted: models occasionally reuse a label for
a different spot, so the coordinates must also fall inside the
element (padded by :data:`LABEL_TOLERANCE_RATIO` of the screen to
absorb rounding). Unknown labels, detection-only labels and points
outside the element pass through untouched.
"""
enriched: list[Any] = []
for cand in candidates:
element = (
self.label_registry.get(str(cand.get("label"))) if isinstance(cand, dict) else None
)
coords = cand.get("coords") if element is not None else None
if element is None or not is_valid_norm_point(coords):
enriched.append(cand)
continue
px, py = norm_to_pixel(int(coords[0]), int(coords[1]), self.width, self.height)
left, top, right, bottom = element.bounds
pad_x = self.width * LABEL_TOLERANCE_RATIO
pad_y = self.height * LABEL_TOLERANCE_RATIO
inside = left - pad_x <= px <= right + pad_x and top - pad_y <= py <= bottom + pad_y
norm_bounds = self._norm_bounds(element) if inside else None
if norm_bounds is None:
enriched.append(cand)
continue
enriched.append({**cand, "bounds": norm_bounds, "source": element.source})
return enriched
def _structural_prepass(self, query: str) -> tuple[list[dict[str, Any]], list[str]]:
"""Resolves the ``|``-separated parts of ``query`` that exactly name one element.
Returns ``(candidates, unresolved_parts)``. Only a *unique* exact
label match is taken: several matches mean the caller's wording does
not disambiguate, which is exactly what the model is for.
"""
parts = [part.strip() for part in query.split("|") if part.strip()]
candidates: list[dict[str, Any]] = []
unresolved: list[str] = []
for part in parts:
hits = self.screen_index.exact_matches(part)
if len(hits) != 1:
unresolved.append(part)
continue
element = hits[0]
label = f"T{len(candidates) + 1}"
self._register_label(label, element)
cx, cy = element.center
candidate: dict[str, Any] = {
"label": label,
"coords": list(pixel_to_norm(cx, cy, self.width, self.height)),
}
norm_bounds = self._norm_bounds(element)
if norm_bounds is not None:
candidate["bounds"] = norm_bounds
candidate["source"] = element.source
candidate["description"] = (
f"{element.text} (exact text match)"
if normalize_text(part) == normalize_text(element.text)
else f"{part} (exact text match: '{element.text}')"
)
candidates.append(candidate)
return candidates, unresolved
@staticmethod
def _merge_prepass(prepass: list[dict[str, Any]], raw: str) -> str:
"""Prepends pre-pass candidates to the engine's outcome JSON.
An engine answer that is not outcome-shaped (a bare error string)
becomes the ``fallback_message`` so the structural hits survive it.
"""
if not prepass:
return raw
try:
data = json.loads(raw)
except (TypeError, ValueError):
data = None
if not isinstance(data, dict):
return json.dumps(
{"candidates": prepass, "fallback_message": str(raw)}, ensure_ascii=False
)
engine_candidates = data.get("candidates") or []
return json.dumps(
{
"candidates": [*prepass, *engine_candidates],
"fallback_message": str(data.get("fallback_message") or ""),
},
ensure_ascii=False,
)
# ------------------------------------------------------------------ #
# Entry point
# ------------------------------------------------------------------ #
@trace(type="agent", name="explorer")
async def run(
@@ -271,41 +481,72 @@ class Explorer(PerceptionToolsMixin, UniversalRunnerMixin, RunSetupMixin, Native
screenshot_path: str,
state: State,
minimal_list: str = "",
enable_caching: bool = False,
enable_caching: bool | None = None,
version: Literal["flash", "pro", "ultra"] = "pro",
) -> str:
if version == "flash":
return await self._run_flash(query, screenshot_path)
"""Locates ``query`` on ``screenshot_path`` under the ``version`` tier.
self.http_client = httpx.AsyncClient()
# 1. Compute current image hash and verify with Data Engine
image_name, record = self._resolve_image_record(screenshot_path)
self.image_name = image_name
Every tier first indexes the screen in memory and tries the
structural pre-pass; only the parts of the query it cannot settle
reach the tier's engine. Returns the JSON string
``{"candidates": [...], "fallback_message": "..."}`` consumed by
``artemis.tools.explorer_tool``.
"""
tier = get_tier(version)
self._apply_tier(tier)
self.screenshot_path = screenshot_path
self.global_label_idx = 1
self.label_registry = {}
# Resolve parameters
mode, max_iterations = self._resolve_version_limits(version)
try:
fused_xml = await self._load_screen(screenshot_path, state, tier)
prepass, unresolved = self._structural_prepass(query)
if prepass and not unresolved:
logger.info(
f"Explorer pre-pass answered {query!r} from the UI tree without a model call."
)
return json.dumps(
{"candidates": prepass, "fallback_message": ""}, ensure_ascii=False
)
engine_query = " | ".join(unresolved) if prepass else query
# 2. Prepare Native Tools declarations
self._resolve_screen_dimensions(state)
if tier.is_oneshot:
raw = await self._run_flash(engine_query, screenshot_path)
else:
raw = await self._run_loop(
tier,
engine_query,
context_feedback,
screenshot_path,
state,
minimal_list,
enable_caching,
fused_xml,
)
return self._merge_prepass(prepass, raw)
finally:
await self._close_http_client()
# Initialize Image Pool
async def _run_loop(
self,
tier: ExplorerTier,
query: str,
context_feedback: str,
screenshot_path: str,
state: State,
minimal_list: str,
enable_caching: bool | None,
fused_xml: list[dict[str, Any]],
) -> str:
"""Shared setup for the loop engines, then the engine-specific loop."""
self._init_image_pool(screenshot_path)
# 4. Initialize or reuse dynamic context-level GenAI client for connection pooling
client = self._ensure_genai_client()
model_name, temperature, thinking_level, fallback_model = self._resolve_model_params()
# 5. Construct Prompt & Initial Message List
prompt_template, prompt_error = self._build_prompt_template(version, mode, max_iterations)
prompt_template, prompt_error = self._build_prompt_template(tier)
if prompt_error is not None:
return prompt_error
return self._failure_outcome(prompt_error)
# Generate initial visual annotations if minimal_list is empty
minimal_list, img_to_read = await self._prepare_initial_annotation(
record, state, screenshot_path, minimal_list, image_name
minimal_list, img_to_read = self._prepare_initial_annotation(
fused_xml, screenshot_path, minimal_list, self.image_name
)
if not self.use_native_gemini:
@@ -315,21 +556,33 @@ class Explorer(PerceptionToolsMixin, UniversalRunnerMixin, RunSetupMixin, Native
screenshot_path=img_to_read,
state=state,
minimal_list=minimal_list,
version=version,
prompt_template=prompt_template,
max_iterations=max_iterations,
max_turns=self.max_turns,
)
client = self._ensure_genai_client()
model_name, temperature, thinking_level, _fallback_model = self._resolve_model_params()
return await self._run_native(
client=client,
query=query,
context_feedback=context_feedback,
minimal_list=minimal_list,
img_to_read=img_to_read,
enable_caching=enable_caching,
enable_caching=self._resolve_caching(tier, enable_caching),
prompt_template=prompt_template,
model_name=model_name,
temperature=temperature,
thinking_level=thinking_level,
max_iterations=max_iterations,
max_turns=self.max_turns,
)
async def _close_http_client(self) -> None:
"""Releases the on-demand OCR HTTP client; both engines share this path."""
client, self.http_client = self.http_client, None
if client is None:
return
try:
await client.aclose()
logger.info("Closed Explorer HTTP client.")
except Exception as close_err:
logger.warning(f"Failed to close Explorer HTTP client: {close_err}")
+73
View File
@@ -0,0 +1,73 @@
# Copyright 2026 Google LLC
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Screen geometry shared by the Explorer agent and the ``ask_explorer`` tool.
Explorer candidates use normalized ``[x, y]`` coordinates on a 0-1000 scale;
device actions use pixels.
"""
from typing import Any
#: Fallback screen size when neither the operator observation nor the device
#: context reports one (a common phone portrait resolution).
FALLBACK_SCREEN_SIZE: tuple[int, int] = (1080, 2400)
NORMALIZED_MAX = 1000
def resolve_screen_size(ctx: Any, state: Any) -> tuple[int, int]:
"""Returns ``(width, height)`` in pixels for the current screenshot.
Precedence: the operator's latest raw observation (which measured the
screenshot it annotated), then the device context, then the fallback.
"""
raw = getattr(state, "operator_raw_data", None) or {}
width = raw.get("width") if isinstance(raw, dict) else None
height = raw.get("height") if isinstance(raw, dict) else None
if isinstance(width, int) and isinstance(height, int) and width > 0 and height > 0:
return width, height
device = getattr(ctx, "device", None)
dev_w = getattr(device, "device_width", None) if device else None
dev_h = getattr(device, "device_height", None) if device else None
fb_w, fb_h = FALLBACK_SCREEN_SIZE
width = dev_w if isinstance(dev_w, int) and dev_w > 0 else fb_w
height = dev_h if isinstance(dev_h, int) and dev_h > 0 else fb_h
return width, height
def norm_to_pixel(nx: float, ny: float, width: int, height: int) -> tuple[int, int]:
"""Maps normalized 0-1000 coordinates to clamped pixel coordinates."""
px = int(max(0, min(width, nx * width / NORMALIZED_MAX)))
py = int(max(0, min(height, ny * height / NORMALIZED_MAX)))
return px, py
def pixel_to_norm(px: float, py: float, width: int, height: int) -> tuple[int, int]:
"""Maps pixel coordinates to clamped normalized 0-1000 coordinates."""
nx = int(max(0, min(NORMALIZED_MAX, px * NORMALIZED_MAX / width)))
ny = int(max(0, min(NORMALIZED_MAX, py * NORMALIZED_MAX / height)))
return nx, ny
def is_valid_norm_point(coords: Any) -> bool:
"""True when ``coords`` is a 2-item ``[nx, ny]`` inside the 0-1000 range."""
if not isinstance(coords, (list, tuple)) or len(coords) != 2:
return False
try:
nx, ny = int(coords[0]), int(coords[1])
except (TypeError, ValueError):
return False
return 0 <= nx <= NORMALIZED_MAX and 0 <= ny <= NORMALIZED_MAX
+216 -203
View File
@@ -17,32 +17,95 @@
Split out of ``artemis.agents.explorer.explorer``: the native-SDK reasoning
loop and its named phases (model invocation, submit_answer interception and
validation, tool dispatching, failure mapping, resource cleanup), packaged as
a mixin consumed by ``Explorer``. Patched collaborators (``settings``,
``logger``, ``_generate_content_with_reliability``) are resolved through the
facade module at call time; see ``artemis.agents.explorer._facade``.
a mixin consumed by ``Explorer``. Every google-genai call goes through the
SDK's ``client.aio`` surface so uploads, cache management and generation
never block the event loop. Patched collaborators (``logger``,
``_generate_content_with_reliability``) are resolved through the facade
module at call time; see ``artemis.agents.explorer._facade``.
"""
import asyncio
from collections.abc import Awaitable, Callable
import json
import os
import time
from typing import TYPE_CHECKING, Any
from google.genai import types
from artemis.agents.explorer._facade import facade
from artemis.agents.explorer.geometry import is_valid_norm_point
from artemis.agents.explorer.tiers import SUBMIT_TOOL
from artemis.constants import SAFETY_SETTINGS_BLOCK_NONE
from artemis.data_engine.trace import TraceSpan
#: Loads a local image as a ``Part`` (inline bytes or a File API reference).
ImagePartGetter = Callable[[str], Awaitable[types.Part]]
def _bare_tool_name(name: str) -> str:
"""Strips a namespace prefix (``ns:tool``) some models add to tool names."""
return name.split(":")[-1] if ":" in name else name
class NativeRunnerMixin:
"""Native Gemini SDK reasoning loop of :class:`Explorer`."""
def _make_image_part_getter(self, client, use_file_api: bool, uploaded_files: list):
# This mixin is composed into ``Explorer``. Declaring the attributes and
# sibling-mixin methods it consumes makes that host contract visible to
# static analysis without adding runtime shims or changing MRO behavior.
if TYPE_CHECKING:
from artemis.context import ArtemisContext
ctx: ArtemisContext
denylisted_tools: set[str]
turn_latencies: list[float]
turn_cached_tokens: list[int]
trace_history: list[dict[str, Any]]
def get_exposed_tools(
self, only_submit: bool = False
) -> list[types.FunctionDeclaration]: ...
def _prune_historical_images(self, contents: list, keep_last: int = 1) -> None: ...
def _enrich_candidates(self, candidates: list[Any]) -> list[Any]: ...
async def exec_ask_perception_tool(
self,
search_query: str,
nx: int,
ny: int,
detect_queries: list[str],
) -> dict[str, Any]: ...
async def exec_detect_objects(
self, queries: list[str], target_image_id: str = "img_0"
) -> dict[str, Any]: ...
async def exec_ask_image_processor(
self, instruction: str, target_image_id: str = "img_0"
) -> dict[str, Any]: ...
async def exec_get_ocr_list(self) -> dict[str, Any]: ...
async def exec_inspect_region(
self,
x_min: int,
y_min: int,
x_max: int,
y_max: int,
zoom_factor: float = 2.0,
) -> dict[str, Any]: ...
def _make_image_part_getter(
self, client, use_file_api: bool, uploaded_files: list
) -> ImagePartGetter:
"""Builds the image-part loader used for screenshots and tool images."""
def get_image_part(file_path: str) -> types.Part:
async def get_image_part(file_path: str) -> types.Part:
if use_file_api:
file_ref = client.files.upload(file=file_path)
file_ref = await client.aio.files.upload(file=file_path)
uploaded_files.append(file_ref)
return types.Part(
file_data=types.FileData(
@@ -50,7 +113,6 @@ class NativeRunnerMixin:
mime_type=file_ref.mime_type or "image/jpeg",
)
)
else:
with open(file_path, "rb") as f:
img_bytes = f.read()
return types.Part.from_bytes(data=img_bytes, mime_type="image/jpeg")
@@ -79,26 +141,14 @@ Initial marked UI elements list (corresponding to numbers ①, ②... in the ima
)
]
def _resolve_caching_flag(self, enable_caching):
"""Resolves the effective caching flag from env or settings when unset."""
_ex = facade()
if enable_caching is None:
env_cache = os.getenv("ARTEMIS_EXPLORER_CACHING", "").lower()
if env_cache in ["true", "false"]:
enable_caching = env_cache == "true"
else:
enable_caching = getattr(_ex.settings, "EXPLORER_CACHING", True)
return enable_caching
def _create_cache_resource(
async def _create_cache_resource(
self, client, model_name: str, prompt_template: str, contents: list
):
"""Creates the explicit cache resource; returns None on failure."""
_ex = facade()
cached_content = None
try:
_ex.logger.info("Creating cache resource for Explorer...")
cached_content = client.caches.create(
cached_content = await client.aio.caches.create(
model=model_name,
config=types.CreateCachedContentConfig(
contents=[contents[0]],
@@ -114,10 +164,10 @@ Initial marked UI elements list (corresponding to numbers ①, ②... in the ima
),
)
_ex.logger.info(f"Cache resource created successfully: {cached_content.name}")
return cached_content
except Exception as cache_err:
_ex.logger.error(f"Failed to create cache resource: {cache_err}")
cached_content = None
return cached_content
return None
async def _run_native(
self,
@@ -127,36 +177,30 @@ Initial marked UI elements list (corresponding to numbers ①, ②... in the ima
context_feedback: str,
minimal_list: str,
img_to_read: str,
enable_caching,
enable_caching: bool,
prompt_template: str,
model_name: str,
temperature,
thinking_level,
max_iterations: int,
max_turns: int,
) -> str:
"""Runs the native Gemini path: cache setup, reasoning loop, cleanup."""
use_file_api = os.getenv("ARTEMIS_USE_FILE_API", "false").lower() == "true"
uploaded_files = []
uploaded_files: list = []
get_image_part = self._make_image_part_getter(client, use_file_api, uploaded_files)
cached_content = None
try:
# Upload initial screenshot
initial_part = get_image_part(img_to_read)
initial_part = await get_image_part(img_to_read)
contents = self._build_initial_contents(
query, context_feedback, minimal_list, initial_part
)
# Caching initialization
enable_caching = self._resolve_caching_flag(enable_caching)
cached_content = None
if enable_caching:
cached_content = self._create_cache_resource(
cached_content = await self._create_cache_resource(
client, model_name, prompt_template, contents
)
# 6. Execution Loop (Native SDK Tools Dispatching)
agent_outcome = await self._native_loop(
client=client,
contents=contents,
@@ -166,7 +210,7 @@ Initial marked UI elements list (corresponding to numbers ①, ②... in the ima
model_name=model_name,
temperature=temperature,
thinking_level=thinking_level,
max_iterations=max_iterations,
max_turns=max_turns,
)
except Exception as e:
@@ -181,29 +225,29 @@ Initial marked UI elements list (corresponding to numbers ①, ②... in the ima
*,
client,
contents: list,
get_image_part,
get_image_part: ImagePartGetter,
cached_content,
prompt_template: str,
model_name: str,
temperature,
thinking_level,
max_iterations: int,
max_turns: int,
) -> str:
"""Main native reasoning loop: one model turn per iteration."""
_ex = facade()
iterations = 0
turn = 0
agent_outcome = ""
self.turn_latencies = []
self.turn_cached_tokens = []
self.trace_history = []
while iterations < max_iterations:
iterations += 1
_ex.logger.info(f"Iteration {iterations}: Invoking Native Gemini SDK for Explorer...")
while turn < max_turns:
turn += 1
_ex.logger.info(f"Turn {turn}/{max_turns}: Invoking Native Gemini SDK for Explorer...")
self._prune_historical_images(contents, keep_last=1)
is_final_turn = iterations == max_iterations
is_final_turn = turn == max_turns
if is_final_turn:
warning_msg = (
"\n[WARNING] This is your final iteration. You MUST"
@@ -229,7 +273,7 @@ Initial marked UI elements list (corresponding to numbers ①, ②... in the ima
)
turn_record = {
"iteration": iterations,
"iteration": turn,
"thoughts": ("\n".join(thinking_parts) if thinking_parts else ""),
"tool_calls": [],
}
@@ -237,25 +281,16 @@ Initial marked UI elements list (corresponding to numbers ①, ②... in the ima
function_calls = response.function_calls
if not function_calls:
self._handle_missing_tool_calls(
contents, response, turn_record, text_parts, max_iterations, iterations
contents, response, turn_record, text_parts, max_turns, turn
)
continue
# Record model's function call request in history
if response.candidates and response.candidates[0].content:
contents.append(response.candidates[0].content)
else:
contents.append(
types.Content(
role="model",
parts=[types.Part(function_call=fc) for fc in function_calls],
)
)
# Record the model's function call request in history
contents.append(self._model_content(response, function_calls))
# Execute function calls
tool_response_parts = []
tool_response_parts: list[types.Part] = []
# 6.1 Intercept and validate submit_answer to manage task lifecycle
# Intercept and validate submit_answer to manage the task lifecycle
submit_outcome = self._process_submit_answer(
function_calls, turn_record, tool_response_parts
)
@@ -268,18 +303,31 @@ Initial marked UI elements list (corresponding to numbers ①, ②... in the ima
)
if tool_response_parts:
# Native Gemini SDK allows 'tool' role to return mixed parts
# Native Gemini SDK allows the 'user' role to return mixed parts
contents.append(types.Content(role="user", parts=tool_response_parts))
self.trace_history.append(turn_record)
if iterations >= max_iterations and not agent_outcome:
if turn >= max_turns and not agent_outcome:
agent_outcome = (
"Error: Explorer reached maximum iterations without a conclusive answer."
)
return agent_outcome
@staticmethod
def _model_content(response, function_calls: list) -> types.Content:
"""The model turn to record: the SDK's content when present, else a rebuild."""
candidates = getattr(response, "candidates", None)
if candidates:
content = getattr(candidates[0], "content", None)
if content:
return content
return types.Content(
role="model",
parts=[types.Part(function_call=fc) for fc in function_calls],
)
def _build_generate_config(
self,
*,
@@ -290,27 +338,22 @@ Initial marked UI elements list (corresponding to numbers ①, ②... in the ima
thinking_level,
) -> types.GenerateContentConfig:
"""Builds the per-turn generation config (cached vs full tool config)."""
thinking_kwargs = (
{"thinking_config": types.ThinkingConfig(thinking_level=thinking_level)}
if thinking_level
else {}
thinking_config = (
types.ThinkingConfig(thinking_level=thinking_level) if thinking_level else None
)
if cached_content and not is_final_turn:
return types.GenerateContentConfig(
cached_content=cached_content.name,
temperature=temperature,
safety_settings=SAFETY_SETTINGS_BLOCK_NONE,
**thinking_kwargs,
thinking_config=thinking_config,
)
else:
return types.GenerateContentConfig(
system_instruction=prompt_template,
temperature=temperature,
safety_settings=SAFETY_SETTINGS_BLOCK_NONE,
tools=[
types.Tool(
function_declarations=self.get_exposed_tools(only_submit=is_final_turn)
)
types.Tool(function_declarations=self.get_exposed_tools(only_submit=is_final_turn))
],
tool_config=types.ToolConfig(
function_calling_config=types.FunctionCallingConfig(
@@ -318,7 +361,7 @@ Initial marked UI elements list (corresponding to numbers ①, ②... in the ima
),
include_server_side_tool_invocations=True,
),
**thinking_kwargs,
thinking_config=thinking_config,
)
@staticmethod
@@ -424,29 +467,39 @@ Initial marked UI elements list (corresponding to numbers ①, ②... in the ima
response,
turn_record: dict,
text_parts: list[str],
max_iterations: int,
iterations: int,
max_turns: int,
turn: int,
) -> None:
"""Handles a turn where the model produced plain text instead of tool calls."""
"""Handles a turn where the model produced plain text instead of tool calls.
Responses without candidates (safety blocks, empty replies) are
recorded as a synthetic model text part so the conversation history
stays well-formed for the retry.
"""
_ex = facade()
_ex.logger.warning(
f"Explorer iteration {iterations}: Model hallucinated"
" plain text. Forcing retry."
)
_ex.logger.warning(f"Explorer turn {turn}: Model hallucinated plain text. Forcing retry.")
model_text = "\n".join(text_parts) if text_parts else ""
turn_record["tool_calls"].append(
{
"name": "hallucinated_plain_text",
"args": {"text": "\n".join(text_parts) if text_parts else ""},
"args": {"text": model_text},
"response": {
"error": (
"Forced retry because model failed to call"
" submit_answer or any other tool"
"Forced retry because model failed to call submit_answer or any other tool"
)
},
}
)
self.trace_history.append(turn_record)
contents.append(response.candidates[0].content)
candidates = getattr(response, "candidates", None)
model_content = getattr(candidates[0], "content", None) if candidates else None
if not model_content:
model_content = types.Content(
role="model",
parts=[types.Part.from_text(text=model_text or "(no response)")],
)
contents.append(model_content)
contents.append(
types.Content(
role="user",
@@ -457,7 +510,7 @@ Initial marked UI elements list (corresponding to numbers ①, ②... in the ima
" to submit your final answer yet. If"
" you are not certain about the final"
" answer, you can continue exploring"
f" {max_iterations - iterations} more"
f" {max_turns - turn} more"
" time(s)."
)
)
@@ -497,20 +550,18 @@ Initial marked UI elements list (corresponding to numbers ①, ②... in the ima
" as an array of exactly 2 integers `[nx,"
" ny]`."
)
else:
elif not is_valid_norm_point(coords):
try:
nx, ny = int(coords[0]), int(coords[1])
if not (0 <= nx <= 1000) or not (0 <= ny <= 1000):
except (ValueError, TypeError):
errors.append(f"Candidate '{label or i}' coordinates are invalid integers.")
else:
errors.append(
f"Candidate '{label or i}' coordinates"
f" `[{nx}, {ny}]` must strictly be in"
" the `[0-1000]` normalized scale"
" range inclusive."
)
except (ValueError, TypeError, IndexError):
errors.append(
f"Candidate '{label or i}' coordinates are invalid integers."
)
return errors
@@ -524,12 +575,7 @@ Initial marked UI elements list (corresponding to numbers ①, ②... in the ima
"""
_ex = facade()
submit_call = next(
(
fc
for fc in function_calls
if (fc.name.split(":")[-1] if ":" in fc.name else fc.name)
== "submit_answer"
),
(fc for fc in function_calls if _bare_tool_name(fc.name) == SUBMIT_TOOL),
None,
)
if not submit_call:
@@ -543,14 +589,14 @@ Initial marked UI elements list (corresponding to numbers ①, ②... in the ima
_ex.logger.warning(f"Explorer submit_answer validation failed: {errors}")
turn_record["tool_calls"].append(
{
"name": "submit_answer",
"name": SUBMIT_TOOL,
"args": args,
"response": {"error": ("Validation Failed:\n" + "\n".join(errors))},
}
)
tool_response_parts.append(
types.Part.from_function_response(
name="submit_answer",
name=SUBMIT_TOOL,
response={
"error": (
"Validation Failed:\n"
@@ -562,126 +608,106 @@ Initial marked UI elements list (corresponding to numbers ①, ②... in the ima
},
)
)
# Allow ReAct loop to continue so LLM can self-correct and re-submit
# Allow the ReAct loop to continue so the model can self-correct
return None
else:
_ex.logger.info("Explorer submit_answer validation passed successfully!")
agent_outcome = json.dumps(args, ensure_ascii=False)
# Candidates submitted by a registered label inherit that element's
# bounds; the rest of the model's arguments are passed through as is.
outcome_args = dict(args)
outcome_args["candidates"] = self._enrich_candidates(candidates)
agent_outcome = json.dumps(outcome_args, ensure_ascii=False)
turn_record["tool_calls"].append(
{
"name": "submit_answer",
"args": args,
"response": {"result": "success"},
}
{"name": SUBMIT_TOOL, "args": args, "response": {"result": "success"}}
)
self.trace_history.append(turn_record)
return agent_outcome
@staticmethod
def _append_tool_response_with_image(
name: str, res: dict, get_image_part, tool_response_parts: list
async def _append_tool_response_with_images(
name: str,
res: dict,
get_image_part: ImagePartGetter,
tool_response_parts: list,
*,
result_key: str = "result",
) -> None:
"""Appends a tool result part plus its annotated image when present."""
if res.get("image_path"):
"""Appends a tool result part plus every annotated image it produced.
Image loading failures are logged and skipped: the textual result is
still delivered, so one unreadable annotation cannot sink the turn.
"""
_ex = facade()
tool_response_parts.append(
types.Part.from_function_response(
name=name,
response={
"result": res.get("text"),
},
)
)
tool_response_parts.append(get_image_part(res["image_path"]))
else:
tool_response_parts.append(
types.Part.from_function_response(
name=name,
response={"result": res.get("text")},
)
types.Part.from_function_response(name=name, response={result_key: res.get("text")})
)
image_paths = [p for p in [res.get("image_path"), *(res.get("image_paths") or [])] if p]
for img_p in image_paths:
try:
tool_response_parts.append(await get_image_part(img_p))
except Exception as e:
_ex.logger.warning(f"Failed to load image response part for {img_p}: {e}")
async def _execute_native_tool(
self,
name: str,
args: dict,
get_image_part,
get_image_part: ImagePartGetter,
tool_call_trace: dict,
tool_response_parts: list,
) -> None:
"""Executes one non-submit tool call and appends its response parts."""
_ex = facade()
if name == "ask_perception_tool":
search_query = args.get("search_query")
nx = args.get("nx")
ny = args.get("ny")
detect_queries = args.get("detect_queries")
res = await self.exec_ask_perception_tool(
search_query=args.get("search_query"),
nx=args.get("nx"),
ny=args.get("ny"),
detect_queries=args.get("detect_queries"),
search_query=search_query if isinstance(search_query, str) else "",
nx=nx if isinstance(nx, int) else -1,
ny=ny if isinstance(ny, int) else -1,
detect_queries=detect_queries if isinstance(detect_queries, list) else [],
)
tool_call_trace["response"] = res
tool_response_parts.append(
types.Part.from_function_response(
name=name,
response={
"text": res.get("text"),
},
await self._append_tool_response_with_images(
name, res, get_image_part, tool_response_parts, result_key="text"
)
)
if res.get("image_paths"):
for img_p in res["image_paths"]:
try:
tool_response_parts.append(get_image_part(img_p))
except Exception as e:
_ex.logger.warning(
f"Failed to load image response part for {img_p}: {e}"
)
elif name == "detect_objects":
res = await self.exec_detect_objects(
queries=args.get("queries"),
queries=args.get("queries") or [],
target_image_id=args.get("target_image_id", "img_0"),
)
tool_call_trace["response"] = res
self._append_tool_response_with_image(name, res, get_image_part, tool_response_parts)
await self._append_tool_response_with_images(
name, res, get_image_part, tool_response_parts
)
elif name == "ask_image_processor":
res = await self.exec_ask_image_processor(
instruction=args.get("instruction"),
instruction=args.get("instruction") or "",
target_image_id=args.get("target_image_id", "img_0"),
)
tool_call_trace["response"] = res
tool_response_parts.append(
types.Part.from_function_response(
name=name,
response={"result": res.get("text")},
await self._append_tool_response_with_images(
name, res, get_image_part, tool_response_parts
)
)
if res.get("image_paths"):
for img_p in res["image_paths"]:
try:
tool_response_parts.append(get_image_part(img_p))
except Exception as e:
_ex.logger.warning(
"Failed to upload image path"
f" {img_p} for ask_image_processor"
f" tool response: {e}"
)
elif name == "get_ocr_list":
res = await self.exec_get_ocr_list()
tool_call_trace["response"] = res
self._append_tool_response_with_image(name, res, get_image_part, tool_response_parts)
await self._append_tool_response_with_images(
name, res, get_image_part, tool_response_parts
)
elif name == "inspect_region":
res = await self.exec_inspect_region(
x_min=args.get("x_min"),
y_min=args.get("y_min"),
x_max=args.get("x_max"),
y_max=args.get("y_max"),
zoom_factor=args.get("zoom_factor"),
x_min=args["x_min"],
y_min=args["y_min"],
x_max=args["x_max"],
y_max=args["y_max"],
zoom_factor=args.get("zoom_factor", 2.0),
)
tool_call_trace["response"] = res
self._append_tool_response_with_image(name, res, get_image_part, tool_response_parts)
await self._append_tool_response_with_images(
name, res, get_image_part, tool_response_parts
)
else:
tool_call_trace["response"] = {"error": f"Tool {name} not found"}
tool_response_parts.append(
@@ -694,37 +720,32 @@ Initial marked UI elements list (corresponding to numbers ①, ②... in the ima
async def _dispatch_tool_calls(
self,
function_calls: list,
get_image_part,
get_image_part: ImagePartGetter,
turn_record: dict,
tool_response_parts: list,
) -> None:
"""Dispatches all non-submit tool calls sequentially with tracing."""
"""Dispatches all non-submit tool calls sequentially with tracing.
Denylisted names are refused here as well, even though they are
absent from the declarations, because models occasionally call tools
they were never offered.
"""
_ex = facade()
for fc in function_calls:
name = fc.name.split(":")[-1] if ":" in fc.name else fc.name
if name == "submit_answer":
name = _bare_tool_name(fc.name)
if name == SUBMIT_TOOL:
continue
args = fc.args or {}
tool_call_trace = {
"name": name,
"args": args,
}
tool_call_trace = {"name": name, "args": args}
if name in self.denylisted_tools:
_ex.logger.warning(
"Explorer attempted to call denylisted tool"
f" '{name}'. Blocking execution."
f"Explorer attempted to call denylisted tool '{name}'. Blocking execution."
)
tool_call_trace["response"] = {
"error": (f"Tool '{name}' is denylisted and unavailable.")
}
error = f"Tool '{name}' is denylisted and unavailable."
tool_call_trace["response"] = {"error": error}
turn_record["tool_calls"].append(tool_call_trace)
tool_response_parts.append(
types.Part.from_function_response(
name=fc.name,
response={
"error": (f"Tool '{name}' is denylisted and unavailable.")
},
)
types.Part.from_function_response(name=fc.name, response={"error": error})
)
continue
@@ -771,26 +792,18 @@ Initial marked UI elements list (corresponding to numbers ①, ②... in the ima
ensure_ascii=False,
)
async def _cleanup_native_resources(
self, client, cached_content, uploaded_files: list
) -> None:
"""Closes the HTTP client and deletes cache/file resources."""
async def _cleanup_native_resources(self, client, cached_content, uploaded_files: list) -> None:
"""Deletes the cache resource and File API uploads created for this run."""
_ex = facade()
if self.http_client:
try:
await self.http_client.aclose()
_ex.logger.info("Closed Explorer HTTP client.")
except Exception as close_err:
_ex.logger.warning(f"Failed to close Explorer HTTP client: {close_err}")
if cached_content:
try:
_ex.logger.info(f"Deleting cache resource: {cached_content.name}")
client.caches.delete(name=cached_content.name)
await client.aio.caches.delete(name=cached_content.name)
except Exception as cleanup_cache_err:
_ex.logger.warning(f"Failed to delete cache resource: {cleanup_cache_err}")
for file_ref in uploaded_files:
try:
client.files.delete(name=file_ref.name)
await client.aio.files.delete(name=file_ref.name)
except Exception as cleanup_err:
_ex.logger.warning(f"Failed to delete uploaded file {file_ref.name}: {cleanup_err}")
_ex.logger.info(f"Explorer turn latencies: {self.turn_latencies}")
+177 -207
View File
@@ -16,10 +16,12 @@
Split out of ``artemis.agents.explorer.explorer``: the ``exec_*`` tool method
group plus its search helpers, packaged as a mixin consumed by ``Explorer``.
Patched collaborators (``settings``, ``draw_dots``, ``search_ui_func``,
``search_by_coordinates_func``, ``_run_object_detection``, ``StorageManager``,
``logger``) are resolved through the facade module at call time; see
``artemis.agents.explorer._facade``.
Text search, the coordinate audit and the OCR inventory all answer from the
run's in-memory :class:`~artemis.agents.explorer.screen_index.ScreenIndex`;
no Data Engine round trip is involved, so an unsynced frame never degrades
to "no UI data". Patched collaborators (``settings``, ``draw_dots``,
``_run_object_detection``, ``logger``) are resolved through the facade module
at call time; see ``artemis.agents.explorer._facade``.
"""
import asyncio
@@ -27,36 +29,40 @@ import glob
import json
from pathlib import Path
import re
from typing import TYPE_CHECKING, Any
import cv2
from artemis.agents.explorer._facade import facade
from artemis.agents.explorer.geometry import norm_to_pixel, pixel_to_norm
from artemis.agents.explorer.screen_index import ScreenElement, ScreenIndex, normalize_text
from artemis.agents.image_processor.image_processor import ImageProcessor
from artemis.data_engine.trace import trace
#: Text-search answer when the screen could not be indexed at all.
NO_UI_TREE_MESSAGE = "No UI-tree data is available for this screen; rely on visual detection."
def load_detector_templates() -> tuple[list, object]:
def load_detector_templates() -> tuple[list[str], float]:
"""Load object-detector prompt templates and the optional global timeout.
Shared by ``exec_detect_objects`` and the flash-mode ``run`` path; the two
call sites historically carried an identical inline copy of this block.
"""
_ex = facade()
detector_prompt_path = (
Path(__file__).parent.parent / "object_detector" / "object_detector.json"
)
global_timeout = None
detector_prompt_path = Path(__file__).parent.parent / "object_detector" / "object_detector.json"
global_timeout = 30.0
try:
with open(detector_prompt_path, encoding="utf-8") as f:
detector_config = json.load(f)
detector_templates = detector_config.get("templates", [])
detector_instructions = detector_config.get("instructions", "")
global_timeout = detector_config.get("global_timeout", None)
raw_timeout = detector_config.get("global_timeout")
if isinstance(raw_timeout, (int, float)) and raw_timeout > 0:
global_timeout = float(raw_timeout)
except Exception as e:
_ex.logger.warning(f"Failed to load detector prompt config: {e}")
detector_templates = [
"Point to the following objects in the provided image: {labels_str}."
]
detector_templates = ["Point to the following objects in the provided image: {labels_str}."]
detector_instructions = ""
templates = [f"{t}\n\n{detector_instructions}" for t in detector_templates]
@@ -66,158 +72,160 @@ def load_detector_templates() -> tuple[list, object]:
class PerceptionToolsMixin:
"""Perception/vision tool method group of :class:`Explorer`."""
async def _search_ui_helper(self, query: str, prefix: str = "S", color: str = "red") -> dict:
# Runtime initialization lives in ``Explorer``/``RunSetupMixin``. Keep a
# precise declaration here so this mixin's host requirements are explicit.
if TYPE_CHECKING:
from artemis.context import ArtemisContext
ctx: ArtemisContext
global_label_idx: int
width: int
height: int
image_name: str | None
screenshot_path: str | None
image_pool: dict[str, dict[str, Any]]
next_img_id: int
screen_index: ScreenIndex
label_registry: dict[str, ScreenElement]
# ------------------------------------------------------------------ #
# Label registry and annotation helpers
# ------------------------------------------------------------------ #
def _register_label(self, label: str, element: ScreenElement) -> None:
"""Remembers which element a label shown to the model stands for.
Only labels backed by a real UI-tree or OCR element are registered;
detection points (``D``) carry no bounds and are deliberately absent.
"""
self.label_registry[label] = element
def _registry_element(self, entry: dict[str, Any]) -> ScreenElement:
"""Maps a numbered-list entry back to its indexed element.
The index is preferred over rebuilding the element from the entry
because it carries the interaction flags the entry dropped.
"""
raw_bounds = entry["bounds"]
bounds = (int(raw_bounds[0]), int(raw_bounds[1]), int(raw_bounds[2]), int(raw_bounds[3]))
text = str(entry.get("text") or "")
source = "ocr" if entry.get("is_ocr") else "xml"
for element in self.screen_index.elements:
if (
element.bounds == bounds
and element.source == source
and normalize_text(element.text) == normalize_text(text)
):
return element
return ScreenElement(
text=text,
bounds=bounds,
source=source,
class_name=str(entry["class"]) if entry.get("class") else None,
resource_id=str(entry["resource_id"]) if entry.get("resource_id") else None,
)
def _next_output_path(self, subdir: str) -> Path:
"""Next free ``images/<subdir>/<image>_<n>.jpg`` under the traces directory."""
_ex = facade()
if not self.image_name:
return {
"text": (
"Error: UI data for the current screen is not found in Data"
" Engine. It might not be synced yet."
),
"image_path": None,
}
out_dir = Path(_ex.settings.TRACES_PATH) / "images" / subdir
out_dir.mkdir(parents=True, exist_ok=True)
stem = self.image_name or "temp_image"
max_seq = 0
for existing in glob.glob(str(out_dir / f"{stem}_*.jpg")):
match = re.search(r"_(\d+)\.jpg$", existing)
if match:
max_seq = max(max_seq, int(match.group(1)))
return out_dir / f"{stem}_{max_seq + 1}.jpg"
# 1. Try with high precision threshold
raw_res = _ex.search_ui_func(self.image_name, query, 0.7)
if "error" in raw_res:
return {"text": raw_res["error"], "image_path": None}
def _draw_labeled_dots(
self, subdir: str, points: list[list[int]], labels: list[str], color: str
) -> str | None:
"""Draws ``labels`` at ``points`` on the screenshot; returns the annotated path.
matches = raw_res.get("matches", [])
Returns None when the screenshot is missing or drawing fails: the
textual result is still delivered, so one bad annotation never sinks
the tool call.
"""
_ex = facade()
if not self.screenshot_path:
return None
try:
output_path = self._next_output_path(subdir)
_ex.draw_dots(self.screenshot_path, points, labels, str(output_path), color=color)
return str(output_path)
except Exception as e:
_ex.logger.warning(f"Failed to draw {subdir} dots: {e}")
return None
# 2. If fewer than 3 matches, try with a lower threshold and merge
# ------------------------------------------------------------------ #
# Screen-index searches
# ------------------------------------------------------------------ #
async def _search_ui_helper(self, query: str, prefix: str = "S", color: str = "red") -> dict:
"""Fuzzy text search over the screen index, annotated with labeled dots.
A strict pass (0.7) keeps the answer short when the label is
unambiguous; when it yields fewer than three hits a lenient pass
(0.4) is merged in so near-misses (abbreviations, truncated labels)
still surface for the model to judge.
"""
if not len(self.screen_index):
return {"text": NO_UI_TREE_MESSAGE, "image_path": None}
matches = list(self.screen_index.search_text(query, 0.7))
if len(matches) < 3:
raw_res_low = _ex.search_ui_func(self.image_name, query, 0.4)
if "error" not in raw_res_low:
low_matches = raw_res_low.get("matches", [])
existing_keys = {
(
m.get("matched_text"),
tuple(m.get("bounds")) if m.get("bounds") else None,
)
for m in matches
}
for m in low_matches:
key = (
m.get("matched_text"),
tuple(m.get("bounds")) if m.get("bounds") else None,
)
if key not in existing_keys:
seen = {(m.matched_text, m.element.bounds) for m in matches}
for m in self.screen_index.search_text(query, 0.4):
key = (m.matched_text, m.element.bounds)
if key not in seen:
matches.append(m)
existing_keys.add(key)
seen.add(key)
if not matches:
return {"text": "No matches found.", "image_path": None}
points = []
labels = []
text_output = []
points: list[list[int]] = []
labels: list[str] = []
text_output: list[str] = []
for m in matches:
m_type = m.get("type", "xml")
m_prefix = "O" if m_type == "ocr" else prefix
element = m.element
m_prefix = "O" if element.source == "ocr" else prefix
label_id = f"{m_prefix}{self.global_label_idx}"
self.global_label_idx += 1
bounds = m.get("bounds")
matched_text = m.get("matched_text", "")
if bounds and len(bounds) == 4:
left, top, right, bottom = bounds
cx_pixel = (left + right) // 2
cy_pixel = (top + bottom) // 2
points.append([cx_pixel, cy_pixel])
self._register_label(label_id, element)
cx, cy = element.center
points.append([cx, cy])
labels.append(label_id)
nx, ny = pixel_to_norm(cx, cy, self.width, self.height)
text_output.append(f"[{label_id}] '{m.matched_text}' at [{nx},{ny}]")
cx_norm = int(max(0, min(1000, cx_pixel * 1000 / self.width)))
cy_norm = int(max(0, min(1000, cy_pixel * 1000 / self.height)))
text_output.append(f"[{label_id}] '{matched_text}' at [{cx_norm},{cy_norm}]")
else:
text_output.append(f"[{label_id}] '{matched_text}' at unknown")
if not points:
return {"text": " | ".join(text_output), "image_path": None}
base_dir = _ex.settings.TRACES_PATH
images_dir = base_dir / "images"
search_ui_dir = images_dir / "search_ui"
search_ui_dir.mkdir(parents=True, exist_ok=True)
existing_files = glob.glob(str(search_ui_dir / f"{self.image_name}_*.jpg"))
max_seq = 0
for f in existing_files:
match = re.search(r"_(\d+)\.jpg$", f)
if match:
max_seq = max(max_seq, int(match.group(1)))
seq = max_seq + 1
output_path = search_ui_dir / f"{self.image_name}_{seq}.jpg"
try:
_ex.draw_dots(
self.screenshot_path,
points,
labels,
str(output_path),
color=color,
)
image_path = str(output_path)
except Exception as e:
_ex.logger.warning(f"Failed to draw dots: {e}")
image_path = None
image_path = self._draw_labeled_dots("search_ui", points, labels, color)
return {"text": " | ".join(text_output), "image_path": image_path}
async def _search_by_coords_helper(
self, nx: int, ny: int, prefix: str = "X", color: str = "blue"
) -> dict:
_ex = facade()
if not self.image_name:
return {
"text": (
"Error: UI data for the current screen is not found in Data"
" Engine. It might not be synced yet."
),
"image_path": None,
}
"""Coordinate audit: names what actually sits under a normalized point.
x = int(max(0, min(self.width, nx * self.width / 1000)))
y = int(max(0, min(self.height, ny * self.height / 1000)))
raw_res = _ex.search_by_coordinates_func(self.image_name, x, y)
UI-tree hits come innermost first (see ``ScreenIndex.elements_at``),
so the label is bound to the node a tap would reach. The blue dot is
drawn even without a hit so the model can see where it probed.
"""
x, y = norm_to_pixel(nx, ny, self.width, self.height)
found = self.screen_index.elements_at(x, y)
label_id = f"{prefix}{self.global_label_idx}"
self.global_label_idx += 1
image_path = self._draw_labeled_dots("coords_audit", [[x, y]], [label_id], color)
# Draw a dot at the query coordinates
base_dir = _ex.settings.TRACES_PATH
images_dir = base_dir / "images"
coords_audit_dir = images_dir / "coords_audit"
coords_audit_dir.mkdir(parents=True, exist_ok=True)
if not found:
return {"text": f"No elements found at [{nx},{ny}].", "image_path": image_path}
existing_files = glob.glob(str(coords_audit_dir / f"{self.image_name}_*.jpg"))
max_seq = 0
for f in existing_files:
match = re.search(r"_(\d+)\.jpg$", f)
if match:
max_seq = max(max_seq, int(match.group(1)))
seq = max_seq + 1
output_path = coords_audit_dir / f"{self.image_name}_{seq}.jpg"
try:
_ex.draw_dots(
self.screenshot_path,
[[x, y]],
[label_id],
str(output_path),
color=color,
)
image_path = str(output_path)
except Exception as e:
_ex.logger.warning(f"Failed to draw blue dots: {e}")
image_path = None
raw_res_str = str(raw_res).replace("\n", " | ").replace(" | | ", " | ")
self._register_label(label_id, found[0])
described = " | ".join(element.describe() for element in found)
return {
"text": (f"Matched element at [{nx},{ny}]: [{label_id}] | {raw_res_str}"),
"text": f"Matched element at [{nx},{ny}]: [{label_id}] | {described}",
"image_path": image_path,
}
@@ -231,6 +239,11 @@ class PerceptionToolsMixin:
) -> dict:
_ex = facade()
try:
if not self.screenshot_path:
return {
"text": "Error: Original screenshot is not available.",
"image_path": None,
}
target_info = self.image_pool.get(target_image_id)
if not target_info:
return {
@@ -289,7 +302,7 @@ class PerceptionToolsMixin:
if target_image_id != "img_0":
text_output.append(
"[NOTE: Coordinates are automatically mapped back to"
" the original 1080x2400 screen space from"
f" the original {self.width}x{self.height} screen space from"
f" {target_image_id}]"
)
@@ -301,6 +314,8 @@ class PerceptionToolsMixin:
x_norm, y_norm = pos
t_img = cv2.imread(target_path)
if t_img is None:
raise ValueError(f"Failed to read target image at {target_path}")
t_h, t_w = t_img.shape[:2]
# Pixels in target image
@@ -390,7 +405,7 @@ class PerceptionToolsMixin:
text_parts.append(f"Text Search Results are: {xml_text}")
coord_text = results[1].get("text", "No elements found.")
if coord_text and coord_text != "No elements found.":
if coord_text and not coord_text.startswith("No elements found"):
text_parts.append(f"Coordinate Search Results are: {coord_text}")
obj_text = results[2].get("text", "No objects detected.")
@@ -502,92 +517,42 @@ class PerceptionToolsMixin:
"text": text,
"image_paths": image_paths,
}
else:
return {
"text": f"ImageProcessor error: {err_msg}",
"text": "ImageProcessor error: no output images were produced.",
"image_paths": [],
}
output_path = result.get("output_path")
trans = result.get("transform")
new_image_id = f"img_{len(self.image_pool)}"
if output_path and trans:
self.image_pool[new_image_id] = {
"path": output_path,
"transform": trans,
}
text_msg = (
f"Image processed successfully. New image saved as"
f" '{new_image_id}'. You can now use '{new_image_id}' as"
" 'target_image_id' in subsequent tool calls."
)
return {
"text": text_msg,
"image_path": output_path,
"image_id": new_image_id,
}
@trace(type="tool", name="get_ocr_list")
async def exec_get_ocr_list(self, prefix: str = "O", color: str = "green") -> dict:
_ex = facade()
if not self.image_name:
return {
"text": ("Error: UI data for the current screen is not found in Data Engine."),
"image_path": None,
}
"""Lists every OCR-detected text on the screen with a labeled dot."""
try:
db_path = _ex.settings.DATA_ENGINE_DB_PATH
base_dir = _ex.settings.TRACES_PATH
storage = _ex.StorageManager(db_path=str(db_path), base_dir=base_dir)
record = storage.get_ui_record(self.image_name)
if not record or not record.ocr_result:
ocr_elements = self.screen_index.ocr_elements
if not ocr_elements:
return {
"text": "No text elements detected on the screen.",
"image_path": None,
}
points = []
labels = []
text_output = []
for item in record.ocr_result:
text = item.get("text", "").strip()
bounds = item.get("bounds", [])
if text and len(bounds) == 4:
left, top, right, bottom = bounds
cx = (left + right) // 2
cy = (top + bottom) // 2
if 0 <= cx <= self.width and 0 <= cy <= self.height:
points: list[list[int]] = []
labels: list[str] = []
text_output: list[str] = []
for element in ocr_elements:
cx, cy = element.center
if not (0 <= cx <= self.width and 0 <= cy <= self.height):
continue
label_id = f"{prefix}{self.global_label_idx}"
self.global_label_idx += 1
self._register_label(label_id, element)
points.append([cx, cy])
labels.append(label_id)
cx_norm = int(max(0, min(1000, cx * 1000 / self.width)))
cy_norm = int(max(0, min(1000, cy * 1000 / self.height)))
text_output.append(f"[{label_id}] '{text}' coords: [{cx_norm},{cy_norm}]")
nx, ny = pixel_to_norm(cx, cy, self.width, self.height)
text_output.append(f"[{label_id}] '{element.text}' coords: [{nx},{ny}]")
if not points:
return {
"text": "No valid OCR results found.",
"image_path": None,
}
return {"text": "No valid OCR results found.", "image_path": None}
images_dir = base_dir / "images"
ocr_dir = images_dir / "ocr"
ocr_dir.mkdir(parents=True, exist_ok=True)
output_path = ocr_dir / f"{self.image_name}_{self.global_label_idx}.jpg"
_ex.draw_dots(self.screenshot_path, points, labels, str(output_path), color=color)
return {
"text": "\n".join(text_output),
"image_path": str(output_path),
}
image_path = self._draw_labeled_dots("ocr", points, labels, color)
return {"text": "\n".join(text_output), "image_path": image_path}
except Exception as e:
return {
@@ -606,6 +571,11 @@ class PerceptionToolsMixin:
) -> dict:
_ex = facade()
try:
if not self.screenshot_path:
return {
"text": "Error: Original screenshot is not available.",
"image_path": None,
}
px_start = int(max(0, min(self.width, x_min * self.width / 1000.0)))
py_start = int(max(0, min(self.height, y_min * self.height / 1000.0)))
px_end = int(max(0, min(self.width, x_max * self.width / 1000.0)))
+167 -142
View File
@@ -14,15 +14,16 @@
"""Setup phases of ``Explorer.run``.
Split out of ``artemis.agents.explorer.explorer``: the flash-mode fast path
and the named preparation phases executed before the reasoning loop (image
record resolution, version limits, screen dimensions, image pool, GenAI
client, model parameters, prompt construction, and initial screenshot
Split out of ``artemis.agents.explorer.explorer``: the flash-tier one-shot
detection path, the per-run screen loading shared by every tier (image record
resolution, screen dimensions, fused hierarchy, in-memory screen index) and
the named preparation phases executed before a reasoning loop (image pool,
model parameters, tier-aware prompt construction, initial screenshot
annotation), packaged as a mixin consumed by ``Explorer``. Patched
collaborators (``settings``, ``StorageManager``, ``genai``,
``is_ocr_configured``, ``perform_ocr``, ``draw_dots``,
``_run_object_detection``, ``logger``) are resolved through the facade module
at call time; see ``artemis.agents.explorer._facade``.
collaborators (``settings``, ``StorageManager``, ``is_ocr_configured``,
``perform_ocr``, ``draw_dots``, ``_run_object_detection``, ``logger``) are
resolved through the facade module at call time; see
``artemis.agents.explorer._facade``.
"""
import base64
@@ -32,26 +33,85 @@ import json
import os
from pathlib import Path
import re
from typing import TYPE_CHECKING, Any
import httpx
from artemis.agents.explorer._facade import facade
from artemis.agents.explorer.constants import EXPLORE_DESCRIPTIONS
from artemis.agents.explorer.constants import EXECUTION_CONSTRAINT_TEMPLATE
from artemis.agents.explorer.geometry import resolve_screen_size
from artemis.agents.explorer.perception_tools import load_detector_templates
from artemis.agents.explorer.screen_index import ScreenElement, ScreenIndex
from artemis.agents.explorer.tiers import SUBMIT_TOOL, ExplorerTier
from artemis.graph.state import State
from artemis.utils.ocr_xml_fusion import fuse_ocr_with_xml
from artemis.utils.visualization import format_minimal_list_with_points
from artemis.utils.visualization import format_minimal_list_with_elements
#: Prompt bullets may be plain strings or ``{"text": ..., "requires": [...]}``
#: objects; the latter are emitted only when every listed tool is exposed.
PromptBullet = str | dict[str, Any]
def _bullet_text(bullet: PromptBullet, exposed_tools: set[str]) -> str | None:
"""Returns the bullet text, or None when its tool requirements are unmet."""
if isinstance(bullet, dict):
required = bullet.get("requires") or []
if not all(tool in exposed_tools for tool in required):
return None
return str(bullet.get("text", ""))
return str(bullet)
def load_prompt_sections(prompt_path: Path) -> dict[str, Any]:
"""Loads ``explorer.json`` tolerating ``//`` and ``/* */`` comments and trailing commas."""
content = prompt_path.read_text(encoding="utf-8")
content = re.sub(r"(?<!:)\/\/.*", "", content)
content = re.sub(r"/\*.*?\*/", "", content, flags=re.DOTALL)
content = re.sub(r",\s*([\]}])", r"\1", content)
return json.loads(content)
class RunSetupMixin:
"""Flash mode and pre-loop setup phases of :class:`Explorer`."""
if TYPE_CHECKING:
from artemis.context import ArtemisContext
DENYLIST_TEMPLATE: str
ctx: ArtemisContext
width: int
height: int
image_name: str | None
screenshot_path: str | None
image_pool: dict[str, dict[str, Any]]
next_img_id: int
global_label_idx: int
http_client: httpx.AsyncClient | None
denylisted_tools: set[str]
_user_denylist: frozenset[str]
screen_index: ScreenIndex
label_registry: dict[str, ScreenElement]
def _hidden_tool_names(self) -> set[str]: ...
def _register_label(self, label: str, element: ScreenElement) -> None: ...
def _registry_element(self, entry: dict[str, Any]) -> ScreenElement: ...
@staticmethod
def _failure_outcome(message: str) -> str:
"""The contract-shaped answer for a run that produced no candidates."""
return json.dumps({"candidates": [], "fallback_message": message}, ensure_ascii=False)
async def _run_flash(self, query: str, screenshot_path: str) -> str:
"""Flash mode: single-shot object detection without a reasoning loop."""
"""Flash tier: one-shot object detection without a reasoning loop.
Several targets may be listed in ``query`` separated by ``|``; each
detection becomes a ``D<n>`` candidate described by its detected label.
"""
_ex = facade()
templates, global_timeout = load_detector_templates()
self.screenshot_path = screenshot_path
self.image_name = None
queries = [q.strip() for q in query.split("|") if q.strip()]
try:
@@ -76,21 +136,12 @@ class RunSetupMixin:
)
fallback_message = "" if candidates else f"Failed to detect: {query}"
return json.dumps(
{
"candidates": candidates,
"fallback_message": fallback_message,
},
{"candidates": candidates, "fallback_message": fallback_message},
ensure_ascii=False,
)
except Exception as e:
_ex.logger.error(f"Flash mode object detection failed: {e}")
return json.dumps(
{
"candidates": [],
"fallback_message": f"Flash mode detection error: {e}",
},
ensure_ascii=False,
)
return self._failure_outcome(f"Flash mode detection error: {e}")
def _resolve_image_record(self, screenshot_path: str) -> tuple:
"""Computes the screenshot hash and looks it up in the Data Engine DB."""
@@ -124,60 +175,28 @@ class RunSetupMixin:
return image_name, record
def _resolve_version_limits(self, version: str) -> tuple[str, int]:
"""Resolves mode label, iteration budget, and the effective tool denylist."""
_ex = facade()
mode = version.capitalize()
version_denylist = set()
if version == "ultra":
max_iterations = 8
elif version == "pro":
max_iterations = 3
version_denylist = {
"ask_image_processor",
"get_ocr_list",
"inspect_region",
"detect_objects",
}
else:
max_iterations = 3
version_denylist = {
"ask_image_processor",
"get_ocr_list",
"inspect_region",
}
if not _ex.is_ocr_configured():
version_denylist.add("get_ocr_list")
try:
denylisted_config = (
self.ctx.agent_config.denylisted_tools.get("explorer", [])
if self.ctx.agent_config
else []
)
self.denylisted_tools = version_denylist.union(set(denylisted_config))
except (TypeError, AttributeError):
self.denylisted_tools = version_denylist
return mode, max_iterations
def _resolve_screen_dimensions(self, state: State) -> None:
"""Resolves device screen dimensions from operator data or the device."""
ctx = self.ctx
self.width = 1080
self.height = 2400
operator_raw_data = getattr(state, "operator_raw_data", {}) or {}
w_raw = operator_raw_data.get("width")
h_raw = operator_raw_data.get("height")
if isinstance(w_raw, int) and isinstance(h_raw, int):
self.width = w_raw
self.height = h_raw
else:
if ctx.device and getattr(ctx.device, "device_width", None):
self.width = ctx.device.device_width
if ctx.device and getattr(ctx.device, "device_height", None):
self.height = ctx.device.device_height
"""Adopts the screenshot's pixel size (operator observation, device, fallback)."""
self.width, self.height = resolve_screen_size(self.ctx, state)
async def _load_screen(
self, screenshot_path: str, state: State, tier: ExplorerTier
) -> list[dict[str, Any]]:
"""Loads the screen once per run: record, screen size, fused hierarchy, index.
Every tier goes through here so the structural pre-pass and the
perception tools share one in-memory snapshot instead of asking the
Data Engine per call. On-the-fly OCR is reserved for loop tiers:
flash never exposes text tools and must stay a single round trip.
"""
image_name, record = self._resolve_image_record(screenshot_path)
self.image_name = image_name
self._resolve_screen_dimensions(state)
fused_xml = await self._build_fused_hierarchy(
record, state, screenshot_path, allow_ocr=not tier.is_oneshot
)
self.screen_index = ScreenIndex.from_hierarchy(fused_xml, self.width, self.height)
return fused_xml
def _init_image_pool(self, screenshot_path: str) -> None:
"""Seeds the Image Pool with the original screenshot as img_0."""
@@ -195,26 +214,11 @@ class RunSetupMixin:
}
self.next_img_id = 1
def _ensure_genai_client(self):
"""Initializes or reuses the context-level GenAI client for connection pooling."""
_ex = facade()
ctx = self.ctx
client = getattr(ctx, "_genai_client", None)
if client is None:
_ex.logger.info(
"Initializing new GenAI client on context for connection pooling (Explorer)..."
)
client = _ex.genai.Client(
api_key=_ex.settings.GOOGLE_API_KEY.get_secret_value()
if _ex.settings.GOOGLE_API_KEY
else None
)
ctx._genai_client = client
return client
def _resolve_model_params(self) -> tuple:
"""Resolves model name, temperature, thinking level, and fallback model."""
llm_cfg = self.ctx.llm_config.explorer
_ex = facade()
llm_config = getattr(self.ctx, "llm_config", None)
llm_cfg = getattr(llm_config, "explorer", None) if llm_config else None
temperature = 0.1
fallback_model = None
thinking_level = None
@@ -231,70 +235,89 @@ class RunSetupMixin:
if "/" in fallback_model:
fallback_model = fallback_model.split("/")[-1]
else:
model_name = "gemini-3.7-flash"
model_name = _ex.DEFAULT_EXPLORER_MODEL
return model_name, temperature, thinking_level, fallback_model
def _build_prompt_template(
self, version: str, mode: str, max_iterations: int
) -> tuple[str | None, str | None]:
"""Builds the system prompt template; returns (prompt, error_message)."""
def _exposed_tool_names(self, tier: ExplorerTier) -> set[str]:
"""Tools the model can actually call in this run (tier minus hidden)."""
return (set(tier.tools) | {SUBMIT_TOOL}) - self._hidden_tool_names()
def _build_prompt_template(self, tier: ExplorerTier) -> tuple[str | None, str | None]:
"""Builds the tier-aware system prompt; returns ``(prompt, error_message)``.
Bullets whose ``requires`` list names a tool the model cannot call in
this tier are dropped, so the prompt never describes capabilities
that are absent from the tool declarations. Tier-hidden tools are
simply never mentioned; only the user's own denylist (restricted to
tools the tier would otherwise expose) is spelled out.
"""
prompt_path = Path(__file__).parent / "explorer.json"
if not prompt_path.exists():
return None, "Error: Explorer prompt template not found."
try:
content = prompt_path.read_text(encoding="utf-8")
content_no_comments = re.sub(r"(?<!:)\/\/.*", "", content)
content_no_comments = re.sub(r"/\*.*?\*/", "", content_no_comments, flags=re.DOTALL)
content_no_comments = re.sub(r",\s*([\]}])", r"\1", content_no_comments)
data = json.loads(content_no_comments)
data = load_prompt_sections(prompt_path)
except Exception as e:
return None, f"Error loading or parsing explorer.json: {e}"
prompt_parts = []
exposed = self._exposed_tool_names(tier)
prompt_parts: list[str] = []
for section, content_val in data.items():
prompt_parts.append(f"# {section}")
if isinstance(content_val, list):
for bullet in content_val:
prompt_parts.append(f"- {bullet}")
bullets = [
text
for text in (_bullet_text(b, exposed) for b in content_val)
if text is not None
]
if not bullets:
continue
prompt_parts.append(f"# {section}")
prompt_parts.extend(f"- {bullet}" for bullet in bullets)
else:
prompt_parts.append(content_val)
text = _bullet_text(content_val, exposed)
if text is None:
continue
prompt_parts.append(f"# {section}")
prompt_parts.append(text)
prompt_parts.append("")
prompt_template = "\n".join(prompt_parts)
prompt_template += "\n{denylist}"
if self.denylisted_tools:
denylist_section = self.DENYLIST_TEMPLATE.format(
tools=", ".join(sorted(self.denylisted_tools))
)
else:
denylist_section = ""
prompt_template = prompt_template.replace("{denylist}", denylist_section)
explore_info = EXPLORE_DESCRIPTIONS.get(version, EXPLORE_DESCRIPTIONS["pro"])
version_prompt = explore_info.get("version_prompt")
if version_prompt:
version_prompt = version_prompt.format(mode=mode, max_iterations=max_iterations)
prompt_template += f"\n# EXECUTION CONSTRAINT\n- {version_prompt}\n"
user_denied = sorted(set(self._user_denylist) & set(tier.tools))
if user_denied:
prompt_template += self.DENYLIST_TEMPLATE.format(tools=", ".join(user_denied))
constraint = EXECUTION_CONSTRAINT_TEMPLATE.format(max_turns=tier.max_turns)
prompt_template += "\n# EXECUTION CONSTRAINT\n- " + constraint + "\n"
return prompt_template, None
async def _build_fused_hierarchy(self, record, state: State, screenshot_path: str):
"""Loads (and if needed OCR-fuses) the UI hierarchy for the screenshot."""
async def _build_fused_hierarchy(
self, record, state: State, screenshot_path: str, allow_ocr: bool = True
) -> list[dict[str, Any]]:
"""Loads (and if needed OCR-fuses) the UI hierarchy for the screenshot.
Precedence: the Data Engine record for the screenshot hash, else the
state's latest hierarchy when the screenshot is the latest one. The
HTTP client backing on-the-fly OCR is created here on first need and
released by ``Explorer.run`` regardless of the engine used.
"""
_ex = facade()
fused_xml = []
fused_xml: Any = []
if record and getattr(record, "ui_tree", None):
ui_tree = record.ui_tree
ocr_results = getattr(record, "ocr_result", None)
if ocr_results is None:
if _ex.is_ocr_configured():
if allow_ocr and _ex.is_ocr_configured():
try:
_ex.logger.info(
"Previous screenshot OCR is missing. Running OCR on-the-fly..."
)
with open(screenshot_path, "rb") as img_file:
img_b64 = base64.b64encode(img_file.read()).decode("utf-8")
if self.http_client is None:
self.http_client = httpx.AsyncClient()
ocr_results = await _ex.perform_ocr(img_b64, client=self.http_client)
except Exception as ocr_err:
_ex.logger.error(
@@ -306,15 +329,18 @@ class RunSetupMixin:
fused_xml = fuse_ocr_with_xml(ui_tree, ocr_results or [])
_ex.logger.info("Successfully loaded and fused UI hierarchy for previous screenshot.")
elif hasattr(state, "latest_ui_hierarchy"):
if screenshot_path == getattr(state, "latest_screenshot", None):
fused_xml = state.latest_ui_hierarchy
return fused_xml
elif screenshot_path == getattr(state, "latest_screenshot", None):
fused_xml = getattr(state, "latest_ui_hierarchy", None)
return list(fused_xml) if isinstance(fused_xml, (list, tuple)) else []
def _annotate_initial_screenshot(
self, fused_xml, screenshot_path: str, image_name, minimal_list: str
) -> tuple[str, object]:
"""Draws labeled dots for the fused hierarchy; returns (minimal_list, marked_path)."""
"""Draws labeled dots for the fused hierarchy; returns (minimal_list, marked_path).
Every numbered label is registered against its element so a
candidate the model later submits by number inherits real bounds.
"""
_ex = facade()
ctx = self.ctx
marked_path = None
@@ -322,10 +348,13 @@ class RunSetupMixin:
_ex.logger.info(
"Explorer self-annotating initial screenshot using latest_ui_hierarchy..."
)
formatted_list, points, labels = format_minimal_list_with_points(
formatted_list, elements, labels = format_minimal_list_with_elements(
fused_xml, self.width, self.height
)
minimal_list = formatted_list
points = [list(entry["center"]) for entry in elements]
for label, entry in zip(labels, elements, strict=True):
self._register_label(label, self._registry_element(entry))
self.global_label_idx = len(points) + 1
base_dir = (
@@ -334,7 +363,6 @@ class RunSetupMixin:
else None
)
if not base_dir:
db_path = _ex.settings.DATA_ENGINE_DB_PATH
base_dir = _ex.settings.TRACES_PATH
images_dir = base_dir / "images"
initial_marked_dir = images_dir / "initial_marked"
@@ -354,15 +382,14 @@ class RunSetupMixin:
# Draw dots on the raw screenshot
_ex.draw_dots(screenshot_path, points, labels, str(marked_path))
_ex.logger.info(
f"Successfully drew {len(points)} dots and saved marked"
f" image to {marked_path}"
f"Successfully drew {len(points)} dots and saved marked image to {marked_path}"
)
except Exception as e:
_ex.logger.error(f"Failed to self-annotate initial screenshot: {e}")
return minimal_list, marked_path
async def _prepare_initial_annotation(
self, record, state: State, screenshot_path: str, minimal_list: str, image_name
def _prepare_initial_annotation(
self, fused_xml, screenshot_path: str, minimal_list: str, image_name
) -> tuple[str, str]:
"""Generates initial visual annotations when no minimal list is provided.
@@ -370,9 +397,7 @@ class RunSetupMixin:
model should read (marked screenshot when available, raw otherwise).
"""
marked_path = None
if not minimal_list:
fused_xml = await self._build_fused_hierarchy(record, state, screenshot_path)
if fused_xml:
if not minimal_list and fused_xml:
minimal_list, marked_path = self._annotate_initial_screenshot(
fused_xml, screenshot_path, image_name, minimal_list
)
+266
View File
@@ -0,0 +1,266 @@
# Copyright 2026 Google LLC
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""In-memory index of the current screen's UI elements for the Explorer.
Built from the fused XML/OCR hierarchy so searches do not depend on the Data
Engine having persisted the frame. Supports fuzzy text search, exact matches
and coordinate hit testing.
"""
from dataclasses import dataclass, field
import difflib
import re
from typing import Any, Literal
from artemis.utils.visualization import parse_bounds
Bounds = tuple[int, int, int, int]
ElementSource = Literal["xml", "ocr"]
#: Attributes that mark an XML node as something the user can interact with.
INTERACTION_KEYS = (
"clickable",
"scrollable",
"long-clickable",
"checkable",
"focusable",
"editable",
"selected",
)
#: Minimum query length for substring matches to count as strong evidence;
#: shorter fragments ("ok", "a") match far too much.
_SUBSTRING_MIN_CHARS = 3
_SUBSTRING_SCORE = 0.85
def normalize_text(value: Any) -> str:
"""Case-folds and collapses whitespace so visually equal labels compare equal."""
return re.sub(r"\s+", " ", str(value or "")).strip().casefold()
def text_similarity(query: str, text: str) -> float:
"""Similarity in ``[0, 1]`` between a query and an element label.
Uses the Data Engine's ``SequenceMatcher`` ratio, with a minimum score
for substring matches when the query is at least three characters long.
"""
q, t = normalize_text(query), normalize_text(text)
if not q or not t:
return 0.0
ratio = difflib.SequenceMatcher(None, q, t).ratio()
if len(q) >= _SUBSTRING_MIN_CHARS and (q in t or t in q):
ratio = max(ratio, _SUBSTRING_SCORE)
return ratio
def _is_truthy_attr(value: Any) -> bool:
return value is True or str(value).strip().lower() == "true"
@dataclass(frozen=True)
class ScreenElement:
"""One text-bearing element of the screen with pixel bounds."""
text: str
bounds: Bounds
source: ElementSource
class_name: str | None = None
resource_id: str | None = None
interactive: bool = False
node: dict[str, Any] = field(default_factory=dict, compare=False, hash=False, repr=False)
@property
def center(self) -> tuple[int, int]:
left, top, right, bottom = self.bounds
return (left + right) // 2, (top + bottom) // 2
@property
def area(self) -> int:
left, top, right, bottom = self.bounds
return max(1, right - left) * max(1, bottom - top)
def contains(self, x: int, y: int) -> bool:
left, top, right, bottom = self.bounds
return left <= x <= right and top <= y <= bottom
def overlap_ratio(self, other: "ScreenElement") -> float:
"""Intersection over the smaller area; 1.0 when one box covers the other."""
l1, t1, r1, b1 = self.bounds
l2, t2, r2, b2 = other.bounds
inter_w = min(r1, r2) - max(l1, l2)
inter_h = min(b1, b2) - max(t1, t2)
if inter_w <= 0 or inter_h <= 0:
return 0.0
return (inter_w * inter_h) / min(self.area, other.area)
def describe(self) -> str:
"""Compact one-line description for tool text output."""
parts = [f"'{self.text}'"] if self.text else []
if self.class_name:
parts.append(f"class={self.class_name}")
if self.resource_id:
parts.append(f"id={self.resource_id}")
parts.append("source=ocr" if self.source == "ocr" else "source=ui-tree")
if self.interactive:
parts.append("interactive")
return " ".join(parts)
@dataclass(frozen=True)
class TextMatch:
element: ScreenElement
score: float
matched_text: str
class ScreenIndex:
"""Queryable snapshot of the visible, text-bearing elements of one screen."""
def __init__(self, elements: list[ScreenElement], width: int, height: int):
self.elements = elements
self.width = width
self.height = height
def __len__(self) -> int:
return len(self.elements)
@property
def ocr_elements(self) -> list[ScreenElement]:
return [e for e in self.elements if e.source == "ocr"]
# ------------------------------------------------------------------ #
# Construction
# ------------------------------------------------------------------ #
@classmethod
def empty(cls, width: int, height: int) -> "ScreenIndex":
return cls([], width, height)
@classmethod
def from_hierarchy(
cls, fused_xml: list[dict[str, Any]] | None, width: int, height: int
) -> "ScreenIndex":
"""Builds the index from a fused UI hierarchy (XML nodes plus OCR text).
Off-screen and zero-area boxes are dropped, and the same label at the
same place is kept once. Nodes without any label are skipped: the
index exists to answer text and hit-test queries, and an unlabeled
wrapper answers neither usefully.
"""
elements: list[ScreenElement] = []
seen: set[tuple[str, Bounds]] = set()
def add(element: ScreenElement) -> None:
key = (normalize_text(element.text), element.bounds)
if key in seen or not cls._is_visible(element.bounds, width, height):
return
seen.add(key)
elements.append(element)
for node in fused_xml or []:
if not isinstance(node, dict):
continue
class_name = node.get("class") or node.get("className")
resource_id = node.get("resource-id") or node.get("resourceId")
interactive = any(_is_truthy_attr(node.get(key)) for key in INTERACTION_KEYS)
for ocr in node.get("ocr_elements") or []:
ocr_bounds = parse_bounds(ocr.get("bounds")) if isinstance(ocr, dict) else None
ocr_text = str(ocr.get("text") or "").strip() if isinstance(ocr, dict) else ""
if ocr_bounds and ocr_text:
add(
ScreenElement(
text=ocr_text,
bounds=ocr_bounds,
source="ocr",
class_name=str(class_name) if class_name else None,
resource_id=str(resource_id) if resource_id else None,
interactive=interactive,
node=node,
)
)
label = str(node.get("text") or node.get("content-desc") or "").strip()
bounds = parse_bounds(node.get("bounds"))
if label and bounds:
add(
ScreenElement(
text=label,
bounds=bounds,
source="xml",
class_name=str(class_name) if class_name else None,
resource_id=str(resource_id) if resource_id else None,
interactive=interactive,
node=node,
)
)
return cls(elements, width, height)
@staticmethod
def _is_visible(bounds: Bounds, width: int, height: int) -> bool:
left, top, right, bottom = bounds
if right <= left or bottom <= top:
return False
return right > 0 and bottom > 0 and left < width and top < height
# ------------------------------------------------------------------ #
# Queries
# ------------------------------------------------------------------ #
def search_text(self, query: str, threshold: float = 0.6, limit: int = 8) -> list[TextMatch]:
"""Fuzzy label search, best matches first."""
matches: list[TextMatch] = []
for element in self.elements:
score = text_similarity(query, element.text)
if score > threshold:
matches.append(TextMatch(element=element, score=score, matched_text=element.text))
matches.sort(key=lambda m: (-m.score, m.element.area))
return matches[:limit]
def exact_matches(self, query: str) -> list[ScreenElement]:
"""Elements whose label equals ``query`` after normalization.
Overlapping duplicates (the same label from the UI tree and from OCR
at the same place) collapse to one element, preferring the UI-tree
node because it carries structure the OCR box lacks.
"""
wanted = normalize_text(query)
if not wanted:
return []
hits = [e for e in self.elements if normalize_text(e.text) == wanted]
hits.sort(key=lambda e: (e.source != "xml", e.area))
unique: list[ScreenElement] = []
for element in hits:
if all(element.overlap_ratio(kept) < 0.5 for kept in unique):
unique.append(element)
return unique
def elements_at(self, x: int, y: int) -> list[ScreenElement]:
"""Elements covering pixel ``(x, y)``: UI-tree nodes innermost first, then OCR.
Mirrors the historical coordinate audit: UI-tree candidates stop at the
first interactive node (at most two are reported) so the answer names
what a tap would actually hit rather than every enclosing wrapper.
"""
covering = [e for e in self.elements if e.contains(x, y)]
xml = sorted((e for e in covering if e.source == "xml"), key=lambda e: e.area)
picked: list[ScreenElement] = []
for element in xml:
picked.append(element)
if element.interactive or len(picked) >= 2:
break
ocr = sorted((e for e in covering if e.source == "ocr"), key=lambda e: e.area)
return picked + ocr
+110
View File
@@ -0,0 +1,110 @@
# Copyright 2026 Google LLC
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Engine, turn budget, tools and caching settings for each Explorer tier.
User configuration selects the tier. The calling agent sees the same
``ask_explorer`` contract at every tier.
"""
from dataclasses import dataclass
from typing import Literal
from artemis.config.constants import ExplorerVersion
ExplorerEngine = Literal["oneshot", "loop"]
#: Tool that terminates a reasoning loop; always exposed by loop engines.
SUBMIT_TOOL = "submit_answer"
#: Every perception / transformation tool the loop engines can expose.
PERCEPTION_TOOLS: frozenset[str] = frozenset(
{
"ask_perception_tool",
"detect_objects",
"get_ocr_list",
"inspect_region",
"ask_image_processor",
}
)
@dataclass(frozen=True)
class ExplorerTier:
"""Behavioral profile of one Explorer tier."""
name: ExplorerVersion
engine: ExplorerEngine
#: Reasoning turns for the loop engine (1 for one-shot detection).
max_turns: int
#: Perception tools exposed to the loop engine (``submit_answer`` is implicit).
tools: frozenset[str]
#: Default for Gemini explicit context caching when the user did not decide.
caching: bool
#: One-line summary used in logs and configuration help.
summary: str
@property
def is_oneshot(self) -> bool:
return self.engine == "oneshot"
@property
def hidden_tools(self) -> frozenset[str]:
"""Perception tools this tier does not expose."""
return PERCEPTION_TOOLS - self.tools
EXPLORER_TIERS: dict[ExplorerVersion, ExplorerTier] = {
"flash": ExplorerTier(
name="flash",
engine="oneshot",
max_turns=1,
tools=frozenset(),
caching=False,
summary="One-shot visual detection on the current screenshot; fastest, no reasoning loop.",
),
"pro": ExplorerTier(
name="pro",
engine="loop",
max_turns=3,
tools=frozenset({"ask_perception_tool"}),
caching=False,
summary=(
"Short reasoning loop combining UI-tree search, coordinate audit and visual"
" detection; balanced default."
),
),
"ultra": ExplorerTier(
name="ultra",
engine="loop",
max_turns=8,
tools=PERCEPTION_TOOLS,
caching=True,
summary=(
"Deep reasoning loop with zooming, OCR and pixel-level image processing for"
" layout-critical searches; slowest."
),
),
}
DEFAULT_TIER: ExplorerVersion = "flash"
def get_tier(version: str | None) -> ExplorerTier:
"""Returns the tier for ``version``; unknown or empty names fall back to the default."""
key = str(version or "").strip().lower()
for tier in EXPLORER_TIERS.values():
if tier.name == key:
return tier
return EXPLORER_TIERS[DEFAULT_TIER]
+5 -5
View File
@@ -30,7 +30,7 @@ UNIVERSAL_EXPLORER_TOOLS = [
"name": "ask_perception_tool",
"description": (
"[Perception] Concurrently executes and awaits three sub-tasks in parallel,"
" including database search, coordinate search, and visual object detection."
" including UI-tree text search, coordinate audit, and visual object detection."
),
"parameters": {
"type": "object",
@@ -154,7 +154,7 @@ UNIVERSAL_EXPLORER_TOOLS = [
"properties": {
"label": {
"type": "string",
"description": "Reference label (e.g. S1, D2, T3)",
"description": "Reference label (e.g. X1, O2, D3, or a number like 3)",
},
"description": {
"type": "string",
@@ -191,7 +191,7 @@ NATIVE_EXPLORER_TOOL_DECLARATIONS = [
name="ask_perception_tool",
description=(
"[Perception] Concurrently executes and awaits three sub-tasks"
" in parallel, including database search, coordinate search,"
" in parallel, including UI-tree text search, coordinate audit,"
" and visual object detection on the screen.\n\nOutput"
" Format:\nReturns:'text' (str): A single string consolidating"
" results with 0-1000 normalized coordinates, including UI"
@@ -264,7 +264,7 @@ NATIVE_EXPLORER_TOOL_DECLARATIONS = [
"[Perception] Retrieves all text elements detected on the"
" original, unprocessed screen via OCR. Returns their"
" normalized coordinates (in a [0, 1000] scale) labeled with"
" [T1], [T2] etc. in the text output, along with an annotated"
" [O1], [O2] etc. in the text output, along with an annotated"
" image containing corresponding visual label dots."
),
parameters=types.Schema(
@@ -324,7 +324,7 @@ NATIVE_EXPLORER_TOOL_DECLARATIONS = [
"label": types.Schema(
type=types.Type.STRING,
description=(
"Reference label (e.g., S1, D2, T3, or number like 3)"
"Reference label (e.g. X1, O2, D3, or a number like 3)"
),
),
"coords": types.Schema(
+148 -89
View File
@@ -16,15 +16,15 @@
Split out of ``artemis.agents.explorer.explorer``: the ``_run_universal``
reasoning loop, packaged as a mixin consumed by ``Explorer``. Patched
collaborators (``get_llm``, ``is_ocr_configured``, ``logger``,
``UNIVERSAL_EXPLORER_TOOLS``) are resolved through the facade module at call
time; see ``artemis.agents.explorer._facade``.
collaborators (``get_llm``, ``logger``, ``UNIVERSAL_EXPLORER_TOOLS``) are
resolved through the facade module at call time; see
``artemis.agents.explorer._facade``.
"""
import asyncio
import base64
import json
from typing import Any
from typing import TYPE_CHECKING, Any
from langchain_core.messages import (
BaseMessage,
@@ -34,46 +34,74 @@ from langchain_core.messages import (
)
from artemis.agents.explorer._facade import facade
from artemis.agents.explorer.geometry import is_valid_norm_point
from artemis.agents.explorer.tiers import SUBMIT_TOOL
from artemis.graph.state import State
class UniversalRunnerMixin:
"""Universal-engine reasoning loop of :class:`Explorer`."""
if TYPE_CHECKING:
from artemis.context import ArtemisContext
ctx: ArtemisContext
denylisted_tools: set[str]
def _hidden_tool_names(self) -> set[str]: ...
def _enrich_candidates(self, candidates: list[Any]) -> list[Any]: ...
async def exec_ask_perception_tool(
self, search_query: str, nx: int, ny: int, detect_queries: list[str]
) -> dict[str, Any]: ...
async def exec_detect_objects(
self, queries: list[str], target_image_id: str = "img_0"
) -> dict[str, Any]: ...
async def exec_ask_image_processor(
self, instruction: str, target_image_id: str = "img_0"
) -> dict[str, Any]: ...
async def exec_get_ocr_list(self) -> dict[str, Any]: ...
async def exec_inspect_region(
self, x_min: int, y_min: int, x_max: int, y_max: int, zoom_factor: float = 2.0
) -> dict[str, Any]: ...
def _universal_exposed_tools(self) -> list[dict[str, Any]]:
"""OpenAI-style tool schemas the model may see (tier-hidden ones excluded)."""
_ex = facade()
hidden = self._hidden_tool_names()
return [t for t in _ex.UNIVERSAL_EXPLORER_TOOLS if t["function"]["name"] not in hidden]
async def _run_universal(
self,
*,
query: str,
context_feedback: str,
screenshot_path: str,
state: State,
minimal_list: str,
version: str,
prompt_template: str,
max_iterations: int,
max_turns: int,
) -> str:
"""Executes Explorer reasoning loop via Universal LangChain ChatModel."""
"""Executes the Explorer reasoning loop via a universal LangChain ChatModel."""
_ex = facade()
llm = _ex.get_llm(self.ctx, name="explorer")
# Filter universal tools based on denylist and OCR configuration
denylisted = set(self.denylisted_tools)
if not _ex.is_ocr_configured():
denylisted.add("get_ocr_list")
exposed_tools = [
t for t in _ex.UNIVERSAL_EXPLORER_TOOLS if t["function"]["name"] not in denylisted
]
exposed_tools = self._universal_exposed_tools()
bound_llm = llm.bind_tools(exposed_tools)
messages = self._build_universal_messages(
query, context_feedback, screenshot_path, minimal_list, prompt_template
)
iterations = 0
agent_outcome = ""
while iterations < max_iterations:
iterations += 1
is_final_turn = iterations == max_iterations
turn = 0
while turn < max_turns:
turn += 1
is_final_turn = turn == max_turns
if is_final_turn:
messages.append(
@@ -85,7 +113,7 @@ class UniversalRunnerMixin:
)
)
submit_only_tools = [
t for t in exposed_tools if t["function"]["name"] == "submit_answer"
t for t in exposed_tools if t["function"]["name"] == SUBMIT_TOOL
]
current_llm = llm.bind_tools(submit_only_tools) if submit_only_tools else bound_llm
else:
@@ -96,9 +124,7 @@ class UniversalRunnerMixin:
tool_calls = getattr(response, "tool_calls", None) or []
if not tool_calls:
_ex.logger.warning(
f"Universal Explorer iteration {iterations}: No tool calls generated."
)
_ex.logger.warning(f"Universal Explorer turn {turn}: No tool calls generated.")
if is_final_turn:
return json.dumps(
{
@@ -109,28 +135,20 @@ class UniversalRunnerMixin:
)
continue
# Check for submit_answer
submit_outcome = self._universal_submit_outcome(tool_calls)
if submit_outcome is not None:
agent_outcome = submit_outcome
return agent_outcome
return submit_outcome
# Execute non-submit tools
await self._universal_dispatch_tools(tool_calls, messages, iterations)
await self._universal_dispatch_tools(tool_calls, messages, turn)
if not agent_outcome:
agent_outcome = json.dumps(
return json.dumps(
{
"candidates": [],
"fallback_message": (
"Explorer reached max iterations without finding candidates."
),
"fallback_message": "Explorer reached max iterations without finding candidates.",
},
ensure_ascii=False,
)
return agent_outcome
def _build_universal_messages(
self,
query: str,
@@ -167,7 +185,7 @@ class UniversalRunnerMixin:
def _universal_submit_outcome(self, tool_calls: list) -> str | None:
"""Returns the validated submit_answer outcome JSON, or None if absent."""
submit_call = next((tc for tc in tool_calls if tc.get("name") == "submit_answer"), None)
submit_call = next((tc for tc in tool_calls if tc.get("name") == SUBMIT_TOOL), None)
if not submit_call:
return None
@@ -175,36 +193,102 @@ class UniversalRunnerMixin:
candidates = args.get("candidates", [])
fallback_message = args.get("fallback_message", "")
# Validate coordinates
valid_candidates = []
for cand in candidates:
if isinstance(cand, dict) and "coords" in cand:
coords = cand["coords"]
if isinstance(coords, list) and len(coords) == 2:
try:
nx, ny = int(coords[0]), int(coords[1])
if 0 <= nx <= 1000 and 0 <= ny <= 1000:
valid_candidates.append(cand)
except (ValueError, TypeError):
pass
valid_candidates = self._enrich_candidates(
[
cand
for cand in candidates
if isinstance(cand, dict) and is_valid_norm_point(cand.get("coords"))
]
)
return json.dumps(
{
"candidates": valid_candidates,
"fallback_message": fallback_message,
},
{"candidates": valid_candidates, "fallback_message": fallback_message},
ensure_ascii=False,
)
async def _universal_dispatch_tools(
self, tool_calls: list, messages: list[BaseMessage], iterations: int
) -> None:
"""Executes non-submit tool calls and appends their ToolMessages."""
async def _universal_execute_tool(self, name: str, args: dict) -> dict[str, Any]:
"""Runs one perception tool by name; unknown names yield an error result."""
if name == "ask_perception_tool":
return await self.exec_ask_perception_tool(
search_query=args.get("search_query"),
nx=args.get("nx"),
ny=args.get("ny"),
detect_queries=args.get("detect_queries"),
)
if name == "detect_objects":
return await self.exec_detect_objects(
queries=args.get("queries"),
target_image_id=args.get("target_image_id", "img_0"),
)
if name == "get_ocr_list":
return await self.exec_get_ocr_list()
if name == "ask_image_processor":
return await self.exec_ask_image_processor(
instruction=args.get("instruction"),
target_image_id=args.get("target_image_id", "img_0"),
)
if name == "inspect_region":
return await self.exec_inspect_region(
x_min=args.get("x_min"),
y_min=args.get("y_min"),
x_max=args.get("x_max"),
y_max=args.get("y_max"),
zoom_factor=args.get("zoom_factor", 2.0),
)
return {"text": f"Error: Tool '{name}' is not recognized."}
@staticmethod
def _tool_image_paths(res: Any) -> list[str]:
"""Collects the annotated image paths a tool result may carry."""
if not isinstance(res, dict):
return []
paths: list[str] = []
single = res.get("image_path")
if single:
paths.append(str(single))
paths.extend(str(p) for p in (res.get("image_paths") or []) if p)
return paths
def _build_tool_images_message(self, images: list[tuple[str, str]]) -> HumanMessage | None:
"""One HumanMessage carrying every annotated image produced this turn.
LangChain ``ToolMessage`` content is text-only for most providers, so
the images ride on a follow-up human turn instead; unreadable files
are skipped so a missing annotation never aborts the loop.
"""
_ex = facade()
blocks: list[dict[str, Any]] = []
tool_names: list[str] = []
for tool_name, path in images:
try:
with open(path, "rb") as f:
img_b64 = base64.b64encode(f.read()).decode("utf-8")
except OSError as read_err:
_ex.logger.warning(f"Skipping unreadable tool image {path}: {read_err}")
continue
if tool_name not in tool_names:
tool_names.append(tool_name)
blocks.append(
{"type": "image_url", "image_url": {"url": f"data:image/jpeg;base64,{img_b64}"}}
)
if not blocks:
return None
header = {
"type": "text",
"text": f"[Annotated image(s) returned by: {', '.join(tool_names)}]",
}
return HumanMessage(content=[header, *blocks])
async def _universal_dispatch_tools(
self, tool_calls: list, messages: list[BaseMessage], turn: int
) -> None:
"""Executes non-submit tool calls, appending ToolMessages and their images."""
_ex = facade()
images: list[tuple[str, str]] = []
for tc in tool_calls:
name = tc.get("name")
args = tc.get("args", {})
call_id = tc.get("id", f"call_{iterations}_{name}")
call_id = tc.get("id", f"call_{turn}_{name}")
if name in self.denylisted_tools:
messages.append(
@@ -217,39 +301,10 @@ class UniversalRunnerMixin:
continue
try:
if name == "ask_perception_tool":
res = await self.exec_ask_perception_tool(
search_query=args.get("search_query"),
nx=args.get("nx"),
ny=args.get("ny"),
detect_queries=args.get("detect_queries"),
)
elif name == "detect_objects":
res = await self.exec_detect_objects(
queries=args.get("queries"),
target_image_id=args.get("target_image_id", "img_0"),
)
elif name == "get_ocr_list":
res = await self.exec_get_ocr_list()
elif name == "ask_image_processor":
res = await self.exec_ask_image_processor(
instruction=args.get("instruction"),
target_image_id=args.get("target_image_id", "img_0"),
)
elif name == "inspect_region":
res = await self.exec_inspect_region(
x_min=args.get("x_min"),
y_min=args.get("y_min"),
x_max=args.get("x_max"),
y_max=args.get("y_max"),
zoom_factor=args.get("zoom_factor", 2.0),
)
else:
res = {"text": f"Error: Tool '{name}' is not recognized."}
res = await self._universal_execute_tool(name, args)
res_text = res.get("text") or res.get("result") or str(res)
messages.append(ToolMessage(tool_call_id=call_id, name=name, content=res_text))
images.extend((name, path) for path in self._tool_image_paths(res))
except Exception as tool_err:
_ex.logger.error(f"Error executing tool '{name}': {tool_err}")
messages.append(
@@ -259,3 +314,7 @@ class UniversalRunnerMixin:
content=f"Error executing tool '{name}': {tool_err}",
)
)
image_message = self._build_tool_images_message(images) if images else None
if image_message is not None:
messages.append(image_message)
+5 -17
View File
@@ -145,9 +145,7 @@ class ScrubEdgeCompressor:
continue
if block.get("type") in ("image_url", "image"):
has_image = True
elif block.get("type") == "text" and self._has_strip_marker(
block.get("text", "")
):
elif block.get("type") == "text" and self._has_strip_marker(block.get("text", "")):
has_xml = True
if has_xml:
@@ -186,9 +184,7 @@ class ScrubEdgeCompressor:
# With a live tail outside the tracked list (tail_offset > 0) the tail
# itself occupies the newest slots of the keep window.
keep_count = max(0, self._xml_scrub_depth - self._tail_offset)
keep_indices = (
{rec["idx"] for rec in self._tracked[-keep_count:]} if keep_count else set()
)
keep_indices = {rec["idx"] for rec in self._tracked[-keep_count:]} if keep_count else set()
still_pending: list[int] = []
for idx in self._xml_candidates:
if idx in keep_indices:
@@ -210,9 +206,7 @@ class ScrubEdgeCompressor:
and block.get("type") == "text"
and self._has_strip_marker(block.get("text", ""))
):
retained_text = _without_marked_suffix(
block.get("text", ""), self._strip_markers
)
retained_text = _without_marked_suffix(block.get("text", ""), self._strip_markers)
if retained_text:
retained_block = dict(block)
retained_block["text"] = retained_text
@@ -275,11 +269,7 @@ class ScrubEdgeCompressor:
return None, False, False, None
summary = summarizer.get_summary(effective_key)
if (
summary is None
and not summarizer.has_job(effective_key)
and rec["legacy"] is not None
):
if summary is None and not summarizer.has_job(effective_key) and rec["legacy"] is not None:
effective_key = rec["legacy"]
summary = summarizer.get_summary(effective_key)
@@ -327,9 +317,7 @@ class ScrubEdgeCompressor:
# Defensive fallback: never leave ToolMessage or HumanMessage content empty
if not new_blocks:
new_blocks = [
{"type": "text", "text": "Action completed." if rec["is_tool"] else ""}
]
new_blocks = [{"type": "text", "text": "Action completed." if rec["is_tool"] else ""}]
msg.content = new_blocks
self._frozen.add(rec["idx"])
+15 -8
View File
@@ -16,26 +16,33 @@ You operate in a dynamic observation-reasoning-execution loop. **CRITICAL RULE:
Keep in mind that the mobile device is an asynchronous, constantly evolving environment. However, your actions entail an inherent "turn latency" (a 3–7 second delay caused by model inference and network transmission between normal consecutive actions).
- **State Drift & Progression Tolerance**: While you think, video playback advances, banners scroll, and web pages finish loading. If the latest observation has progressed further than expected (e.g., an ad automatically skipped or a redirect landed), **do not treat it as an error**. Adapt smoothly to the live screen in front of you.
{% if "wait_for_delay" in available_tools %}- **Timed Waiting & Transitions**: When facing mid-transition animations, loading spinners, required watch durations, or ad countdown timers, use the precise `wait_for_delay` command to allow time to pass or the UI state to stabilize. Some transition states are subtle and may even resemble a phone bug or system freeze; it is always advisable to wait first, and only consider retrying or choosing an alternative path if the state remains broken.{% endif %}
- **Time-Sensitive Tasks & Transient UI**: First, you must understand that each of your decision turns takes several seconds of model inference and network delay, while the mobile device state is continuously changing. A target element that you see on the current screenshot (e.g., auto-dismissing overlays, temporary message bars, menus or control bars with short timeouts) may have already vanished by the time your physical click command reaches the device! This is one of the most common reasons why the UI does not change as expected after a single step. We call these **Time-Sensitive Tasks**.
- **Time-Sensitive Tasks & Transient UI**: Because of the turn latency above, a target element that you see on the current screenshot (e.g., auto-dismissing overlays, temporary message bars, menus or control bars with short timeouts) may have already vanished by the time your physical click command reaches the device! This is one of the most common reasons why the UI does not change as expected after a single step. We call these **Time-Sensitive Tasks**.
- *Mental Model*: When facing these tasks, any attempt to interact step-by-step (such as tapping once to wake up a menu/bar and waiting for the next turn to tap the target button) WILL fail because the element will auto-dismiss during your decision delay. You MUST combine the summon action on the trigger element (`Trigger`) and the target action (`Target`) into a single atomic sequence.
- *Chained Execution*: Use `click_sequence(sequence=[[trigger_x, trigger_y], [target_x, target_y]])` in `0-1000` normalized scale to execute both taps consecutively with a 50ms delay, catching the transient element before the timeout closes it.
---
# 3. STAGNATION & LOOP PREVENTION
- **Retrying is not the only option.**: If you perform the same action on the same target for two consecutive turns and the screen or XML shows **no substantial change**, further retries are unlikely to be useful; the probability of the execution tool itself malfunctioning is very low, so you should pause and consider the underlying reason.
# 3. READING YOUR HISTORY & THE SESSION CLOCK
- **Conversation = execution history**: The earlier turns of this conversation are your raw execution history. Each observation opens with `# CURRENT OBSERVATION [T+mm:ss]`, and each turn that executed a device action ends with an `--- Action Execution Result (T+mm:ss) ---` message (`Status: success` means the action was dispatched and observed; `Status: failed` carries the `Error:` line). Every timestamp is a session-relative `T+mm:ss` offset from the start of this session; it is the only clock you have, for pacing waits as well as for reasoning about elapsed time.
- **Compression**: Screenshots older than a few turns are replaced by short `--- Historical Visual Transition ---` summaries and old `--- Visible UI Elements ---` lists are dropped. Long spans render as `[Era N | Steps a–b | T+.. → T+..]` blocks (a merged synopsis, segment titles, and a per-step action ledger). Because of this, if you spot critical text that you will need later (e.g., verification codes, specific titles, search lists), **you must explicitly verbalize and write those details into your text reasoning** so they persist across turns.
{% if "recall_history" in available_tools %}- **Cold-History Recall (`recall_history`)**: Compressed history keeps the step numbers and `T+` offsets but not every detail. When you need exactly what you did or saw at an earlier step (a value you read, the result of an action, which screen you were on), call `recall_history` with keywords and/or the `[start, end]` step range shown in a chunk block; it searches the full stored history (summaries, actions, results, reasoning, on-screen text). It is a pre-decision helper: read its answer, then act.{% endif %}
{% if "video_analyzer" in available_tools %}- **Video Recall (`video_analyzer`)**: The whole session is continuously screen-recorded on the same `T+` clock. When a static screenshot is not enough — a video or animation that played, something that changed between two observations, a transient toast, a fast-scrolling list — call `video_analyzer` with a time range expressed on that clock (e.g., `from T+01:20 to T+01:35`, or `from 80s to 95s`) and a precise question. Prefer narrow ranges; ask for audio explicitly when only audio matters. It is a pre-decision helper: read its answer, then act.{% endif %}
---
# 4. STAGNATION & LOOP PREVENTION
- **Retrying is not the only option.**: If you perform the same action on the same target for two consecutive turns and the screen or the UI element list shows **no substantial change**, further retries are unlikely to be useful; the probability of the execution tool itself malfunctioning is very low, so you should pause and consider the underlying reason.
- **Flexibly Adjust Strategies:** Always prioritize resolving the immediate issue. If you encounter overlays or pop-ups blocking input, or if an element is unresponsive, actively consider alternative ways to complete the task. If an action fails to proceed as expected, systematically consider whether the issue lies with the device itself, the operational path, or a change in state occurring between your actual interaction and the screenshot (Time-Sensitive Tasks).
---
# 4. ACTION & MEMORY RULES
# 5. ACTION RULES
1. **Normalized Coordinates (`0-1000` Scale)**: Always use normalized `[x, y]` coordinates in `0-1000` scale for any location-based target (e.g., `target=[500, 600]` or `click_sequence=[[300, 400], [500, 600]]`).
2. **Do not determine element coordinates based on guesswork.** If the element is visible in the screenshot but its coordinates are missing from the provided XML tree, please use the `ask_explorer` tool to locate the element first.
3. **Screenshot Pruning Awareness**: To optimize speed, the system **prunes old intermediate screenshots from your history**—you only ever see the *latest* live screenshot and UI tree. Therefore, if you spot critical text in an earlier step that you will need later (e.g., verification codes, specific titles, search lists), **you must explicitly verbalize and write those details into your text thinking/notes** so they persist across turns.
{% if "manage_app" in available_tools %}4. **App Launching**: To start or reset an application, always use `manage_app(action="launch", app_name="...")` or `action="stop"`. Do not manually swipe across home screens.{% endif %}
2. **Do not determine element coordinates based on guesswork.** If the element is visible in the screenshot but its coordinates are missing from the provided `--- Visible UI Elements ---` list, please use the `ask_explorer` tool to locate the element first.
{% if "manage_app" in available_tools %}3. **App Launching**: To start or reset an application, always use `manage_app(action="launch", app_name="...")` or `action="stop"`. Do not manually swipe across home screens.{% endif %}
---
# 5. 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 user's objective (`{{ goal }}`) is fully achieved.
- **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.
+347 -149
View File
@@ -16,10 +16,21 @@
Executes autonomous reactive mobile workflows across Google Gemini,
OpenAI GPT-4o/o3, Anthropic Claude 3.5/3.7, and OpenRouter endpoints.
Conversation shape (shared with the Pro operator, history redesign §3.2):
the prompt is built every turn from a session :class:`TranscriptLedger` —
a byte-stable system prefix, the committed earlier turns (append-only; old UI
lists stripped at depth 1, screenshots resolved to visual summaries at depth
K, long spans chunk-compressed), and a fresh tail carrying the current
observation under a ``# CURRENT OBSERVATION [T+mm:ss]`` header. Each
committed turn ends with an ``--- Action Execution Result (T+mm:ss) ---``
message, so every timestamp the model sees is a session-relative offset — the
same clock the video analyzer uses for the session recording.
"""
import asyncio
import base64
from dataclasses import dataclass, field
import json
from pathlib import Path
import re
@@ -33,7 +44,6 @@ from langchain_core.messages import (
ToolMessage,
)
from artemis.agents.flash.context_compressor import ScrubEdgeCompressor
from artemis.agents.flash.summarizer import VisualStepSummarizer
from artemis.agents.validator.tool_declarations import (
ASK_EXPLORER_TOOL,
@@ -56,6 +66,7 @@ from artemis.graph.perception import _check_injected_instruction_file
from artemis.graph.state import State
from artemis.llm.structured import ParseFailure, parse_structured
from artemis.mcp.action_executor import McpActionExecutor
from artemis.memory.transcript import PRO_UI_LIST_MARKER, TranscriptLedger
from artemis.services.llm import (
RobustChatModelWrapper,
acomplete,
@@ -68,6 +79,47 @@ from artemis.utils.logger import get_logger
logger = get_logger(__name__)
#: Reminder carried by the first observation only (the system prompt states the rule).
_REASONING_FIRST_REMINDER = (
"CRITICAL RULE: In every single turn, you MUST FIRST output a natural"
" language reasoning/explanation paragraph BEFORE invoking any tool call."
)
_NO_TOOL_CALL_NOTICE = (
"You did not call any tools last turn. Please make progress by calling an"
" action tool or 'report_task_status'."
)
_FINAL_TURN_WARNING = "[WARNING] This is your final turn; only 'report_task_status' is available."
@dataclass
class _TurnRecord:
"""What one reactive turn produced, for committing it into the ledger.
``step_keys`` are the DataEngine step ids recorded this turn (one per
executed action; the tool_call_id stands in without a DataEngine) — the
first keys the observation screenshot to its visual-transition summary,
all of them feed the chunk ledger. ``actions`` collects the outcome of
every device action so the turn's execution result can be rendered.
"""
step_keys: list[str] = field(default_factory=list)
actions: list[tuple[str, str, str]] = field(default_factory=list)
def result(self) -> dict | None:
"""The turn's execution result in the validator-report shape.
``None`` when no device action ran (helper-only turns have no result
message, as in Pro).
"""
if not self.actions:
return None
for name, status, text in self.actions:
if status != "success":
return {"status": "failed", "error": f"{name}: {text}"}
return {"status": "success"}
class FlashRunner:
"""Reactive, ultra-fast agent execution loop supporting all VLM providers."""
@@ -81,14 +133,18 @@ class FlashRunner:
self.step_summarizer_cfg = cfg.flash.step_summarizer
self.memory_runtime_cfg = cfg.memory.runtime
self.transcript_cfg = cfg.memory.transcript
self.chunking_cfg = cfg.memory.chunking
except Exception:
self.max_turns = max_turns if max_turns is not None else 30
self.max_turns = max_turns if max_turns is not None else 0
self.step_summarizer_cfg = StepSummarizerConfig()
self.memory_runtime_cfg = MemoryRuntimeConfig()
self.transcript_cfg = MemoryTranscriptConfig()
self.chunking_cfg = None
self.controller = UnifiedMobileController(ctx)
self.executor = McpActionExecutor(ctx, self.controller)
# ``agent_name="flash"`` makes ask_explorer follow ``explorer.flash_mode``
# (the Flash profile knob) instead of the Pro profile's tier.
self.executor = McpActionExecutor(ctx, self.controller, agent_name="flash")
self.summarizer = (
VisualStepSummarizer(
ctx,
@@ -105,13 +161,46 @@ class FlashRunner:
if self.summarizer is not None and getattr(ctx, "step_memory", None) is None:
try:
ctx.step_memory = self.summarizer
except Exception:
except (AttributeError, TypeError, ValueError):
pass
@property
def turn_limit(self) -> int | None:
"""The reactive turn cap, or ``None`` when the loop is unbounded."""
try:
limit = int(self.max_turns) if self.max_turns is not None else 0
except (TypeError, ValueError):
return None
return limit if limit > 0 else None
def _video_tools_enabled(self) -> bool:
"""Same gate the Pro graph applies before binding ``video_analyzer``."""
setup = getattr(self.ctx, "execution_setup", None)
return getattr(setup, "video_recording_tools_enabled", False) is True
def _recall_enabled(self) -> bool:
"""Same gate as the Pro operator: a DataEngine session and the recall config."""
from artemis.tools.history_recall import _recall_available
try:
return bool(_recall_available(self.ctx))
except Exception:
return False
def _get_tools(self) -> list:
tools = [t for t in VALIDATOR_TOOLS_DECLARATION if t.name != "report_failure_analysis"]
tools = list(VALIDATOR_TOOLS_DECLARATION)
tools.insert(1, CLICK_SEQUENCE_TOOL)
tools.append(ASK_EXPLORER_TOOL)
# Helper tools shared with the Pro operator, declared next to their
# LangChain tools so each contract has one owner.
if self._recall_enabled():
from artemis.tools.history_recall import RECALL_HISTORY_TOOL
tools.append(RECALL_HISTORY_TOOL)
if self._video_tools_enabled():
from artemis.tools.video_tool import VIDEO_ANALYZER_TOOL
tools.append(VIDEO_ANALYZER_TOOL)
tools.append(REPORT_TASK_STATUS_TOOL)
# With an actuator installed, drop declarations for device actions the
# backend does not implement (and append its extension tools); without
@@ -131,19 +220,46 @@ class FlashRunner:
# run() setup helpers
# ------------------------------------------------------------------
def _build_compressor(self) -> ScrubEdgeCompressor:
"""Builds the per-run scrub-edge compressor.
def _build_ledger(self) -> TranscriptLedger:
"""Builds the session transcript ledger with the shared Pro history policy.
Fresh scrub-edge ledger per run: the frozen/watermark bookkeeping is
execution state bound to this run's append-only message list.
The ``T+mm:ss`` clock is anchored to the DataEngine session start so
the observation headers, the chunk ledger lines and the video
analyzer's action timeline share one origin. L2/L3 chunk compression
needs DataEngine step records; without an engine the ledger runs
scrub-edge-only.
"""
return ScrubEdgeCompressor(
summarizer=self.summarizer,
engine = getattr(self.ctx, "data_engine", None)
session_start = getattr(engine, "session_start_time", None) if engine else None
cfg = self.transcript_cfg
ledger = TranscriptLedger(
step_memory=self.summarizer,
prune_history_xml=self.step_summarizer_cfg.prune_history_xml,
image_scrub_depth=self.transcript_cfg.image_scrub_depth,
pending_grace_steps=self.transcript_cfg.pending_grace_steps,
xml_scrub_depth=self.transcript_cfg.xml_scrub_depth,
image_scrub_depth=getattr(cfg, "image_scrub_depth", 3),
pending_grace_steps=getattr(cfg, "pending_grace_steps", 3),
xml_scrub_depth=getattr(cfg, "xml_scrub_depth", 1),
session_start=session_start if isinstance(session_start, (int, float)) else None,
)
if engine is not None:
try:
from artemis.memory import HistoryChunkManager
ledger.attach_chunker(
HistoryChunkManager(
engine=engine,
ctx=self.ctx,
chunking_config=self.chunking_cfg,
transcript_config=cfg,
goal=self.goal,
)
)
except Exception as e:
logger.error(f"History chunk manager unavailable for FlashRunner: {e}")
try:
self.ctx.transcript_ledger = ledger
except (AttributeError, TypeError, ValueError):
pass
return ledger
def _init_llm(self):
"""Initializes the Universal LLM via the Service Layer."""
@@ -154,37 +270,6 @@ class FlashRunner:
return RobustChatModelWrapper(get_google_llm(model_name="gemini-2.5-flash"), self.ctx)
def _build_initial_user_content(self, img_bytes, xml_list) -> list[dict]:
"""Builds the first HumanMessage content (goal + screenshot + UI tree)."""
user_content: list[dict] = [
{"type": "text", "text": f"Your objective is: {self.goal}"},
]
if img_bytes:
img_b64 = base64.b64encode(img_bytes).decode("utf-8")
user_content.append(
{
"type": "image_url",
"image_url": {"url": f"data:image/jpeg;base64,{img_b64}"},
}
)
if xml_list:
user_content.append(
{
"type": "text",
"text": f"--- UI Element List ---\n{xml_list}",
}
)
user_content.append(
{
"type": "text",
"text": (
"CRITICAL RULE: In every single turn, you MUST FIRST output a natural"
" language reasoning/explanation paragraph BEFORE invoking any tool call."
),
}
)
return user_content
def _render_system_prompt(self, tools_declaration: list) -> str:
"""Renders the system prompt from the flash_runner.md template.
@@ -195,23 +280,26 @@ class FlashRunner:
prompt_path = Path(__file__).parent / "flash_runner.md"
prompt_template = prompt_path.read_text(encoding="utf-8")
available_tools = frozenset(t.name for t in tools_declaration)
return Template(prompt_template).render(
goal=self.goal, available_tools=available_tools
)
return Template(prompt_template).render(goal=self.goal, available_tools=available_tools)
# ------------------------------------------------------------------
# Per-turn helpers (observe / think)
# ------------------------------------------------------------------
async def _append_injected_instruction(self, messages: list[BaseMessage]) -> None:
"""Checks for real-time injected instructions and appends them."""
if self.ctx.data_engine and self.ctx.data_engine.base_dir:
async def _read_injected_instruction(self) -> str | None:
"""Returns the real-time injected instruction text for this turn, if any."""
if not (self.ctx.data_engine and self.ctx.data_engine.base_dir):
return None
try:
injected_payload = await asyncio.to_thread(
_check_injected_instruction_file,
str(self.ctx.data_engine.base_dir),
)
if injected_payload and injected_payload.get("instruction"):
except (OSError, ValueError, AttributeError) as e:
logger.warning(f"Failed to check injected instruction in FlashRunner: {e}")
return None
if not (injected_payload and injected_payload.get("instruction")):
return None
injected_text = (
"[REAL-TIME INJECTED INSTRUCTION from user]:"
f" {injected_payload['instruction']}\nYou MUST immediately"
@@ -223,34 +311,52 @@ class FlashRunner:
" ongoing monitoring loop; you may now wrap up and"
" complete the task."
)
messages.append(HumanMessage(content=injected_text))
except Exception as e:
logger.warning(f"Failed to check injected instruction in FlashRunner: {e}")
return injected_text
def _select_turn_tools(
self, turns: int, tools_declaration: list, messages: list[BaseMessage]
) -> list:
"""Applies the tool restriction on the final turn."""
if turns == self.max_turns:
messages.append(
HumanMessage(
content=(
"[WARNING] This is your final turn; only"
" 'report_task_status' is available."
def _build_tail(
self,
ledger: TranscriptLedger,
turns: int,
img_bytes,
xml_list,
*,
injected: str | None = None,
notices: list[str] | None = None,
is_final: bool = False,
) -> HumanMessage:
"""Builds this turn's observation tail (Pro observation shape).
Header, screenshot and UI list carry the same markers as the Pro
operator's tail so the ledger's scrub edge treats both alike.
"""
blocks: list[dict] = []
if turns == 1:
blocks.append({"type": "text", "text": f"Your objective is: {self.goal}"})
blocks.append({"type": "text", "text": f"# CURRENT OBSERVATION [{ledger.elapsed_label()}]"})
if img_bytes:
img_b64 = base64.b64encode(img_bytes).decode("utf-8")
blocks.append({"type": "text", "text": "--- Current Screenshot ---"})
blocks.append(
{"type": "image_url", "image_url": {"url": f"data:image/jpeg;base64,{img_b64}"}}
)
)
)
return [t for t in tools_declaration if t.name == "report_task_status"]
return tools_declaration
if xml_list:
blocks.append({"type": "text", "text": f"{PRO_UI_LIST_MARKER}\n{xml_list}"})
for notice in notices or []:
blocks.append({"type": "text", "text": notice})
if injected:
blocks.append({"type": "text", "text": injected})
if is_final:
blocks.append({"type": "text", "text": _FINAL_TURN_WARNING})
if turns == 1:
blocks.append({"type": "text", "text": _REASONING_FIRST_REMINDER})
return HumanMessage(content=blocks)
def _extract_response_text(self, response) -> str:
"""Extracts the natural-language thought text from the model response."""
raw_text = response.content if isinstance(response.content, str) else ""
if isinstance(response.content, list):
raw_text = "".join(
b.get("text", "")
for b in response.content
if isinstance(b, dict) and "text" in b
b.get("text", "") for b in response.content if isinstance(b, dict) and "text" in b
)
return raw_text
@@ -271,9 +377,9 @@ class FlashRunner:
elif hasattr(response, "response_metadata") and isinstance(
response.response_metadata, dict
):
u = response.response_metadata.get(
"usage_metadata"
) or response.response_metadata.get("token_usage")
u = response.response_metadata.get("usage_metadata") or response.response_metadata.get(
"token_usage"
)
if isinstance(u, dict):
pr = (
u.get("input_tokens")
@@ -321,9 +427,7 @@ class FlashRunner:
"total_tokens": max(1, prompt_tokens + completion_tokens),
}
def _resolve_token_usage(
self, response, messages: list[BaseMessage], raw_text: str
) -> dict:
def _resolve_token_usage(self, response, messages: list[BaseMessage], raw_text: str) -> dict:
"""Extracts token usage from response metadata, estimating as fallback."""
step_token_usage = self._token_usage_from_response(response)
if not step_token_usage or step_token_usage.get("total_tokens", 0) <= 0:
@@ -390,9 +494,7 @@ class FlashRunner:
ui_tree=xml_list,
action_taken={"action": "report_task_status", "args": args},
operator_raw_thinking=raw_text,
last_execution_result={
"result": "Task completed with final report."
},
last_execution_result={"result": "Task completed with final report."},
extra_metadata={"token_usage": step_token_usage},
)
except Exception as step_err:
@@ -420,9 +522,7 @@ class FlashRunner:
if not exec_result.ui_elements_text:
exec_result.ui_elements_text = screen_data.elements
except Exception as shot_err:
logger.warning(
f"Failed to capture fallback screenshot in FlashRunner: {shot_err}"
)
logger.warning(f"Failed to capture fallback screenshot in FlashRunner: {shot_err}")
return post_img_bytes
def _extract_normalized_coordinates(self, name: str, args: dict):
@@ -432,11 +532,7 @@ class FlashRunner:
norm_end = None
if name == "swipe":
kind, target_val, _ = parse_swipe_parameters(args)
if (
kind == "coords"
and isinstance(target_val, list)
and len(target_val) == 4
):
if kind == "coords" and isinstance(target_val, list) and len(target_val) == 4:
norm_coords = target_val
norm_start = target_val[:2]
norm_end = target_val[2:]
@@ -473,6 +569,7 @@ class FlashRunner:
pre_screenshot_bytes,
xml_list,
post_img_bytes,
injected: str | None = None,
):
"""Records telemetry / step in DataEngine; returns the step id or None."""
recorded_step_id = None
@@ -480,9 +577,7 @@ class FlashRunner:
if self.ctx.data_engine.current_step_id is None:
self.ctx.data_engine.allocate_step_id()
norm_coords, norm_start, norm_end = self._extract_normalized_coordinates(
name, args
)
norm_coords, norm_start, norm_end = self._extract_normalized_coordinates(name, args)
action_dict = {
"action": name,
@@ -503,25 +598,49 @@ class FlashRunner:
# Record-time enrichment computed by the executor from
# the pre-action frame (target_text / target_class /
# target_resource_id / target_label_source).
target_semantics = (exec_result.metadata or {}).get(
"target_semantics"
)
target_semantics = (exec_result.metadata or {}).get("target_semantics")
if isinstance(target_semantics, dict):
action_dict.update(target_semantics)
succeeded = exec_result.status == "success"
last_execution_result = {
"status": "success" if succeeded else "failed",
"result": exec_result.text_summary,
}
if not succeeded:
last_execution_result["error"] = exec_result.text_summary
extra_metadata: dict = {"token_usage": step_token_usage}
if injected:
# Stamped verbatim on the step it reached: the chunk ledger
# keeps it as a never-evicted line at every compression level.
extra_metadata["injected_instruction"] = injected
recorded_step_id = self.ctx.data_engine.record_step(
pre_screenshot_bytes=pre_screenshot_bytes,
post_screenshot_bytes=post_img_bytes,
ui_tree=(exec_result.ui_elements_text or xml_list),
action_taken=action_dict,
operator_raw_thinking=raw_text,
last_execution_result={"result": exec_result.text_summary},
extra_metadata={"token_usage": step_token_usage},
last_execution_result=last_execution_result,
extra_metadata=extra_metadata,
)
self._notify_history_chunker(recorded_step_id)
except Exception as step_err:
logger.warning(f"Error recording step in FlashRunner: {step_err}")
return recorded_step_id
def _notify_history_chunker(self, step_id) -> None:
"""Stamps the recorded step for the chunk manager (single segment, no plan)."""
ledger = getattr(self.ctx, "transcript_ledger", None)
chunker = getattr(ledger, "chunker", None) if ledger is not None else None
if chunker is None or step_id is None:
return
try:
chunker.on_step_stamped(str(step_id), None)
except Exception as e:
logger.warning(f"History chunker step stamp failed: {e}")
async def _execute_and_record_action(
self,
name: str,
@@ -534,22 +653,23 @@ class FlashRunner:
pre_screenshot_bytes,
xml_list,
action_sequence: int,
turn: _TurnRecord,
injected: str | None = None,
):
"""Executes one action tool call, records it, and updates loop state.
"""Executes one tool call, records it, and updates loop state.
The tool message carries the outcome text only; the post-action
screenshot and UI list become the next turn's observation tail.
Returns the (possibly updated) pre_screenshot_bytes, xml_list, and
action_sequence for the next iteration.
"""
try:
exec_result = await self.executor.execute(name, args, tc_id, state)
# Dynamic dispatch set: manifest device actions plus any backend
# extension tools, so extension steps are recorded like actions.
action_names = self.executor.action_tool_names
try:
exec_result = await self.executor.execute(name, args, tc_id, state)
post_img_bytes = await self._capture_post_screenshot(
exec_result, name, action_names
)
post_img_bytes = await self._capture_post_screenshot(exec_result, name, action_names)
# Record telemetry / step in DataEngine
recorded_step_id = None
@@ -563,8 +683,13 @@ class FlashRunner:
pre_screenshot_bytes,
xml_list,
post_img_bytes,
injected=injected,
)
if name in action_names:
turn.step_keys.append(str(recorded_step_id) if recorded_step_id else str(tc_id))
turn.actions.append((name, exec_result.status, exec_result.text_summary))
# ⚡ Non-blocking dispatch of objective visual transition summarizer
if self.summarizer and name in action_names:
action_sequence += 1
@@ -584,10 +709,29 @@ class FlashRunner:
if exec_result.ui_elements_text:
xml_list = exec_result.ui_elements_text
messages.append(exec_result.to_langchain_tool_message())
# Helper tools may return multimodal blocks (recalled screenshots);
# device actions always report text — their post screenshot is the
# next observation tail, never a tool-message image.
raw_blocks = exec_result.raw_result if name not in action_names else None
content = (
raw_blocks
if isinstance(raw_blocks, list) and raw_blocks
else exec_result.text_summary or f"Action '{name}' completed."
)
messages.append(
ToolMessage(
tool_call_id=tc_id,
name=name,
content=content,
status=exec_result.status,
)
)
except Exception as e:
logger.error(f"Error executing tool {name}: {e}")
if name in action_names:
turn.step_keys.append(str(tc_id))
turn.actions.append((name, "error", f"Error executing tool {name}: {e}"))
messages.append(
ToolMessage(
tool_call_id=tc_id,
@@ -620,6 +764,8 @@ class FlashRunner:
pre_screenshot_bytes,
xml_list,
action_sequence: int,
turn: _TurnRecord,
injected: str | None = None,
):
"""Dispatches the turn's tool calls.
@@ -634,71 +780,113 @@ class FlashRunner:
if name == "report_task_status":
final_report = await self._finalize_task_report(
name, args, tc_id, raw_text, step_token_usage,
pre_screenshot_bytes, xml_list, messages,
name,
args,
tc_id,
raw_text,
step_token_usage,
pre_screenshot_bytes,
xml_list,
messages,
)
return final_report, pre_screenshot_bytes, xml_list, action_sequence
pre_screenshot_bytes, xml_list, action_sequence = (
await self._execute_and_record_action(
name, args, tc_id, state, messages, raw_text, step_token_usage,
pre_screenshot_bytes, xml_list, action_sequence,
)
pre_screenshot_bytes, xml_list, action_sequence = await self._execute_and_record_action(
name,
args,
tc_id,
state,
messages,
raw_text,
step_token_usage,
pre_screenshot_bytes,
xml_list,
action_sequence,
turn,
injected=injected,
)
return None, pre_screenshot_bytes, xml_list, action_sequence
async def _prepare_conversation(self, state: State, tools_declaration: list):
"""Captures the initial device state and builds the seed messages."""
# 2. Capture Initial State (Screenshot + UI Tree)
"""Installs the static prefix and captures the initial device state."""
ledger = self._build_ledger()
ledger.set_static_prefix(
[SystemMessage(content=self._render_system_prompt(tools_declaration))]
)
# Capture Initial State (Screenshot + UI Tree)
shot_path, img_bytes, xml_list = await capture_screenshot_and_parse_ui(
self.ctx, state, self.controller, skip_settling=False
)
state.latest_screenshot = shot_path
return ledger, img_bytes, xml_list
messages: list[BaseMessage] = [
SystemMessage(content=self._render_system_prompt(tools_declaration)),
HumanMessage(content=self._build_initial_user_content(img_bytes, xml_list)),
]
return messages, img_bytes, xml_list
@staticmethod
def _commit_turn(ledger: TranscriptLedger, turn: _TurnRecord | None) -> None:
"""Commits the previous turn once its step ids and outcomes are known."""
if turn is None:
return
ledger.commit_staged(
step_key=turn.step_keys[0] if turn.step_keys else None,
validator_result=turn.result(),
extra_step_keys=turn.step_keys[1:],
)
@trace(type="agent", name="FlashRunner")
async def run(self, state: State) -> dict:
logger.info(f"Starting Artemis Flash reactive loop for goal: {self.goal}")
compressor = self._build_compressor()
limit = self.turn_limit
logger.info(
f"Starting Artemis Flash reactive loop for goal: {self.goal}"
f" (turn limit: {limit if limit else 'unlimited'})"
)
# 1. Initialize Universal LLM via Service Layer
llm = self._init_llm()
tools_declaration = self._get_tools()
report_only_tools = [t for t in tools_declaration if t.name == "report_task_status"]
messages, img_bytes, xml_list = await self._prepare_conversation(
state, tools_declaration
)
ledger, img_bytes, xml_list = await self._prepare_conversation(state, tools_declaration)
turns = 0
action_sequence = 0
final_report = None
current_pre_screenshot_bytes = img_bytes
current_xml_list = xml_list
previous_turn: _TurnRecord | None = None
pending_notices: list[str] = []
while turns < self.max_turns:
while limit is None or turns < limit:
turns += 1
logger.info(f"--- Artemis Flash Turn {turns}/{self.max_turns} ---")
logger.info(f"--- Artemis Flash Turn {turns}{f'/{limit}' if limit else ''} ---")
if self.ctx.data_engine:
self.ctx.data_engine.allocate_step_id()
# Commit the previous turn: its step ids and outcomes exist now.
self._commit_turn(ledger, previous_turn)
previous_turn = None
# Check for real-time injected instructions
await self._append_injected_instruction(messages)
injected = await self._read_injected_instruction()
# Advance the scrub edge even when visual summarization is
# disabled, so screenshot and historical XML pruning keep one
# code path. Messages behind the edge are frozen permanently.
compressor.compress(messages)
# Tool restriction on the final turn (bounded loops only)
is_final = limit is not None and turns == limit
tail = self._build_tail(
ledger,
turns,
current_pre_screenshot_bytes,
current_xml_list,
injected=injected,
notices=pending_notices,
is_final=is_final,
)
pending_notices = []
# Tool restriction on the final turn
current_tools = self._select_turn_tools(turns, tools_declaration, messages)
# S + F + A + tail: chunk compression and the scrub edge advance here.
messages = ledger.render([tail])
turn_base = len(messages) - 1
current_tools = report_only_tools if is_final else tools_declaration
response = await self._invoke_model(llm, current_tools, messages)
@@ -722,36 +910,46 @@ class FlashRunner:
f"FlashRunner received response without tool calls at turn {turns}:"
f" {raw_text[:100]}..."
)
if turns == self.max_turns:
if is_final:
final_report = {"status": "failed", "explanation": raw_text}
break
messages.append(
HumanMessage(
content=(
"You did not call any tools. Please make progress"
" by calling an action tool or 'report_task_status'."
)
)
)
pending_notices.append(_NO_TOOL_CALL_NOTICE)
ledger.stage_turn(messages[turn_base:])
previous_turn = _TurnRecord()
continue
# Process tool calls
turn = _TurnRecord()
(
final_report_from_calls,
current_pre_screenshot_bytes,
current_xml_list,
action_sequence,
) = await self._process_tool_calls(
tool_calls, state, messages, raw_text, step_token_usage,
current_pre_screenshot_bytes, current_xml_list, action_sequence,
tool_calls,
state,
messages,
raw_text,
step_token_usage,
current_pre_screenshot_bytes,
current_xml_list,
action_sequence,
turn,
injected=injected,
)
ledger.stage_turn(messages[turn_base:])
previous_turn = turn
if final_report_from_calls is not None:
return final_report_from_calls
if self.summarizer:
await self.summarizer.flush()
return final_report or {
"status": "failed",
"explanation": "Max turns reached without final status report.",
}
if final_report is None:
explanation = (
"Max turns reached without final status report."
if limit is not None and turns >= limit
else "The model returned no response; the reactive loop stopped."
)
final_report = {"status": "failed", "explanation": explanation}
return final_report
+8 -5
View File
@@ -98,13 +98,16 @@ class VisualStepSummarizer(StepMemoryService):
except Exception:
self._llm = get_google_llm(model_name=target_model, temperature=0.0)
try:
configured = getattr(self._llm, "model", None) or getattr(
self._llm, "model_name", None
)
configured = getattr(self._llm, "model", None) or getattr(self._llm, "model_name", None)
if isinstance(configured, str) and configured:
self._model_name = configured
except Exception:
pass
except Exception as exc:
logger.debug(
"Could not read the configured summarizer model name; keeping %s: %s",
self._model_name,
exc,
exc_info=True,
)
# Load system prompt templates: the dual-frame transition prompt and
# the single-frame variant (§5 revision: describe whichever frames
+1 -1
View File
@@ -1,3 +1,3 @@
{
"main_template": "# ROLE\nYou are the Android Device Operator. Your priority is execution accuracy. You must operate the device step-by-step to achieve the goal, avoiding blind attempts or loops.\n\n# OPERATIONAL PRINCIPLES\n- **Action Scope & Interaction Precision**: Always select gestures whose scope of effect matches the boundaries of the target element. Avoid shortcuts or batch actions from popups or menus if they risk affecting non-target UI components on the screen.[% if \"swipe\" in available_tools %] For any task requiring continuous range interactions, sliding, or boundary selection, prefer using targeted swipe/drag gestures spanning from the element's start bounds [xmin, ymin] to end bounds [xmax, ymax] to ensure the action is strictly confined to the target element. For drag-and-drop, list reordering, or sliding sliders/SeekBars, set duration >= 1000 (e.g. 1500) and always drag slightly PAST the target position to overcome touch slop and reliably trigger the drop/snap/value update. When setting a slider to Maximum (100%) or Minimum (0%), always swipe fully to the extreme boundary to guarantee reaching the absolute limits.[% endif %]\n- **Vision-First Priority**: The screenshot is the primary source of truth for the real device state. Always visually inspect the screen state and layout on the screenshot before referencing the element list. Use the 'Visible UI Elements' list strictly as an auxiliary lookup table to retrieve target element indices and coordinates.\n- **Tool Calling Contract**: Tools that return information needed for the decision ([[ tool_enum(pre_decision_tools, \"and\") ]]) are result-dependent Pre-Decision Tools. Do not submit them in the same tool-call list as a Turn-Ending Action; inspect their result first. The write-through tools `update_note` and `append_note` may accompany at most one Turn-Ending Action when their content is based entirely on evidence already available before that action. Never write an action's expected outcome as if it had already been observed.\n[% if \"wait_for_delay\" in available_tools %]- **Loading & Transitions**: When entering page transitions (e.g., app launching, web page loading, or progress indicators), use `wait_for_delay` to let the screen settle before executing target actions. Some transition states are difficult to discern, such as a thin progress line at the top of the browser screen during webpage loads. Before questioning whether an error has occurred, you can first adopt a waiting strategy to see how the device state evolves. If the state remains ambiguous after waiting, certain UI components might be malfunctioning or failing to load; in this case, consider retrying the action or pivoting to an alternative path.\n[% endif %]- **Memory Preservation & Paginated Exploration**: Using note tools is entirely zero-cost. Actively use this tool to maintain goals and logs. The note tool is the only reliable way to persist memory. Therefore, when you find information on the screen that might be relevant to the task, even if you are not certain, you should first save the information currently visible on the screen before exploring further. If you have already recorded some information, you can use `append_note` to append the new information to the existing content. The note tool can also help you organize lists or information across multiple screens; you can consolidate all information into notes for unified processing.\n- **Large List & Long Text Exploration Strategy (Zero-Miss, Zero-Duplication Single-Pass Principle)**: When traversing large lists, browsing long text bodies, or scanning continuous content feeds, strictly adhere to the single-pass exploration standard:\n 1. **Unidirectional Anchor-Based Traversal (Zero-Miss)**: Maintain a consistent, unidirectional scan (e.g., uniform downward scrolling). Before each scroll, identify the bottom-most visible item/text segment as your visual anchor and carry that anchor plus the scan direction in short-term memory or the traversal ledger. After the scroll, evaluate this handoff before doing anything unrelated: if the anchor remains visible with new content beyond it, record the new content and continue in the same direction without scrolling back to verify; if the anchor is missing, continuity is uncertain, so perform at most one minimal reverse recovery to re-establish overlap, then resume the original direction. While the traversal's declared exit condition remains unresolved, keep this traversal active and do not advance to another milestone or mark it complete.\n 2. **Boundary-Proven Completion**: A single scroll that reveals no new content is not sufficient evidence that the list is complete. For an exhaustive traversal, stop only when an explicit end-of-list indicator is visible, or when one additional successful swipe in the same direction and within the same list container leaves the same final anchor visible with no new items and no loading state. This boundary probe must remain unidLine truncated
"main_template": "# ROLE\nYou are the Android Device Operator. Your priority is execution accuracy. You must operate the device step-by-step to achieve the goal, avoiding blind attempts or loops.\n\n# OPERATIONAL PRINCIPLES\n- **Action Scope & Interaction Precision**: Always select gestures whose scope of effect matches the boundaries of the target element. Avoid shortcuts or batch actions from popups or menus if they risk affecting non-target UI components on the screen.\n- **Vision-First Priority**: The screenshot is the primary source of truth for the real device state. Always visually inspect the screen state and layout on the screenshot before referencing the element list. Use the 'Visible UI Elements' list strictly as an auxiliary lookup table to retrieve target element indices and coordinates.\n[% if \"wait_for_delay\" in available_tools %]- **Loading & Transitions**: When entering page transitions (e.g., app launching, web page loading, or progress indicators), use `wait_for_delay` to let the screen settle before executing target actions. Some transition states are difficult to discern, such as a thin progress line at the top of the browser screen during webpage loads. Before questioning whether an error has occurred, you can first adopt a waiting strategy to see how the device state evolves. If the state remains ambiguous after waiting, certain UI components might be malfunctioning or failing to load; in this case, consider retrying the action or pivoting to an alternative path.\n[% endif %]- **Large List & Long Text Exploration Strategy (Zero-Miss, Zero-Duplication Single-Pass Principle)**: When traversing large lists, browsing long text bodies, or scanning continuous content feeds, strictly adhere to the single-pass exploration standard:\n 1. **Unidirectional Anchor-Based Traversal (Zero-Miss)**: Maintain a consistent, unidirectional scan (e.g., uniform downward scrolling). Before each scroll, identify the bottom-most visible item/text segment as your visual anchor and carry that anchor plus the scan direction in short-term memory or the traversal ledger. After the scroll, evaluate this handoff before doing anything unrelated: if the anchor remains visible with new content beyond it, record the new content and continue in the same direction without scrolling back to verify; if the anchor is missing, continuity is uncertain, so perform at most one minimal reverse recovery to re-establish overlap, then resume the original direction. While the traversal's declared exit condition remains unresolved, keep this traversal active and do not advance to another milestone or mark it complete.\n 2. **Boundary-Proven Completion**: A single scroll that reveals no new content is not sufficient evidence that the list is complete. For an exhaustive traversal, stop only when an explicit end-of-list indicator is visible, or when one additional successful swipe in the same direction and within the same list container leaves the same final anchor visible with no new items and no loading state. This boundary probe must remain unidirectional; never reverse direction merely to prove completion. If the task declares an earlier exit condition (for example, find one verified match or process N items), that explicit condition may end the traversal without reaching the list boundary.\n 3. **Memory & Plan-Driven Deduplication (Zero-Duplication)**: Systematically log extracted item names, identifiers, or processing states into notes or memory as you progress. After every screen transition, cross-reference visible items against your recorded ledger and strictly interact only with newly surfaced, unrecorded items to eliminate redundant actions on overlapping elements. Proactively maintain subtasks in `task_plan` (e.g., segmenting by batch, category, or alphabetical ranges like `a-d`), tracking exact paths and progress to prevent backtracking.\n 4. **Single-Pass Trust & Anti-Oscillation (No Over-Verification)**: Treat recorded notes as verified ground truth. Once a segment is traversed with confirmed anchor continuity and logged, consider it permanently resolved. Do NOT scroll back-and-forth or perform redundant re-scans for \"double-checking\", even if older execution history has been pruned. Resume from the last recorded anchor and direction. Only the single bounded recovery above, or a refreshed/mutated list, permits limited re-verification; otherwise continue the single pass over new content.\n- **Dynamic Monitoring & Reactive Loop Protocol (Monitor-Trigger-Action)**: When executing an active milestone matching `[Loop] <Goal> into note <TargetNote> (Exit: <ExitCondition>; Interval: <Interval>)` or `[Loop:continuous] <Goal> into note <TargetNote> (Interval: <Interval>)` (the milestone grammar itself is defined under Task Plan Grammar below), run this 5-step cyclic state machine:\n - **Step 1 (Baseline Sync)**: On the initial cycle, observe all visible entries/metrics in the primary monitoring view and save the full snaLine truncated
}
+62 -67
View File
@@ -30,10 +30,8 @@ from artemis.graph.visibility import strict_state
from artemis.mcp.action_specs import OPERATOR_SHELL_ORDER, operator_shell_tool
from artemis.services.llm import acomplete, get_llm, invoke_llm_with_timeout_message
from artemis.tools.command_tool import (
_BACKGROUND_TASKS,
_FINISHED_TASKS_LOGS,
_is_output_long,
analyze_task_output_wrapper,
get_adb_task_registry,
)
from artemis.tools.index import get_tool_by_name
from artemis.tools.tool_wrapper import (
@@ -59,7 +57,6 @@ DEFERRING_TOOLS = {
"video_analyzer",
"read_note",
"list_notes",
"save_note",
"recall_history",
"run_adb_command",
"manage_task",
@@ -68,6 +65,7 @@ DEFERRING_TOOLS = {
}
from artemis.agents.operator.prompts import (
OPERATOR_MAX_TOOL_ITERATIONS,
PromptBuilder,
PromptComponent,
TemplatePromptComponent,
@@ -76,6 +74,7 @@ from artemis.agents.operator.prompts import (
ScreenshotSimilarityPromptComponent,
HistoricalStateHintPromptComponent,
FeedbackPromptComponent,
ExecutionIncidentPromptComponent,
CheckItemsExplainerPromptComponent,
BackgroundTasksPromptComponent,
TaskPlanWarningPromptComponent,
@@ -147,20 +146,6 @@ class OperatorNode:
logger.warning(f"Failed to read actuator capabilities: {e}")
return REQUIRED_ACTIONS | OPTIONAL_ACTIONS
def _check_infinite_loop(self, state: State):
subagent_calls = state.subagent_calls or []
if len(subagent_calls) >= 3:
last_three = subagent_calls[-3:]
if len(set(last_three)) == 1:
logger.error(
f"Infinite loop detected: Sub-agent {last_three[0]} called"
" 3 times consecutively."
)
raise RuntimeError(
f"Infinite loop detected: Sub-agent {last_three[0]} called"
" 3 times consecutively."
)
def _get_history_and_plan(self) -> tuple[list[dict], str]:
steps = []
if self.ctx.data_engine:
@@ -225,6 +210,7 @@ class OperatorNode:
{"template_name": "main_template"},
),
(CheckItemsExplainerPromptComponent(), {}),
(ExecutionIncidentPromptComponent(), {}),
(ObservationPromptComponent(), {}),
(ScreenshotSimilarityPromptComponent(), {}),
(HistoricalStateHintPromptComponent(), {}),
@@ -269,11 +255,16 @@ class OperatorNode:
if isinstance(ledger, TranscriptLedger):
return ledger
cfg = self._transcript_cfg
# Anchor the ``T+mm:ss`` clock to the DataEngine session start so the
# tail offsets agree with the chunk ledger lines, the restored-history
# block and the video analyzer's action timeline.
session_start = getattr(self.ctx.data_engine, "session_start_time", None)
ledger = TranscriptLedger(
step_memory=ensure_step_memory(self.ctx),
image_scrub_depth=getattr(cfg, "image_scrub_depth", 3),
pending_grace_steps=getattr(cfg, "pending_grace_steps", 3),
xml_scrub_depth=getattr(cfg, "xml_scrub_depth", 1),
session_start=session_start if isinstance(session_start, (int, float)) else None,
)
# L2/L3 chunk compression (M3) rides the same flag; without a
# DataEngine there are no step records to chunk, so the ledger simply
@@ -287,8 +278,12 @@ class OperatorNode:
from artemis.config import load_agent_config
chunking_cfg = load_agent_config().memory.chunking
except Exception:
pass
except Exception as exc:
logger.debug(
"Chunking config unavailable; using chunker defaults: %s",
exc,
exc_info=True,
)
ledger.attach_chunker(
HistoryChunkManager(
engine=self.ctx.data_engine,
@@ -302,7 +297,7 @@ class OperatorNode:
logger.error(f"History chunk manager unavailable: {e}")
try:
self.ctx.transcript_ledger = ledger
except Exception:
except (AttributeError, TypeError, ValueError):
pass
return ledger
@@ -377,6 +372,7 @@ class OperatorNode:
components = [
(PlanRecitationPromptComponent(), {}),
(CheckItemsExplainerPromptComponent(), {}),
(ExecutionIncidentPromptComponent(), {}),
(ObservationPromptComponent(), {}),
(ScreenshotSimilarityPromptComponent(), {}),
(HistoricalStateHintPromptComponent(), {}),
@@ -433,7 +429,7 @@ class OperatorNode:
new_subagent_calls: list,
state: State,
) -> tuple[list[dict] | None, str | None, str | None, bool]:
max_iterations = 20
max_iterations = OPERATOR_MAX_TOOL_ITERATIONS
action_result = None
raw_thoughts = []
native_thoughts = []
@@ -714,7 +710,30 @@ class OperatorNode:
" Translating and processing all."
)
actions_list = []
burst_cap = self._max_burst_actions()
if len(action_calls) > burst_cap:
# A multi-action turn is a fast-action burst that the Validator
# fires without the safety net; cap its length before it runs.
validation_errors = True
for tc in action_calls:
tool_outputs.append(
ToolMessage(
tool_call_id=tc["id"],
content=(
f"Error: {len(action_calls)} Turn-Ending Actions in one"
" turn exceed the fast-action burst limit of"
f" {burst_cap}. Nothing was executed. Re-issue at most"
f" {burst_cap} actions (a burst is for sub-second"
" sequences on transient UI, not for batching normal"
" steps), or a single vetted action."
),
status="error",
)
)
action_calls_to_translate = []
else:
action_calls_to_translate = action_calls
for tc in action_calls_to_translate:
translated_actions, error = self._translate_and_validate_tool(tc, state)
if error:
validation_errors = True
@@ -805,10 +824,7 @@ class OperatorNode:
@trace(type="agent", name="operator")
async def __call__(self, state: State):
state = strict_state(state, "operator")
# 1. Check for infinite loops
self._check_infinite_loop(state)
# 2. Get perception data from state (populated by Perception node)
# 1. Get perception data from state (populated by Perception node)
operator_raw_data = getattr(state, "operator_raw_data", {}) or {}
latest_screenshot_b64 = operator_raw_data.get("screenshot_b64")
xml_hierarchy = operator_raw_data.get("xml_hierarchy")
@@ -843,7 +859,7 @@ class OperatorNode:
width = w
if isinstance(h, int):
height = h
except Exception:
except (AttributeError, TypeError):
pass
minimal_list, elements, labels = format_minimal_list_with_elements(fused_xml, width, height)
@@ -863,48 +879,18 @@ class OperatorNode:
operator_shell_tool(name) for name in OPERATOR_SHELL_ORDER if name in available_actions
] + self.tools
# 5. Evaluate dynamic tools and prepare background tasks
has_long_output = False
for tinfo in _FINISHED_TASKS_LOGS.values():
if _is_output_long(tinfo.get("output", "")):
has_long_output = True
break
if not has_long_output:
for t in _BACKGROUND_TASKS.values():
if _is_output_long("".join(t.stdout_log)):
has_long_output = True
break
if has_long_output and not any(t.name == "analyze_task_output" for t in all_tools):
analyze_tool_fn = analyze_task_output_wrapper.tool_fn_getter(self.ctx)
all_tools.append(analyze_tool_fn)
# 5. Mount the task-output analyzer and collect background ADB task state.
# The analyzer is always available: the "output truncated, use
# analyze_task_output" hint arrives mid-turn, and tools are bound once per
# turn, so a conditional mount would be one turn late.
if not any(t.name == "analyze_task_output" for t in all_tools):
all_tools.append(analyze_task_output_wrapper.tool_fn_getter(self.ctx))
traced_tools = [trace_langchain_tool(t, self.ctx) for t in all_tools]
active_background_tasks = []
for tid, t in _BACKGROUND_TASKS.items():
active_background_tasks.append(
{
"task_id": tid,
"command": t.command,
"cwd": t.cwd,
"terminal_id": t.terminal_id,
"output_line_count": len(t.stdout_log),
}
)
newly_finished_tasks = []
for tid, tinfo in _FINISHED_TASKS_LOGS.items():
if not tinfo.get("notified", False):
newly_finished_tasks.append(
{
"task_id": tid,
"command": tinfo.get("command", ""),
"status": tinfo.get("status", "completed"),
"output_text": tinfo.get("output", ""),
}
)
tinfo["notified"] = True # Mark as read
adb_registry = get_adb_task_registry(self.ctx)
active_background_tasks = adb_registry.active_task_summaries()
newly_finished_tasks = adb_registry.pop_unnotified_finished()
# 6. Prepare LLM
llm = get_llm(ctx=self.ctx, name="operator")
@@ -989,6 +975,15 @@ class OperatorNode:
last_n_detailed=self.last_n_detailed,
)
def _max_burst_actions(self) -> int:
"""Configured ceiling for actions in one fast-action burst (default 4)."""
try:
from artemis.config import load_agent_config
return int(load_agent_config().pro.execution.max_burst_actions)
except Exception:
return 4
def _translate_and_validate_tool(self, tc: dict, state: State) -> tuple[list[dict], str | None]:
actions, err = self._translate_and_validate_tool_inner(tc, state)
if err:
@@ -1019,7 +1014,7 @@ class OperatorNode:
width = w
if isinstance(h, int):
height = h
except Exception:
except (AttributeError, TypeError):
pass
def resolve_target_element(
+272 -63
View File
@@ -51,8 +51,8 @@ from artemis.mcp.action_specs import OPERATOR_SHELL_ORDER
# artemis/agents/prompt_assembly.py for the assembly rationale.
_PRE_DECISION_HELPER_TOOLS = ("ask_explorer", "ask_diagnoser", "video_analyzer")
_PRE_DECISION_ADB_TOOLS = ("run_adb_command", "manage_task", "analyze_task_output")
_PRE_DECISION_MEMORY_TOOLS = ("read_note", "list_notes", "save_note", "recall_history")
_PRE_DECISION_ADB_TOOLS = ("run_adb_command", "manage_task")
_PRE_DECISION_MEMORY_TOOLS = ("read_note", "list_notes", "recall_history")
_PRE_DECISION_ALL_TOOLS = (
_PRE_DECISION_HELPER_TOOLS + _PRE_DECISION_ADB_TOOLS + _PRE_DECISION_MEMORY_TOOLS
)
@@ -71,6 +71,10 @@ _TURN_ENDING_ORDER = (
"wait_for_delay",
)
#: Tool-loop ceiling per Operator turn (recited in the template, enforced in
#: ``OperatorNode._invoke_llm_loop``).
OPERATOR_MAX_TOOL_ITERATIONS = 20
#: Every tool name the operator prompt slots can reference. ``available_tools=None``
#: resolves to this set, preserving the historical output.
OPERATOR_PROMPT_TOOLSET: frozenset[str] = frozenset(
@@ -243,9 +247,7 @@ def render_transcript_static_system(
"""
prompt_template = prompts.get(template_name)
if not prompt_template:
raise KeyError(
"Failed to format prompt, template not found in operator prompts config."
)
raise KeyError("Failed to format prompt, template not found in operator prompts config.")
if prompt_template.count(PLAN_HISTORY_TEMPLATE_SECTION) != 1:
# Hard dependency of the M2 split (redesign §9): without a clean
# section boundary the S region cannot be byte-stable.
@@ -258,9 +260,7 @@ def render_transcript_static_system(
)
available = resolve_operator_prompt_tools(ctx)
static_template = apply_operator_prompt_contract(
static_template, available_tools=available
)
static_template = apply_operator_prompt_contract(static_template, available_tools=available)
include_checks, verification_active = _operator_grammar_flags(ctx)
return Template(static_template).render(
initial_goal=state.initial_goal,
@@ -269,9 +269,26 @@ def render_transcript_static_system(
unified_history="",
plan_grammar=render_plan_grammar_spec(include_checks),
verification_active=verification_active,
checks_active=include_checks,
transcript_history=True,
max_burst_actions=_max_burst_actions_for_prompt(),
max_tool_calls=OPERATOR_MAX_TOOL_ITERATIONS,
)
def _legacy_elapsed_suffix(ctx: ArtemisContext | None) -> str:
"""`` [T+mm:ss]`` for the legacy observation header (empty without a session clock)."""
import time
from artemis.memory.transcript import format_session_offset
engine = getattr(ctx, "data_engine", None)
start = getattr(engine, "session_start_time", None)
if not isinstance(start, (int, float)):
return ""
return f" [{format_session_offset(time.time() - start)}]"
class TemplatePromptComponent(PromptComponent):
async def __call__(self, builder: PromptBuilder, state: State, ctx: ArtemisContext, **kwargs):
prompts = kwargs.get("prompts", {})
@@ -284,9 +301,7 @@ class TemplatePromptComponent(PromptComponent):
available = resolve_operator_prompt_tools(ctx)
prompt_template = apply_operator_prompt_contract(
prompt_template, available_tools=available
)
prompt_template = apply_operator_prompt_contract(prompt_template, available_tools=available)
plan_and_history = kwargs.get("plan_and_history", "No plan or history yet.")
@@ -299,10 +314,14 @@ class TemplatePromptComponent(PromptComponent):
unified_history="",
plan_grammar=render_plan_grammar_spec(include_checks),
verification_active=verification_active,
checks_active=include_checks,
transcript_history=False,
max_burst_actions=_max_burst_actions_for_prompt(),
max_tool_calls=OPERATOR_MAX_TOOL_ITERATIONS,
)
parts = full_prompt.split("# CURRENT OBSERVATION")
builder.add_system_text(parts[0] + "# CURRENT OBSERVATION\n")
builder.add_system_text(parts[0] + f"# CURRENT OBSERVATION{_legacy_elapsed_suffix(ctx)}\n")
if len(parts) > 1:
builder.set_human_footer(parts[1])
@@ -356,13 +375,194 @@ class FeedbackPromptComponent(PromptComponent):
"--- Verification Findings ---\n"
f"{lines}\n"
"Each finding is tagged with its source. Checker verdicts"
" ([verify failed], [final check]) are independent judgments —"
" ([verify failed], [final check], [unmet subgoal]) are independent judgments —"
" address any reverted subgoal accordingly; do not re-litigate"
" them. [planner] findings explain why a plan change was rejected"
" and rolled back — factor the reason into your next strategy."
" them. [planner] findings are advisory: a lightweight reviewer"
" had a concern about a plan change that stayed applied — weigh"
" the reason against your own observations."
)
#: Header of the execution-incident block in the observation tail.
EXECUTION_INCIDENT_MARKER = "--- Execution Incident (OPEN) ---"
def _max_burst_actions_for_prompt() -> int:
"""The configured fast-action burst ceiling, recited in the operator template."""
try:
from artemis.config import load_agent_config
return int(load_agent_config().pro.execution.max_burst_actions)
except Exception:
return 4
def _last_successful_action_before(steps: list, step_number: int | None) -> tuple[str, int] | None:
"""The most recent step before ``step_number`` whose terminal action executed.
Returns ``(clean action description, step number)`` or None. This is the
Operator's best candidate for the *trigger* of a transient state: the
action that summoned the UI the failed target belonged to.
"""
from artemis.utils.task_tree import format_actions_clean
candidates = []
for step in steps or []:
number = step.get("step_number")
if not isinstance(number, int):
continue
if step_number is not None and number >= step_number:
continue
action = step.get("action_taken")
if not action:
continue
result = step.get("last_execution_result")
if isinstance(result, dict) and result.get("status") not in (None, "success"):
continue
candidates.append((number, format_actions_clean(action)))
if not candidates:
return None
number, description = max(candidates, key=lambda c: c[0])
return description, number
def _incident_target_label(incident: dict) -> str:
action = incident.get("action") or {}
normalized = action.get("normalized_coordinates")
pixels = action.get("coordinates")
text = action.get("target_text")
parts = []
if normalized:
parts.append(f"normalized {list(normalized)}")
elif pixels:
parts.append(f"pixel {list(pixels)}")
if text:
parts.append(f'"{text}"')
return " ".join(parts) if parts else "the recorded target"
def render_execution_incident(incident: dict, steps: list) -> str:
"""The Operator-facing explanation of an open execution incident.
Structure (deliberately the same every turn so it reads as one continuing
incident, not a new alarm): facts -> category-specific evidence. The
response protocol is stated once, in the static system prompt.
"""
kind = incident.get("kind") or "exec_error"
category = str(incident.get("category") or "general")
consecutive = int(incident.get("consecutive_failures") or 1)
description = incident.get("action_description") or "the planned action"
reason = str(incident.get("reason") or "").strip()
burst_size = int(incident.get("burst_size") or 1)
index = int(incident.get("action_index") or 0)
evidence = incident.get("evidence") or {}
step_number = incident.get("step_number")
target_label = _incident_target_label(incident)
prior = _last_successful_action_before(steps, step_number)
lines = [EXECUTION_INCIDENT_MARKER]
opened = f"Opened at Step {step_number}" if step_number else "Opened last turn"
lines.append(f"{opened}; consecutive failed turns: {consecutive}.")
# --- What happened -------------------------------------------------------------
if burst_size > 1:
remaining = burst_size - index - 1
skipped = (
f" The {remaining} action(s) after it were NOT executed, so the device may"
" be mid-sequence."
if remaining > 0
else ""
)
lines.append(
f"What happened: action {index + 1} of your {burst_size}-action fast burst,"
f" `{description}`, failed: {reason}.{skipped}"
)
elif kind == "safety_net":
lines.append(
f"What happened: your planned action `{description}` was NOT executed. The"
f" pre-execution safety net refused it: {reason}"
)
else:
lines.append(
f"What happened: your planned action `{description}` was dispatched, but the"
f" device/executor reported: {reason}"
)
# --- Category-specific evidence -------------------------------------------------
if category == "target_shifted":
location = evidence.get("new_location")
bounds = evidence.get("new_bounds")
where = f" at normalized {location}" if location else ""
bounds_str = f" (bounds {bounds})" if bounds else ""
lines.append(
f"Evidence: the same element still exists but has moved{where}{bounds_str};"
" the shift exceeded the safety net's auto-correction tolerance."
)
elif category == "target_occupied":
occupant = evidence.get("occupant") or "a different element"
lines.append(
f"Evidence: the target position is now covered by {occupant}. Something"
" appeared on top of your target (dialog, sheet, banner, keyboard, or a"
" re-laid-out screen)."
)
elif category in ("target_disappeared", "pixel_target_disappeared"):
prior_str = (
f" Your last successfully executed action was `{prior[0]}` (Step {prior[1]});"
" if the vanished target belonged to a state that action summoned, that action"
" is the trigger."
if prior
else ""
)
lines.append(
"Evidence: the element you saw in the previous screenshot is no longer on"
f" screen. Its recorded target was {target_label}.{prior_str}"
)
# The response protocol lives in the static system prompt (Execution Incident).
return "\n".join(lines)
def render_closed_incident(closed: dict) -> str:
"""One-turn notice after an incident closes: settle the original intent."""
opened = closed.get("step_number")
closed_at = closed.get("closed_at_step")
description = closed.get("action_description") or "the blocked action"
header = (
f"--- Execution Incident (CLOSED at Step {closed_at}) ---"
if closed_at
else "--- Execution Incident (CLOSED) ---"
)
opened_str = f" opened at Step {opened}" if opened else ""
return (
f"{header}\n"
f"The incident{opened_str} on `{description}` closed because your last Turn-Ending"
" Action executed. Settle its original intent against the plan and the live screen:"
" already served, still pending, or no longer needed."
)
class ExecutionIncidentPromptComponent(PromptComponent):
"""Renders the open execution incident until a terminal action succeeds,
then a one-turn CLOSED notice asking the Operator to settle the intent.
Reads ``state.open_incident`` / ``state.last_closed_incident`` (both written
by the Validator). The block is its own text part of the observation tail,
so the transcript ledger keeps it verbatim: the whole resolution effort
stays legible across turns.
"""
async def __call__(self, builder: PromptBuilder, state: State, ctx: ArtemisContext, **kwargs):
incident = getattr(state, "open_incident", None)
if isinstance(incident, dict) and incident.get("reason"):
builder.add_human_content(
render_execution_incident(incident, kwargs.get("steps") or [])
)
return
closed = getattr(state, "last_closed_incident", None)
if isinstance(closed, dict) and closed.get("kind"):
builder.add_human_content(render_closed_incident(closed))
class CheckItemsExplainerPromptComponent(PromptComponent):
"""Behavioral guidance for check lines, rendered iff the CURRENT plan
actually contains check lines (content-driven, not switch-driven: a resumed
@@ -381,20 +581,25 @@ class CheckItemsExplainerPromptComponent(PromptComponent):
return
if not snapshot.all_check_items:
return
setup = getattr(ctx, "execution_setup", None)
max_repairs = getattr(setup, "checkpoint_max_repairs", None)
if not isinstance(max_repairs, int):
max_repairs = 2
builder.add_human_content(
"--- About the plan's check lines ---\n"
"The task plan declares `- verify:` / `- assert:` check lines."
" `verify:` lines are the acceptance criteria for their subgoal — use"
" them to confirm your work is complete, but the judgment is made by"
" an independent Checker: never declare a check passed yourself or"
" record conclusions on its behalf. `assert:` lines are test"
" assertions — take NO extra actions for them and never construct or"
" fake state to satisfy one; a failing assertion is a legitimate test"
" result. Check lines must not be deleted or reworded (deletions are"
" automatically restored by the system); keep them verbatim when"
" rewriting the plan — adding new ones is allowed. Simply mark"
" completions per the plan grammar as usual; checking runs"
" asynchronously in the background and does not block you."
"The task plan declares `- verify:` / `- assert:` check lines (see Task"
" Plan Grammar). Use `verify:` lines to confirm your work is complete,"
" but never declare a check passed yourself or record conclusions on"
" its behalf. Take NO extra actions for `assert:` lines and never"
" construct or fake state to satisfy one. Check lines must not be"
" deleted or reworded (deletions are automatically restored by the"
" system); keep them verbatim when rewriting the plan — adding new ones"
" is allowed. Simply mark completions per the plan grammar as usual;"
" checking runs asynchronously in the background and does not block you."
f" A failed `verify:` reopens its milestone at most {max_repairs} time(s);"
" once that repair budget is exhausted, its standing `finding:` line"
" disappears and the failure stands as the recorded result — do not"
" keep repairing it."
)
@@ -438,35 +643,39 @@ class BackgroundTasksPromptComponent(PromptComponent):
class TaskPlanWarningPromptComponent(PromptComponent):
async def __call__(self, builder: PromptBuilder, state: State, ctx: ArtemisContext, **kwargs):
steps = kwargs.get("steps", [])
if len(steps) >= 2:
last_two_steps = steps[-2:]
modified_in_last_two = False
for step in last_two_steps:
tool_calls = step.get("tool_calls", [])
for tc in tool_calls:
if tc.get("name") in [
"update_note",
"save_note",
"append_note",
]:
"""Suggests a task_plan update after an action turn that did not touch it.
Only the most recent step is inspected: if it executed a Turn-Ending
Action and no note tool wrote to ``task_plan``, a soft suggestion is
injected into the next turn. Pure diagnosis / waiting / observation turns
legitimately leave the plan alone and get no reminder.
"""
NOTE_TOOLS = ("update_note", "save_note", "append_note")
@classmethod
def _step_updated_task_plan(cls, step: dict) -> bool:
for tc in step.get("tool_calls", []):
if tc.get("name") not in cls.NOTE_TOOLS:
continue
args = tc.get("args", {})
note_key = args.get("key") or args.get("name")
if note_key == "task_plan":
modified_in_last_two = True
break
if modified_in_last_two:
break
return True
return False
if not modified_in_last_two:
async def __call__(self, builder: PromptBuilder, state: State, ctx: ArtemisContext, **kwargs):
steps = kwargs.get("steps", [])
if not steps:
return
last = steps[-1]
if not last.get("action_taken") or self._step_updated_task_plan(last):
return
builder.add_human_content(
"\nReminder: You have not updated the task_plan for two"
" consecutive turns. Please check the latest progress,"
" reflect on whether the task planning is detailed enough,"
" and whether every pending item is listed as a subtask."
" Please update the completed tasks and pending tasks in"
" detail."
"\nReminder: your last turn executed a Turn-Ending Action without"
" updating task_plan. If progress was made, record it now with"
" surgical `update_note` edits: mark what completed and add the"
" subtasks you are now pursuing."
)
@@ -474,11 +683,10 @@ class ToolLimitWarningPromptComponent(PromptComponent):
async def __call__(self, builder: PromptBuilder, state: State, ctx: ArtemisContext, **kwargs):
if getattr(state, "operator_tool_limit_exceeded", False):
builder.add_human_content(
"\n Warning: You did not execute any screen interaction actions"
" in your last turn. Please re-examine the task goal and revise"
" your plan; your current approach may not be the correct path."
" Actively calling ask_diagnoser can help you diagnose the"
" issue."
"\nWarning: your last turn used up its tool-call budget without a"
" Turn-Ending Action. Re-examine the task goal and your plan;"
" the current approach may not be the right path. If the cause"
" is unclear, ask_diagnoser can help."
)
@@ -566,11 +774,7 @@ class ScreenshotSimilarityPromptComponent(PromptComponent):
# 4. Inject note if any identical screenshots are found
if matched_step_nums:
steps_str = ", ".join(matched_step_nums)
note_text = (
f"Note: Screenshot in step {steps_str} seem to be identical to"
" current screen. This could be intended since not all actions"
" alter screens."
)
note_text = f"Note: the screen is unchanged since step {steps_str} (pixel-identical)."
builder.add_human_content(note_text)
def _count_differing_pixels(
@@ -648,8 +852,13 @@ class HistoricalStateHintPromptComponent(PromptComponent):
if not getattr(transcript_cfg, "similarity_hint", True):
return
max_distance = int(getattr(transcript_cfg, "similarity_max_distance", 8))
except Exception:
pass
except Exception as exc:
logger.debug(
"Transcript similarity config unavailable; using max_distance=%s: %s",
max_distance,
exc,
exc_info=True,
)
try:
from artemis.utils.image_hash import dhash_hex, hamming_distance_hex
+3 -3
View File
@@ -6,9 +6,9 @@
"output_protocol_initial": "## Task Plan & Scratchpad Protocol\n\n1. **Articulate Thinking**: Begin by reasoning through the 5-step **Systematic Thinking Process** to establish your strategy, funnel approach, bounds, and pre-mortem checks.\n2. **Designate Notes in Milestones & Pre-generate Answer Cards**: If the task involves collecting findings, logging periodic monitoring events, recording candidates, or analytical summaries, designate clear, descriptive note names directly in the milestone descriptions (e.g., `candidates_list`, `monitoring_log`, `schedule_summary`). You can and should invoke the `save_note` tool to pre-generate these structured template files (answer card / scratchpad skeletons with Markdown headers, sections, or tables) before submitting `task_plan`. This provides downstream executors with a clear, ready-to-fill schema.\n3. **Submit Plan**: Submit your initial plan by invoking the `save_note` tool with `key='task_plan'`. Avoid writing the plan only in your text response, as it will only be recognized by the system if submitted via the tool.\n\n### Plan Formatting Rules\n- Format the plan strictly as a Markdown task list starting with `- [ ]` for uncompleted items.\n{{ plan_grammar }}\n- For linear macro-milestones: `- [ ] <Phase Description>`\n- For bounded repetitive, iterative, or periodic polling milestones, tag the item with `[Loop]` and declare its Exit Boundary and Interval (e.g., `- [ ] [Loop] <Overall Loop / Monitoring Goal> (Exit: <Condition>; Interval: <e.g., every 5 minutes>)`).\n- For unbounded continuous monitoring that only the user can stop, tag the item with `[Loop:continuous]` instead (e.g., `- [ ] [Loop:continuous] <Monitoring Goal> (Interval: <e.g., every 5 minutes>)`); never declare a self-serve exit condition for it.\n- **Strict Two-Tier Hierarchy & Loop Subtask Contract**:\n 1. **Subtask Nesting Rule**: ALL individual iteration instances, polling checks, candidate inspections, or baseline setups MUST be formatted as 2-space indented sub-tasks (` - [ ]`) directly nested under their parent `[Loop]` milestone.\n 2. **Mandatory First-Instance Initialization**: Every `[Loop]` milestone MUST be initialized with its first concrete child sub-task (e.g., ` - [ ] Inspect Candidate #1: ...` or ` - [ ] Polling Check #1 (Baseline & Initial Sync): ...`).\n 3. **No Flattened Cycles**: NEVER elevate individual checks, recurring polling cycles, or candidate inspections to top-level milestones outside the `[Loop]`. Top-level items are reserved exclusively for strategic macro-phases.\n- Ensure the content of the `task_plan` note contains only the task list, excluding introductions, explanations, conversational text, or headers.",
"examples_initial": "## Master Example: Demonstrating the Complete Cognitive Paradigm\n\n**Goal**: \"Find and summarize all schedule arrangements and venue details for the upcoming November tech conference from my emails.\"\n\n### Naive / Incorrect Approaches:\n1. *Flawed Semantics & Too Granular*:\n- [ ] Tap search icon in Gmail\n- [ ] Type \"November\"\n- [ ] Open first email and tap back\n*(Fails: Micromanages atomic UI taps; assumes emails for November arrangements must have \"November\" in the title or be received in November)*\n2. *Vague & Unbounded Trap*:\n- [ ] Open email and check all emails for conference details\n*(Fails: Unbounded scan trap; causes infinite scrolling and step budget exhaustion on mobile)*\n\n---\n\n### Correct Approach (Full Cognitive Thinking & Robust Plan):\n\n#### Step-by-Step Thinking:\n1. **Intent & Semantic Expansion**:\n - *Core Objective*: Extract confirmed schedule dates, sessions, and venue info for the November tech conference.\n - *Semantic Insight*: Conference communications are often sent months in advance (e.g., ticket confirmations or registration emails in August/September). The subject line may say \"Global Developer Summit\", \"Tech Expo Agenda\", or \"Registration Confirmed\" rather than literally \"November conference\".\n - *Task Composition*: Linear setup (open email app and search) -> Iterative candidate inspection loop (examine each promising email) -> Final consolidation.\n2. **Visual Grounding**:\n - The device screenshot shows the Home screen with the Gmail app icon visible. The plan must start by opening the email client.\n3. **Tiered Strategy, Funnel Approach & Safeguards**:\n - *Tier 1 (Optimal)*: Use in-app search with rich semantic keywords (\"conference\", \"summit\", \"schedule\", \"agenda\", \"venue\", \"November\").\n - *Tier 2 (Fallback)*: If search is unavailable or yields zero results, fallback to chronological scanning of recent 3 months.\n - *Funnel (Broad Gathering -> Deep Inspection)*: Gather candidate emails from search results; do not discard candidates based solely on list previews. Open each candidate thread to inspect full body details.\n - *Feasibility Boundary*: Cap the candidate email inspection pool at top 15 emails to prevent infinite scrolling.\n4. **Risk Pre-mortem**:\n - *Risks*: Promotional marketing spam; conflicting drafts with preliminary dates; cancelled sessions.\n - *Defensive Check*: Instruct downstream executor to filter out promotional newsletters, verify session timestamps, and confirm final venue address.\n5. **Milestone Granularity & Scratchpad Blueprint**:\n - Focus milestones on business outcomes (search, inspect candidates, consolidate), avoiding micromanaged click-by-click steps.\n - Use indented sub-tasks to guide the loop of inspecting each candidate email, initializing with Item #1 and adding subsequent items as they are identified. This prevents the agent from losing state or re-inspecting the same items.\n - Designate note `november_conference_summary` as the destination for recording findings and pre-generate the structured template note.\n\n#### Tool Invocation:\n1. Pre-generate designated answer card note template:\n`save_note(key='november_conference_summary', content='# November Tech Conference Summary\\n\\n## Key Details\\n- Conference Name: \\n- Dates: \\n- Venue / Address: \\n\\n## Daily Schedule & Sessions\\n(To be populated during email inspection)\\n')`\n\n2. Submit milestone plan:\n`save_note(key='task_plan', content='- [ ] Open email application and search for candidate emails regarding the November tech conference (Search targeted keywords like \"conference\", \"summit\", \"agenda\", \"venue\"; if search is unavailable, fallback to scanning recent 3 months, capped at top 15 emails)\\n- [ ] [Loop] Inspect candidate emails and record verified schedule sessions and venue details into note `november_conference_summary` (Exit: All key conference details identified OR candidate pool exhausted / max 15 inspected)\\n - [ ] Inspect Candidate #1: Open candidate, filter out non-relevant content, check body for scheduling/venue, and log findings if present\\n- [ ] Review and consolidate all extracted information in `november_conference_summary` into a clear, finalized conference itinerary')`\n\n#### Content of `task_plan` Note:\n- [ ] Open email application and search for candidate emails regarding the November tech conference (Search targeted keywords like \"conference\", \"summit\", \"agenda\", \"venue\"; if search is unavailable, fallback to scanning recent 3 months, capped at top 15 emails)\n- [ ] [Loop] Inspect candidate emails and record verified schedule sessions and venue details into note `november_conference_summary` (Exit: All key conference details identified OR candidate pool exhausted / max 15 inspected)\n - [ ] Inspect Candidate #1: Open candidate, filter out non-relevant content, check body for scheduling/venue, and log findings if present\n- [ ] ReviLine truncated
"check_generation": "## Declaring Verification Standards (check lines)\nYou may attach check lines to the plan; an independent Checker judges them asynchronously.\n\n- **User-declared expectations become `- assert:` lines.** Whenever the user's goal states an explicit expectation (\"should show X\", \"must not crash\", \"the toast Y should appear\"), declare it as an assert line preserving the user's wording verbatim. Expectations about the whole run or the final state use the `@end` suffix (task-level lines at the end of the plan).\n- **`- verify:` lines only for milestones that genuinely need acceptance checks** — completion points of irreversible operations, or milestones that directly contribute to the final goal. Do NOT attach a verify line to every subgoal: restraint is an explicit requirement (checking cost scales with the number of check lines).\n- **Do not fabricate assertions** for subgoals that have nothing verifiable. Rewrite transient expectations as persistently probeable state wherever possible (e.g., \"alarm exists in the alarm list\" instead of \"a toast flashed\") — evidence for transient moments only exists if the execution history happened to capture it.\n- **Ordering requirements are post-hoc.** If the user demands \"A must be confirmed before B runs\", still generate the assertion preserving their semantics, but annotate in the plan that ordering is verified retroactively from history — the system performs post-hoc audits and CANNOT block B from executing. Never silently promise enforcement that does not exist.",
"check_audit": "- **Check Standard Protection**: Check lines (`- verify:` / `- assert:`) declared in the plan are the task's verification standards. REJECT any proposed plan that deletes, weakens, or rewords existing check lines — softening a declared standard is never an acceptable adaptation. Adding new check lines is allowed.",
"role_validator": "## Role\nYou are the Task Plan Validator. The Operator has attempted to modify the top-level task plan. Your job is to ensure the proposed new plan still aligns with the user's original intent and is viable.",
"validator_guidelines": "## Validation Principles\n- **Goal Alignment**: Ensure the new plan ultimately accomplishes the `Initial Goal` requested by the user. If the Operator has abandoned the core objective or fundamentally altered it, you must reject the plan.\n- **Logical Coherence**: Verify that the proposed milestones form a logical, achievable sequence to reach the goal. Support valid ongoing iteration/polling subgoals and trigger branches without misconstruing periodic checks as infinite loops. Reject the plan if it introduces contradictory steps, unhandled deadlocks, or obvious logical fallacies.\n{{ plan_grammar }}\n- **Loop & Monitoring Protection**: If the existing plan contains an active `[Loop]` or `[Loop:continuous]` milestone, you MUST REJECT any proposed plan that deletes the milestone or removes its loop tag, as well as any plan that marks a `[Loop:continuous]` continuous monitoring milestone as completed (`[x]`) without an explicit external user stop signal.\n- **Adaptive Acceptance**: The Operator operates in a dynamic environment and may encounter unexpected UI states or dynamic event triggers. You should approve alternative strategies, extra intermediate steps, loop status tracking, or necessary workarounds as long as they serve the primary goal effectively.\n- **Constructive Feedback**: If you must reject the plan, provide clear, objective, and actionable feedback explaining the logical flaw or goal deviation, and suggest how the Operator should correct the plan."
"check_audit": "- **Check Standard Protection**: Check lines (`- verify:` / `- assert:`) declared in the plan are the task's verification standards. FLAG any proposed plan that deletes, weakens, or rewords existing check lines — softening a declared standard is never an acceptable adaptation. Adding new check lines is allowed.",
"role_validator": "## Role\nYou are the Task Plan Reviewer, a fast advisory second opinion. The Operator has modified the top-level task plan and the change is already applied. Your job is to spot a concrete concern — goal drift, a broken loop contract, a weakened check standard, a logical dead end — and, if there is one, state it with its reason so the Operator can decide. You never block or roll back anything.",
"validator_guidelines": "## Validation Principles\n- **Goal Alignment**: Ensure the new plan ultimately accomplishes the `Initial Goal` requested by the user. If the Operator has abandoned the core objective or fundamentally altered it, flag the plan.\n- **Logical Coherence**: Verify that the proposed milestones form a logical, achievable sequence to reach the goal. Support valid ongoing iteration/polling subgoals and trigger branches without misconstruing periodic checks as infinite loops. Flag the plan if it introduces contradictory steps, unhandled deadlocks, or obvious logical fallacies.\n{{ plan_grammar }}\n- **Loop & Monitoring Protection**: If the existing plan contains an active `[Loop]` or `[Loop:continuous]` milestone, you MUST FLAG any proposed plan that deletes the milestone or removes its loop tag, as well as any plan that marks a `[Loop:continuous]` continuous monitoring milestone as completed (`[x]`) without an explicit external user stop signal.\n- **Adaptive Acceptance**: The Operator operates in a dynamic environment and may encounter unexpected UI states or dynamic event triggers. You should approve alternative strategies, extra intermediate steps, loop status tracking, or necessary workarounds as long as they serve the primary goal effectively.\n- **Advisory Verdict & Constructive Feedback**: Your verdict is advisory — the change stays applied whether or not you approve it. Approve unless you have a concrete, evidence-backed concern; a vague unease is not a concern. If you flag the plan, give one or two sentences naming the flaw or goal deviation and the correction you suggest, concrete enough for the Operator to weigh it against its own observations."
},
"modes": {
"initial_plan": {
+10 -4
View File
@@ -122,13 +122,17 @@ class ValidationResult(BaseModel):
model_config = {"ignored_types": (CyFunctionDetector,)}
is_approved: bool = Field(
description=(
"True if the new plan is acceptable and aligns with the initial goal, False otherwise."
"True if the new plan still serves the initial goal and is coherent."
" False if you have a concrete concern (goal drift, broken loop"
" contract, weakened check standard, logical dead end)."
)
)
feedback: str = Field(
description=(
"If rejected, provide constructive feedback explaining why and what"
" to do instead. If approved, can be empty."
"If not approved: one or two sentences naming the concern and the"
" suggested correction. This is advisory feedback shown to the"
" Operator, not a rejection — the plan change stays applied."
" If approved, leave empty."
)
)
@@ -185,7 +189,9 @@ async def run_async_planner_validation(
HumanMessage(content=human_message),
]
llm = get_llm(ctx=ctx, name="planner").with_structured_output(ValidationResult)
# Advisory review on the lightweight judge node (shared default with
# the pixel safety net), never the heavyweight Planner model.
llm = get_llm(ctx=ctx, name="planner_validation").with_structured_output(ValidationResult)
result: ValidationResult = await invoke_llm_with_timeout_message(llm.ainvoke(messages))
+4 -3
View File
@@ -8,13 +8,13 @@ You are the Summarizer Agent for a mobile automation system. Your task is to sum
# CORE PHILOSOPHY & RULES
1. **Perspective**: You MUST write the summary from the Operator's first-person perspective using "I" (e.g., "I intended to...", "I clicked..."). NEVER use third-person terms like "The operator" or "The agent", nor second-person terms like "You" when referring to the Operator. For other agents (e.g., Failure Analyzer, Explorer), you can use their names.
1. **Perspective**: You MUST write the summary from the Operator's first-person perspective using "I" (e.g., "I intended to...", "I clicked..."). NEVER use third-person terms like "The operator" or "The agent", nor second-person terms like "You" when referring to the Operator. For other agents (e.g., Explorer, Diagnoser), you can use their names.
2. **Zero Subjective Validation**: Absolutely avoid declaring semantic success, completion, or failure. In historical compression, any subjective validation will cause future planners to falsely believe a goal was definitively met or permanently broken.
- **BANNED WORDS**: "successfully", "completed", "entered", "navigated to", "failed", "unsuccessful", "could not", "achieved".
3. **Focus on Intent & Strategy**: The specific physical action (exact coordinates, swipes) is now recorded automatically. Focus primarily on the high-level Intent and reasoning. If you realize the Operator is on the wrong path and needs to backtrack or pivot, describe this as a shift in strategy.
4. **Intended Context Transitions**: Describe the intended navigation path ("From Where -> Intended Where"). Do not declare that the transition actually happened. (e.g., "From the Home screen, I intended to open the Network Settings view").
5. **Preserve Verifications & Iteration Progress**: If the Operator verified any state in this step, processed a specific candidate item/polling round, or explicitly mentioned what prerequisites or items are still missing, you MUST succinctly preserve these concrete progress details and verified items in the summary.
6. **Objective Intervention Logs**: If the Failure Analyzer intervened, faithfully summarize what triggered the intervention and what the analyzer executed, without judging the outcome.
6. **Objective Incident Logs**: If the safety net intercepted the action, the device rejected it, or a fast-action burst aborted midway (an `[Result]` line starting with "Error:" / an execution incident), faithfully record what was blocked and the stated reason, without judging whether the Operator's next move will resolve it.
# LOOP & STAGNATION DETECTION
Use the `RECENT 10 STEPS HISTORY` to intelligently detect loops or stagnation:
@@ -30,5 +30,6 @@ You will receive:
- **CHRONOLOGICAL STEP TRACE (CURRENT STEP)**: The step-by-step chronological history of the current execution turn, including:
- `[Operator Monologue] / [Operator Native Thought]`: The operator's planning, intentions, progress counting, reasoning, verified states, and explicit mentions of missing prerequisites.
- `[Operator Tool Call] / [Operator Final Action]`: The actual action(s) planned and executed.
- `[Planned Fast-Action Burst]`: A multi-action turn executed back to back without the safety net, with each member's outcome.
- `[Pre-Execution Safety Net]`: Validation checks prior to action execution.
- `[Failure Analyzer Recovery Loop]`: Interventions, thoughts, auxiliary tool calls, and outcomes of the Failure Analyzer.
- `[Result]`: The Validator's execution outcome; an "Error:" line here is an execution incident the Operator must resolve.
+1 -3
View File
@@ -101,9 +101,7 @@ class SummarizerNode:
exec_outcome=exec_outcome,
data_engine_step_id=step_id,
)
logger.info(
f"Dispatched visual transition summary for step {step_number} ({step_id})"
)
logger.info(f"Dispatched visual transition summary for step {step_number} ({step_id})")
def _read_step_image(self, step_number: int | None, which: str) -> bytes | None:
if not isinstance(step_number, int):
+33
View File
@@ -0,0 +1,33 @@
# Copyright 2026 Google LLC
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Safety-net failure taxonomy shared by the Validator's precondition gates.
The categories are produced by the XML gate (``precondition_xml``) and the
pixel gate (``precondition_pixel``) and consumed by the execution loop when it
opens an :mod:`artemis.agents.validator.incidents` record for the Operator.
"""
from enum import StrEnum
class ValidationErrorCategory(StrEnum):
NONE = "none"
TARGET_DISAPPEARED = "target_disappeared"
TARGET_SHIFTED = "target_shifted"
TARGET_OCCUPIED = "target_occupied"
PIXEL_TARGET_DISAPPEARED = "pixel_target_disappeared"
PIXEL_BYPASSED = "pixel_bypassed"
XML_BYPASSED = "xml_bypassed"
GENERAL = "general"
+144 -107
View File
@@ -14,9 +14,21 @@
"""Per-turn action execution loop for the Validator.
Orchestrates, for each Operator decision: precondition safety-net checks
(XML-first with pixel fallback), local execution with retries, screenshot and
trace bookkeeping, and FailureAnalyzer-driven local repair.
Two execution tiers, selected by how many turn-ending actions the Operator
emitted in the turn:
* **Vetted single action** (one action): the pre-execution Safety Net runs
first (XML-first with pixel fallback), then the action executes locally with
a short retry loop.
* **Fast-action burst** (two or more actions): the actions fire back to back
with no safety net, no retries and no screenshots in between. The burst is
the Operator's tool against turn latency (transient menus, toasts, control
bars); the Operator owns the risk. The first failure aborts the rest.
Either way, a failure does not spawn a repair agent. The loop opens an
*execution incident* (:mod:`artemis.agents.validator.incidents`) that rides in
graph state and in the step's execution report, and the Operator resolves it
on its next turn with the live screen in front of it.
Extracted from ``validator.py``; the public entry point remains
``ValidatorNode.__call__``, which delegates here. All patchable seams
@@ -27,21 +39,26 @@ are dispatched through the node so existing test patches keep working.
import asyncio
import base64
from dataclasses import dataclass
from pathlib import Path
import time
import uuid
from uuid import UUID
from artemis.agents.validator.failure_analyzer import (
FailureAnalyzer,
ValidationErrorCategory,
from artemis.agents.validator.categories import ValidationErrorCategory
from artemis.agents.validator.incidents import (
KIND_EXEC_ERROR,
KIND_SAFETY_NET,
open_incident,
)
from artemis.data_engine.trace import CURRENT_TRACE_ID
from artemis.graph.state import State
from artemis.utils.logger import get_logger
from artemis.utils.task_tree import format_action_clean
logger = get_logger(__name__)
#: ``attempts`` marker for actions a burst never reached.
BURST_SKIPPED_MARKER = "Skipped (burst aborted)"
@dataclass
class _ActionOutcome:
@@ -51,6 +68,7 @@ class _ActionOutcome:
success: bool = False
error_msg: str = ""
error_category: ValidationErrorCategory = ValidationErrorCategory.GENERAL
intercepted: bool = False
post_screenshot_b64: str | None = None
@@ -109,19 +127,16 @@ async def _run_precondition_gate(
session, action_item, pre_screenshot_b64, original_coords, state=state
)
if pixel_passed and pixel_category == ValidationErrorCategory.NONE:
logger.success(
"Pixel-based validation fallback PASSED! Overriding XML validation failure."
)
logger.success("Pixel-based validation fallback PASSED! Overriding XML validation failure.")
return True, ValidationErrorCategory.NONE, ""
return validation_passed, validation_category, validation_error
async def _attempt_local_execution(
node, session, action_item: dict, action_name, attempts_log: list
node, session, action_item: dict, action_name, attempts_log: list, *, max_local_retries: int
) -> tuple[bool, str]:
"""Executes the action locally with a short retry loop."""
max_local_retries = 1 if action_name == "launch_app" else 2
success = False
error_msg = ""
for attempt in range(max_local_retries):
@@ -157,13 +172,22 @@ async def _capture_live_screenshot(session, context_desc: str) -> str | None:
async def _process_action(
node, state: State, session, action_item: dict, action_name, pre_screenshot_b64: str
node,
state: State,
session,
action_item: dict,
action_name,
pre_screenshot_b64: str,
*,
burst: bool,
) -> _ActionOutcome:
"""Runs precondition checks and execution for one action item."""
"""Runs precondition checks (vetted tier only) and execution for one action item."""
action_item = dict(action_item) # Make a copy to avoid mutating original state actions
outcome = _ActionOutcome(action_item=action_item)
attempts_log = []
validation_passed = True
if not burst:
# 1. Pre-execution validation (Safety Net)
original_coords = list(action_item.get("coordinates") or [])
(
@@ -173,27 +197,32 @@ async def _process_action(
) = await _run_precondition_gate(
node, session, action_item, state, pre_screenshot_b64, original_coords
)
if not validation_passed:
outcome.success = False
outcome.intercepted = True
outcome.error_msg = f"Pre-execution validation failed: {validation_error}"
outcome.error_category = validation_category
attempts_log.append(outcome.error_msg)
# Capture the live mismatch screenshot to provide context for FailureAnalyzer
outcome.post_screenshot_b64 = await _capture_live_screenshot(
session, "live screenshot for failure analysis"
)
if validation_passed:
# 2. Local execution: a vetted action gets a short retry loop, a burst
# member fires exactly once (retries would break the burst's timing).
if burst:
max_local_retries = 1
else:
# 2. Local execution attempts
max_local_retries = 1 if action_name == "launch_app" else 2
outcome.success, outcome.error_msg = await _attempt_local_execution(
node, session, action_item, action_name, attempts_log
node,
session,
action_item,
action_name,
attempts_log,
max_local_retries=max_local_retries,
)
if not outcome.success and not outcome.post_screenshot_b64:
if not outcome.success:
logger.info("Action failed, capturing failure screenshot...")
outcome.post_screenshot_b64 = await _capture_live_screenshot(
session, "failure screenshot"
)
outcome.post_screenshot_b64 = await _capture_live_screenshot(session, "failure screenshot")
# Enrich the executed action record
if len(attempts_log) > 1 or not outcome.success:
@@ -241,6 +270,8 @@ def _record_action_end(
parent_id,
action_trace_id,
start_time: float,
*,
burst: bool,
):
duration = time.time() - start_time
if ctx.data_engine and step_id:
@@ -255,6 +286,7 @@ def _record_action_end(
"post_screenshot": post_image_name,
"timestamp": time.time(),
"relative_time": relative_time,
"burst": burst,
},
status="success" if outcome.success else "failed",
duration=duration,
@@ -264,55 +296,61 @@ def _record_action_end(
)
def _reload_post_repair_screenshot(
ctx, state: State, last_screenshot_b64: str, last_screenshot_name: str | None
) -> tuple[str, str | None]:
"""Reloads the latest screenshot updated by FailureAnalyzer during repair."""
def _normalize_point(ctx, point) -> list[int] | None:
"""Pixel [x, y] -> normalized 0-1000 [x, y] using the device dims."""
try:
screenshot_path = getattr(state, "latest_screenshot", None)
if screenshot_path:
if Path(screenshot_path).exists():
with open(screenshot_path, "rb") as f:
last_screenshot_b64 = base64.b64encode(f.read()).decode("utf-8")
if ctx.data_engine:
last_screenshot_name = ctx.data_engine.get_or_create_image(
base64.b64decode(last_screenshot_b64)
x, y = int(point[0]), int(point[1])
except (TypeError, ValueError, IndexError, KeyError):
return None
width = getattr(getattr(ctx, "device", None), "device_width", None) or 1080
height = getattr(getattr(ctx, "device", None), "device_height", None) or 2400
return [
max(0, min(1000, round(x * 1000 / width))),
max(0, min(1000, round(y * 1000 / height))),
]
def _incident_evidence(ctx, outcome: _ActionOutcome) -> dict:
"""Structured facts the safety net attached to the action item, normalized."""
raw = outcome.action_item.pop("safety_net_evidence", None) or {}
evidence: dict = {}
if raw.get("new_center"):
normalized = _normalize_point(ctx, raw["new_center"])
if normalized:
evidence["new_location"] = normalized
if raw.get("new_bounds") is not None:
evidence["new_bounds"] = raw["new_bounds"]
if raw.get("occupant"):
evidence["occupant"] = raw["occupant"]
if raw.get("occupant_bounds") is not None:
evidence["occupant_bounds"] = raw["occupant_bounds"]
return evidence
def _current_step_number(ctx, state: State) -> int | None:
step_id = getattr(state, "current_step_id", None)
if ctx.data_engine and step_id:
try:
return ctx.data_engine.get_step_number(UUID(step_id))
except Exception:
return None
return None
def _build_incident(ctx, state: State, outcome: _ActionOutcome, *, index: int, total: int) -> dict:
step_number = _current_step_number(ctx, state)
return open_incident(
previous=getattr(state, "open_incident", None),
kind=KIND_SAFETY_NET if outcome.intercepted else KIND_EXEC_ERROR,
category=outcome.error_category,
reason=outcome.error_msg,
action_item=outcome.action_item,
action_description=format_action_clean(outcome.action_item),
action_index=index,
burst_size=total,
step_number=step_number,
evidence=_incident_evidence(ctx, outcome),
)
logger.info(f"Validator reloaded post-repair screenshot from: {screenshot_path}")
except Exception as e:
logger.error(f"Failed to reload post-repair screenshot: {e}")
return last_screenshot_b64, last_screenshot_name
async def _analyze_failure(
node,
state: State,
outcome: _ActionOutcome,
*,
decision_screenshot_b64,
decision_screenshot_name,
post_image_name,
executed_actions,
unexecuted_actions,
) -> dict:
"""Triggers the FailureAnalyzer for a failed action and records its analysis."""
logger.warning(f"Action failed: {outcome.action_item}. Triggering failure analysis.")
analyzer = FailureAnalyzer(node.ctx)
analysis_result = await analyzer.analyze(
state,
outcome.action_item,
outcome.error_msg,
pre_screenshot=decision_screenshot_b64,
post_screenshot=outcome.post_screenshot_b64,
pre_screenshot_name=decision_screenshot_name,
post_screenshot_name=post_image_name,
executed_actions=executed_actions,
unexecuted_actions=unexecuted_actions,
error_category=outcome.error_category,
)
outcome.action_item["repair"] = analysis_result.get("analysis", "No analysis provided.")
return analysis_result
async def run_validation_loop(node, state: State) -> dict:
@@ -328,8 +366,14 @@ async def run_validation_loop(node, state: State) -> dict:
return {}
execution: list[dict] = []
failed_action = None
actions_to_execute = list(actions)
total_actions = len(actions_to_execute)
burst = total_actions > 1
if burst:
logger.info(
f"Fast-action burst: {total_actions} actions will fire back to back"
" without the safety net."
)
session = await node._get_mcp_session()
@@ -338,15 +382,15 @@ async def run_validation_loop(node, state: State) -> dict:
session, state
)
# Preserve the turn-initial screenshot (what the operator saw when making decisions)
decision_screenshot_b64 = last_screenshot_b64
decision_screenshot_name = last_screenshot_name
except Exception:
return {}
step_id = UUID(state.current_step_id) if state.current_step_id else None
analysis_result: dict = {}
incident: dict | None = None
success = False
index = 0
while actions_to_execute:
action_item = actions_to_execute.pop(0)
@@ -363,16 +407,14 @@ async def run_validation_loop(node, state: State) -> dict:
try:
outcome = await _process_action(
node, state, session, action_item, action_name, last_screenshot_b64
node, state, session, action_item, action_name, last_screenshot_b64, burst=burst
)
success = outcome.success
execution.append(outcome.action_item)
post_image_name = None
if outcome.post_screenshot_b64:
post_image_name = _absorb_post_screenshot(
ctx, state, outcome.post_screenshot_b64
)
post_image_name = _absorb_post_screenshot(ctx, state, outcome.post_screenshot_b64)
last_screenshot_b64 = outcome.post_screenshot_b64
if post_image_name:
last_screenshot_name = post_image_name
@@ -386,48 +428,32 @@ async def run_validation_loop(node, state: State) -> dict:
parent_id,
action_trace_id,
start_time,
burst=burst,
)
finally:
CURRENT_TRACE_ID.reset(token)
if not success:
failed_action = outcome.action_item
analysis_result = await _analyze_failure(
node,
state,
outcome,
decision_screenshot_b64=decision_screenshot_b64,
decision_screenshot_name=decision_screenshot_name,
post_image_name=post_image_name,
executed_actions=execution[:-1],
unexecuted_actions=list(actions_to_execute),
incident = _build_incident(ctx, state, outcome, index=index, total=total_actions)
logger.warning(
f"Action failed: {outcome.action_item}. Opened execution incident"
f" ({incident['kind']}/{incident['category']},"
f" consecutive={incident['consecutive_failures']}) for the Operator."
)
if analysis_result.get("status") == "fixed":
logger.success("Failure repaired locally!")
actions_to_execute = []
last_screenshot_b64, last_screenshot_name = _reload_post_repair_screenshot(
ctx, state, last_screenshot_b64, last_screenshot_name
)
continue
else:
break
index += 1
# Append any skipped actions to the execution log
for skipped in actions_to_execute:
skipped_copy = dict(skipped)
skipped_copy["attempts"] = ["Skipped"]
skipped_copy["attempts"] = [BURST_SKIPPED_MARKER if burst else "Skipped"]
execution.append(skipped_copy)
report = {
"execution": execution,
"status": (
"success"
if success or (failed_action and analysis_result.get("status") == "fixed")
else "failed"
),
"repair_status": (analysis_result.get("status") if failed_action else None),
"status": "success" if success else "failed",
"burst": burst,
"incident": incident,
}
# Determine if a distinct post-action screenshot was captured
@@ -442,4 +468,15 @@ async def run_validation_loop(node, state: State) -> dict:
step_id, report, post_image_name=distinct_post_image_name
)
return {"last_execution_result": report}
# A successful turn closes whatever incident was open (handing the Operator
# a one-turn CLOSED notice); a failed one keeps and escalates it.
closed = None
previous = getattr(state, "open_incident", None)
if success and isinstance(previous, dict) and previous.get("kind"):
closed = dict(previous)
closed["closed_at_step"] = _current_step_number(ctx, state)
return {
"last_execution_result": report,
"open_incident": incident,
"last_closed_incident": closed,
}
@@ -1,25 +0,0 @@
# Identity & Goal
You are a specialized error recovery agent. You will be provided with a failed action. Your ONLY task is to repair this specific failure and restore the system state so the main system can resume.
# Context
- **Initial Goal**: {{ initial_goal }}
- **History & Plan**:
{{ plan_and_history }}
# Core Workflow
1. **Analyze**: Compare the two states provided to you:
- **Decision State (`Screenshot Seen During System Decision`)**: This is the screen the system saw when it originally planned the action. It shows the target element successfully located.
- **Failed State (`Latest Screenshot (Failed State)`)**: This is the live screen captured at the moment you were summoned. It shows the current state where the target is missing or unresponsive.
Your goal in this comparison is to:
- Read the Operator's thought process in the decision loop of the failed step to understand what they were originally trying to accomplish with this failed action.
- Understand the expected target layout.
- Identify the cause of the failure based on the error message and the visual mismatch.
2. **Recover & Execute**: Restore the target state and execute the failed action.
- **Flexibility**: Focus on outcome over form. Take alternative/adaptive steps (e.g., scroll, navigate, different click paths) if original coordinates are invalid.
3. **Report**: Call `report_failure_analysis` to conclude:
- `status="fixed"`: Successfully executed the failed action, reached the destination state, or false alarm.
- `status="cannot_fix"`: Target is unrecoverable, the app is broken, or the 15-step budget is exceeded.
- `analysis`: Summarize the root cause, the fix applied, and explicitly confirming that control is handed back to the main system.
# Constraints
The most recent step (Step {{ failed_step_number }}: {{ failed_action_description }}) failed to execute. You must resolve the issue and report the results in 15 interactive steps or fewer. Now please begin the repair analysis. Please note that during the **Recover & Execute** phase, you only need to resolve the currently failed step; do not attempt to execute any subsequent steps or complete the overall goal yourself. Once the failure is resolved, please immediately enter the **Report** phase and return control to the system.
@@ -1,642 +0,0 @@
# Copyright 2026 Google LLC
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Universal Multi-Model Failure Analyzer for Artemis.
Analyzes execution failures and performs autonomous self-healing across
Google Gemini, OpenAI GPT-4o/o3, Anthropic Claude 3.5/3.7, and OpenRouter endpoints.
"""
import asyncio
from enum import StrEnum
import json
from pathlib import Path
from typing import Any
import uuid
from jinja2 import Template
from langchain_core.messages import (
BaseMessage,
HumanMessage,
SystemMessage,
ToolMessage,
)
from artemis.agents.validator.tool_declarations import (
ASK_EXPLORER_TOOL,
CLICK_SEQUENCE_TOOL,
MobileActionExecutor, # Legacy shims below; the loop itself uses McpActionExecutor.
ToolDeclaration,
VALIDATOR_TOOLS_DECLARATION,
capture_screenshot_and_parse_ui,
prune_intermediate_screenshots,
)
from artemis.context import ArtemisContext
from artemis.controllers.unified_controller import UnifiedMobileController
from artemis.data_engine.trace import trace
from artemis.graph.state import State
from artemis.mcp.action_executor import McpActionExecutor
from artemis.services.llm import get_llm
from artemis.utils.logger import get_logger
from artemis.utils.notes import get_note_file_path
from artemis.utils.ocr_xml_fusion import fuse_ocr_with_xml
from artemis.memory.context_policy import build_history_for
from artemis.utils.task_tree import (
format_action_clean,
get_active_subgoal_hashes,
)
from artemis.utils.visualization import format_minimal_list_with_elements
class ValidationErrorCategory(StrEnum):
NONE = "none"
TARGET_DISAPPEARED = "target_disappeared"
TARGET_SHIFTED = "target_shifted"
TARGET_OCCUPIED = "target_occupied"
PIXEL_TARGET_DISAPPEARED = "pixel_target_disappeared"
PIXEL_BYPASSED = "pixel_bypassed"
XML_BYPASSED = "xml_bypassed"
GENERAL = "general"
logger = get_logger(__name__)
# ==============================================================================
# Backward-Compatibility Helper Aliases
# ==============================================================================
async def _capture_screenshot_and_parse_ui(
ctx: ArtemisContext,
state: State,
controller: UnifiedMobileController,
skip_settling: bool = False,
) -> tuple[str | None, bytes | None, str | None]:
return await capture_screenshot_and_parse_ui(
ctx, state, controller, skip_settling=skip_settling
)
async def _exec_click(
target: list[int],
state: State,
controller: UnifiedMobileController,
ctx: ArtemisContext,
times: int = 1,
delay_ms: int = 100,
) -> tuple[str, bytes | None, str | None, str | None]:
return await MobileActionExecutor(ctx, controller).exec_click(
target, state, times=times, delay_ms=delay_ms
)
async def _exec_click_sequence(
sequence: list[int | list[int]],
state: State,
controller: UnifiedMobileController,
ctx: ArtemisContext,
) -> tuple[str, bytes | None, str | None, str | None]:
return await MobileActionExecutor(ctx, controller).exec_click_sequence(sequence, state)
async def _exec_long_press(
target: list[int],
state: State,
controller: UnifiedMobileController,
ctx: ArtemisContext,
duration_ms: int = 1000,
duration: int | None = None,
) -> tuple[str, bytes | None, str | None, str | None]:
return await MobileActionExecutor(ctx, controller).exec_long_press(
target, state, duration_ms=duration_ms, duration=duration
)
async def _exec_input_text(
text: str,
target: list[int] | None,
state: State,
controller: UnifiedMobileController,
ctx: ArtemisContext,
clear_exist: bool = True,
) -> tuple[str, bytes | None, str | None, str | None]:
return await MobileActionExecutor(ctx, controller).exec_input_text(
text, target, state, clear_exist=clear_exist
)
async def _exec_swipe(
action: str | list[int],
duration: int = 400,
state: State | None = None,
controller: UnifiedMobileController | None = None,
ctx: ArtemisContext | None = None,
duration_ms: int | None = None,
) -> tuple[str, bytes | None, str | None, str | None]:
return await MobileActionExecutor(ctx, controller).exec_swipe(
action, duration=duration, state=state, duration_ms=duration_ms
)
async def _exec_press_key(
key: str,
state: State,
controller: UnifiedMobileController,
ctx: ArtemisContext,
) -> tuple[str, bytes | None, str | None, str | None]:
return await MobileActionExecutor(ctx, controller).exec_press_key(key, state)
async def _exec_manage_app(
action: str,
app_name: str,
state: State,
controller: UnifiedMobileController,
ctx: ArtemisContext,
) -> tuple[str, bytes | None, str | None, str | None]:
return await MobileActionExecutor(ctx, controller).exec_manage_app(action, app_name, state)
async def _exec_wait_for_delay(
time_in_ms: int,
state: State,
controller: UnifiedMobileController,
ctx: ArtemisContext,
) -> tuple[str, bytes | None, str | None, str | None]:
return await MobileActionExecutor(ctx, controller).exec_wait_for_delay(time_in_ms, state)
async def _exec_wait_for_text(
text: str,
wait_state: str | None,
timeout_ms: int | None,
state: State,
controller: UnifiedMobileController,
ctx: ArtemisContext,
) -> tuple[str, bytes | None, str | None, str | None]:
return await MobileActionExecutor(ctx, controller).exec_wait_for_text(
text, wait_state, timeout_ms, state
)
async def _exec_read_note(
key: str,
ctx: ArtemisContext,
start_line: int | None = None,
end_line: int | None = None,
) -> str:
return await MobileActionExecutor(ctx).exec_read_note(
key, start_line=start_line, end_line=end_line
)
async def _exec_list_notes(ctx: ArtemisContext) -> str:
return await MobileActionExecutor(ctx).exec_list_notes()
async def _exec_ask_explorer(
query: str,
context_feedback: str | None,
state: State,
ctx: ArtemisContext,
version: str = "flash",
) -> str:
return await MobileActionExecutor(ctx).exec_ask_explorer(
query, context_feedback, state, version=version
)
def _get_ask_explorer_tool_declaration() -> ToolDeclaration:
return ASK_EXPLORER_TOOL
class FailureAnalysisStrategy:
"""Base strategy class for FailureAnalyzer strategies."""
def get_prompt_template_name(self) -> str:
return "failure_analyzer.md"
def get_tools(self) -> list:
return VALIDATOR_TOOLS_DECLARATION
def required_tools(self) -> frozenset[str]:
"""Tools this strategy's prompt depends on structurally.
A strategy whose prompt is built around a tool (rather than merely listing it)
must declare it here; strategy selection falls back to the base strategy when
the current backend does not provide every required tool, so the dependent
prompt is never rendered against a backend that cannot satisfy it.
"""
return frozenset()
def get_user_message_suffix(self, pre_screenshot: bool, post_screenshot: bool) -> str:
suffix = (
"Your objective is:\n"
"1. Analyze why the action planned in the failed step failed, using the failed step's"
" details, the decision screenshot (Screenshot Seen During System Decision), the"
" latest failed screenshot (Latest Screenshot (Failed State)), and their corresponding"
" UI Element lists.\n"
"2. Resolve any timing, state mismatches (e.g., re-triggering disappeared elements), or"
" environmental obstacles using your execution tools.\n"
"3. Crucially: You MUST execute the failed action yourself using your execution tools"
" to completion.\n"
"4. Once you have successfully executed the repair and the failed action, call the"
" `report_failure_analysis` tool with status='fixed'.\n"
"5. If you are not confident or cannot resolve the failure, call"
" `report_failure_analysis` with status='cannot_fix'."
)
if pre_screenshot and post_screenshot:
suffix += (
"\nI have attached the screenshot seen when the system made its"
" decision (Screenshot Seen During System Decision) and the"
" latest screenshot in the failed state (Latest Screenshot"
" (Failed State))."
)
return suffix
class TargetDisappearedStrategy(FailureAnalysisStrategy):
"""Specialized strategy for target disappeared errors."""
def get_prompt_template_name(self) -> str:
return "target_disappeared_analyzer.md"
def get_tools(self) -> list:
tools = list(VALIDATOR_TOOLS_DECLARATION)
tools.insert(1, CLICK_SEQUENCE_TOOL)
tools.append(ASK_EXPLORER_TOOL)
return tools
def required_tools(self) -> frozenset[str]:
# The whole prompt is built around atomic chained execution to defeat turn
# latency, which *is* click_sequence.
return frozenset({"click_sequence"})
def get_user_message_suffix(self, pre_screenshot: bool, post_screenshot: bool) -> str:
return ""
class PixelTargetDisappearedStrategy(FailureAnalysisStrategy):
"""Specialized strategy for pixel-level target disappeared errors."""
def get_prompt_template_name(self) -> str:
return "pixel_target_disappeared_analyzer.md"
def get_tools(self) -> list:
tools = list(VALIDATOR_TOOLS_DECLARATION)
tools.insert(1, CLICK_SEQUENCE_TOOL)
tools.append(ASK_EXPLORER_TOOL)
return tools
def required_tools(self) -> frozenset[str]:
return frozenset({"click_sequence"})
def get_user_message_suffix(self, pre_screenshot: bool, post_screenshot: bool) -> str:
return ""
class FailureAnalyzer:
"""Universal Multi-Model Failure Analyzer."""
def __init__(self, ctx: ArtemisContext):
self.ctx = ctx
self.max_iterations = 15
def _select_strategy(
self,
error_category: ValidationErrorCategory,
available_tools: frozenset[str] | None = None,
) -> FailureAnalysisStrategy:
if error_category == ValidationErrorCategory.PIXEL_TARGET_DISAPPEARED:
logger.info("Routing failure analysis to specialized PixelTargetDisappearedStrategy.")
strategy: FailureAnalysisStrategy = PixelTargetDisappearedStrategy()
elif error_category == ValidationErrorCategory.TARGET_DISAPPEARED:
logger.info("Routing failure analysis to specialized TargetDisappearedStrategy.")
strategy = TargetDisappearedStrategy()
else:
logger.info("Routing failure analysis to DefaultAnalysisStrategy.")
strategy = FailureAnalysisStrategy()
# Capability gating: a strategy and its prompt are one unit. If the backend
# cannot satisfy the tools the prompt is built around, fall back to the base
# strategy so the dependent prompt is never rendered. `None` = no gating.
if available_tools is not None:
missing = strategy.required_tools() - available_tools
if missing:
logger.warning(
f"Strategy {type(strategy).__name__} requires unavailable tool(s)"
f" {sorted(missing)}; falling back to base FailureAnalysisStrategy."
)
return FailureAnalysisStrategy()
return strategy
def _get_active_subgoal_hash(self) -> str:
if not self.ctx.data_engine:
return "default"
task_plan_path = get_note_file_path(self.ctx.data_engine.base_dir, "task_plan")
if not task_plan_path.exists():
return "default"
try:
content = task_plan_path.read_text(encoding="utf-8")
parent_hash, _ = get_active_subgoal_hashes(content)
return parent_hash
except Exception as e:
logger.error(f"Failed to parse active subgoal: {e}")
return "default"
def _build_plan_and_history(self, steps: list[dict], task_plan: str) -> str:
active_subgoal_hash = self._get_active_subgoal_hash()
return build_history_for(
"failure_analyzer",
task_plan,
steps,
active_subgoal_hash,
)
def _prune_intermediate_screenshots(self, messages: list[Any]):
"""Prunes binary screenshot blocks from intermediate messages."""
prune_intermediate_screenshots(messages)
@trace(type="agent", name="failure_analyzer")
async def analyze(
self,
state: State,
failed_action: dict,
error_msg: str,
pre_screenshot: str | None = None,
post_screenshot: str | None = None,
pre_screenshot_name: str | None = None,
post_screenshot_name: str | None = None,
executed_actions: list[dict] | None = None,
unexecuted_actions: list[dict] | None = None,
error_category: ValidationErrorCategory = ValidationErrorCategory.GENERAL,
) -> dict:
if not post_screenshot or post_screenshot == "None":
try:
controller = UnifiedMobileController(self.ctx)
post_screenshot = await controller.take_screenshot()
except Exception as e:
logger.warning(f"Failed to capture post-action fallback screenshot: {e}")
if not pre_screenshot or pre_screenshot == "None":
pre_screenshot = post_screenshot
if not pre_screenshot:
raise ValueError("Failure analysis requires valid screenshots.")
controller = UnifiedMobileController(self.ctx)
executor = McpActionExecutor(self.ctx, controller)
strategy = self._select_strategy(
error_category, available_tools=frozenset(executor.action_tool_names)
)
prompt_template_name = strategy.get_prompt_template_name()
tools_declaration = strategy.get_tools()
try:
llm = get_llm(self.ctx, name="validator_failure_analyzer")
except Exception:
llm = get_llm(self.ctx, name="validator")
# Pre-Action and Post-Action XML formatting
pre_xml_list_str = "Not available."
if hasattr(state, "latest_ui_hierarchy") and state.latest_ui_hierarchy:
try:
width = getattr(self.ctx.device, "device_width", 1080)
height = getattr(self.ctx.device, "device_height", 2400)
pre_xml_list_str, _, _ = format_minimal_list_with_elements(
state.latest_ui_hierarchy, width, height
)
except Exception as e:
logger.warning(f"Failed to format pre-action XML list: {e}")
post_xml_list_str = "Not available."
try:
xml_hierarchy = await controller.get_ui_elements()
width = getattr(self.ctx.device, "device_width", 1080)
height = getattr(self.ctx.device, "device_height", 2400)
ocr_results = []
fused_xml = fuse_ocr_with_xml(xml_hierarchy, ocr_results)
post_xml_list_str, elements, _ = format_minimal_list_with_elements(
fused_xml, width, height
)
state.indexed_points = [el["center"] for el in elements]
state.indexed_elements = elements
except Exception as e:
logger.warning(f"Failed to fetch/format post-action XML list: {e}")
prompt_path = Path(__file__).parent.joinpath(prompt_template_name)
prompt_template = prompt_path.read_text(encoding="utf-8")
steps = []
task_plan = "No task plan yet."
if self.ctx.data_engine:
steps = self.ctx.data_engine.get_agent_friendly_steps()
task_plan_path = get_note_file_path(self.ctx.data_engine.base_dir, "task_plan")
if task_plan_path.exists():
try:
task_plan = task_plan_path.read_text(encoding="utf-8")
except Exception as e:
task_plan = f"Error reading task plan: {e}"
plan_and_history = self._build_plan_and_history(steps, task_plan)
failed_step_number = steps[-1]["step_number"] if steps else 1
failed_action_description = (
format_action_clean(failed_action) if failed_action else "Planned Action"
)
system_prompt = Template(prompt_template).render(
initial_goal=getattr(state, "initial_goal", "Unknown Goal"),
plan_and_history=plan_and_history,
failed_step_number=failed_step_number,
failed_action_description=failed_action_description,
)
suffix = strategy.get_user_message_suffix(bool(pre_screenshot), bool(post_screenshot))
user_msg = (
suffix.strip()
if suffix
else "Please analyze and repair the failure shown in the last step of the history."
)
is_pixel_strategy = isinstance(strategy, PixelTargetDisappearedStrategy)
user_content: list[dict] = [{"type": "text", "text": user_msg}]
if pre_screenshot:
user_content.append(
{"type": "text", "text": "--- Screenshot Seen During System Decision ---"}
)
user_content.append(
{
"type": "image_url",
"image_url": {"url": f"data:image/jpeg;base64,{pre_screenshot}"},
}
)
if not is_pixel_strategy:
user_content.append(
{
"type": "text",
"text": (
"--- UI Element List Seen During System Decision ---\n"
f"{pre_xml_list_str}"
),
}
)
if post_screenshot:
user_content.append(
{"type": "text", "text": "--- Latest Screenshot (Failed State) ---"}
)
user_content.append(
{
"type": "image_url",
"image_url": {"url": f"data:image/jpeg;base64,{post_screenshot}"},
}
)
if not is_pixel_strategy:
user_content.append(
{
"type": "text",
"text": (
f"--- Latest UI Element List (Failed State) ---\n{post_xml_list_str}"
),
}
)
if suffix:
user_content.append(
{"type": "text", "text": f"\n\n--- INSTRUCTIONS ---\n{suffix.strip()}"}
)
messages: list[BaseMessage] = [
SystemMessage(content=system_prompt),
HumanMessage(content=user_content),
]
max_iterations = getattr(self, "max_iterations", 15)
iterations = 0
final_report = None
intermediate_thoughts = []
while iterations < max_iterations:
iterations += 1
self._prune_intermediate_screenshots(messages)
logger.info(f"Iteration {iterations}: Invoking Universal LLM for FailureAnalyzer...")
if iterations == max_iterations:
messages.append(
HumanMessage(
content=(
"[WARNING] This is your final iteration; only"
" 'report_failure_analysis' is available."
)
)
)
current_tools = [
t for t in tools_declaration if t.name == "report_failure_analysis"
]
else:
current_tools = tools_declaration
bound_llm = llm.bind_tools(current_tools)
max_retries = 3
response = None
for attempt in range(max_retries):
try:
response = await bound_llm.ainvoke(messages)
break
except Exception as e:
logger.warning(f"Failure analyzer turn failed on attempt {attempt + 1}: {e}")
if attempt == max_retries - 1:
raise e
await asyncio.sleep(1.0 * (2**attempt))
if response is None:
break
messages.append(response)
raw_text = response.content if isinstance(response.content, str) else ""
if isinstance(response.content, list):
raw_text = "".join(
b.get("text", "")
for b in response.content
if isinstance(b, dict) and "text" in b
)
if raw_text:
intermediate_thoughts.append(raw_text)
tool_calls = response.tool_calls or []
if not tool_calls:
messages.append(
HumanMessage(
content=(
"CRITICAL: You did not call the `report_failure_analysis` tool."
" You MUST call an action tool to repair or `report_failure_analysis`"
" to finish."
)
)
)
continue
submit_call = next(
(tc for tc in tool_calls if tc["name"].split(":")[-1] == "report_failure_analysis"),
None,
)
if submit_call:
args = submit_call.get("args") or {}
actions = args.get("new_remaining_actions", [])
final_report = {
"status": args.get("status", "cannot_fix"),
"analysis": args.get("analysis", ""),
"new_remaining_actions": actions,
"thoughts": intermediate_thoughts,
}
messages.append(
ToolMessage(
tool_call_id=submit_call.get("id") or str(uuid.uuid4()),
name=submit_call["name"],
content=json.dumps({"status": "acknowledged"}),
status="success",
)
)
break
# Execute other tool calls via MobileActionExecutor
for tc in tool_calls:
name = tc["name"].split(":")[-1] if ":" in tc["name"] else tc["name"]
args = tc.get("args") or {}
tc_id = tc.get("id") or str(uuid.uuid4())
logger.info(f"Failure analyzer executing tool '{name}'...")
try:
exec_result = await executor.execute(name, args, tc_id, state)
messages.append(exec_result.to_langchain_tool_message())
except Exception as e:
logger.error(f"Error executing tool {name}: {e}")
messages.append(
ToolMessage(
tool_call_id=tc_id,
name=name,
content=f"Error executing tool {name}: {e}",
status="error",
)
)
return final_report or {
"status": "cannot_fix",
"analysis": "Max iterations reached without resolution.",
}
+120
View File
@@ -0,0 +1,120 @@
# Copyright 2026 Google LLC
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Execution failures passed from the Validator to the Operator.
Incidents are stored in ``state.open_incident`` and the step's
``last_execution_result``. Consecutive failures increment the count; a
successful terminal action closes the incident. Prompt rendering lives in
``artemis.agents.operator.prompts.ExecutionIncidentPromptComponent``.
"""
from dataclasses import asdict, dataclass, field
import time
from typing import Any
from artemis.agents.validator.categories import ValidationErrorCategory
#: The safety net refused to execute the action (target missing/shifted/covered).
KIND_SAFETY_NET = "safety_net"
#: The action was dispatched but the device/executor reported a failure.
KIND_EXEC_ERROR = "exec_error"
#: Keys of an action item that are bulky or internal and never useful to the
#: Operator when the incident is rendered or persisted.
_ACTION_ITEM_DROP_KEYS = frozenset(
{
"attempts",
"safety_net_evidence",
"thought",
}
)
@dataclass
class ExecutionIncident:
"""One blocked turn-ending action, as reported to the Operator."""
kind: str
category: str
reason: str
action: dict[str, Any]
action_description: str
action_index: int = 0
burst_size: int = 1
step_number: int | None = None
consecutive_failures: int = 1
evidence: dict[str, Any] = field(default_factory=dict)
opened_at: float = field(default_factory=time.time)
@property
def is_burst(self) -> bool:
return self.burst_size > 1
def to_dict(self) -> dict[str, Any]:
return asdict(self)
@classmethod
def from_dict(cls, data: dict[str, Any] | None) -> "ExecutionIncident | None":
if not isinstance(data, dict) or not data.get("kind"):
return None
known = {f for f in cls.__dataclass_fields__}
# Older step ledgers may lack these fields.
kwargs: dict[str, Any] = {
"category": str(ValidationErrorCategory.GENERAL),
"reason": "",
"action": {},
"action_description": "",
}
kwargs.update({k: v for k, v in data.items() if k in known})
return cls(**kwargs)
def sanitize_action_item(action_item: dict[str, Any]) -> dict[str, Any]:
"""A copy of the action item without bulky/internal keys."""
return {k: v for k, v in (action_item or {}).items() if k not in _ACTION_ITEM_DROP_KEYS}
def open_incident(
*,
previous: dict[str, Any] | None,
kind: str,
category: ValidationErrorCategory | str,
reason: str,
action_item: dict[str, Any],
action_description: str,
action_index: int = 0,
burst_size: int = 1,
step_number: int | None = None,
evidence: dict[str, Any] | None = None,
) -> dict[str, Any]:
"""Builds the incident dict for a failed action.
Continue the failure count from ``previous`` if an incident is still open.
"""
prior = ExecutionIncident.from_dict(previous)
consecutive = (prior.consecutive_failures + 1) if prior else 1
incident = ExecutionIncident(
kind=kind,
category=str(category.value if isinstance(category, ValidationErrorCategory) else category),
reason=(reason or "").strip(),
action=sanitize_action_item(action_item),
action_description=action_description,
action_index=action_index,
burst_size=burst_size,
step_number=step_number,
consecutive_failures=consecutive,
evidence=dict(evidence or {}),
)
return incident.to_dict()
+1 -1
View File
@@ -4,7 +4,7 @@ UI State Verification Expert. Compare Image 1 (Reference) and Image 2 (Current S
Rules
1. Check Intent: Read the `[Planned Action & Original Thinking]` to identify what Action the Agent intended to perform and why.
2. Specific UI Elements (Priority): If targeting a specific UI control (e.g., button, icon, text, slider handle) — even if it overlays a dynamic video, map, or live stream — focus ONLY on that element's shape. Output `{"is_present": false, ...}` if that exact element has disappeared, shifted away, or is blocked. Ignore background media changes underneath.
3. Blank Space & Backgrounds: If targeting open/blank space (e.g., to wake up hidden controls or dismiss dialogs), ignore natural video/background frame changes. Output `{"is_present": true, "confidence": 1.0}` unless unexpected blocking popups appear.
3. Blank Space, Backgrounds & Media Surfaces: If targeting open/blank space or the body of a playing video / animation / map / live stream itself rather than a specific control drawn on top of it (e.g., tapping a full-screen video to wake its hidden controls, tapping the backdrop to dismiss a dialog), the frames are expected to differ: ignore natural video/background frame changes and the appearance or fading of overlay controls. Output `{"is_present": true, "confidence": 1.0}` unless an unexpected blocking popup now covers the tap point.
Output
Return EXACTLY this raw JSON and nothing else. Keep reasoning strictly under 25 words.
@@ -1,65 +0,0 @@
# Identity & Goal
You are a specialized error recovery agent. You will be provided with a failed action. Your ONLY task is to repair this specific failure and restore the system state so the main system can resume.
# Latency Awareness Principle
You operate in an environment with unavoidable "Turn Latency" (a delay of several seconds between screen capture, decision making, and tool execution).
Consequently, any transient UI element (e.g., auto-dismissing overlays, temporary message bars, menus with short timeouts) WILL disappear before your next decision turn if you attempt to interact with them step-by-step.
# Context
- **Initial Goal**: {{ initial_goal }}
- **History & Plan**:
{{ plan_and_history }}
# Core Workflow
1. **Analyze**: Compare the two states provided to you:
- **Decision State (`Screenshot Seen During System Decision`)**: This is the screen the system saw when it originally planned the action. It shows the target element successfully located.
- **Failed State (`Latest Screenshot (Failed State)`)**: This is the live screen captured at the moment you were summoned. It shows the current state where the target is missing or unresponsive.
Your goal in this comparison is to:
- Read the Operator's thought process in the decision loop of the failed step to understand what they were originally trying to accomplish with this failed action.
- Understand the expected target layout.
- Identify the specific visual pattern (e.g., a play triangle, a close 'X', an arrow icon) at the failed target area from the reference state.
- Identify why this target element visually disappeared or is no longer responsive (use the **Diagnosis Tips** below).
2. **Recover & Execute**: Restore the target state and execute the failed action.
- **Flexibility**: Focus on outcome over form. Take alternative/adaptive steps (e.g., scroll, navigate, different click paths) if original target positions are invalid.
- **Transient State Handling**: If the failed target is part of a transient state, you MUST NOT execute the recovery and target interaction in separate turns.
- **Atomic Chained Execution**: Combine the summon action (on the Trigger Element) and the target action (on the Transient Target) into a single atomic sequence. Use `click_sequence(sequence=[Trigger, Target])` to execute them with a 50ms delay, bypassing the Turn Latency gap.
3. **Report**: Call `report_failure_analysis` to conclude:
- `status="fixed"`: Successfully executed the failed action or reached the destination state.
- `status="cannot_fix"`: Target is unrecoverable, the app is broken, or the 15-step budget is exceeded.
- `analysis`: Summarize visual mismatches and actions taken.
# Special Tools
- **`click_sequence(sequence)`**: Designed to defeat Turn Latency. Chain your click sequence to handle transient elements.
- **`ask_explorer(query, context_feedback)`**: Call this visual parsing subagent to search for the visual icon/pattern on the current screen (e.g., `query="play icon"`) if you suspect the target has shifted, or coordinates are ambiguous.
# Diagnosis Tips
* **Time-Sensitive States (MOST COMMON & PRIMARY CAUSE)**:
- *Cue*: The target element was visible when the system made its decision, but has disappeared in the failed state because it belongs to an auto-closing, short-timeout, or transient UI state.
- *Action*: Re-trigger the state dynamically from the current screen and chain it with the failed step in a rapid consecutive sequence.
- **Mental Model**: Tap the persistent summoner element (Trigger) that wakes up the transient UI + Tap the target coordinates (Failed Target) before it has time to auto-close, combining both into a single atomic movement.
- **Procedure**:
1. **Locate the Trigger (Target 1)**: Inspect the current screen to identify a persistent parent element or background area (e.g., the container element or parent dropdown header) that, when tapped, forces the transient UI state to reappear.
2. **Retrieve the Failed Target (Target 2)**: Look up the coordinates of the FAILED action from the failed step in the history (since the target button is currently invisible on your screen).
3. **Chained Execution**: Call `click_sequence(sequence=[Target 1, Target 2])` to execute both taps consecutively with a 50ms delay, catching the transient element before the timeout closes it. *(Note: This sequence fulfills your workflow requirement to "execute the failed action".)*
* **False Alarm**:
- *Cue*: Target element is actually present on the current screen (minor validation flake).
- *Action*: Directly report it as `"fixed"` and hand control back to the system.
* **Visual Target Shifts**:
- *Cue*: The target visual icon has shifted due to screen layout changes, orientations, or dynamic list updates.
- *Action*: Visually locate the icon on the live screenshot and click its new coordinates. You may call `ask_explorer(query="describe the visual icon shape or labels")` to dynamically locate it and obtain the new coordinates if they are hard to estimate manually.
* **Overlays**:
- *Cue*: Unexpected permission prompts, dialogs, or system popups block the screen.
- *Action*: Tap outside or click the dismiss/close buttons to clear the overlay.
* **Keyboard & Scroll Positioning**:
- *Cue*: Target is scrolled off-screen or blocked by the on-screen keyboard.
- *Action*: Scroll to reposition the target, or hide the keyboard.
* **Navigation Drift**:
- *Cue*: App unexpectedly transitioned to a different tab or screen.
- *Action*: Navigate back to the correct target screen.
* **App Instability**:
- *Cue*: Application crashed, froze, or became unresponsive.
- *Action*: Force stop and relaunch the app.
# Constraints
The most recent step (Step {{ failed_step_number }}: {{ failed_action_description }}) failed to execute. You must resolve the issue and report the results in 15 interactive steps or fewer. Now please begin the repair analysis. Please note that during the **Recover & Execute** phase, you only need to resolve the currently failed step; do not attempt to execute any subsequent steps or complete the overall goal yourself. Once the failure is resolved, please immediately enter the **Report** phase and return control to the system.
@@ -30,7 +30,7 @@ from pathlib import Path
from langchain_core.messages import HumanMessage, SystemMessage
from artemis.agents.validator.failure_analyzer import ValidationErrorCategory
from artemis.agents.validator.categories import ValidationErrorCategory
from artemis.context import ArtemisContext
from artemis.graph.state import State
from artemis.services.llm import acomplete_structured
@@ -191,8 +191,7 @@ async def _run_attempt(
)
logger.warning(
"Pixel validation failed but confidence"
f" ({confidence:.2f}) is below 0.7. Bypassing check."
f"Pixel validation failed but confidence ({confidence:.2f}) is below 0.7. Bypassing check."
)
return True, ValidationErrorCategory.PIXEL_BYPASSED, ""
@@ -237,18 +236,14 @@ async def validate_action_precondition_pixel(
orig_bytes, orig_coords, crop_size=None, dot_radius=15
)
except Exception as e:
logger.error(
f"Failed to prepare original crop for pixel safety net: {e}. Bypassing check."
)
logger.error(f"Failed to prepare original crop for pixel safety net: {e}. Bypassing check.")
return True, ValidationErrorCategory.PIXEL_BYPASSED, ""
# 3. Get LLM Configuration once outside the loop
try:
llm, prompt = _init_llm_and_prompt(ctx, get_llm_fn)
except Exception as e:
logger.error(
f"Failed to initialize LLM/prompt for pixel safety net: {e}. Bypassing check."
)
logger.error(f"Failed to initialize LLM/prompt for pixel safety net: {e}. Bypassing check.")
return True, ValidationErrorCategory.PIXEL_BYPASSED, ""
# 4. Start high-precision execution validation retry loop
+14 -9
View File
@@ -17,7 +17,7 @@
Checks that the element the Operator decided to interact with is still present
on the live screen (via the UI hierarchy) and substantially consistent, with
coordinate self-healing for small drifts and a structured failure taxonomy
(shifted / occupied / disappeared) consumed by the FailureAnalyzer.
(shifted / occupied / disappeared) surfaced to the Operator as an execution incident.
Extracted from ``validator.py``; the public entry points remain the
``ValidatorNode`` methods, which delegate here.
@@ -28,7 +28,7 @@ import difflib
import math
import re
from artemis.agents.validator.failure_analyzer import ValidationErrorCategory
from artemis.agents.validator.categories import ValidationErrorCategory
from artemis.constants import VALIDATOR_UI_HIERARCHY_TIMEOUT
from artemis.context import ArtemisContext
from artemis.graph.state import State
@@ -125,8 +125,7 @@ async def _fetch_live_elements(session):
elements = await session.ui_hierarchy(timeout=VALIDATOR_UI_HIERARCHY_TIMEOUT)
except Exception as e:
logger.warning(
f"Failed to get live XML via MCP: {e!r}. Falling back to"
" Pixel-based validation."
f"Failed to get live XML via MCP: {e!r}. Falling back to Pixel-based validation."
)
return None, (
False,
@@ -146,8 +145,7 @@ async def _fetch_live_elements(session):
except Exception as e:
logger.error(
f"Unexpected error during live XML fetch: {e}. Falling back to"
" Pixel-based validation."
f"Unexpected error during live XML fetch: {e}. Falling back to Pixel-based validation."
)
return None, (
False,
@@ -278,9 +276,7 @@ def _combine_signals(
signals.append(W_COORD * (1.0 if contains_coord else 0.0))
score = sum(signals) / sum(weights) if weights else 1.0
identity_score = (
sum(identity_signals) / sum(identity_weights) if identity_weights else 1.0
)
identity_score = sum(identity_signals) / sum(identity_weights) if identity_weights else 1.0
return score, identity_score
@@ -554,6 +550,11 @@ def _classify_failure(
f" has shifted.\n- New location: {best['center']} (bounds:"
f" {best['bounds']})"
)
# Structured facts for the execution incident handed to the Operator.
action_item["safety_net_evidence"] = {
"new_center": list(best["center"]),
"new_bounds": best.get("bounds"),
}
return False, ValidationErrorCategory.TARGET_SHIFTED, reason
# 2. Occupied Case
@@ -568,6 +569,10 @@ def _classify_failure(
" occupied/intercepted by a different element:"
f" {occupant_desc} at bounds {occupant_bounds}."
)
action_item["safety_net_evidence"] = {
"occupant": occupant_desc,
"occupant_bounds": occupant_bounds,
}
return False, ValidationErrorCategory.TARGET_OCCUPIED, reason
# 3. Completely Missing Case
@@ -1,61 +0,0 @@
# Identity & Goal
You are a specialized error recovery agent. You will be provided with a failed action. Your ONLY task is to repair this specific failure and restore the system state so the main system can resume.
# Latency Awareness Principle
You operate in an environment with unavoidable "Turn Latency" (a delay of several seconds between screen capture, decision making, and tool execution).
Consequently, any transient UI element (e.g., auto-dismissing overlays, temporary message bars, menus with short timeouts) WILL disappear before your next decision turn if you attempt to interact with them step-by-step.
# Context
- **Initial Goal**: {{ initial_goal }}
- **History & Plan**:
{{ plan_and_history }}
# Core Workflow
1. **Analyze**: Compare the two states provided to you:
- **Decision State (`Screenshot Seen During System Decision`)**: This is the screen the system saw when it originally planned the action. It shows the target element successfully located.
- **Failed State (`Latest Screenshot (Failed State)`)**: This is the live screen captured at the moment you were summoned. It shows the current state where the target is missing or unresponsive.
Your goal in this comparison is to:
- Read the Operator's thought process in the decision loop of the failed step to understand what they were originally trying to accomplish with this failed action.
- Understand the expected target layout.
- Identify why the target element disappeared (use the **Diagnosis Tips** below).
2. **Recover & Execute**: Restore the target state and execute the failed action.
- **Flexibility**: Focus on outcome over form. Take alternative/adaptive steps (e.g., scroll, navigate, different click paths) if original coordinates are invalid.
- **Transient State Handling**: If the failed target is part of a transient state, you MUST NOT execute the recovery and target interaction in separate turns.
- **Atomic Chained Execution**: Combine the summon action (on the Trigger Element) and the target action (on the Transient Target) into a single atomic sequence. Use `click_sequence(sequence=[Trigger, Target])` to execute them with a 50ms delay, bypassing the Turn Latency gap.
3. **Report**: Call `report_failure_analysis` to conclude:
- `status="fixed"`: Successfully executed the failed action or reached the destination state.
- `status="cannot_fix"`: Target is unrecoverable, the app is broken, or the 15-step budget is exceeded.
- `analysis`: Summarize visual mismatches and actions taken.
# Special Tools
- **`click_sequence(sequence)`**: Designed to defeat Turn Latency. Chain your click sequence to handle transient elements.
- **`ask_explorer(query, context_feedback)`**: Call this visual parsing subagent to search for the visual icon/pattern on the current screen if the target is absent from the standard "UI Element List" but you see it is visually present on the screenshot.
# Diagnosis Tips
* **Time-Sensitive States (MOST COMMON & PRIMARY CAUSE)**:
- *Cue*: The target element was visible when the system made its decision, but has disappeared in the failed state because it belongs to an auto-closing, short-timeout, or transient UI state.
- *Action*: Re-trigger the state dynamically from the current screen and chain it with the failed step in a rapid consecutive sequence.
- **Mental Model**: Tap the persistent summoner element (Trigger) that wakes up the transient UI + Tap the target coordinates (Failed Target) before it has time to auto-close, combining both into a single atomic movement.
- **Procedure**:
1. **Locate the Trigger (Target 1)**: Inspect the current screen to identify a persistent parent element or background area (e.g., the container element or parent dropdown header) that, when tapped, forces the transient UI state to reappear.
2. **Retrieve the Failed Target (Target 2)**: Look up the coordinates of the FAILED action from the failed step in the history (since the target button is currently invisible on your screen).
3. **Chained Execution**: Call `click_sequence(sequence=[Target 1, Target 2])` to execute both taps consecutively with a 50ms delay, catching the transient element before the timeout closes it. *(Note: This sequence fulfills your workflow requirement to "execute the failed action".)*
* **False Alarm**:
- *Cue*: Target element is actually present on the current screen (minor validation flake).
- *Action*: Directly report it as `"fixed"` and hand control back to the system.
* **Overlays**:
- *Cue*: Unexpected permission prompts, dialogs, or system popups block the screen.
- *Action*: Tap outside or click the dismiss/close buttons to clear the overlay.
* **Keyboard & Scroll Positioning**:
- *Cue*: Target is scrolled off-screen or blocked by the on-screen keyboard.
- *Action*: Scroll to reposition the target, or hide the keyboard.
* **Navigation Drift**:
- *Cue*: App unexpectedly transitioned to a different tab or screen.
- *Action*: Navigate back to the correct target screen.
* **App Instability**:
- *Cue*: Application crashed, froze, or became unresponsive.
- *Action*: Force stop and relaunch the app.
# Constraints
The most recent step (Step {{ failed_step_number }}: {{ failed_action_description }}) failed to execute. You must resolve the issue and report the results in 15 interactive steps or fewer. Now please begin the repair analysis. Please note that during the **Recover & Execute** phase, you only need to resolve the currently failed step; do not attempt to execute any subsequent steps or complete the overall goal yourself. Once the failure is resolved, please immediately enter the **Report** phase and return control to the system.
+59 -72
View File
@@ -25,7 +25,12 @@ from google.genai import types as genai_types
from langchain_core.messages import ToolMessage
from pydantic import BaseModel, Field
from artemis.config import resolve_explorer_version
from artemis.agents.explorer.constants import (
ASK_EXPLORER_CONTEXT_FEEDBACK_DESCRIPTION,
ASK_EXPLORER_DESCRIPTION,
ASK_EXPLORER_QUERY_DESCRIPTION,
ASK_EXPLORER_TOOL_NAME,
)
from artemis.context import ArtemisContext
from artemis.controllers.unified_controller import UnifiedMobileController
from artemis.core.tool_declaration import ToolDeclaration
@@ -34,7 +39,7 @@ from artemis.mcp.action_specs import tool_declaration
from artemis.mcp.action_types import ActionCode
from artemis.mcp.actuators.adb import AdbActuator
from artemis.mcp.observation import observe
from artemis.tools.explorer_tool import _run_explorer_logic
from artemis.tools.explorer_tool import locate, register_candidates, render_text
from artemis.graph.state import State
from artemis.utils.coordinates import (
compute_smart_swipe_coordinates,
@@ -143,8 +148,13 @@ def prune_intermediate_screenshots(messages: list[Any]) -> None:
)
)
)
except Exception:
pass
except Exception as exc:
logger.debug(
"Could not build placeholder text part for pruned screenshot;"
" dropping it: %s",
exc,
exc_info=True,
)
else:
new_parts.append(part)
content.parts = new_parts
@@ -264,6 +274,14 @@ def serialize_mobile_action_result(result: Any) -> dict[str, Any]:
}
def serialize_explorer_result(result: Any) -> dict[str, Any]:
"""Stores an ``(text, ok)`` Explorer answer as a flat trace payload."""
if isinstance(result, tuple) and len(result) == 2:
text, ok = result
return {"outcome": text, "status": "success" if ok else "error"}
return {"outcome": str(result) if result is not None else ""}
class MobileActionExecutor:
"""Adapts LLM tool calls onto an actuator backend and captures observations.
@@ -278,10 +296,14 @@ class MobileActionExecutor:
ctx: ArtemisContext,
controller: UnifiedMobileController | None = None,
actuator: "AdbActuator | None" = None,
agent_name: str = "validator",
):
self.ctx = ctx
self.actuator = actuator or AdbActuator(ctx, controller)
self.controller = self.actuator.controller
# Identifies the calling agent to the Explorer tier resolver: the tier
# is a user setting keyed by profile / agent, never chosen by the agent.
self.agent_name = agent_name
@trace(type="action", name="click", serializer=serialize_mobile_action_result)
async def exec_click(
@@ -327,7 +349,7 @@ class MobileActionExecutor:
except Exception:
try:
sequence = ast.literal_eval(sequence_str)
except Exception:
except (ValueError, TypeError, SyntaxError, MemoryError, RecursionError):
pass
if not isinstance(sequence, (list, tuple)):
@@ -622,37 +644,27 @@ class MobileActionExecutor:
except Exception as e:
return format_list_notes_failure(str(e))
@trace(type="tool", name="ask_explorer")
@trace(type="tool", name="ask_explorer", serializer=serialize_explorer_result)
async def exec_ask_explorer(
self,
query: str,
context_feedback: str | None,
state: State,
version: str | None = None,
) -> str:
) -> tuple[str, bool]:
"""Runs the Explorer pipeline; returns ``(text, ok)``.
``ok`` is False only when the Explorer run itself failed. A clean
"not found" is a successful answer the agent must reason about, so it
must not be reported as a tool error.
"""
try:
active_version = resolve_explorer_version(
self.ctx,
explicit_version=version,
agent_or_profile_name="validator",
outcome = await locate(
self.ctx, state, query, context_feedback or "", agent_name=self.agent_name
)
result = await _run_explorer_logic(
self.ctx,
state,
query,
context_feedback or "",
version=active_version,
)
if isinstance(result, list):
texts = [
item["text"]
for item in result
if isinstance(item, dict) and item.get("type") == "text"
]
return "\n".join(texts) if texts else str(result)
return str(result)
registered = register_candidates(self.ctx, state, outcome)
return render_text(query, outcome, registered), not outcome.error
except Exception as e:
return f"Error executing ask_explorer: {e}"
return f"Error executing ask_explorer: {e}", False
async def execute(
self,
@@ -665,6 +677,9 @@ class MobileActionExecutor:
raw_name = name.split(":")[-1] if ":" in name else name
res_text = ""
img_bytes, shot_path, xml_list = None, None, None
# Explicit status for tools that report it (the Explorer); None keeps the
# historical text-sniffing fallback for the device actions.
explicit_ok: bool | None = None
if raw_name == "click":
res_text, img_bytes, shot_path, xml_list = await self.exec_click(
@@ -724,7 +739,9 @@ class MobileActionExecutor:
elif raw_name == "list_notes":
res_text = await self.exec_list_notes()
elif raw_name == "ask_explorer":
res_text = await self.exec_ask_explorer(
res_text, explicit_ok = await self.exec_ask_explorer(
# ``task_description`` is the pre-contract argument name kept
# for old MCP clients.
args.get("query") or args.get("task_description") or "",
args.get("context_feedback"),
state,
@@ -738,7 +755,9 @@ class MobileActionExecutor:
status: Literal["success", "error"] = (
"success" if (img_bytes is not None or raw_name not in ACTION_TOOL_NAMES) else "error"
)
if "Error" in res_text or "Failed" in res_text:
if explicit_ok is not None:
status = "success" if explicit_ok else "error"
elif "Error" in res_text or "Failed" in res_text:
status = "error"
return ToolExecutionResult(
@@ -799,34 +818,6 @@ MANAGE_APP_TOOL = tool_declaration("manage_app")
WAIT_FOR_DELAY_TOOL = tool_declaration("wait_for_delay")
REPORT_FAILURE_ANALYSIS_TOOL = ToolDeclaration(
name="report_failure_analysis",
description=(
"[REPORT] Use this tool to return your final answer when you are done"
" with the analysis and optional repair. This is the ONLY way to return"
" your final conclusion."
),
parameters={
"type": "object",
"properties": {
"status": {
"type": "string",
"description": (
"Whether the failure was repaired locally ('fixed') or not ('cannot_fix')."
),
},
"analysis": {
"type": "string",
"description": (
"Provide the cause analysis and handling method description in a single"
" paragraph, and append the specific operations you ran."
),
},
},
"required": ["status", "analysis"],
},
)
REPORT_TASK_STATUS_TOOL = ToolDeclaration(
name="report_task_status",
description=(
@@ -852,27 +843,24 @@ REPORT_TASK_STATUS_TOOL = ToolDeclaration(
},
)
# Same contract as the Operator's LangChain tool (artemis/tools/explorer_tool.py):
# the Explorer tier is a user setting, so no tier argument is exposed here.
ASK_EXPLORER_TOOL = ToolDeclaration(
name="ask_explorer",
description=(
"[EXPLORER] Call the Explorer sub-agent to locate an element or"
" coordinate on the screen when XML hierarchy lacks coordinates or"
" visual target is ambiguous."
),
name=ASK_EXPLORER_TOOL_NAME,
description=ASK_EXPLORER_DESCRIPTION,
parameters={
"type": "object",
"properties": {
"task_description": {
"query": {
"type": "string",
"description": "The description of what to find on the screen.",
"description": ASK_EXPLORER_QUERY_DESCRIPTION,
},
"roi": {
"type": "array",
"items": {"type": "integer"},
"description": "Optional bounding box [ymin, xmin, ymax, xmax] in 0-1000 scale.",
"context_feedback": {
"type": "string",
"description": ASK_EXPLORER_CONTEXT_FEEDBACK_DESCRIPTION,
},
},
"required": ["task_description"],
"required": ["query"],
},
)
@@ -886,5 +874,4 @@ VALIDATOR_TOOLS_DECLARATION: list[ToolDeclaration] = [
LIST_NOTES_TOOL,
MANAGE_APP_TOOL,
WAIT_FOR_DELAY_TOOL,
REPORT_FAILURE_ANALYSIS_TOOL,
]
+8 -6
View File
@@ -40,10 +40,7 @@ from artemis.agents.validator import (
precondition_pixel,
precondition_xml,
)
from artemis.agents.validator.failure_analyzer import (
FailureAnalyzer, # noqa: F401 (re-exported for backward compatibility)
ValidationErrorCategory,
)
from artemis.agents.validator.categories import ValidationErrorCategory
from artemis.constants import ( # noqa: F401 (patchable seams; the UI-change
# polling path that consumed the poll constants was removed as dead code,
# but the names stay importable/patchable at this module path.)
@@ -137,8 +134,13 @@ class ValidatorNode:
"traceback": err_stack,
},
)
except Exception:
pass
except Exception as record_err:
logger.debug(
"Could not persist validator error status for step %s: %s",
step_id,
record_err,
exc_info=True,
)
raise e
finally:
if self.ctx.data_engine:
+12 -45
View File
@@ -83,9 +83,7 @@ async def exec_analyze_audio_only(
specific_query,
modality="audio",
model_name=analyzer.model_name,
source_generation=(
active_session.generation if active_session is not None else None
),
source_generation=(active_session.generation if active_session is not None else None),
)
if claim.state == "cached":
result = (
@@ -225,8 +223,7 @@ async def _finish_failed_audio(
):
try:
logger.warning(
"Native audio analysis failed"
f" [{failure.category.value}]; using universal fallback"
f"Native audio analysis failed [{failure.category.value}]; using universal fallback"
)
_va._record_llm_event(
"llm_fallback",
@@ -311,25 +308,17 @@ def _build_audio_prompt(
warnings = analyzer.get_overlapping_warnings(start_time, end_time)
warning_block = ""
if warnings:
lines = [
"WARNING: The following queries were already tried in this timeframe:\n"
]
lines = ["WARNING: The following queries were already tried in this timeframe:\n"]
for w in warnings:
lines.append(f"Searched for: {w['target']} -> {w['summary']}")
warning_block = "\n".join(lines) + "\n\n"
duration_secs = getattr(result, "duration_seconds", None)
actual_end = (
actual_start + duration_secs
if isinstance(duration_secs, (int, float))
else None
)
actual_end = actual_start + duration_secs if isinstance(duration_secs, (int, float)) else None
end_str = f" to {actual_end:.1f}s" if actual_end is not None else ""
warning_val = getattr(result, "warning", None)
truncation_note = (
f" (NOTE: {warning_val})"
if isinstance(warning_val, str) and warning_val
else ""
f" (NOTE: {warning_val})" if isinstance(warning_val, str) and warning_val else ""
)
prompt_with_context = (
@@ -377,9 +366,7 @@ async def _run_native_audio_conversation(
with TraceSpan(name="upload_audio_to_gemini") as span:
file = await analyzer.upload_and_poll_file(media.audio_path)
span.result = f"Uploaded {file.name}"
logger.info(
f"Invoking Gemini for audio-only task with model {analyzer.model_name}..."
)
logger.info(f"Invoking Gemini for audio-only task with model {analyzer.model_name}...")
sub_agent_contents = [
types.Content(
@@ -439,9 +426,7 @@ async def _run_native_audio_conversation(
logger.error(f"Failed to delete cloud file {file.name}: {ce}")
async def _drive_audio_agent_loop(
analyzer, sub_agent_contents: list
) -> tuple[str, str, float]:
async def _drive_audio_agent_loop(analyzer, sub_agent_contents: list) -> tuple[str, str, float]:
"""Iterates the audio agent conversation until submit_answer is accepted."""
sub_max_iterations = 2
sub_iterations = 0
@@ -475,11 +460,7 @@ async def _drive_audio_agent_loop(
contents=sub_agent_contents,
config=types.GenerateContentConfig(
system_instruction=analyzer.audio_system_prompt,
tools=[
types.Tool(
function_declarations=[analyzer.submit_answer_tool]
)
],
tools=[types.Tool(function_declarations=[analyzer.submit_answer_tool])],
safety_settings=SAFETY_SETTINGS_BLOCK_NONE,
),
),
@@ -528,9 +509,7 @@ def _handle_audio_function_calls(
"""Processes audio-agent function calls; returns (answered, summary, analysis, score)."""
answered = False
for fc in function_calls:
if (
fc.name.split(":")[-1] if ":" in fc.name else fc.name
) == "submit_answer":
if (fc.name.split(":")[-1] if ":" in fc.name else fc.name) == "submit_answer":
args = fc.args
if (
"confidence_score" not in args
@@ -567,15 +546,9 @@ def _handle_audio_function_calls(
answered = "error"
break
final_summary = (
args.get("summary", "No summary provided.")
.strip()
.replace("\n", " ")
)
final_summary = args.get("summary", "No summary provided.").strip().replace("\n", " ")
final_analysis = (
args.get("analysis", "No analysis provided.")
.strip()
.replace("\n", " ")
args.get("analysis", "No analysis provided.").strip().replace("\n", " ")
)
final_confidence_score = float(args["confidence_score"])
answered = True
@@ -596,13 +569,7 @@ def _handle_audio_function_calls(
parts=[
types.Part.from_function_response(
name=function_calls[0].name,
response={
"error": (
"Tool not recognized."
" Please use"
" submit_answer."
)
},
response={"error": ("Tool not recognized. Please use submit_answer.")},
)
],
)
@@ -64,8 +64,7 @@ async def _run_native_chunk_conversation(
file = await analyzer.upload_and_poll_file(media.compressed_path)
span.result = f"Uploaded {file.name}"
logger.info(
"Invoking Gemini for sub-agent task with model"
f" {analyzer.model_name} (streaming)..."
f"Invoking Gemini for sub-agent task with model {analyzer.model_name} (streaming)..."
)
trace_id = CURRENT_TRACE_ID.get()
@@ -209,11 +208,7 @@ async def _stream_sub_agent_turn(
contents=sub_agent_contents,
config=types.GenerateContentConfig(
system_instruction=analyzer.sub_system_prompt,
tools=[
types.Tool(
function_declarations=[analyzer.submit_answer_tool]
)
],
tools=[types.Tool(function_declarations=[analyzer.submit_answer_tool])],
safety_settings=SAFETY_SETTINGS_BLOCK_NONE,
),
),
@@ -256,18 +251,12 @@ async def _read_sub_agent_stream(analyzer, span, target_stream, current_trace_id
if chunk_text:
text += chunk_text
if analyzer.ctx.data_engine and current_trace_id:
analyzer.ctx.data_engine.stream_output(
current_trace_id, chunk_text
)
analyzer.ctx.data_engine.stream_output(current_trace_id, chunk_text)
if chunk.function_calls:
function_calls.extend(chunk.function_calls)
candidates = getattr(chunk, "candidates", None)
if (
candidates
and isinstance(candidates, list)
and len(candidates) > 0
):
if candidates and isinstance(candidates, list) and len(candidates) > 0:
content = getattr(candidates[0], "content", None)
parts = getattr(content, "parts", None) if content else None
if parts:
@@ -304,20 +293,12 @@ async def _handle_sub_agent_calls(
"""Processes function calls from one turn; returns (answered, summary, analysis)."""
answered = False
for fc in function_calls:
if (
fc.name.split(":")[-1] if ":" in fc.name else fc.name
) == "submit_answer":
if (fc.name.split(":")[-1] if ":" in fc.name else fc.name) == "submit_answer":
args = fc.args
timeline_events = args.get("timeline_events", [])
final_summary = (
args.get("summary", "No summary provided.")
.strip()
.replace("\n", " ")
)
final_summary = args.get("summary", "No summary provided.").strip().replace("\n", " ")
final_analysis = (
args.get("analysis", "No analysis provided.")
.strip()
.replace("\n", " ")
args.get("analysis", "No analysis provided.").strip().replace("\n", " ")
)
error_msg = _validate_timeline_events(timeline_events, start_time, end_time)
@@ -349,11 +330,7 @@ async def _handle_sub_agent_calls(
function_calls,
full_text,
function_calls[0].name,
(
"Tool not recognized."
" Please use"
" submit_answer."
),
("Tool not recognized. Please use submit_answer."),
)
return answered, final_summary, final_analysis
@@ -388,9 +365,7 @@ def _validate_timeline_events(
event["verification_timestamp_secs"],
(int, float),
)
or isinstance(
event["verification_timestamp_secs"], bool
)
or isinstance(event["verification_timestamp_secs"], bool)
):
return (
"Error:"
@@ -409,9 +384,7 @@ def _validate_timeline_events(
)
confidence = float(event.get("confidence_score", 0.0)) # noqa: F841
if verification_ts < start_time or (
end_time is not None and verification_ts > end_time
):
if verification_ts < start_time or (end_time is not None and verification_ts > end_time):
return (
"Error:"
" 'verification_timestamp_secs'"
@@ -439,15 +412,8 @@ def _append_rejection(
role="model",
parts=original_parts
if original_parts
else [
types.Part(function_call=fc)
for fc in function_calls
]
+ (
[types.Part.from_text(text=full_text)]
if full_text
else []
),
else [types.Part(function_call=fc) for fc in function_calls]
+ ([types.Part.from_text(text=full_text)] if full_text else []),
)
)
sub_agent_contents.append(
+8 -27
View File
@@ -45,9 +45,7 @@ async def exec_single_chunk(
current_start = round(start_time, 1) if isinstance(start_time, (int, float)) else start_time
current_end = round(end_time, 1) if isinstance(end_time, (int, float)) else end_time
if not isinstance(current_start, (int, float)) or not isinstance(
current_end, (int, float)
):
if not isinstance(current_start, (int, float)) or not isinstance(current_end, (int, float)):
raise ValueError("Child video analysis requires a closed numeric interval")
device_id = getattr(getattr(analyzer.ctx, "device", None), "device_id", None)
@@ -57,9 +55,7 @@ async def exec_single_chunk(
current_end,
specific_query,
model_name=analyzer.model_name,
source_generation=(
active_session.generation if active_session is not None else None
),
source_generation=(active_session.generation if active_session is not None else None),
)
if claim.state == "cached":
cached = (
@@ -213,9 +209,7 @@ async def _finish_failed_chunk(
and media.prompt_with_context is not None
):
try:
logger.warning(
"Native video chunk exhausted retries; using universal fallback"
)
logger.warning("Native video chunk exhausted retries; using universal fallback")
_va._record_llm_event(
"llm_fallback",
{
@@ -281,10 +275,7 @@ async def _prepare_chunk_media(
media.actual_start = actual_start
slowdown_factor = 1.0
if (
current_end is not None
and (current_end - current_start) <= _va.SLOWDOWN_THRESHOLD_SECONDS
):
if current_end is not None and (current_end - current_start) <= _va.SLOWDOWN_THRESHOLD_SECONDS:
slowdown_factor = _va.SLOWDOWN_FACTOR
async with _va.TRANSCODE_SEMAPHORE:
@@ -319,32 +310,22 @@ def _build_chunk_prompt(
warnings = analyzer.get_overlapping_warnings(start_time, end_time)
warning_block = ""
if warnings:
lines = [
"WARNING: The following queries were already tried in this timeframe:\n"
]
lines = ["WARNING: The following queries were already tried in this timeframe:\n"]
for w in warnings:
lines.append(f"Searched for: {w['target']} -> {w['summary']}")
warning_block = "\n".join(lines) + "\n\n"
duration_secs = getattr(result, "duration_seconds", None)
actual_end = (
actual_start + duration_secs
if isinstance(duration_secs, (int, float))
else None
)
actual_end = actual_start + duration_secs if isinstance(duration_secs, (int, float)) else None
end_str = f" to {actual_end:.1f}s" if actual_end is not None else ""
warning_val = getattr(result, "warning", None)
truncation_note = (
f" (NOTE: {warning_val})"
if isinstance(warning_val, str) and warning_val
else ""
f" (NOTE: {warning_val})" if isinstance(warning_val, str) and warning_val else ""
)
slowdown_note = ""
if slowdown_factor != 1.0:
slowdown_note = (
"WARNING: This video is slowed down to capture fast micro-actions.\n\n"
)
slowdown_note = "WARNING: This video is slowed down to capture fast micro-actions.\n\n"
prompt_with_context = (
"IMPORTANT CONTEXT: This video segment corresponds to"
@@ -97,9 +97,7 @@ async def cleanup_abandoned_gemini_files(client) -> None:
logger.warning(f"Routine cloud maintenance skipped: {e}")
async def upload_and_poll_file(
client, compressed_path: Path, cloud_files_to_cleanup: set
) -> any:
async def upload_and_poll_file(client, compressed_path: Path, cloud_files_to_cleanup: set) -> any:
"""Upload a media file to the Gemini File API and poll until it is ACTIVE."""
logger.info(f"Uploading {compressed_path} to Gemini File API...")
file_size_mb = compressed_path.stat().st_size / (1024 * 1024)
@@ -120,9 +118,7 @@ async def upload_and_poll_file(
while True:
try:
f_state = await asyncio.wait_for(
client.aio.files.get(name=file.name), timeout=20
)
f_state = await asyncio.wait_for(client.aio.files.get(name=file.name), timeout=20)
retry_count = 0
except Exception as poll_error:
retry_count += 1
@@ -47,11 +47,7 @@ class _DummyResponse:
types.Candidate(
content=types.Content(
role="model",
parts=parts
if parts
else (
[types.Part.from_text(text=text)] if text else []
),
parts=parts if parts else ([types.Part.from_text(text=text)] if text else []),
)
)
]
@@ -240,11 +236,7 @@ def _record_main_llm_trace(
for c in contents:
if hasattr(c, "model_dump"):
dumped = c.model_dump()
if (
isinstance(dumped, dict)
and "parts" in dumped
and dumped["parts"]
):
if isinstance(dumped, dict) and "parts" in dumped and dumped["parts"]:
for p in dumped["parts"]:
if (
isinstance(p, dict)
@@ -343,11 +335,7 @@ async def _consume_main_stream(
else [types.Tool(function_declarations=tools_declaration)],
safety_settings=SAFETY_SETTINGS_BLOCK_NONE,
**(
{
"thinking_config": types.ThinkingConfig(
thinking_level=thinking_level
)
}
{"thinking_config": types.ThinkingConfig(thinking_level=thinking_level)}
if thinking_level
else {}
),
@@ -383,9 +371,7 @@ async def _consume_main_stream(
chunk_text = chunk.text or ""
text += chunk_text
if ctx.data_engine and trace_id and chunk_text:
ctx.data_engine.stream_output(
trace_id, chunk_text, is_thinking=False
)
ctx.data_engine.stream_output(trace_id, chunk_text, is_thinking=False)
if chunk.function_calls:
function_calls.extend(chunk.function_calls)
@@ -422,8 +408,7 @@ async def _execute_native_tool_calls(analyzer, function_calls: list) -> list:
audio_only_calls = [
fc
for fc in function_calls
if (fc.name.split(":")[-1] if ":" in fc.name else fc.name)
== "analyze_audio_only"
if (fc.name.split(":")[-1] if ":" in fc.name else fc.name) == "analyze_audio_only"
]
other_calls = [
fc
@@ -445,9 +430,7 @@ async def _execute_native_tool_calls(analyzer, function_calls: list) -> list:
return tool_response_parts
async def _run_spawn_calls(
analyzer, sub_agent_calls: list, tool_response_parts: list
) -> None:
async def _run_spawn_calls(analyzer, sub_agent_calls: list, tool_response_parts: list) -> None:
"""Runs spawn_sub_agent calls in parallel and appends their responses."""
logger.info(f"Executing {len(sub_agent_calls)} sub-agent calls in parallel...")
@@ -469,25 +452,17 @@ async def _run_spawn_calls(
error_msg = f"Sub-agent failed. Error: {result}"
logger.error(error_msg)
tool_response_parts.append(
types.Part.from_function_response(
name=fc.name, response={"error": error_msg}
)
types.Part.from_function_response(name=fc.name, response={"error": error_msg})
)
else:
tool_response_parts.append(
types.Part.from_function_response(
name=fc.name, response={"result": result}
)
types.Part.from_function_response(name=fc.name, response={"result": result})
)
async def _run_audio_calls(
analyzer, audio_only_calls: list, tool_response_parts: list
) -> None:
async def _run_audio_calls(analyzer, audio_only_calls: list, tool_response_parts: list) -> None:
"""Runs analyze_audio_only calls in parallel and appends their responses."""
logger.info(
f"Executing {len(audio_only_calls)} analyze_audio_only calls in parallel..."
)
logger.info(f"Executing {len(audio_only_calls)} analyze_audio_only calls in parallel...")
async def bounded_audio_spawn(fc):
async with _va.API_SEMAPHORE:
@@ -505,21 +480,15 @@ async def _run_audio_calls(
error_msg = f"Audio analysis failed. Error: {result}"
logger.error(error_msg)
tool_response_parts.append(
types.Part.from_function_response(
name=fc.name, response={"error": error_msg}
)
types.Part.from_function_response(name=fc.name, response={"error": error_msg})
)
else:
tool_response_parts.append(
types.Part.from_function_response(
name=fc.name, response={"result": result}
)
types.Part.from_function_response(name=fc.name, response={"result": result})
)
async def _run_other_calls(
analyzer, other_calls: list, tool_response_parts: list
) -> None:
async def _run_other_calls(analyzer, other_calls: list, tool_response_parts: list) -> None:
"""Runs remaining coordinator tool calls sequentially."""
for fc in other_calls:
name = fc.name.split(":")[-1] if ":" in fc.name else fc.name
@@ -532,15 +501,11 @@ async def _run_other_calls(
args.get("start_time"), args.get("end_time")
)
tool_response_parts.append(
types.Part.from_function_response(
name=name, response={"result": res}
)
types.Part.from_function_response(name=name, response={"result": res})
)
except Exception as e:
tool_response_parts.append(
types.Part.from_function_response(
name=name, response={"error": str(e)}
)
types.Part.from_function_response(name=name, response={"error": str(e)})
)
@@ -559,9 +524,7 @@ async def _cleanup_native_run(analyzer) -> None:
analyzer.cloud_files_to_cleanup.clear()
if os.environ.get("KEEP_VIDEOS") or os.environ.get("ARTEMIS_DEBUG"):
logger.info(
"Skipping cleanup of local video files and directories due to debug mode."
)
logger.info("Skipping cleanup of local video files and directories due to debug mode.")
else:
for path in list(analyzer.local_files_to_cleanup):
try:
@@ -55,9 +55,7 @@ async def exec_single_chunk_universal(
force_fallback: bool = False,
) -> str:
"""Executes sub-agent chunk analysis via keyframe extraction and LangChain ChatModel."""
keyframes = _gather_universal_frames(
analyzer, raw_path, start_time, end_time, actual_start
)
keyframes = _gather_universal_frames(analyzer, raw_path, start_time, end_time, actual_start)
user_blocks, audio_block = await _build_universal_user_blocks(
analyzer, keyframes, actual_start, prompt_with_context, raw_path
@@ -100,8 +98,7 @@ async def exec_single_chunk_universal(
end_str = f"{end_time:.1f}s" if end_time is not None else "unknown"
return (
f"[from {start_time:.1f}s to {end_str}] Summary: {final_summary}"
f" Analysis: {final_analysis}"
f"[from {start_time:.1f}s to {end_str}] Summary: {final_summary} Analysis: {final_analysis}"
)
@@ -133,9 +130,7 @@ def _gather_universal_frames(
max_dimension=1080,
)
merged_frames = {round(timestamp, 3): data for timestamp, data in keyframes}
merged_frames.update(
{round(timestamp, 3): data for timestamp, data in targeted_frames}
)
merged_frames.update({round(timestamp, 3): data for timestamp, data in targeted_frames})
return sorted(merged_frames.items())
@@ -218,9 +213,7 @@ async def _invoke_universal_with_visual_retry(
label="Universal visual-only sub-agent",
force_fallback=force_fallback,
)
span.result = (
f"Received response (tool_calls={len(getattr(response, 'tool_calls', []))})"
)
span.result = f"Received response (tool_calls={len(getattr(response, 'tool_calls', []))})"
return response
@@ -218,8 +218,7 @@ def build_native_tools_declaration() -> list[types.FunctionDeclaration]:
"end_time": types.Schema(
type=types.Type.NUMBER,
description=(
"End time in seconds. Use the maximum available"
" time if not specified."
"End time in seconds. Use the maximum available time if not specified."
),
),
"specific_query": types.Schema(
+18 -47
View File
@@ -121,8 +121,7 @@ async def _invoke_with_retry(operation, label: str, max_attempts: int = 3):
allowed_attempts = min(max_attempts, policy.max_attempts)
if attempt >= allowed_attempts:
logger.warning(
f"{label} exhausted {attempt} attempt(s) "
f"[{failure.category.value}]: {exc}"
f"{label} exhausted {attempt} attempt(s) [{failure.category.value}]: {exc}"
)
raise
delay = policy.delay_for(attempt)
@@ -153,34 +152,22 @@ class VideoAnalyzer:
"enable_ledger",
getattr(agent_config, "enable_video_ledger", True),
)
self.chunk_size_seconds = float(
getattr(self.video_config, "chunk_size_seconds", 60.0)
)
self.min_chunk_seconds = float(
getattr(self.video_config, "min_chunk_seconds", 4.0)
)
self.chunk_size_seconds = float(getattr(self.video_config, "chunk_size_seconds", 60.0))
self.min_chunk_seconds = float(getattr(self.video_config, "min_chunk_seconds", 4.0))
self.max_split_depth = int(getattr(self.video_config, "max_split_depth", 4))
self.action_window_seconds = float(
getattr(self.video_config, "action_window_seconds", 2.0)
)
self.dense_action_fps = float(
getattr(self.video_config, "dense_action_fps", 4.0)
)
self.action_window_seconds = float(getattr(self.video_config, "action_window_seconds", 2.0))
self.dense_action_fps = float(getattr(self.video_config, "dense_action_fps", 4.0))
self.max_dense_action_frames = int(
getattr(self.video_config, "max_dense_action_frames", 24)
)
self.native_max_retries = int(
getattr(self.video_config, "native_max_retries", 1)
)
self.native_max_retries = int(getattr(self.video_config, "native_max_retries", 1))
self.model_call_timeout_seconds = float(
getattr(self.video_config, "model_call_timeout_seconds", 120.0)
)
breaker = getattr(ctx, "_video_circuit_breaker", None)
if breaker is None:
breaker = VideoCircuitBreaker(
threshold=int(
getattr(self.video_config, "circuit_breaker_threshold", 3)
),
threshold=int(getattr(self.video_config, "circuit_breaker_threshold", 3)),
cooldown_seconds=float(
getattr(
self.video_config,
@@ -311,9 +298,7 @@ class VideoAnalyzer:
if resolved not in protected and directory.exists():
shutil.rmtree(directory, ignore_errors=True)
except OSError as error:
logger.warning(
f"Failed to delete temporary video directory {directory}: {error}"
)
logger.warning(f"Failed to delete temporary video directory {directory}: {error}")
def _action_timestamps(self, start: float, end: float) -> list[float]:
"""Return test-relative timestamps for recorded actions in an interval."""
@@ -337,9 +322,7 @@ class VideoAnalyzer:
logger.warning(f"Failed to load action timestamps for video sampling: {error}")
return []
def _dense_action_offsets(
self, start: float, end: float, actual_start: float
) -> list[float]:
def _dense_action_offsets(self, start: float, end: float, actual_start: float) -> list[float]:
"""Build bounded, video-relative sampling offsets around mobile actions."""
if self.max_dense_action_frames <= 0:
@@ -417,10 +400,10 @@ class VideoAnalyzer:
utils_cfg = getattr(llm_config, "utils", None) if llm_config else None
llm_cfg = getattr(utils_cfg, "video_analyzer", None) if utils_cfg else None
model_str = (
llm_cfg.model if (llm_cfg and hasattr(llm_cfg, "model")) else "gemini-3.7-flash"
llm_cfg.model if (llm_cfg and hasattr(llm_cfg, "model")) else "gemini-3.8-flash"
).lower()
self.model_name = (
llm_cfg.model if (llm_cfg and hasattr(llm_cfg, "model")) else "gemini-3.7-flash"
llm_cfg.model if (llm_cfg and hasattr(llm_cfg, "model")) else "gemini-3.8-flash"
)
if "/" in self.model_name:
self.model_name = self.model_name.split("/")[-1]
@@ -557,9 +540,7 @@ class VideoAnalyzer:
"""Resolve an open-ended request to a stable numeric interval (or an error)."""
controller = get_controller(self.ctx)
async with TRANSCODE_SEMAPHORE:
metadata_result = await controller.extract_segment_metadata(
requested_start, None
)
metadata_result = await controller.extract_segment_metadata(requested_start, None)
if not metadata_result.success:
return (
requested_start,
@@ -567,9 +548,7 @@ class VideoAnalyzer:
f"Error fetching segment metadata: {metadata_result.message}",
)
duration = getattr(metadata_result, "duration_seconds", None)
actual_start = getattr(
metadata_result, "actual_start_relative_time", requested_start
)
actual_start = getattr(metadata_result, "actual_start_relative_time", requested_start)
if not isinstance(duration, (int, float)) or duration <= 0:
return (
requested_start,
@@ -613,9 +592,7 @@ class VideoAnalyzer:
cached_results = self.blackboard.format_cached_segments(
requested_start, requested_end, specific_query
)
missing = self.blackboard.missing_intervals(
requested_start, requested_end, specific_query
)
missing = self.blackboard.missing_intervals(requested_start, requested_end, specific_query)
if not missing:
logger.info(
"Reusing complete video-blackboard coverage for "
@@ -662,15 +639,11 @@ class VideoAnalyzer:
analyze_with_recovery(midpoint, ce, depth + 1),
)
return left[0] + right[0], left[1] + right[1]
failure_text = (
f"{cs:.1f}s-{ce:.1f}s [{failure.category.value}]: {error}"
)
failure_text = f"{cs:.1f}s-{ce:.1f}s [{failure.category.value}]: {error}"
logger.error(f"Video chunk terminal failure: {failure_text}")
return [], [failure_text]
recovered = await asyncio.gather(
*(analyze_with_recovery(cs, ce) for cs, ce in chunks)
)
recovered = await asyncio.gather(*(analyze_with_recovery(cs, ce) for cs, ce in chunks))
fresh_results = [item for values, _ in recovered for item in values]
failed_chunks = [item for _, failures in recovered for item in failures]
@@ -696,9 +669,7 @@ class VideoAnalyzer:
) -> str:
from artemis.agents.video_analyzer import chunk_native
return await chunk_native.exec_single_chunk(
self, start_time, end_time, specific_query
)
return await chunk_native.exec_single_chunk(self, start_time, end_time, specific_query)
@trace(type="tool", name="analyze_audio_only")
async def exec_analyze_audio_only(
@@ -804,7 +775,7 @@ class VideoAnalyzer:
if getattr(llm_cfg, "thinking_level", None) is not None:
thinking_level = llm_cfg.thinking_level
else:
self.model_name = "gemini-3.7-flash"
self.model_name = "gemini-3.8-flash"
# Track files for cleanup
self.local_files_to_cleanup = set()
+14
View File
@@ -18,6 +18,7 @@ from artemis.config.agent import (
AgentGlobalConfig,
CheckerConfig,
CommitteeConfig,
DEFAULT_VERIFICATION_LEVEL,
ExplorerConfig,
FlashProfileConfig,
MemoryChunkingConfig,
@@ -31,6 +32,10 @@ from artemis.config.agent import (
VideoAnalyzerConfig,
load_agent_config,
resolve_explorer_version,
VERIFICATION_LEVEL_PRESETS,
VerificationLevel,
checker_overrides_for_level,
verification_level_for_checker,
)
from artemis.config.constants import (
AGENT_CONFIG_FILENAME,
@@ -126,6 +131,7 @@ from artemis.config.paths import (
get_config_path,
get_data_engine_db_path,
get_default_traces_path,
get_env_file,
get_images_dir,
get_ipc_port_file,
get_ls_address_file,
@@ -136,6 +142,7 @@ from artemis.config.paths import (
get_test_outputs_dir,
get_traces_dir,
is_frozen_bundle,
is_source_checkout,
)
from artemis.config.runtime import (
cleanup_temp_dir,
@@ -172,10 +179,12 @@ __all__ = [
"get_app_dir",
"get_traces_dir",
"get_default_traces_path",
"get_env_file",
"get_temp_dir",
"get_config_path",
"get_data_engine_db_path",
"get_pause_file",
"is_source_checkout",
"get_replay_dir",
"get_test_data_dir",
"get_test_outputs_dir",
@@ -199,6 +208,7 @@ __all__ = [
"AgentGlobalConfig",
"CheckerConfig",
"CommitteeConfig",
"DEFAULT_VERIFICATION_LEVEL",
"ExplorerConfig",
"FlashProfileConfig",
"MemoryChunkingConfig",
@@ -212,6 +222,10 @@ __all__ = [
"VideoAnalyzerConfig",
"load_agent_config",
"resolve_explorer_version",
"VERIFICATION_LEVEL_PRESETS",
"VerificationLevel",
"checker_overrides_for_level",
"verification_level_for_checker",
# Output Config
"OutputConfig",
"prepare_output_files",
+122 -52
View File
@@ -117,11 +117,12 @@ class PlannerValidationConfig(BaseModel):
enabled: bool = Field(
default=True,
description="Whether to enable async planner validation when task plan milestones change.",
)
similarity_threshold: float = Field(
default=0.85,
description="Difflib similarity threshold below which milestone text changes trigger re-validation.",
description=(
"Whether every top-level milestone text change is reviewed by the"
" lightweight async planner validator. The review is advisory: a"
" flagged change is never rolled back, the Operator only receives"
" the concern and its reason as feedback."
),
)
model_config = {"extra": "allow"}
@@ -208,6 +209,63 @@ class CheckerConfig(BaseModel):
model_config = {"extra": "allow"}
#: Coarse verification presets exposed to the CLI (``--verification-level``)
#: and the admin console (``verification_level`` on ``/api/run``). Each preset
#: is a partial :class:`CheckerConfig` override; unspecified fields keep the
#: values from ``artemis.jsonc``. ``final`` mirrors the factory layering.
VerificationLevel = Literal["off", "final", "checkpoints", "strict"]
DEFAULT_VERIFICATION_LEVEL: VerificationLevel = "final"
VERIFICATION_LEVEL_PRESETS: dict[str, dict[str, Any]] = {
# No Checker at all: the Operator self-reports completion, nothing audits it.
"off": {"enabled": False},
# Factory layering: one exit audit against the user's goal, no midway checkpoints.
"final": {"enabled": True, "midway_checks": False, "final_check": True},
# Every plan-declared checkpoint runs at its subgoal's completion, plus the exit audit.
"checkpoints": {"enabled": True, "midway_checks": True, "final_check": True},
# Checkpoints + exit audit with a larger repair budget; a failed assert halts the run.
"strict": {
"enabled": True,
"midway_checks": True,
"final_check": True,
"assert_failure_policy": "halt",
"checkpoint_max_repairs": 4,
"final_check_max_attempts": 5,
"max_iterations": 30,
},
}
def checker_overrides_for_level(level: str | None) -> dict[str, Any]:
"""Return the :class:`CheckerConfig` field overrides for a verification level.
Args:
level: One of ``off``, ``final``, ``checkpoints`` or ``strict``
(case-insensitive, surrounding whitespace ignored).
Raises:
ValueError: when ``level`` is not a known preset.
"""
key = str(level or "").strip().lower()
preset = VERIFICATION_LEVEL_PRESETS.get(key)
if preset is None:
known = ", ".join(VERIFICATION_LEVEL_PRESETS)
raise ValueError(f"Unknown verification level {level!r}; expected one of: {known}")
return dict(preset)
def verification_level_for_checker(checker: "CheckerConfig") -> VerificationLevel:
"""Classify an effective :class:`CheckerConfig` back onto the coarse ladder.
Used by launcher UIs to show where the configured defaults sit; the inverse of
:func:`checker_overrides_for_level` for the fields the presets control.
"""
if not checker.enabled or (not checker.midway_checks and not checker.final_check):
return "off"
if checker.midway_checks:
return "strict" if checker.assert_failure_policy == "halt" else "checkpoints"
return "final"
class OutputterConfig(BaseModel):
"""Configuration specific to Outputter post-execution report synthesis agent."""
@@ -237,9 +295,12 @@ class ExplorerConfig(BaseModel):
default="flash",
description="Explorer version mode when main system runs under Pro profile (Graph / Operator / Validator).",
)
caching: bool = Field(
default=True,
description="Whether to enable context caching for multi-turn pro/ultra Explorer.",
caching: bool | None = Field(
default=None,
description=(
"Gemini explicit context caching for multi-turn Explorer tiers. ``None``"
" (default) uses the tier's own default: off for pro, on for ultra."
),
)
model_config = {"extra": "allow"}
@@ -497,7 +558,7 @@ class MemoryChunkingConfig(BaseModel):
),
)
model: str = Field(
default="gemini-3.7-flash",
default="gemini-3.8-flash",
description="Model used for the chunk-level StepCapsuleLens (bands ①+②).",
)
max_chunks: int = Field(
@@ -555,10 +616,14 @@ class FlashProfileConfig(BaseModel):
"""Configuration options specific to the ⚡ Flash execution profile (FlashRunner/ReactiveRunner)."""
max_turns: int = Field(
default=30,
ge=1,
le=100,
description="Maximum reactive turns for Flash execution before timeout.",
default=0,
ge=0,
description=(
"Maximum reactive turns for Flash execution; 0 (the default) means"
" unlimited. Context growth is bounded by the transcript ledger's"
" scrub edge and chunk compression, not by a turn cap, so a long"
" task simply runs until it reports its status."
),
)
explorer_mode: ExplorerVersion = Field(
default="flash",
@@ -585,9 +650,28 @@ class ProExplorerConfig(BaseModel):
model_config = {"extra": "allow"}
class ExecutionConfig(BaseModel):
"""Validator execution tiers for the Pro profile."""
max_burst_actions: int = Field(
default=4,
ge=2,
le=10,
description=(
"Maximum turn-ending actions the Operator may chain into one fast-action"
" burst (executed back to back without the safety net). A longer turn is"
" rejected before execution and fed back to the Operator."
),
)
class ProProfileConfig(BaseModel):
"""Configuration options specific to the 🚀 Pro execution profile (LangGraph / Multi-Agent Closed-Loop)."""
execution: ExecutionConfig = Field(
default_factory=ExecutionConfig,
description="Validator execution tiers (vetted single action vs fast-action burst).",
)
explorer: ProExplorerConfig = Field(
default_factory=ProExplorerConfig,
description="Explorer perception settings for Pro profile.",
@@ -602,7 +686,7 @@ class ProProfileConfig(BaseModel):
)
checker: CheckerConfig = Field(
default_factory=CheckerConfig,
description="Checker visual verification and rollback settings.",
description="Checker checkpoint verification and exit final review settings.",
)
video_analyzer: VideoAnalyzerConfig = Field(
default_factory=VideoAnalyzerConfig,
@@ -630,8 +714,12 @@ class AgentGlobalConfig(BaseModel):
description="UI Explorer sub-agent runtime options.",
)
explorer_versions: dict[str, str] = Field(
default_factory=lambda: {"operator": "flash", "validator": "flash"},
description="Per-agent mapping of UI explorer version (e.g. flash, pro, ultra).",
default_factory=dict,
description=(
"Advanced per-agent override of the Explorer tier (e.g."
' {"validator": "ultra"}). Empty by default so that'
" ``explorer.flash_mode`` / ``explorer.pro_mode`` decide."
),
)
planner_validation: PlannerValidationConfig = Field(
default_factory=PlannerValidationConfig,
@@ -643,7 +731,7 @@ class AgentGlobalConfig(BaseModel):
)
checker: CheckerConfig = Field(
default_factory=CheckerConfig,
description="Checker subgoal verification and rollback runtime options.",
description="Checker checkpoint verification and exit final review runtime options.",
)
outputter: OutputterConfig = Field(
default_factory=OutputterConfig,
@@ -792,47 +880,29 @@ def resolve_explorer_version(
explicit_version: str | None = None,
agent_or_profile_name: str | None = "operator",
) -> ExplorerVersion:
"""Resolves active Explorer version based on explicit args, env vars, agent_config, and execution setup."""
if explicit_version:
v = str(explicit_version).strip().lower()
if v in ("flash", "pro", "ultra"):
return v # type: ignore
"""Resolves the active Explorer tier for a calling agent or profile.
env_v = os.getenv("ARTEMIS_EXPLORER_VERSION", "").strip().lower()
if env_v in ("flash", "pro", "ultra"):
return env_v # type: ignore
if ctx:
agent_cfg = getattr(ctx, "agent_config", None)
exec_setup = getattr(ctx, "execution_setup", None)
if agent_cfg:
if hasattr(agent_cfg, "get_explorer_version"):
return agent_cfg.get_explorer_version(
explicit_version=explicit_version,
agent_name=agent_or_profile_name,
)
if hasattr(agent_cfg, "explorer") and hasattr(agent_cfg.explorer, "resolve"):
overrides = getattr(agent_cfg, "explorer_versions", None)
return agent_cfg.explorer.resolve(
explicit_version=explicit_version,
agent_name=agent_or_profile_name,
per_agent_overrides=overrides,
)
if (
exec_setup
and hasattr(exec_setup, "explorer")
and hasattr(exec_setup.explorer, "resolve")
Precedence: explicit argument, ``ARTEMIS_EXPLORER_VERSION`` environment
override, then the user's agent configuration (per-agent override, profile
mode, default version) found on ``ctx.agent_config`` or, failing that, on
``ctx.execution_setup``. The tier is a user setting: calling agents never
pass it themselves.
"""
for source in (
getattr(ctx, "agent_config", None),
getattr(ctx, "execution_setup", None),
):
overrides = getattr(exec_setup, "explorer_versions", None)
return exec_setup.explorer.resolve(
explorer_cfg = getattr(source, "explorer", None)
if explorer_cfg is not None and hasattr(explorer_cfg, "resolve"):
return explorer_cfg.resolve(
explicit_version=explicit_version,
agent_name=agent_or_profile_name,
per_agent_overrides=overrides,
per_agent_overrides=getattr(source, "explorer_versions", None),
)
return "flash"
return ExplorerConfig().resolve(
explicit_version=explicit_version, agent_name=agent_or_profile_name
)
def load_agent_config(
+1 -1
View File
@@ -134,7 +134,6 @@ AgentNode = Literal[
"log_reader_sub_agent",
"log_analyzer",
"diagnoser",
"validator_failure_analyzer",
"checker",
"planner_avatar",
"history_analyzer_expert",
@@ -142,6 +141,7 @@ AgentNode = Literal[
"explorer",
"history_analyzer",
"validator_pixel_safety_net",
"planner_validation",
"validator",
"output_analyzer",
]
+26 -17
View File
@@ -116,6 +116,21 @@ class LLMWithFallback(LLM):
return f"{self.provider}/{self.model} (fallback: {self.fallback})"
def lightweight_judge_default() -> "LLMWithFallback":
"""Factory default for the lightweight judge nodes (pixel safety net and
planner validation): a flash-lite model at temperature 0."""
return LLMWithFallback(
provider="google",
model="gemini-3.5-flash-lite",
temperature=0.0,
fallback=LLM(
provider="google",
model="gemini-3.1-flash-lite",
temperature=0.0,
),
)
class LLMConfigUtils(BaseModel):
"""Configuration container for auxiliary utility agents/nodes."""
@@ -138,7 +153,6 @@ class LLMConfig(BaseModel):
log_reader_sub_agent: LLMWithFallback
log_analyzer: LLMWithFallback
diagnoser: LLMWithFallback
validator_failure_analyzer: LLMWithFallback
checker: LLMWithFallback
planner_avatar: LLMWithFallback
history_analyzer_expert: LLMWithFallback
@@ -146,6 +160,7 @@ class LLMConfig(BaseModel):
explorer: LLMWithFallback
history_analyzer: LLMWithFallback | None = None
validator_pixel_safety_net: LLMWithFallback | None = None
planner_validation: LLMWithFallback | None = None
output_analyzer: LLMWithFallback | None = None
def validate_providers(self) -> None:
@@ -163,7 +178,6 @@ class LLMConfig(BaseModel):
self.log_reader_sub_agent.validate_provider("LogReaderSubAgent")
self.log_analyzer.validate_provider("LogAnalyzer")
self.diagnoser.validate_provider("Diagnoser")
self.validator_failure_analyzer.validate_provider("ValidatorFailureAnalyzer")
self.checker.validate_provider("Checker")
self.planner_avatar.validate_provider("PlannerAvatar")
self.history_analyzer_expert.validate_provider("HistoryAnalyzerExpert")
@@ -173,6 +187,8 @@ class LLMConfig(BaseModel):
self.history_analyzer.validate_provider("HistoryAnalyzer")
if self.validator_pixel_safety_net:
self.validator_pixel_safety_net.validate_provider("ValidatorPixelSafetyNet")
if self.planner_validation:
self.planner_validation.validate_provider("PlannerValidation")
if self.output_analyzer:
self.output_analyzer.validate_provider("OutputAnalyzer")
@@ -192,17 +208,11 @@ class LLMConfig(BaseModel):
if val is None:
if item == "history_analyzer":
return self.operator
elif item == "validator_pixel_safety_net":
return LLMWithFallback(
provider="google",
model="gemini-3.5-flash-lite",
temperature=0.0,
fallback=LLM(
provider="google",
model="gemini-3.1-flash-lite",
temperature=0.0,
),
)
elif item in ("validator_pixel_safety_net", "planner_validation"):
# Both are cheap, high-frequency judges: the pixel safety net
# runs before actions, the planner validator after every
# milestone edit. They share one lightweight default.
return lightweight_judge_default()
elif item == "output_analyzer":
return self.log_analyzer
return val
@@ -227,10 +237,10 @@ def _expand_default_into_nodes(config_dict: dict) -> dict:
"default",
{
"provider": "google",
"model": "gemini-3.7-flash",
"model": "gemini-3.8-flash",
"fallback": {
"provider": "google",
"model": "gemini-3.6-flash",
"model": "gemini-3.7-flash",
},
},
)
@@ -245,7 +255,6 @@ def _expand_default_into_nodes(config_dict: dict) -> dict:
"log_reader_sub_agent",
"log_analyzer",
"diagnoser",
"validator_failure_analyzer",
"checker",
"planner_avatar",
"history_analyzer_expert",
@@ -345,7 +354,7 @@ def load_llm_config_override(path: Path | str) -> LLMConfig:
if not resolved_path.exists():
try:
resolved_path = get_config_path(str(path))
except Exception:
except OSError:
pass
override_config_dict = {}
+42 -17
View File
@@ -34,6 +34,7 @@ from artemis.config.constants import (
TEST_OUTPUTS_DIRNAME,
)
from artemis.platform import platform
from artemis.resources import get_bundled_config_path
# Project root directory (3 levels up from artemis/config/paths.py)
ROOT_DIR = Path(__file__).resolve().parent.parent.parent
@@ -45,6 +46,27 @@ def is_frozen_bundle() -> bool:
return getattr(sys, "frozen", False) and hasattr(sys, "_MEIPASS")
def is_source_checkout() -> bool:
"""Return whether ``ROOT_DIR`` is an Artemis source checkout.
Installed wheels also contain an ``artemis`` directory, so the repository
metadata file is the important discriminator. Runtime code must not treat
a wheel's ``site-packages`` directory as writable application state.
"""
return (ROOT_DIR / "pyproject.toml").is_file() and (ROOT_DIR / "artemis").is_dir()
def _use_user_app_dir() -> bool:
"""Return whether mutable runtime state belongs in the PAL user directory."""
return (
is_frozen_bundle()
or not is_source_checkout()
or os.getenv(ENV_ARTEMIS_USE_USER_DIR) == "true"
or bool(os.getenv(ENV_ARTEMIS_APP_DIR))
or bool(os.getenv(ENV_ANTIGRAVITY_APP_DIR))
)
def get_app_dir() -> Path:
"""Returns the central application directory for ARTEMIS configurations, DBs, and traces.
@@ -56,6 +78,11 @@ def get_app_dir() -> Path:
return platform.paths.resolve_app_dir()
def get_env_file() -> Path:
"""Return the canonical writable dotenv file for this installation mode."""
return get_app_dir() / ".env" if _use_user_app_dir() else ROOT_DIR / ".env"
def get_default_traces_path() -> Path:
"""Returns default traces directory.
@@ -64,12 +91,7 @@ def get_default_traces_path() -> Path:
env_traces = os.getenv(ENV_ARTEMIS_TRACES_DIR)
if env_traces:
traces_dir = Path(env_traces)
elif (
is_frozen_bundle()
or os.getenv(ENV_ARTEMIS_USE_USER_DIR) == "true"
or os.getenv(ENV_ARTEMIS_APP_DIR)
or os.getenv(ENV_ANTIGRAVITY_APP_DIR)
):
elif _use_user_app_dir():
traces_dir = get_app_dir() / "traces"
else:
traces_dir = ROOT_DIR / "traces"
@@ -103,7 +125,7 @@ def get_data_engine_db_path() -> Path:
def get_ipc_port_file() -> Path:
"""Returns the location of the IPC port synchronization file."""
app_dir_file = get_app_dir() / IPC_PORT_FILENAME
if app_dir_file.exists():
if _use_user_app_dir() or app_dir_file.exists():
return app_dir_file
return ROOT_DIR / IPC_PORT_FILENAME
@@ -111,7 +133,7 @@ def get_ipc_port_file() -> Path:
def get_ls_address_file() -> Path:
"""Returns the location of the Language Server address synchronization file."""
app_dir_file = get_app_dir() / LS_ADDRESS_FILENAME
if app_dir_file.exists():
if _use_user_app_dir() or app_dir_file.exists():
return app_dir_file
return ROOT_DIR / LS_ADDRESS_FILENAME
@@ -119,7 +141,7 @@ def get_ls_address_file() -> Path:
def get_server_info_file() -> Path:
"""Returns the location of the Artemis server metadata file."""
app_dir_file = get_app_dir() / SERVER_INFO_FILENAME
if app_dir_file.exists():
if _use_user_app_dir() or app_dir_file.exists():
return app_dir_file
return ROOT_DIR / SERVER_INFO_FILENAME
@@ -131,8 +153,8 @@ def get_config_path(filename: str, default_bundled_path: Path | None = None) ->
1. Environment variable override: `ARTEMIS_<FILENAME_UPPER>`
2. Central `config/` directory: `<ROOT_DIR>/config/<filename>`
3. Project root directory: `<ROOT_DIR>/<filename>`
4. Custom default bundled path (if provided and exists)
5. User application/config directory via PAL: `<CONFIG_DIR>/<filename>` or `<APP_DIR>/<filename>`
4. User application/config directory via PAL: `<CONFIG_DIR>/<filename>` or `<APP_DIR>/<filename>`
5. Custom bundled path, then the immutable template included in the wheel
"""
env_var = f"ARTEMIS_{filename.upper().replace('.', '_').replace('-', '_')}"
env_path_str = os.getenv(env_var)
@@ -151,10 +173,6 @@ def get_config_path(filename: str, default_bundled_path: Path | None = None) ->
if project_path.exists():
return project_path
# Check bundled fallback
if default_bundled_path and default_bundled_path.exists():
return default_bundled_path
# Check user PAL config directory
pal_config_path = platform.paths.config_dir / filename
if pal_config_path.exists():
@@ -165,9 +183,16 @@ def get_config_path(filename: str, default_bundled_path: Path | None = None) ->
if app_dir_path.exists():
return app_dir_path
# Explicit and wheel-bundled defaults are immutable last-resort templates.
if default_bundled_path and default_bundled_path.exists():
return default_bundled_path
bundled_path = get_bundled_config_path(filename)
if bundled_path is not None:
return bundled_path
raise FileNotFoundError(
f"Configuration file '{filename}' not found in config dir ({config_dir_path}), "
f"project root ({project_path}), or app dir ({app_dir_path})."
f"project root ({project_path}), app dir ({app_dir_path}), or bundled resources."
)
@@ -177,7 +202,7 @@ GLOBAL_JETSKI_DIR = GLOBAL_APP_DIR # Backward-compatibility alias
def get_pause_file() -> Path:
"""Returns the central pause signal file path."""
return ROOT_DIR / PAUSE_FILENAME
return get_app_dir() / PAUSE_FILENAME if _use_user_app_dir() else ROOT_DIR / PAUSE_FILENAME
def get_replay_dir() -> Path:
+12 -11
View File
@@ -29,6 +29,7 @@ from artemis.config.paths import (
get_ipc_port_file,
get_ls_address_file,
get_temp_dir,
is_source_checkout,
)
from artemis.utils.logger import get_logger
@@ -41,19 +42,17 @@ def read_ipc_port() -> int | None:
if env_port and env_port.strip().isdigit():
return int(env_port.strip())
candidate_paths = [
get_temp_dir() / "artemis-ipc-port",
get_app_dir() / IPC_PORT_FILENAME,
ROOT_DIR / IPC_PORT_FILENAME,
get_ipc_port_file(),
]
candidate_paths = [get_temp_dir() / "artemis-ipc-port", get_app_dir() / IPC_PORT_FILENAME]
if is_source_checkout():
candidate_paths.append(ROOT_DIR / IPC_PORT_FILENAME)
candidate_paths.append(get_ipc_port_file())
for p in candidate_paths:
if p.exists():
try:
content = p.read_text(encoding="utf-8").strip()
if content.isdigit():
return int(content)
except Exception:
except (OSError, ValueError):
pass
# Dynamic fallback: check local admin console status endpoint
@@ -71,8 +70,8 @@ def read_ipc_port() -> int | None:
ipc_port = data.get("ipc_port")
if ipc_port and str(ipc_port).isdigit():
return int(ipc_port)
except Exception:
pass
except Exception as exc:
logger.debug(f"IPC port discovery via admin console status skipped: {exc}", exc_info=True)
return None
@@ -85,8 +84,9 @@ def write_ipc_port(port: int) -> Path:
port_file,
get_temp_dir() / "artemis-ipc-port",
get_app_dir() / IPC_PORT_FILENAME,
ROOT_DIR / IPC_PORT_FILENAME,
]
if is_source_checkout():
all_targets.append(ROOT_DIR / IPC_PORT_FILENAME)
for target in all_targets:
try:
target.parent.mkdir(parents=True, exist_ok=True)
@@ -103,8 +103,9 @@ def clear_ipc_port() -> None:
get_ipc_port_file(),
get_temp_dir() / "artemis-ipc-port",
get_app_dir() / IPC_PORT_FILENAME,
ROOT_DIR / IPC_PORT_FILENAME,
]
if is_source_checkout():
all_targets.append(ROOT_DIR / IPC_PORT_FILENAME)
for target in all_targets:
if target.exists():
try:
+15 -19
View File
@@ -24,7 +24,6 @@ from pydantic_settings import BaseSettings
from artemis.config.constants import (
DEFAULT_ADB_HOST,
DEFAULT_ADB_PORT,
DEFAULT_EXPLORER_VERSION,
DEFAULT_MODEL,
DEFAULT_PROFILE,
ENV_ANTHROPIC_API_KEY,
@@ -37,22 +36,21 @@ from artemis.config.constants import (
ENV_OPENAI_API_KEY,
ENV_VISION_API_KEY,
ENV_XAI_API_KEY,
ExplorerVersion,
)
from artemis.config.paths import (
GLOBAL_APP_DIR,
ROOT_DIR,
get_app_dir,
get_data_engine_db_path,
get_default_traces_path,
get_env_file,
get_temp_dir,
)
from artemis.utils.logger import get_logger
# Load environment configuration from workspace root dir and app dir if present
load_dotenv(dotenv_path=ROOT_DIR / ".env", verbose=True)
# Installed wheels load .env from the user directory, outside site-packages.
_canonical_env = get_env_file()
load_dotenv(dotenv_path=_canonical_env, verbose=True)
_global_env = GLOBAL_APP_DIR / ".env"
if _global_env.exists() and _global_env.resolve() != (ROOT_DIR / ".env").resolve():
if _global_env.exists() and _global_env.resolve() != _canonical_env.resolve():
load_dotenv(dotenv_path=_global_env, verbose=True)
logger = get_logger(__name__)
@@ -121,14 +119,14 @@ class Settings(BaseSettings):
ARTEMIS_DEFAULT_PROFILE: str = Field(default=DEFAULT_PROFILE)
ARTEMIS_DEFAULT_MODEL: str = Field(default=DEFAULT_MODEL)
# Explorer Tool Settings
EXPLORER_VERSION: ExplorerVersion = Field(
default=DEFAULT_EXPLORER_VERSION,
description="Active version mode for the UI Explorer tool ('flash', 'pro', or 'ultra')",
)
EXPLORER_CACHING: bool = Field(
default=True,
description="Enable context caching when running multi-turn pro/ultra Explorer",
# Explorer Tool Settings (the tier itself is configured in artemis.jsonc or
# via ARTEMIS_EXPLORER_VERSION; see artemis.config.agent.ExplorerConfig)
EXPLORER_CACHING: bool | None = Field(
default=None,
description=(
"Environment-level override for Explorer context caching; unset"
" defers to the agent configuration and the tier default."
),
)
# LLM Reliability
@@ -251,13 +249,11 @@ class Settings(BaseSettings):
os.environ[ENV_VISION_API_KEY] = key
if persist_to_env and env_key_name:
target_env_files = [
ROOT_DIR / ".env",
get_app_dir() / ".env",
]
target_env_files = [get_env_file()]
seen_paths = set()
for env_file in target_env_files:
try:
env_file.parent.mkdir(parents=True, exist_ok=True)
resolved = env_file.resolve()
if resolved in seen_paths:
continue
+14 -13
View File
@@ -122,7 +122,6 @@ class ExecutionSetup(BaseModel):
assert_failure_policy: Literal["continue", "halt"] = "continue"
disable_device_probes: bool = False
disable_planner_validation: bool = False
planner_validation_threshold: float = 0.85
enable_committee: bool = False
committee_debate_rounds: int = 2
disable_outputter: bool = False
@@ -160,7 +159,7 @@ class ExecutionSetup(BaseModel):
return self.explorer.pro_mode
@property
def explorer_caching(self) -> bool:
def explorer_caching(self) -> bool | None:
return self.explorer.caching
def get_locked_app_package(self) -> str | None:
@@ -185,6 +184,9 @@ class ArtemisContext(BaseModel):
agent_config: Any = None
adb_client: Any | None = None
ui_adb_client: Any | None = None
adb_task_registry: Any | None = None
"""Per-task ADB background-task registry (``artemis.tools.command_tool``).
Created lazily on first ADB tool use; killed at task end."""
execution_setup: ExecutionSetup | None = None
@@ -211,7 +213,6 @@ class ArtemisContext(BaseModel):
final_check_attempts: int = 0
"""Number of final-check passes already executed at exit settlement."""
task_plan_content_before: str | None = None
last_validated_plan: str | None = None
"""Ratchet baseline for planner validation: the last plan content whose
top-level milestones were validated (or the initial plan). Drift is always
@@ -254,8 +255,8 @@ class ArtemisContext(BaseModel):
if self.action_session is not None:
try:
await self.action_session.aclose()
except Exception:
pass
except Exception as exc:
logger.debug(f"Action session close failed; skipped: {exc}", exc_info=True)
finally:
self.action_session = None
@@ -268,8 +269,8 @@ class ArtemisContext(BaseModel):
await self.step_memory.flush()
else:
await self.step_memory.flush(timeout_seconds=0.0)
except Exception:
pass
except Exception as exc:
logger.debug(f"Step-memory flush failed; skipped: {exc}", exc_info=True)
# Drain in-flight chunk capsule jobs the same way (M3): the chunk
# manager persists any harvested capsules so the DB copy is complete
@@ -281,8 +282,8 @@ class ArtemisContext(BaseModel):
await chunker.flush()
else:
await chunker.flush(timeout_seconds=0.0)
except Exception:
pass
except Exception as exc:
logger.debug(f"History chunk flush failed; skipped: {exc}", exc_info=True)
if self.background_tasks:
tasks = list(self.background_tasks)
@@ -321,8 +322,8 @@ class ArtemisContext(BaseModel):
if self.mcp_client_ctx:
try:
await self.mcp_client_ctx.__aexit__(exc_type, exc_val, exc_tb)
except Exception:
pass
except Exception as exc:
logger.debug(f"MCP client context exit failed; skipped: {exc}", exc_info=True)
finally:
self.mcp_client_ctx = None
self.mcp_session = None
@@ -330,8 +331,8 @@ class ArtemisContext(BaseModel):
if self.data_engine:
try:
await self.data_engine.shutdown()
except Exception:
pass
except Exception as exc:
logger.debug(f"DataEngine shutdown failed; skipped: {exc}", exc_info=True)
def get_adb_client(self) -> Any:
if self.adb_client is None:
+33 -23
View File
@@ -61,9 +61,7 @@ class UnifiedMobileController:
def __init__(self, ctx: ArtemisContext):
self.ctx = ctx
self._driver: BaseDeviceDriver = get_driver(ctx)
self._segment_cache: dict[
tuple[str, int, float, float], VideoRecordingResult
] = {}
self._segment_cache: dict[tuple[str, int, float, float], VideoRecordingResult] = {}
@property
def driver(self) -> BaseDeviceDriver:
@@ -323,7 +321,12 @@ class UnifiedMobileController:
round(end_time, 1),
)
cached_res = self._segment_cache.get(cache_key)
if cached_res and cached_res.success and cached_res.video_path and cached_res.video_path.exists():
if (
cached_res
and cached_res.success
and cached_res.video_path
and cached_res.video_path.exists()
):
logger.info(
"Reusing generation-scoped trimmed video segment for range "
f"{cache_key[2]}s to {cache_key[3]}s"
@@ -434,9 +437,7 @@ class UnifiedMobileController:
video_id=session.video_id,
generation=session.generation,
sealed_until=session.sealed_until,
source_revision=(
f"{session.video_id}:{session.generation}:{round(actual_end, 3)}"
),
source_revision=(f"{session.video_id}:{session.generation}:{round(actual_end, 3)}"),
)
if cache_key is not None:
self._segment_cache[cache_key] = res
@@ -451,7 +452,9 @@ class UnifiedMobileController:
@staticmethod
def _segment_mp4_path(source_path: Path, index: int) -> Path:
return source_path.parent / ("recording.mp4" if index == 0 else f"recording_{index:03d}.mp4")
return source_path.parent / (
"recording.mp4" if index == 0 else f"recording_{index:03d}.mp4"
)
@staticmethod
async def _remux_segment_record(record: dict[str, Any]) -> bool:
@@ -549,7 +552,9 @@ class UnifiedMobileController:
if not crashed:
await self._stop_scrcpy(session.process)
self._finalize_current_segment(session, time.time())
reason = "rotation" if rotated else "time limit" if not crashed else "recorder crash"
reason = (
"rotation" if rotated else "time limit" if not crashed else "recorder crash"
)
logger.info(f"Rolling screen recording segment after {reason}")
if not await self._start_next_recording_segment(session, display_state):
return
@@ -699,8 +704,12 @@ class UnifiedMobileController:
session.watchdog_task.cancel()
try:
await session.watchdog_task
except (asyncio.CancelledError, Exception):
except asyncio.CancelledError:
pass
except Exception as exc: # pylint: disable=broad-exception-caught
logger.debug(
f"Recording watchdog for {device_id} ended with an error: {exc}", exc_info=True
)
try:
process = session.process
@@ -711,13 +720,10 @@ class UnifiedMobileController:
logger.warning(f"Error terminating scrcpy process: {proc_e}")
output_path = session.local_video_path
has_existing_recording = (
(output_path and output_path.exists())
or any(
has_existing_recording = (output_path and output_path.exists()) or any(
Path(r.get("output_path", "")).exists() or Path(r.get("path", "")).exists()
for r in session.android_segment_records
)
)
if not has_existing_recording:
message = "Recording file not found on disk"
self._record_recording_failure(session, message)
@@ -732,22 +738,24 @@ class UnifiedMobileController:
if not record.get("conversion_scheduled"):
record["conversion_scheduled"] = True
session.android_conversion_tasks.append(
asyncio.create_task(
self._remux_segment_record(record)
)
asyncio.create_task(self._remux_segment_record(record))
)
if session.android_conversion_tasks:
await asyncio.gather(
*session.android_conversion_tasks, return_exceptions=True
)
await asyncio.gather(*session.android_conversion_tasks, return_exceptions=True)
mp4_paths = [
Path(record["output_path"])
for record in session.android_segment_records
if Path(record["output_path"]).exists() and Path(record["output_path"]).stat().st_size > 0
if Path(record["output_path"]).exists()
and Path(record["output_path"]).stat().st_size > 0
]
# Emergency fallback: if no segment MP4 was produced, attempt to remux local_video_path directly
if not mp4_paths and output_path and output_path.exists() and output_path.stat().st_size > 0:
if (
not mp4_paths
and output_path
and output_path.exists()
and output_path.stat().st_size > 0
):
fallback_mp4 = (
output_path.parent / "recording.mp4"
if output_path.name != "recording.mp4"
@@ -757,7 +765,9 @@ class UnifiedMobileController:
mp4_paths.append(fallback_mp4)
if not mp4_paths:
message = "Recording finalization failed; no complete browser-safe video was produced"
message = (
"Recording finalization failed; no complete browser-safe video was produced"
)
self._record_recording_failure(session, message)
remove_active_session(device_id)
return VideoRecordingResult(success=False, message=message)
+1 -3
View File
@@ -161,9 +161,7 @@ def inspect_adb_keys() -> AdbKeyStatus:
)
def heal_adb_keys(
adb_path: str | None = None, force: bool = False
) -> dict[str, Any]:
def heal_adb_keys(adb_path: str | None = None, force: bool = False) -> dict[str, Any]:
"""Auto-heal corrupted ADB authentication keys.
If keys are corrupted (or if `force=True`), this function:
+13 -30
View File
@@ -177,16 +177,12 @@ class AdbDeviceProbe(BaseProbe):
trust_output,
flags=re.IGNORECASE,
)
trust_locked = (
trust_match.group(1).lower() in {"true", "1"} if trust_match else None
)
trust_locked = trust_match.group(1).lower() in {"true", "1"} if trust_match else None
# Prefer the modern, current-user signals. Legacy fields can coexist in
# dumpsys output as stale or display-specific diagnostics and must not
# override an explicit modern unlocked result.
modern_states = [
state for state in [policy_showing, trust_locked] if state is not None
]
modern_states = [state for state in [policy_showing, trust_locked] if state is not None]
if any(modern_states):
return True
if modern_states:
@@ -217,9 +213,7 @@ class AdbDeviceProbe(BaseProbe):
stderr=subprocess.PIPE,
)
try:
stdout, _ = await asyncio.wait_for(
proc.communicate(), timeout=timeout_seconds
)
stdout, _ = await asyncio.wait_for(proc.communicate(), timeout=timeout_seconds)
except TimeoutError:
proc.kill()
await proc.communicate()
@@ -245,9 +239,7 @@ class AdbDeviceProbe(BaseProbe):
timeout_seconds: float = 2.0,
) -> bool | None:
"""Confirm a positive lock result so one transient sample cannot flap the UI."""
first = await self._get_device_lock_state(
adb_path, serial, timeout_seconds=timeout_seconds
)
first = await self._get_device_lock_state(adb_path, serial, timeout_seconds=timeout_seconds)
if first is not True:
return first
@@ -306,9 +298,7 @@ class AdbDeviceProbe(BaseProbe):
stderr=subprocess.PIPE,
)
try:
stdout, _ = await asyncio.wait_for(
proc.communicate(), timeout=timeout_seconds
)
stdout, _ = await asyncio.wait_for(proc.communicate(), timeout=timeout_seconds)
except TimeoutError:
proc.kill()
await proc.communicate()
@@ -336,9 +326,7 @@ class AdbDeviceProbe(BaseProbe):
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
)
stdout, _ = await asyncio.wait_for(
proc.communicate(), timeout=timeout_seconds
)
stdout, _ = await asyncio.wait_for(proc.communicate(), timeout=timeout_seconds)
if proc.returncode != 0:
return None
@@ -363,9 +351,7 @@ class AdbDeviceProbe(BaseProbe):
logger.debug(f"Failed submission-time ADB device check: {exc}")
return None
async def probe_submission_readiness(
self, target_serial: str | None = None
) -> ProbeResult:
async def probe_submission_readiness(self, target_serial: str | None = None) -> ProbeResult:
"""Run the minimal fail-safe device check required before enqueueing.
The full diagnostics probe enriches device metadata, scans packages,
@@ -417,8 +403,7 @@ class AdbDeviceProbe(BaseProbe):
"installed": True,
"submission_probe": True,
"devices": [
{"serial": serial, "state": state}
for serial, state in device_states
{"serial": serial, "state": state} for serial, state in device_states
],
},
)
@@ -563,9 +548,7 @@ class AdbDeviceProbe(BaseProbe):
"getprop",
"ro.build.version.release",
)
size_task = self._run_adb_shell(
adb_path, dev.serial, "wm", "size"
)
size_task = self._run_adb_shell(adb_path, dev.serial, "wm", "size")
packages_task = self._run_adb_shell(
adb_path,
dev.serial,
@@ -831,7 +814,9 @@ class AdbDeviceProbe(BaseProbe):
# 4. Ready device available
preferred_dev = None
if self._target_serial:
preferred_dev = next((d for d in ready_devices if d.serial == self._target_serial), None)
preferred_dev = next(
(d for d in ready_devices if d.serial == self._target_serial), None
)
if preferred_dev and preferred_dev.is_locked is False:
active_dev = preferred_dev
@@ -850,9 +835,7 @@ class AdbDeviceProbe(BaseProbe):
display_name = f"{display_name} ({active_dev.screen_resolution})"
metadata["active_device"] = active_dev.model_dump()
metadata["lock_state_source"] = self._lock_state_sources.get(
active_dev.serial, "unknown"
)
metadata["lock_state_source"] = self._lock_state_sources.get(active_dev.serial, "unknown")
if active_dev.is_locked is not False:
is_locked = active_dev.is_locked is True
+34 -119
View File
@@ -205,7 +205,7 @@ class DataEngine:
logger.warning(f"IPC send failed; reconnecting: {exc}")
try:
self.ipc_socket.close()
except Exception:
except OSError:
pass
self.ipc_socket = None
self._ipc_retry_after = 0.0
@@ -257,8 +257,8 @@ class DataEngine:
)
except RuntimeError:
pass
except Exception:
pass
except Exception as exc:
logger.debug(f"Cloud gateway event bridge skipped: {exc}", exc_info=True)
self._send_ipc_event(event_type, data)
@@ -586,7 +586,7 @@ class DataEngine:
app = foreground_app or _derive_foreground_app(ui_tree)
if app:
extra_metadata.setdefault("foreground_app", app)
except Exception:
except (AttributeError, TypeError, ValueError):
pass
# M4: stamp perceptual hashes for the local screen-similarity hint
@@ -601,7 +601,7 @@ class DataEngine:
post_dhash = dhash_hex(post_screenshot_bytes) if post_image_name else pre_dhash
if post_dhash:
extra_metadata.setdefault("post_image_dhash", post_dhash)
except Exception:
except ImportError:
pass
step = StepRecord(
@@ -626,22 +626,9 @@ class DataEngine:
self.get_or_create_image(post_screenshot_bytes)
self.storage.create_step(step)
# Run storage in background
try:
task = asyncio.create_task(asyncio.to_thread(_run_background_storage))
self._pending_tasks.add(task)
task.add_done_callback(self._on_background_task_done)
except RuntimeError:
# Fallback if no event loop
def run_and_cleanup():
_run_background_storage()
curr_thread = threading.current_thread()
if curr_thread in self._pending_threads:
self._pending_threads.remove(curr_thread)
thread = threading.Thread(target=run_and_cleanup)
self._pending_threads.append(thread)
thread.start()
# Run storage in the shared background scheduler. It chooses an
# asyncio worker when a loop is active and a tracked thread otherwise.
self._run_in_background(_run_background_storage)
logger.info(f"Recorded step {step_number} for session {self.current_session_id}")
@@ -736,23 +723,7 @@ class DataEngine:
payload=payload,
)
# Run storage in background
try:
asyncio.get_running_loop()
task = asyncio.create_task(asyncio.to_thread(self.storage.create_trace, trace))
self._pending_tasks.add(task)
task.add_done_callback(self._on_background_task_done)
except RuntimeError:
# Fallback if no event loop
def run_and_cleanup():
self.storage.create_trace(trace)
curr_thread = threading.current_thread()
if curr_thread in self._pending_threads:
self._pending_threads.remove(curr_thread)
thread = threading.Thread(target=run_and_cleanup)
self._pending_threads.append(thread)
thread.start()
self._run_in_background(self.storage.create_trace, trace)
# Publish event
trace_dict = trace.model_dump()
@@ -787,21 +758,7 @@ class DataEngine:
error_message=error_message,
)
try:
task = asyncio.create_task(asyncio.to_thread(self.storage.create_failed_output, record))
self._pending_tasks.add(task)
task.add_done_callback(self._pending_tasks.discard)
except RuntimeError:
def run_and_cleanup():
self.storage.create_failed_output(record)
curr_thread = threading.current_thread()
if curr_thread in self._pending_threads:
self._pending_threads.remove(curr_thread)
thread = threading.Thread(target=run_and_cleanup)
self._pending_threads.append(thread)
thread.start()
self._run_in_background(self.storage.create_failed_output, record)
def has_pending_operations(self) -> bool:
"""Check if there are any pending background tasks or threads."""
@@ -830,7 +787,7 @@ class DataEngine:
if self.ipc_socket is not None:
try:
self.ipc_socket.close()
except Exception:
except OSError:
pass
self.ipc_socket = None
@@ -918,8 +875,11 @@ class DataEngine:
"completion_tokens": step_c,
"total_tokens": step_t,
}
except Exception:
pass
except Exception as exc:
logger.debug(
f"Step token usage aggregation for SSE skipped for {step_id}: {exc}",
exc_info=True,
)
return None
def _on_background_task_done(self, task: asyncio.Task):
@@ -927,27 +887,30 @@ class DataEngine:
if not task.cancelled() and task.exception():
exc = task.exception()
logger.error(f"Background task failed with exception: {exc}", exc_info=exc)
raise exc
def _run_in_background(self, fn, *args):
"""Safely offload blocking storage/disk operation to background without stalling event loop."""
try:
task = asyncio.create_task(asyncio.to_thread(fn, *args))
self._pending_tasks.add(task)
task.add_done_callback(self._on_background_task_done)
loop = asyncio.get_running_loop()
except RuntimeError:
def run_and_cleanup():
try:
fn(*args)
except Exception as exc:
logger.error(f"Background storage operation failed: {exc}", exc_info=exc)
finally:
curr_thread = threading.current_thread()
if curr_thread in self._pending_threads:
self._pending_threads.remove(curr_thread)
thread = threading.Thread(target=run_and_cleanup)
thread = threading.Thread(target=run_and_cleanup, name="artemis-storage", daemon=True)
self._pending_threads.append(thread)
thread.start()
else:
task = loop.create_task(asyncio.to_thread(fn, *args))
self._pending_tasks.add(task)
task.add_done_callback(self._on_background_task_done)
def update_step_action(
self,
@@ -1008,8 +971,10 @@ class DataEngine:
if step_record and getattr(step_record, "step_number", None) is not None:
self._step_number_cache[sid] = int(step_record.step_number)
return int(step_record.step_number)
except Exception:
pass
except Exception as exc:
logger.debug(
f"Step number lookup from storage skipped for {sid}: {exc}", exc_info=True
)
return None
def get_step_record(self, step_number: int):
@@ -1260,10 +1225,7 @@ class DataEngine:
None,
)
if parent_trace:
if parent_trace.name in (
"operator",
"failure_analyzer",
):
if parent_trace.name == "operator":
is_relevant_llm = True
t.name = parent_trace.name
break
@@ -1301,14 +1263,9 @@ class DataEngine:
block.get("type") == "thinking"
and block.get("thinking", "").strip()
):
e_type = (
"failure_analyzer_native_thought"
if t.name == "failure_analyzer"
else "native_thought"
)
interleaved_events.append(
{
"type": e_type,
"type": "native_thought",
"content": (block["thinking"].strip()),
}
)
@@ -1316,14 +1273,9 @@ class DataEngine:
block.get("type") == "text"
and block.get("text", "").strip()
):
e_type = (
"failure_analyzer_thought"
if t.name == "failure_analyzer"
else "thought"
)
interleaved_events.append(
{
"type": e_type,
"type": "thought",
"content": (block["text"].strip()),
}
)
@@ -1339,14 +1291,9 @@ class DataEngine:
thought_text = safe_extract_text(thought_text)
if thought_text and thought_text.strip():
e_type = (
"failure_analyzer_thought"
if t.name == "failure_analyzer"
else "thought"
)
interleaved_events.append(
{
"type": e_type,
"type": "thought",
"content": thought_text.strip(),
}
)
@@ -1453,23 +1400,7 @@ class DataEngine:
self._bg_task_to_trace_id = {}
self._bg_task_to_trace_id[task_id] = str(trace_id)
try:
task = asyncio.create_task(
asyncio.to_thread(self.storage.create_background_task, record)
)
self._pending_tasks.add(task)
task.add_done_callback(self._pending_tasks.discard)
except RuntimeError:
def run_and_cleanup():
self.storage.create_background_task(record)
curr_thread = threading.current_thread()
if curr_thread in self._pending_threads:
self._pending_threads.remove(curr_thread)
thread = threading.Thread(target=run_and_cleanup)
self._pending_threads.append(thread)
thread.start()
self._run_in_background(self.storage.create_background_task, record)
self._publish("background_tasks_updated", self.get_all_background_tasks())
@@ -1484,29 +1415,13 @@ class DataEngine:
if trace_id_str and hasattr(self, "_accumulated_logs"):
logs = self._accumulated_logs.pop(trace_id_str, "")
try:
task = asyncio.create_task(
asyncio.to_thread(
self._run_in_background(
self.storage.update_background_task_status_and_logs,
task_id,
status,
end_time,
logs,
)
)
self._pending_tasks.add(task)
task.add_done_callback(self._pending_tasks.discard)
except RuntimeError:
def run_and_cleanup():
self.storage.update_background_task_status_and_logs(task_id, status, end_time, logs)
curr_thread = threading.current_thread()
if curr_thread in self._pending_threads:
self._pending_threads.remove(curr_thread)
thread = threading.Thread(target=run_and_cleanup)
self._pending_threads.append(thread)
thread.start()
self._publish("background_tasks_updated", self.get_all_background_tasks())
+5 -5
View File
@@ -388,9 +388,9 @@ class StorageManager:
start_time=row["start_time"],
end_time=row["end_time"],
local_video_path=row["local_video_path"],
status=row["status"] if "status" in row.keys() else (
"ready" if row["end_time"] is not None else "recording"
),
status=row["status"]
if "status" in row.keys()
else ("ready" if row["end_time"] is not None else "recording"),
error=row["error"] if "error" in row.keys() else None,
)
return None
@@ -839,7 +839,7 @@ class StorageManager:
if "action_taken" in row.keys() and row["action_taken"]:
try:
action_taken = json.loads(row["action_taken"])
except Exception:
except (ValueError, TypeError):
pass
operator_raw_thinking = None
@@ -854,7 +854,7 @@ class StorageManager:
if "last_execution_result" in row.keys() and row["last_execution_result"]:
try:
last_execution_result = json.loads(row["last_execution_result"])
except Exception:
except (ValueError, TypeError):
pass
steps.append(
+6 -6
View File
@@ -59,7 +59,7 @@ def smart_serialize(obj: Any) -> str:
if len(img_bytes) > 1000:
sha = hashlib.sha256(img_bytes).hexdigest()
return f"<ImageRef: sha256={sha} length={len(obj)}>"
except Exception:
except (ValueError, TypeError):
pass
return (
f"<Massive String length={len(obj)} characters (truncated for"
@@ -89,7 +89,7 @@ def _deep_smart_clean(obj: Any) -> Any:
if len(img_bytes) > 1000:
sha = hashlib.sha256(img_bytes).hexdigest()
return f"<ImageRef: sha256={sha} length={len(obj)}>"
except Exception:
except (ValueError, TypeError):
pass
return (
f"<Massive String length={len(obj)} characters (truncated for"
@@ -480,7 +480,7 @@ def trace(
and state_arg.current_step_id
):
step_id = UUID(str(state_arg.current_step_id))
except Exception:
except (TypeError, ValueError, AttributeError):
pass
ctx_obj.data_engine.record_trace(
@@ -613,9 +613,9 @@ class TraceSpan:
self.parent_id = None
self.token = None
self.status = "success"
self.result = None
self.error = None
self.payload = {}
self.result: Any = None
self.error: Any = None
self.payload: dict[str, Any] = {}
def __enter__(self):
self.start_time = time.time()
+2 -6
View File
@@ -323,11 +323,7 @@ class AndroidAdbDriver(BaseDeviceDriver):
)
# Normalize literal escaped newlines from LLM / tool call serialization
norm_text = (
text.replace(r"\r\n", "\n")
.replace(r"\n", "\n")
.replace(r"\r", "\n")
)
norm_text = text.replace(r"\r\n", "\n").replace(r"\n", "\n").replace(r"\r", "\n")
# 1. Tier 1: Try clipboard injection + KEYCODE_PASTE (Zero IME interference, preserves multiline, works for all charsets)
if self._ui_adb_client:
@@ -469,7 +465,7 @@ class AndroidAdbDriver(BaseDeviceDriver):
try:
self._scrcpy_process.terminate()
await asyncio.wait_for(self._scrcpy_process.wait(), timeout=5.0)
except (ProcessLookupError, OSError, asyncio.TimeoutError) as e:
except (TimeoutError, ProcessLookupError, OSError) as e:
# Already exited, or did not stop within the timeout; a lingering
# scrcpy may keep the MKV file locked on Windows.
logger.debug(f"scrcpy process did not terminate cleanly: {e}")
+2 -3
View File
@@ -26,7 +26,7 @@ from pathlib import Path
import re
import time
from typing import Any, Literal
from pydantic import BaseModel, Field
from pydantic import BaseModel, ConfigDict, Field
class SwipeDirection(str, Enum):
@@ -61,8 +61,7 @@ class ScreenData(BaseModel):
timestamp: float = Field(default_factory=time.time, description="Capture timestamp")
platform: str = Field(default="android", description="Platform identifier")
class Config:
arbitrary_types_allowed = True
model_config = ConfigDict(arbitrary_types_allowed=True)
class BaseDeviceDriver(ABC):
+172 -5
View File
@@ -58,6 +58,12 @@ logger = get_logger(__name__)
LEDGER_FILENAME = "check_ledger.jsonl"
RUN_OUTCOME_FILENAME = "run_outcome.json"
#: Event type published on the DataEngine bus (and forwarded over IPC/SSE to the
#: admin console) for every visible step of the Checker's process.
CHECKER_EVENT = "checker_event"
FINAL_CHECKPOINT_ID = "final"
FINAL_SUBGOAL_TEXT = "Final review against the user's original goal"
# --- Config accessors (all fail-safe against a missing execution_setup) --------------
@@ -133,6 +139,116 @@ class RunOutcome(BaseModel):
tests: TestSummary
# --- Live process events --------------------------------------------------------------
def publish_checker_event(ctx, payload: dict) -> None:
"""Publishes one ``checker_event`` on the DataEngine bus.
The event is a *projection* for observers (admin console / SSE): the
append-only ledger stays the single source of truth, and publishing never
influences booking or release. Best-effort: a missing engine or a failing
subscriber is logged and ignored.
"""
engine = getattr(ctx, "data_engine", None)
publish = getattr(engine, "_publish", None)
if not callable(publish):
return
now = time.time()
try:
# ``timestamp`` mirrors the other bus events so the UI orders checker
# blocks on the same timeline as steps and traces.
publish(CHECKER_EVENT, {"ts": now, "timestamp": now, **payload})
except Exception as e:
logger.debug(f"Failed to publish checker event: {e}")
def announce_checker_attempt(
ctx,
*,
attempt_id: str | None,
phase: str,
checkpoint_id: str,
subgoal_text: str,
anchor_step_id: str | None,
check_items,
) -> None:
"""Publishes ``attempt_started`` from inside the Checker's traced scope.
Called at the entry of the checker functions so the event can carry the
Checker's own ``trace_id``: the UI uses it to route the Checker's streamed
reasoning (``llm_stream``) and tool traces into the attempt's timeline
block instead of the Operator step that happens to be current.
"""
if not attempt_id:
return
try:
from artemis.data_engine.context_vars import CURRENT_TRACE_ID
trace_id = CURRENT_TRACE_ID.get()
except (ImportError, LookupError):
trace_id = None
if trace_id:
_attempt_traces(ctx)[attempt_id] = str(trace_id)
publish_checker_event(
ctx,
{
"event": "attempt_started",
"phase": phase,
"attempt_id": attempt_id,
"checkpoint_id": checkpoint_id,
"subgoal_text": subgoal_text,
"anchor_step_id": anchor_step_id,
"trace_id": str(trace_id) if trace_id else None,
"items": check_item_payloads(check_items),
},
)
def _attempt_traces(ctx) -> dict:
"""attempt_id -> the Checker's trace id for that attempt (set at announce
time). Persisted on ledger records so the UI backfill can re-attach the
attempt's tool traces (which carry that trace as parent) to its block."""
traces = getattr(ctx, "checker_attempt_traces", None)
if not isinstance(traces, dict):
traces = {}
try:
ctx.checker_attempt_traces = traces
except (AttributeError, TypeError, ValueError):
pass
return traces
def attempt_trace_id(ctx, attempt_id: str | None) -> str | None:
if not attempt_id:
return None
value = _attempt_traces(ctx).get(attempt_id)
return str(value) if value else None
def verdict_payloads(verdicts) -> list[dict]:
"""Plain-dict projection of verdict objects (pydantic or attribute bags)."""
out: list[dict] = []
for v in verdicts or []:
out.append(
{
"item_text": str(getattr(v, "item_text", "") or ""),
"kind": str(getattr(v, "kind", "") or ""),
"status": str(getattr(v, "status", "") or ""),
"evidence": str(getattr(v, "evidence", "") or ""),
"suggestion": str(getattr(v, "suggestion", "") or ""),
}
)
return out
def check_item_payloads(check_items) -> list[dict]:
return [
{"kind": ci.kind, "text": ci.text, "when": getattr(ci, "when", "on_complete")}
for ci in (check_items or [])
]
# --- Ledger (append-only) ------------------------------------------------------------
@@ -279,7 +395,7 @@ def _finding_registry(ctx) -> dict:
registry = {}
try:
ctx.checker_findings = registry
except Exception:
except (AttributeError, TypeError, ValueError):
pass
return registry
@@ -419,6 +535,8 @@ def _record_verdicts(
{
"attempt_id": run.attempt_id,
"checkpoint_id": run.checkpoint.checkpoint_id,
"subgoal_text": run.checkpoint.subgoal_text,
"trace_id": attempt_trace_id(ctx, run.attempt_id),
"item_text": v.item_text,
"kind": v.kind,
"when": when_by_text.get((v.kind, v.item_text), "on_complete"),
@@ -460,6 +578,8 @@ def _record_attempt_failure(ctx, run: CheckpointRun, status: str, evidence: str)
{
"attempt_id": run.attempt_id,
"checkpoint_id": run.checkpoint.checkpoint_id,
"subgoal_text": run.checkpoint.subgoal_text,
"trace_id": attempt_trace_id(ctx, run.attempt_id),
"item_text": ci.text,
"kind": ci.kind,
"when": ci.when,
@@ -490,6 +610,35 @@ def harvest_run(
findings: list[str] = []
checkpoint = run.checkpoint
def _event(status: str, verdicts_payload: list[dict], **extra) -> None:
publish_checker_event(
ctx,
{
"event": "attempt_finished",
"phase": "checkpoint",
"attempt_id": run.attempt_id,
"checkpoint_id": checkpoint.checkpoint_id,
"subgoal_text": checkpoint.subgoal_text,
"trace_id": attempt_trace_id(ctx, run.attempt_id),
"status": status,
"verdicts": verdicts_payload,
"findings": list(findings),
**extra,
},
)
def _failure_verdicts(status: str, evidence: str) -> list[dict]:
return [
{
"item_text": ci.text,
"kind": ci.kind,
"status": status,
"evidence": evidence,
"suggestion": "",
}
for ci in checkpoint.check_items
]
if task.cancelled():
evidence = (
"attempt superseded by a newer completion of the same subgoal"
@@ -497,6 +646,7 @@ def harvest_run(
else "cancelled at exit settlement (settlement timeout)"
)
_record_attempt_failure(ctx, run, cancelled_status, evidence)
_event(cancelled_status, _failure_verdicts(cancelled_status, evidence))
return findings
exc = task.exception()
@@ -512,6 +662,7 @@ def harvest_run(
f" ({reason}); releasing (fail-open) but recording inconclusive."
)
_record_attempt_failure(ctx, run, status, reason)
_event("error", _failure_verdicts(status, reason), error=reason)
return findings
report = task.result()
@@ -532,6 +683,7 @@ def harvest_run(
registry = _finding_registry(ctx)
registry_changed = False
reverted = False
if verify_failures and applicable and not user_stopped:
max_repairs = int(_setting(ctx, "checkpoint_max_repairs", 2))
@@ -584,6 +736,13 @@ def harvest_run(
"Assert failure under 'halt' policy: latching halt flag for exit settlement."
)
_event(
"done",
verdict_payloads(verdicts),
applicable=bool(applicable),
reverted=bool(reverted),
repairs_used=int(ctx.checkpoint_repairs.get(checkpoint.checkpoint_id, 0)),
)
return findings
@@ -638,8 +797,12 @@ async def spawn_pending_checkpoints(ctx, state, anchor_step_id: str | None) -> l
old_run.task.cancel()
try:
await old_run.task
except (asyncio.CancelledError, Exception):
pass
except (asyncio.CancelledError, Exception) as exc:
logger.debug(
f"Superseded checkpoint attempt {old_run.attempt_id} ended with "
f"{exc!r}; harvesting anyway",
exc_info=True,
)
harvest_run(ctx, state, old_run, allow_side_effects=False)
seq = ctx.checkpoint_attempt_seq.get(cid, 0) + 1
@@ -658,6 +821,7 @@ async def spawn_pending_checkpoints(ctx, state, anchor_step_id: str | None) -> l
anchor=anchor,
goal=goal,
subgoal_text=pending.subgoal_text,
attempt_id=attempt_id,
),
timeout=timeout,
)
@@ -699,8 +863,11 @@ async def settle_all_checkpoints(ctx, state) -> None:
for t in pending:
try:
await t
except (asyncio.CancelledError, Exception):
pass
except (asyncio.CancelledError, Exception) as exc:
logger.debug(
f"Cancelled checkpoint attempt ended with {exc!r} at settlement; harvesting anyway",
exc_info=True,
)
for run in runs:
# Settlement is bookkeeping, not a repair point: side effects off.
+116 -68
View File
@@ -38,27 +38,32 @@ from artemis.agents.validator.validator import ValidatorNode
from artemis.context import ArtemisContext
from artemis.graph.checkpoints import (
append_ledger_record,
attempt_trace_id,
compute_run_outcome,
final_check_enabled,
FINAL_CHECKPOINT_ID,
FINAL_SUBGOAL_TEXT,
harvest_finished_checkpoints,
has_ledger_records,
is_checker_note_key,
publish_checker_event,
queue_checkpoints,
read_ledger,
revert_subgoal_status,
settle_all_checkpoints,
spawn_pending_checkpoints,
sync_finding_lines,
verdict_payloads,
write_run_outcome,
)
from artemis.graph.perception import perception_node
from artemis.graph.state import State
from artemis.graph.visibility import strict_state
from artemis.tools.command_tool import (
HANG_GUIDANCE,
manage_task_wrapper,
run_adb_command_wrapper,
)
from artemis.tools.committee_tool import ask_committee_wrapper
from artemis.tools.diagnostic_tool import ask_diagnoser_wrapper
from artemis.tools.explorer_tool import ask_explorer_wrapper
from artemis.tools.index import get_tools_from_wrappers
@@ -173,6 +178,7 @@ async def execution_check_node(state: State, ctx: ArtemisContext):
# Harvest finished checkpoint attempts (non-blocking: done() tasks only).
check_findings = harvest_finished_checkpoints(ctx, state)
turn_metadata: dict = {}
if hasattr(ctx, "planner_task") and ctx.planner_task:
logger.info("Waiting for planner validation task to complete...")
@@ -181,50 +187,32 @@ async def execution_check_node(state: State, ctx: ArtemisContext):
planner_result = await awaited_planner_task
ctx.planner_task = None
if planner_result and planner_result.get("status") == "failed":
logger.warning("Planner rejected the task plan changes.")
# Targeted rollback: restore task_plan.md to the last content
# consistent with the validated baseline
if ctx.task_plan_content_before is not None:
task_plan_path = get_note_file_path(ctx.data_engine.base_dir, "task_plan")
if task_plan_path.exists():
task_plan_path.write_text(ctx.task_plan_content_before, encoding="utf-8")
logger.info("Rolled back task_plan.md to its original state.")
ctx.pending_validated_plan = None
ctx.task_plan_content_before = None
# Surface the rejection through operator_feedback — the only
# feedback channel the Operator's next prompt actually renders.
feedback = planner_result.get("feedback", "Your task plan changes were rejected.")
check_findings.append(
"[planner] Your recent modification to the top-level task"
" plan was rejected and the plan was rolled back to its"
f" previous state. Reason: {feedback} Any terminal actions"
" you outputted were not executed. Review the task goal"
" again and consider what the optimal strategy is now."
flagged = bool(planner_result and planner_result.get("status") == "failed")
if flagged:
# Advisory outcome: the plan change stays applied and the
# turn's actions run. The Operator only receives the concern
# and its reason through operator_feedback — the one feedback
# channel its next prompt renders.
logger.warning("Planner validation flagged the task plan changes.")
feedback = planner_result.get(
"feedback", "The reviewer had a concern about this plan change."
)
# Even a rejected turn leaves a step record: the record reflects
# the decision made and the fact that its terminal actions were
# intercepted before execution.
rejected_step_id = _record_turn({"planner_rejected": True})
return {
"checker_success": False,
"structured_decisions": "",
"current_step_id": rejected_step_id,
"operator_feedback": check_findings or None,
}
check_findings.append(
"[planner] Advisory review of your recent top-level plan"
f" change: {feedback} The change was NOT rolled back —"
" weigh this concern against your own observations and"
" correct the plan only if you agree."
)
turn_metadata["planner_flagged"] = True
else:
logger.info("Planner approved the task plan changes.")
# Advance the ratchet baseline: the validated content becomes
# the new reference for future drift comparisons.
logger.info("Planner validation approved the task plan changes.")
# Advance the ratchet baseline either way: the reviewed content is
# the new reference, so an already-flagged change is not re-flagged
# on every subsequent write.
if ctx.pending_validated_plan is not None:
ctx.last_validated_plan = ctx.pending_validated_plan
ctx.pending_validated_plan = None
ctx.task_plan_content_before = None
except asyncio.CancelledError:
if not awaited_planner_task.cancelled():
raise # this node itself is being cancelled, not the awaited task
@@ -234,7 +222,7 @@ async def execution_check_node(state: State, ctx: ArtemisContext):
logger.error(f"Planner validation task failed: {e}")
# Every Operator turn is recorded — verification never gates history.
current_step_id = _record_turn({})
current_step_id = _record_turn(turn_metadata)
# Spawn queued checkpoints only now: the just-recorded step is the evidence
# anchor (its pre screenshot plus the previous turn's post state capture
@@ -295,8 +283,26 @@ async def exit_settlement_node(state: State, ctx: ArtemisContext):
if final_check_enabled(ctx):
attempt_no = ctx.final_check_attempts + 1
ctx.final_check_attempts = attempt_no
final_attempt_id = f"final#{attempt_no}"
checkpoint_timeout = float(getattr(setup, "checkpoint_timeout", 180.0) or 180.0)
ledger = read_ledger(base_dir)
def _final_event(status: str, verdicts: list[dict], **extra) -> None:
publish_checker_event(
ctx,
{
"event": "attempt_finished",
"phase": "final",
"attempt_id": final_attempt_id,
"checkpoint_id": FINAL_CHECKPOINT_ID,
"subgoal_text": FINAL_SUBGOAL_TEXT,
"trace_id": attempt_trace_id(ctx, final_attempt_id),
"status": status,
"verdicts": verdicts,
**extra,
},
)
try:
report = await asyncio.wait_for(
run_final_check(
@@ -305,18 +311,22 @@ async def exit_settlement_node(state: State, ctx: ArtemisContext):
plan_text=plan_text,
ledger=ledger,
check_items=list(snapshot.all_check_items),
attempt_id=final_attempt_id,
),
timeout=checkpoint_timeout,
)
except Exception as e:
# Fail-open: release, but the verdict value stays inconclusive.
logger.warning(f"Final check errored ({e}); releasing fail-open.")
error_verdicts: list[dict] = []
for ci in snapshot.all_check_items:
append_ledger_record(
base_dir,
{
"attempt_id": f"final#{attempt_no}",
"checkpoint_id": "final",
"attempt_id": final_attempt_id,
"checkpoint_id": FINAL_CHECKPOINT_ID,
"subgoal_text": FINAL_SUBGOAL_TEXT,
"trace_id": attempt_trace_id(ctx, final_attempt_id),
"item_text": ci.text,
"kind": ci.kind,
"when": ci.when,
@@ -325,6 +335,16 @@ async def exit_settlement_node(state: State, ctx: ArtemisContext):
"anchor_step_id": None,
},
)
error_verdicts.append(
{
"item_text": ci.text,
"kind": ci.kind,
"status": "inconclusive",
"evidence": f"final check error: {e}",
"suggestion": "",
}
)
_final_event("error", error_verdicts, error=str(e), route="end")
report = None
if report is not None:
@@ -340,8 +360,10 @@ async def exit_settlement_node(state: State, ctx: ArtemisContext):
append_ledger_record(
base_dir,
{
"attempt_id": f"final#{attempt_no}",
"checkpoint_id": "final",
"attempt_id": final_attempt_id,
"checkpoint_id": FINAL_CHECKPOINT_ID,
"subgoal_text": FINAL_SUBGOAL_TEXT,
"trace_id": attempt_trace_id(ctx, final_attempt_id),
"item_text": v.item_text,
"kind": v.kind,
"when": when,
@@ -368,6 +390,13 @@ async def exit_settlement_node(state: State, ctx: ArtemisContext):
" budget is exhausted (or a stop/halt is latched);"
" ending with a blocked outcome."
)
_final_event(
"done",
verdict_payloads(report.verdicts),
unmet_subgoals=list(report.unmet_subgoals),
findings=list(blocked_findings),
route="blocked",
)
else:
findings: list[str] = []
for text in report.unmet_subgoals:
@@ -384,7 +413,22 @@ async def exit_settlement_node(state: State, ctx: ArtemisContext):
)
update["operator_feedback"] = findings or None
update["exit_settlement_route"] = "continue"
_final_event(
"done",
verdict_payloads(report.verdicts),
unmet_subgoals=list(report.unmet_subgoals),
findings=list(findings),
route="continue",
)
return update
else:
_final_event(
"done",
verdict_payloads(report.verdicts),
unmet_subgoals=[],
findings=[],
route="end",
)
# END path: assemble the machine-readable run outcome.
records = read_ledger(base_dir)
@@ -394,6 +438,15 @@ async def exit_settlement_node(state: State, ctx: ArtemisContext):
# BLOCKED/partial wrap-ups carry the last findings in the metadata file.
extra = {"last_findings": blocked_findings} if blocked_findings else None
write_run_outcome(base_dir, outcome, extra)
publish_checker_event(
ctx,
{
"event": "run_outcome",
"phase": "outcome",
**outcome.model_dump(),
"last_findings": list(blocked_findings),
},
)
logger.info(
f"Run outcome: task_status={outcome.task_status},"
f" tests(passed={outcome.tests.passed}, failed={outcome.tests.failed},"
@@ -624,13 +677,6 @@ async def _process_plan_write(
# A newer write supersedes the in-flight validation.
ctx.planner_task.cancel()
if not milestones_changed(baseline, before):
# Last content still consistent with the baseline: preserves
# status progress made since the baseline as rollback target.
ctx.task_plan_content_before = content_before
elif ctx.task_plan_content_before is None:
ctx.task_plan_content_before = ctx.last_validated_plan
ctx.pending_validated_plan = content_after
initial_goal = (
state.initial_goal if state and hasattr(state, "initial_goal") else "Unknown Goal"
@@ -650,10 +696,11 @@ async def _process_plan_write(
if isinstance(result, ToolMessage) and isinstance(result.content, str):
result.content = (
f"{result.content}\n\nWe have applied your changes"
" to the task plan. A background verification task"
" is currently reviewing these changes. If there"
" are any issues with your modifications, you will"
" be notified shortly."
" to the task plan. A lightweight background review"
" is checking the milestone change against the"
" original goal; if it has a concern you will get an"
" advisory hint with the reason (the change stays"
" applied either way)."
)
# Queue (never spawn) a checkpoint for EVERY newly completed top-level
@@ -818,8 +865,10 @@ def convergence_gate(
logger.warning("Assert-halt latched; routing to exit settlement.")
return _terminal_route()
# Check planner-validation result
if hasattr(state, "checker_success") and not state.checker_success:
# Check planner-validation result. ``None`` means "not yet evaluated"
# (the first pass after the Planner node) — only an explicit False is a
# failure; otherwise the log would misreport the initial plan as rejected.
if getattr(state, "checker_success", None) is False:
logger.info("Plan validation failed, returning to operator for retry.")
return "continue"
@@ -894,8 +943,7 @@ async def get_graph(ctx: ArtemisContext) -> CompiledStateGraph:
ask_explorer_wrapper,
recall_history_wrapper,
]
if ctx.execution_setup and ctx.execution_setup.enable_committee:
operator_specialized_wrappers.append(ask_committee_wrapper)
# ask_committee does not follow the Operator's pre-decision / turn-ending contract.
operator_specialized_tools = get_tools_from_wrappers(ctx, operator_specialized_wrappers)
@@ -903,18 +951,18 @@ async def get_graph(ctx: ArtemisContext) -> CompiledStateGraph:
for t in operator_specialized_tools:
if t.name == "run_adb_command":
t.description = (
"[EXPLORER] Executes a shell command directly on the Android"
" mobile device via ADB shell. Use this tool when normal"
" operating tools cannot achieve the goal, do not rely on it."
" You must not use this tool to bypass goals that could be"
" achieved by action tools.\nCan run synchronously or"
" transition to a background task if execution takes longer"
"[SHELL] Executes a shell command directly on the Android"
" mobile device via ADB shell. Use it when the device action"
" tools cannot achieve the goal, or to start background"
" instrumentation (performance, power, traces); never to bypass"
" goals that the action tools can achieve.\nCan run synchronously"
" or transition to a background task if execution takes longer"
" than WaitMsBeforeAsync.\nSupports persistent environments"
" (environment variables) on the phone across invocations."
" (environment variables) on the phone across invocations.\n" + HANG_GUIDANCE
)
elif t.name == "manage_task":
t.description = (
"[EXPLORER] Manage background ADB shell tasks launched via"
"[SHELL] Manage background ADB shell tasks launched via"
" run_adb_command to monitor or terminate ongoing background"
" commands."
)
+12
View File
@@ -124,6 +124,18 @@ class State(BaseModel):
last_execution_result: Annotated[
dict | None, "Last execution result from validator", take_last
] = None
open_incident: Annotated[
dict | None,
"Execution incident (blocked/failed terminal action) still awaiting the"
" Operator's resolution; None once a later action executes successfully",
take_last,
] = None
last_closed_incident: Annotated[
dict | None,
"The incident the most recent successful terminal action closed (with"
" closed_at_step); rendered once so the Operator settles the original intent",
take_last,
] = None
subagent_calls: Annotated[
list[str], "List of sub-agent calls in current session", take_last
] = []
Loaded 100 of 288 files, more files were not shown because too many files have changed in this diff. Show more