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:
1 parent
5ccf06d677
commit
67248c887e
289 files changed
+15883
-6919
No files matched your search
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"broad_exception_handlers": 754,
|
||||
"silent_broad_exception_handlers": 0,
|
||||
"type_ignore_comments": 18
|
||||
}
|
||||
+18
-1
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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..."
|
||||
|
||||
@@ -220,7 +220,7 @@ async def main():
|
||||
client = ArtemisClient(
|
||||
"http://artemis-host:8000",
|
||||
device_serial="emulator-5554", # optional: target specific device serial
|
||||
default_profile="flash", # "flash" (fast reactive) or "pro" (deep reasoning)
|
||||
default_profile="flash", # "flash" (fast reactive) or "pro" (deep reasoning)
|
||||
)
|
||||
|
||||
result = await client.run(
|
||||
@@ -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
|
||||
|
||||
|
||||
+3
-3
@@ -221,7 +221,7 @@ async def main():
|
||||
client = ArtemisClient(
|
||||
"http://artemis-host:8000",
|
||||
device_serial="emulator-5554", # 可选:指定目标设备序列号(不传则自动选择空闲设备)
|
||||
default_profile="flash", # "flash" 极速校验 或 "pro" 深度推理自愈
|
||||
default_profile="flash", # "flash" 极速校验 或 "pro" 深度推理自愈
|
||||
)
|
||||
|
||||
result = await client.run(
|
||||
@@ -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]` 持续监控以及可选的书面报告。
|
||||
|
||||
## 路线图
|
||||
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
|
||||
@@ -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,15 +326,17 @@ 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))
|
||||
v_url = cls.path_to_video_url(p)
|
||||
if cls.is_browser_playable(p):
|
||||
v_url = cls.path_to_video_url(p)
|
||||
|
||||
orig_rec = video_rec_map.get(s_id)
|
||||
if not v_url and orig_rec:
|
||||
p = Path(orig_rec)
|
||||
if p.exists():
|
||||
p = cls.ensure_browser_playable_video(p)
|
||||
v_url = cls.path_to_video_url(p)
|
||||
else:
|
||||
if cls.is_browser_playable(p):
|
||||
v_url = cls.path_to_video_url(p)
|
||||
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,13 +350,16 @@ class MediaService:
|
||||
vfile = sess_dir / f"recording{ext}"
|
||||
if vfile.exists():
|
||||
vfile = cls.ensure_browser_playable_video(vfile)
|
||||
v_url = cls.path_to_video_url(vfile)
|
||||
if cls.is_browser_playable(vfile):
|
||||
v_url = cls.path_to_video_url(vfile)
|
||||
break
|
||||
if not v_url:
|
||||
try:
|
||||
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"
|
||||
|
||||
@@ -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,37 +688,68 @@ 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 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
|
||||
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 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(
|
||||
"recording_failed",
|
||||
{"session_id": sess_id, "error": recording_error},
|
||||
)
|
||||
if recovered_url:
|
||||
cls._broadcast_event(
|
||||
"recording_ready",
|
||||
{"session_id": sess_id, "video_url": recovered_url},
|
||||
)
|
||||
return
|
||||
|
||||
recording_error = "Task worker exited before recording finalization completed"
|
||||
if session_repo.mark_recording_failed_if_pending(sess_id, recording_error):
|
||||
cls._broadcast_event(
|
||||
"recording_failed",
|
||||
{"session_id": sess_id, "error": recording_error},
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def _announce_session_end(
|
||||
@@ -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,20 +1171,22 @@ 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:
|
||||
try:
|
||||
run_proc.kill()
|
||||
except (ProcessLookupError, OSError):
|
||||
# Process already exited between the check and the kill.
|
||||
pass
|
||||
if not cls._stop_proc_gracefully(run_proc, run.get("session_id") or run_key):
|
||||
try:
|
||||
run_proc.kill()
|
||||
except (ProcessLookupError, OSError):
|
||||
# Process already exited between the check and the kill.
|
||||
pass
|
||||
# active_runs entries are popped by each run's finalizer once the
|
||||
# process exit is observed; clearing them here would free the device
|
||||
# slots before the processes are actually gone.
|
||||
if state.current_process:
|
||||
try:
|
||||
state.current_process.kill()
|
||||
except (ProcessLookupError, OSError):
|
||||
# Process already exited between the check and the kill.
|
||||
pass
|
||||
if not cls._stop_proc_gracefully(state.current_process, state.active_session_id):
|
||||
try:
|
||||
state.current_process.kill()
|
||||
except (ProcessLookupError, OSError):
|
||||
# Process already exited between the check and the kill.
|
||||
pass
|
||||
state.current_process = None
|
||||
|
||||
@classmethod
|
||||
@@ -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 (
|
||||
local_run is not None
|
||||
or not target_sid
|
||||
or str(state.active_session_id) == target_sid
|
||||
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
|
||||
try:
|
||||
local_proc.kill()
|
||||
stopped = True
|
||||
except (ProcessLookupError, OSError):
|
||||
# Process already exited; terminate_tree above may have got it.
|
||||
pass
|
||||
if not deferred:
|
||||
try:
|
||||
local_proc.kill()
|
||||
stopped = True
|
||||
except (ProcessLookupError, OSError):
|
||||
# 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
|
||||
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
<!-- Robot Silhouette Mask -->
|
||||
<mask id="robotMeshMask">
|
||||
<rect width="240" height="240" fill="black" />
|
||||
|
||||
|
||||
<!-- Antennae -->
|
||||
<path d="M 90 60 Q 75 35 65 28" stroke="white" stroke-width="10" stroke-linecap="round" />
|
||||
<circle cx="62" cy="24" r="10" fill="white" />
|
||||
|
||||
|
Before Width: | Height: | Size: 2.5 KiB After Width: | Height: | Size: 2.5 KiB |
@@ -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,22 +700,36 @@ 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 (nativeText) {
|
||||
events.push({
|
||||
type: 'thinking',
|
||||
data: { text: stepData.operator_native_thinking },
|
||||
timestamp: getItemTimestamp(stepData.operator_native_thinking_timestamp, fallbackTime),
|
||||
sequence: sequence++
|
||||
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',
|
||||
data: { text: stepData.operator_native_thinking },
|
||||
timestamp: getItemTimestamp(stepData.operator_native_thinking_timestamp, fallbackTime),
|
||||
sequence: sequence++
|
||||
});
|
||||
}
|
||||
|
||||
if (rawText) {
|
||||
events.push({
|
||||
type: 'text',
|
||||
data: { text: stepData.operator_raw_thinking },
|
||||
timestamp: getItemTimestamp(stepData.operator_raw_thinking_timestamp, fallbackTime),
|
||||
sequence: sequence++
|
||||
});
|
||||
if (rawText) {
|
||||
events.push({
|
||||
type: 'text',
|
||||
data: { text: stepData.operator_raw_thinking },
|
||||
timestamp: getItemTimestamp(stepData.operator_raw_thinking_timestamp, fallbackTime),
|
||||
sequence: sequence++
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Add action_taken if present and valid.
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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"]
|
||||
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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."
|
||||
)
|
||||
@@ -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
@@ -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}")
|
||||
@@ -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
|
||||
@@ -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,10 +113,9 @@ 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")
|
||||
with open(file_path, "rb") as f:
|
||||
img_bytes = f.read()
|
||||
return types.Part.from_bytes(data=img_bytes, mime_type="image/jpeg")
|
||||
|
||||
return get_image_part
|
||||
|
||||
@@ -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,36 +338,31 @@ 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)
|
||||
)
|
||||
],
|
||||
tool_config=types.ToolConfig(
|
||||
function_calling_config=types.FunctionCallingConfig(
|
||||
mode=types.FunctionCallingConfigMode.ANY
|
||||
),
|
||||
include_server_side_tool_invocations=True,
|
||||
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))
|
||||
],
|
||||
tool_config=types.ToolConfig(
|
||||
function_calling_config=types.FunctionCallingConfig(
|
||||
mode=types.FunctionCallingConfigMode.ANY
|
||||
),
|
||||
**thinking_kwargs,
|
||||
)
|
||||
include_server_side_tool_invocations=True,
|
||||
),
|
||||
thinking_config=thinking_config,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _extract_response_texts(response) -> tuple[list[str], list[str]]:
|
||||
@@ -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,19 +550,17 @@ 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):
|
||||
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):
|
||||
except (ValueError, TypeError):
|
||||
errors.append(f"Candidate '{label or i}' coordinates are invalid integers.")
|
||||
else:
|
||||
errors.append(
|
||||
f"Candidate '{label or i}' coordinates are invalid integers."
|
||||
f"Candidate '{label or i}' coordinates"
|
||||
f" `[{nx}, {ny}]` must strictly be in"
|
||||
" the `[0-1000]` normalized scale"
|
||||
" range inclusive."
|
||||
)
|
||||
|
||||
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)
|
||||
turn_record["tool_calls"].append(
|
||||
{
|
||||
"name": "submit_answer",
|
||||
"args": args,
|
||||
"response": {"result": "success"},
|
||||
}
|
||||
)
|
||||
self.trace_history.append(turn_record)
|
||||
return agent_outcome
|
||||
|
||||
_ex.logger.info("Explorer submit_answer validation passed successfully!")
|
||||
# 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_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"):
|
||||
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")},
|
||||
)
|
||||
)
|
||||
"""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_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}")
|
||||
@@ -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:
|
||||
matches.append(m)
|
||||
existing_keys.add(key)
|
||||
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)
|
||||
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])
|
||||
labels.append(label_id)
|
||||
|
||||
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
|
||||
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}]")
|
||||
|
||||
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}",
|
||||
"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,
|
||||
"text": "ImageProcessor error: no output images were produced.",
|
||||
"image_paths": [],
|
||||
}
|
||||
|
||||
@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:
|
||||
label_id = f"{prefix}{self.global_label_idx}"
|
||||
self.global_label_idx += 1
|
||||
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}]")
|
||||
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)
|
||||
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)))
|
||||
|
||||
@@ -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,12 +397,10 @@ 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:
|
||||
minimal_list, marked_path = self._annotate_initial_screenshot(
|
||||
fused_xml, screenshot_path, image_name, minimal_list
|
||||
)
|
||||
if not minimal_list and fused_xml:
|
||||
minimal_list, marked_path = self._annotate_initial_screenshot(
|
||||
fused_xml, screenshot_path, image_name, minimal_list
|
||||
)
|
||||
|
||||
if marked_path and os.path.exists(str(marked_path)):
|
||||
img_to_read = str(marked_path)
|
||||
|
||||
@@ -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
|
||||
@@ -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]
|
||||
@@ -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(
|
||||
|
||||
@@ -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,27 +135,19 @@ 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(
|
||||
{
|
||||
"candidates": [],
|
||||
"fallback_message": (
|
||||
"Explorer reached max iterations without finding candidates."
|
||||
),
|
||||
},
|
||||
ensure_ascii=False,
|
||||
)
|
||||
|
||||
return agent_outcome
|
||||
return json.dumps(
|
||||
{
|
||||
"candidates": [],
|
||||
"fallback_message": "Explorer reached max iterations without finding candidates.",
|
||||
},
|
||||
ensure_ascii=False,
|
||||
)
|
||||
|
||||
def _build_universal_messages(
|
||||
self,
|
||||
@@ -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)
|
||||
@@ -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"])
|
||||
|
||||
@@ -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.
|
||||
+365
-167
@@ -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,62 +280,83 @@ 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:
|
||||
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"):
|
||||
injected_text = (
|
||||
"[REAL-TIME INJECTED INSTRUCTION from user]:"
|
||||
f" {injected_payload['instruction']}\nYou MUST immediately"
|
||||
" follow this instruction and adjust your plan/actions."
|
||||
)
|
||||
if injected_payload.get("release_loop"):
|
||||
injected_text += (
|
||||
"\nThe user has explicitly authorized stopping any"
|
||||
" 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}")
|
||||
|
||||
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."
|
||||
)
|
||||
)
|
||||
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),
|
||||
)
|
||||
return [t for t in tools_declaration if t.name == "report_task_status"]
|
||||
return tools_declaration
|
||||
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"
|
||||
" follow this instruction and adjust your plan/actions."
|
||||
)
|
||||
if injected_payload.get("release_loop"):
|
||||
injected_text += (
|
||||
"\nThe user has explicitly authorized stopping any"
|
||||
" ongoing monitoring loop; you may now wrap up and"
|
||||
" complete the task."
|
||||
)
|
||||
return injected_text
|
||||
|
||||
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}"}}
|
||||
)
|
||||
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.
|
||||
"""
|
||||
# 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)
|
||||
|
||||
# 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
|
||||
|
||||
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
|
||||
@@ -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,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
|
||||
}
|
||||
@@ -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 = []
|
||||
for tc in action_calls:
|
||||
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(
|
||||
|
||||
@@ -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,47 +643,50 @@ class BackgroundTasksPromptComponent(PromptComponent):
|
||||
|
||||
|
||||
class TaskPlanWarningPromptComponent(PromptComponent):
|
||||
"""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":
|
||||
return True
|
||||
return False
|
||||
|
||||
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",
|
||||
]:
|
||||
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
|
||||
|
||||
if not modified_in_last_two:
|
||||
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."
|
||||
)
|
||||
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: 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."
|
||||
)
|
||||
|
||||
|
||||
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
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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))
|
||||
|
||||
|
||||
@@ -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.
|
||||
@@ -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):
|
||||
|
||||
@@ -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"
|
||||
@@ -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,43 +172,57 @@ 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 = []
|
||||
|
||||
# 1. Pre-execution validation (Safety Net)
|
||||
original_coords = list(action_item.get("coordinates") or [])
|
||||
(
|
||||
validation_passed,
|
||||
validation_category,
|
||||
validation_error,
|
||||
) = await _run_precondition_gate(
|
||||
node, session, action_item, state, pre_screenshot_b64, original_coords
|
||||
)
|
||||
|
||||
if not validation_passed:
|
||||
outcome.success = False
|
||||
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"
|
||||
validation_passed = True
|
||||
if not burst:
|
||||
# 1. Pre-execution validation (Safety Net)
|
||||
original_coords = list(action_item.get("coordinates") or [])
|
||||
(
|
||||
validation_passed,
|
||||
validation_category,
|
||||
validation_error,
|
||||
) = await _run_precondition_gate(
|
||||
node, session, action_item, state, pre_screenshot_b64, original_coords
|
||||
)
|
||||
else:
|
||||
# 2. Local execution attempts
|
||||
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)
|
||||
|
||||
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:
|
||||
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:
|
||||
logger.info("Action failed, capturing failure screenshot...")
|
||||
outcome.post_screenshot_b64 = await _capture_live_screenshot(
|
||||
session, "failure screenshot"
|
||||
)
|
||||
|
||||
if not outcome.success:
|
||||
logger.info("Action failed, capturing 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)
|
||||
)
|
||||
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
|
||||
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))),
|
||||
]
|
||||
|
||||
|
||||
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.")
|
||||
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
|
||||
|
||||
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,
|
||||
|
||||
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),
|
||||
)
|
||||
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
|
||||
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.",
|
||||
}
|
||||
@@ -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()
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
@@ -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,
|
||||
]
|
||||
@@ -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:
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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",
|
||||
|
||||
+123
-53
@@ -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")
|
||||
):
|
||||
overrides = getattr(exec_setup, "explorer_versions", None)
|
||||
return 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),
|
||||
):
|
||||
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(
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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:
|
||||
|
||||
@@ -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,12 +720,9 @@ 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(
|
||||
Path(r.get("output_path", "")).exists() or Path(r.get("path", "")).exists()
|
||||
for r in session.android_segment_records
|
||||
)
|
||||
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"
|
||||
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
+40
-125
@@ -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.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._run_in_background(
|
||||
self.storage.update_background_task_status_and_logs,
|
||||
task_id,
|
||||
status,
|
||||
end_time,
|
||||
logs,
|
||||
)
|
||||
|
||||
self._publish("background_tasks_updated", self.get_all_background_tasks())
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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}")
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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.
|
||||
|
||||
+119
-71
@@ -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.
|
||||
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
|
||||
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
|
||||
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."
|
||||
)
|
||||
|
||||
Loaded 100 of 289 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user