feat: surface task execution progress across Host Agent and Cloud

Host Agent now persists step-level execution detail locally (via a real
TaskMetadataStore/Timeline wired into TaskRunner) and reports a bounded
in-progress snapshot piggybacked on lease renewal. Cloud persists that
snapshot per active assignment and exposes it through the existing task
list/detail query path; Cloud Console renders it as a live badge. Host
Agent's local console gains authenticated, read-only task list and
detail/timeline pages (same-origin, server-rendered) with inlined
screenshots.

Also fixes a pre-existing gap in the shared Timeline: the actual
per-step LLM prompt is now recorded instead of the task goal, benefiting
both Runtime and Host Agent consoles. When a host uses the cloud planner
transport, each decide call's prompt and resulting tool decision are
durably logged in a new planner_decision_log table (with bounded
retention) and browsable from Cloud Console; direct-transport hosts
explicitly surface a "not reported" state.

Includes Alembic migrations 0008 (progress columns on scheduled_tasks)
and 0009 (planner_decision_log), bounded Host-Agent-local retention,
dual-backend repository parity, and Vitest + pytest coverage. Task 6.5
(manual end-to-end device verification) remains.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-07-14 12:47:49 +08:00
co-authored by Claude Opus 4.6
parent c049c3c1b1
commit ec261d57c2
59 changed files with 3801 additions and 122 deletions
+38
View File
@@ -187,6 +187,19 @@ def create_app(
), ),
name="cloud-lease-reaper", name="cloud-lease-reaper",
), ),
asyncio.create_task(
_run_planner_decision_log_pruner_loop(
services,
stop_workers,
interval_seconds=(
control_config.planner_decision_log_prune_interval_seconds
),
retention_days=(
control_config.planner_decision_log_retention_days
),
),
name="cloud-planner-decision-log-pruner",
),
] ]
app.state.worker_tasks = tuple(worker_tasks) app.state.worker_tasks = tuple(worker_tasks)
app.state.startup_complete = True app.state.startup_complete = True
@@ -442,3 +455,28 @@ async def _wait_for_stop(stop: asyncio.Event, interval_seconds: float) -> bool:
except TimeoutError: except TimeoutError:
return False return False
return True return True
async def _run_planner_decision_log_pruner_loop(
services: CloudApplicationServices,
stop: asyncio.Event,
*,
interval_seconds: float,
retention_days: int,
) -> None:
while not stop.is_set():
correlation_token = bind_correlation_id(new_correlation_id())
try:
services.repository.prune_planner_decision_log(
now=utc_now(),
prune_after_terminal_seconds=retention_days * 86_400,
)
except Exception:
logger.exception(
"planner decision log prune failed",
extra={"worker": "planner_decision_log_pruner"},
)
finally:
reset_correlation_id(correlation_token)
if await _wait_for_stop(stop, interval_seconds):
return
+47 -8
View File
@@ -22,10 +22,14 @@ from host_agent.lease import ActiveAssignmentRunner
from host_agent.local_account import LocalAccountStore from host_agent.local_account import LocalAccountStore
from host_agent.policy_cache import HostPolicyCacheStore from host_agent.policy_cache import HostPolicyCacheStore
from host_agent.processor import AssignmentProcessingResult, AssignmentProcessor from host_agent.processor import AssignmentProcessingResult, AssignmentProcessor
from host_agent.retention import prune_task_history
from host_agent.status import AgentStatusTracker from host_agent.status import AgentStatusTracker
from host_agent.web.app import create_console_app from host_agent.web.app import create_console_app
from host_agent.web.auth import SessionManager from host_agent.web.auth import SessionManager
from storage.artifact_store import ArtifactStore
from storage.device_config import DeviceConfigStore from storage.device_config import DeviceConfigStore
from storage.task_metadata import TaskMetadataStore
from storage.timeline import Timeline
@dataclass @dataclass
@@ -178,6 +182,16 @@ def create_application(
console_enrollment_client: HostAgentEnrollmentClient | None = None console_enrollment_client: HostAgentEnrollmentClient | None = None
if resolved_config.enrollment_managed: if resolved_config.enrollment_managed:
console_enrollment_client = HostAgentEnrollmentClient(resolved_config) console_enrollment_client = HostAgentEnrollmentClient(resolved_config)
metadata_store = TaskMetadataStore(db_path=resolved_config.task_progress_db_path)
timeline = Timeline(ArtifactStore(root=resolved_config.task_artifact_dir))
executor = AssignmentExecutor(
create_execution_factories(
resolved_manager,
metadata_store=metadata_store,
timeline=timeline,
host_agent_config=resolved_config,
)
)
console_app = create_console_app( console_app = create_console_app(
config=resolved_config, config=resolved_config,
manager=resolved_manager, manager=resolved_manager,
@@ -190,6 +204,9 @@ def create_application(
ttl_seconds=resolved_config.console_session_ttl_seconds ttl_seconds=resolved_config.console_session_ttl_seconds
), ),
enrollment_client=console_enrollment_client, enrollment_client=console_enrollment_client,
metadata_store=metadata_store,
timeline=timeline,
executor=executor,
) )
console_server = _EmbeddedConsoleServer( console_server = _EmbeddedConsoleServer(
uvicorn.Config( uvicorn.Config(
@@ -215,19 +232,18 @@ def create_application(
revision=revision revision=revision
), ),
) )
executor = AssignmentExecutor(
create_execution_factories(
resolved_manager,
host_agent_config=resolved_config,
)
)
active_runner = ActiveAssignmentRunner(client, executor) active_runner = ActiveAssignmentRunner(client, executor)
processor = AssignmentProcessor( processor = AssignmentProcessor(
client, client,
active_runner, active_runner,
status_tracker=status_tracker, status_tracker=status_tracker,
on_result=lambda assignment, result: _record_assignment_history( on_result=lambda assignment, result: _on_assignment_finished(
history_store, assignment, result history_store,
metadata_store,
timeline,
resolved_config,
assignment,
result,
), ),
) )
dependency_supervisor: DependencySupervisor | None = None dependency_supervisor: DependencySupervisor | None = None
@@ -245,6 +261,18 @@ def create_application(
) )
def _on_assignment_finished(
history_store: ConsoleHistoryStore,
metadata_store: TaskMetadataStore,
timeline: Timeline,
config: HostAgentConfig,
assignment: AssignmentModel,
result: AssignmentProcessingResult,
) -> None:
_record_assignment_history(history_store, assignment, result)
_prune_task_history(metadata_store, timeline, config)
def _record_assignment_history( def _record_assignment_history(
history_store: ConsoleHistoryStore, history_store: ConsoleHistoryStore,
assignment: AssignmentModel, assignment: AssignmentModel,
@@ -260,6 +288,17 @@ def _record_assignment_history(
) )
def _prune_task_history(
metadata_store: TaskMetadataStore,
timeline: Timeline,
config: HostAgentConfig,
) -> None:
try:
prune_task_history(metadata_store, timeline, config=config)
except Exception:
pass
def _configured_device_manager( def _configured_device_manager(
config_store: DeviceConfigStore, config_store: DeviceConfigStore,
*, *,
@@ -8,6 +8,7 @@ from cloud.internal_api.models import AssignmentModel
from core.models import Task from core.models import Task
from host_agent.execution import ExecutionFactories from host_agent.execution import ExecutionFactories
from host_agent.planner_context import bind_planner_execution_context from host_agent.planner_context import bind_planner_execution_context
from host_agent.progress import TaskProgressHolder, TaskProgressSnapshot
@dataclass(frozen=True) @dataclass(frozen=True)
@@ -20,6 +21,11 @@ class AssignmentExecutionResult:
class AssignmentExecutor: class AssignmentExecutor:
def __init__(self, factories: ExecutionFactories) -> None: def __init__(self, factories: ExecutionFactories) -> None:
self.factories = factories self.factories = factories
self._progress = TaskProgressHolder()
def latest_progress(self) -> TaskProgressSnapshot | None:
"""Latest step progress reported by the currently-running assignment."""
return self._progress.snapshot()
def execute( def execute(
self, self,
@@ -27,6 +33,7 @@ class AssignmentExecutor:
*, *,
should_stop: Callable[[], bool] | None = None, should_stop: Callable[[], bool] | None = None,
) -> AssignmentExecutionResult: ) -> AssignmentExecutionResult:
self._progress.clear()
with bind_planner_execution_context(assignment): with bind_planner_execution_context(assignment):
if should_stop is not None and should_stop(): if should_stop is not None and should_stop():
return AssignmentExecutionResult( return AssignmentExecutionResult(
@@ -50,6 +57,7 @@ class AssignmentExecutor:
) -> AssignmentExecutionResult: ) -> AssignmentExecutionResult:
task = Task(goal=assignment.goal or "", device_id=assignment.device_id) task = Task(goal=assignment.goal or "", device_id=assignment.device_id)
runner = self.factories.task_runner_factory() runner = self.factories.task_runner_factory()
runner.on_step_progress = self._progress.update
if should_stop is None: if should_stop is None:
completed = runner.run(task) completed = runner.run(task)
else: else:
+24 -6
View File
@@ -16,9 +16,13 @@ from cloud.internal_api.models import (
HostEnrollmentResponse, HostEnrollmentResponse,
HostTaskSubmissionResponse, HostTaskSubmissionResponse,
LeaseRenewalResponse, LeaseRenewalResponse,
TaskProgressModel,
TerminalResultResponse, TerminalResultResponse,
) )
from host_agent.config import HostAgentConfig from host_agent.config import HostAgentConfig
from host_agent.progress import TaskProgressSnapshot
_VALID_STEP_STATUSES = frozenset({"running", "completed", "failed"})
class HostAgentAPIError(RuntimeError): class HostAgentAPIError(RuntimeError):
@@ -194,19 +198,33 @@ class HostAgentClient:
async def renew( async def renew(
self, self,
assignment: AssignmentModel, assignment: AssignmentModel,
*,
progress: TaskProgressSnapshot | None = None,
) -> LeaseRenewalResponse: ) -> LeaseRenewalResponse:
payload: dict[str, Any] = {
"host_id": self.config.host_id,
"task_id": assignment.task_id,
"attempt": assignment.attempt,
"lease_id": assignment.lease_id,
}
if progress is not None:
step_status = (
progress.step_status
if progress.step_status in _VALID_STEP_STATUSES
else "running"
)
payload["progress"] = TaskProgressModel(
step_index=max(progress.step_index, 0),
step_status=step_status,
summary=progress.summary[:500],
).model_dump(mode="json")
response = await self._request( response = await self._request(
"POST", "POST",
( (
f"/internal/v1/hosts/{self.config.host_id}/assignments/" f"/internal/v1/hosts/{self.config.host_id}/assignments/"
f"{assignment.task_id}/renew" f"{assignment.task_id}/renew"
), ),
json={ json=payload,
"host_id": self.config.host_id,
"task_id": assignment.task_id,
"attempt": assignment.attempt,
"lease_id": assignment.lease_id,
},
) )
return LeaseRenewalResponse.model_validate(response.json()) return LeaseRenewalResponse.model_validate(response.json())
@@ -43,6 +43,10 @@ class HostAgentConfig:
runtime_host: str = "127.0.0.1" runtime_host: str = "127.0.0.1"
runtime_port: int = 8000 runtime_port: int = 8000
dependency_restart_max_attempts: int = 5 dependency_restart_max_attempts: int = 5
task_progress_db_path: Path = Path("host_agent_data/task_progress.sqlite3")
task_artifact_dir: Path = Path("host_agent_data/history")
task_retention_max_count: int = 50
task_retention_max_age_days: int = 7
def load_host_agent_config( def load_host_agent_config(
@@ -135,6 +139,23 @@ def load_host_agent_config(
dependency_restart_max_attempts=_positive_int( dependency_restart_max_attempts=_positive_int(
values, "HOST_AGENT_DEPENDENCY_RESTART_MAX_ATTEMPTS", 5 values, "HOST_AGENT_DEPENDENCY_RESTART_MAX_ATTEMPTS", 5
), ),
task_progress_db_path=Path(
values.get(
"HOST_AGENT_TASK_PROGRESS_DB_PATH",
"host_agent_data/task_progress.sqlite3",
).strip()
),
task_artifact_dir=Path(
values.get(
"HOST_AGENT_TASK_ARTIFACT_DIR", "host_agent_data/history"
).strip()
),
task_retention_max_count=_positive_int(
values, "HOST_AGENT_TASK_RETENTION_MAX_COUNT", 50
),
task_retention_max_age_days=_positive_int(
values, "HOST_AGENT_TASK_RETENTION_MAX_AGE_DAYS", 7
),
) )
if config.max_retry_backoff_seconds < config.retry_backoff_seconds: if config.max_retry_backoff_seconds < config.retry_backoff_seconds:
raise HostAgentConfigurationError( raise HostAgentConfigurationError(
+6 -1
View File
@@ -11,6 +11,7 @@ import httpx
from cloud.internal_api.models import AssignmentModel from cloud.internal_api.models import AssignmentModel
from host_agent.assignment import AssignmentExecutionResult from host_agent.assignment import AssignmentExecutionResult
from host_agent.client import HostAgentAPIError, HostAgentClient, StaleLeaseError from host_agent.client import HostAgentAPIError, HostAgentClient, StaleLeaseError
from host_agent.progress import TaskProgressSnapshot
class InterruptibleAssignmentExecutor(Protocol): class InterruptibleAssignmentExecutor(Protocol):
@@ -21,6 +22,8 @@ class InterruptibleAssignmentExecutor(Protocol):
should_stop: Callable[[], bool] | None = None, should_stop: Callable[[], bool] | None = None,
) -> AssignmentExecutionResult: ... ) -> AssignmentExecutionResult: ...
def latest_progress(self) -> TaskProgressSnapshot | None: ...
class LeaseGuard: class LeaseGuard:
def __init__(self) -> None: def __init__(self) -> None:
@@ -92,7 +95,9 @@ class ActiveAssignmentRunner:
if done: if done:
return return
try: try:
response = await self.client.renew(assignment) response = await self.client.renew(
assignment, progress=self.executor.latest_progress()
)
except StaleLeaseError: except StaleLeaseError:
guard.mark_lost("lease rejected by control plane") guard.mark_lost("lease rejected by control plane")
return return
@@ -0,0 +1,52 @@
from __future__ import annotations
import threading
from dataclasses import dataclass
from datetime import UTC, datetime
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from collections.abc import Callable
_MAX_SUMMARY_LENGTH = 500
@dataclass(frozen=True)
class TaskProgressSnapshot:
step_index: int
step_status: str
summary: str
updated_at: datetime
class TaskProgressHolder:
"""Thread-safe latest-step-progress holder for one in-flight assignment.
Written by the execution thread (via ``update``, wired as the
``TaskRunner.on_step_progress`` callback) and read by the asyncio
lease-renewal loop (via ``snapshot``) just before each renewal call.
"""
def __init__(self, *, now: Callable[[], datetime] | None = None) -> None:
self._now = now or (lambda: datetime.now(UTC))
self._lock = threading.Lock()
self._snapshot: TaskProgressSnapshot | None = None
def update(self, step_index: int, step_status: str, summary: str) -> None:
if len(summary) > _MAX_SUMMARY_LENGTH:
summary = summary[:_MAX_SUMMARY_LENGTH]
with self._lock:
self._snapshot = TaskProgressSnapshot(
step_index=step_index,
step_status=step_status,
summary=summary,
updated_at=self._now(),
)
def clear(self) -> None:
with self._lock:
self._snapshot = None
def snapshot(self) -> TaskProgressSnapshot | None:
with self._lock:
return self._snapshot
@@ -0,0 +1,63 @@
from __future__ import annotations
import logging
from datetime import UTC, datetime, timedelta
from host_agent.config import HostAgentConfig
from storage.task_metadata import TaskMetadataStore
from storage.timeline import Timeline
logger = logging.getLogger(__name__)
def prune_task_history(
metadata_store: TaskMetadataStore,
timeline: Timeline,
*,
config: HostAgentConfig,
now: datetime | None = None,
) -> int:
"""Delete tasks beyond the configured retention window/count.
Computes two candidate retained sets -- the last ``max_count`` tasks
and tasks younger than ``max_age_days`` -- and keeps whichever set
is smaller (i.e. the more restrictive bound always wins).
Returns the number of tasks pruned.
"""
reference = now or datetime.now(UTC)
tasks = metadata_store.list_tasks()
keep_by_count = {task["id"] for task in tasks[: config.task_retention_max_count]}
cutoff = reference - timedelta(days=config.task_retention_max_age_days)
keep_by_age = {
task["id"] for task in tasks if _parse_timestamp(task["created_at"]) >= cutoff
}
keep_ids = keep_by_count if len(keep_by_count) <= len(keep_by_age) else keep_by_age
pruned = 0
for task in tasks:
if task["id"] not in keep_ids:
_safe_delete(timeline, metadata_store, task["id"])
pruned += 1
return pruned
def _safe_delete(
timeline: Timeline, metadata_store: TaskMetadataStore, task_id: str
) -> None:
try:
timeline.delete_task(task_id)
except Exception:
logger.debug("timeline delete failed for task %s", task_id, exc_info=True)
try:
metadata_store.delete_task(task_id)
except Exception:
logger.debug("metadata delete failed for task %s", task_id, exc_info=True)
def _parse_timestamp(value: str) -> datetime:
parsed = datetime.fromisoformat(value)
if parsed.tzinfo is None:
parsed = parsed.replace(tzinfo=UTC)
return parsed
@@ -6,6 +6,7 @@ from datetime import UTC, datetime
from typing import TYPE_CHECKING, Any from typing import TYPE_CHECKING, Any
from cloud.internal_api.models import AssignmentModel from cloud.internal_api.models import AssignmentModel
from host_agent.progress import TaskProgressSnapshot
if TYPE_CHECKING: if TYPE_CHECKING:
from collections.abc import Callable from collections.abc import Callable
@@ -34,6 +35,7 @@ class AgentStatusTracker:
self._current_assignment: _CurrentAssignment | None = None self._current_assignment: _CurrentAssignment | None = None
self._last_heartbeat: _LastHeartbeat | None = None self._last_heartbeat: _LastHeartbeat | None = None
self._host_policy: dict[str, Any] | None = None self._host_policy: dict[str, Any] | None = None
self._latest_progress: TaskProgressSnapshot | None = None
def mark_assignment_started(self, assignment: AssignmentModel) -> None: def mark_assignment_started(self, assignment: AssignmentModel) -> None:
with self._lock: with self._lock:
@@ -48,6 +50,11 @@ class AgentStatusTracker:
def mark_assignment_finished(self) -> None: def mark_assignment_finished(self) -> None:
with self._lock: with self._lock:
self._current_assignment = None self._current_assignment = None
self._latest_progress = None
def set_latest_progress(self, snapshot: TaskProgressSnapshot | None) -> None:
with self._lock:
self._latest_progress = snapshot
def mark_heartbeat(self, *, ok: bool, device_count: int) -> None: def mark_heartbeat(self, *, ok: bool, device_count: int) -> None:
with self._lock: with self._lock:
@@ -74,6 +81,7 @@ class AgentStatusTracker:
with self._lock: with self._lock:
current_assignment = self._current_assignment current_assignment = self._current_assignment
last_heartbeat = self._last_heartbeat last_heartbeat = self._last_heartbeat
progress = self._latest_progress
return { return {
"current_assignment": ( "current_assignment": (
{ {
@@ -96,4 +104,14 @@ class AgentStatusTracker:
else None else None
), ),
"host_policy": self._host_policy.copy() if self._host_policy else None, "host_policy": self._host_policy.copy() if self._host_policy else None,
"progress": (
{
"step_index": progress.step_index,
"step_status": progress.step_status,
"summary": progress.summary,
"updated_at": progress.updated_at.isoformat(),
}
if progress is not None
else None
),
} }
+145 -1
View File
@@ -1,14 +1,17 @@
from __future__ import annotations from __future__ import annotations
import asyncio import asyncio
import base64
import json import json
from html import escape as _escape from html import escape as _escape
from pathlib import Path
from typing import Any from typing import Any
from fastapi import Depends, FastAPI, HTTPException, Request from fastapi import Depends, FastAPI, HTTPException, Request
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse, Response from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse, Response
from device.manager import DeviceManager from device.manager import DeviceManager
from host_agent.assignment import AssignmentExecutor
from host_agent.client import HostAgentEnrollmentClient from host_agent.client import HostAgentEnrollmentClient
from host_agent.config import HostAgentConfig from host_agent.config import HostAgentConfig
from host_agent.devices import register_local_device, unregister_local_device from host_agent.devices import register_local_device, unregister_local_device
@@ -23,6 +26,8 @@ from host_agent.web.auth import (
change_password, change_password,
) )
from storage.device_config import DeviceConfigStore from storage.device_config import DeviceConfigStore
from storage.task_metadata import TaskMetadataStore
from storage.timeline import Timeline
SESSION_COOKIE_NAME = "host_console_session" SESSION_COOKIE_NAME = "host_console_session"
CSRF_HEADER_NAME = "X-CSRF-Token" CSRF_HEADER_NAME = "X-CSRF-Token"
@@ -58,6 +63,7 @@ def _chrome(title: str, body_html: str, *, session: SessionState | None) -> str:
<nav> <nav>
<a href="/">Status</a> <a href="/">Status</a>
<a href="/devices">Devices</a> <a href="/devices">Devices</a>
<a href="/tasks">Tasks</a>
<a href="/account">Account</a> <a href="/account">Account</a>
<a href="/history">History</a> <a href="/history">History</a>
<form class="inline" method="post" action="/logout"> <form class="inline" method="post" action="/logout">
@@ -142,11 +148,19 @@ def _dashboard_body(
if assignment if assignment
else "none" else "none"
) )
progress = snapshot.get("progress")
progress_text = (
f"step {progress['step_index']}{progress['step_status']}: {progress['summary']}"
if progress
else ""
)
policy_text = ( policy_text = (
"revision {revision}; self-submission {self_submission}; " "revision {revision}; self-submission {self_submission}; "
"max active tasks {max_active}; daily token budget {daily_budget}".format( "max active tasks {max_active}; daily token budget {daily_budget}".format(
revision=policy["revision"], revision=policy["revision"],
self_submission="enabled" if policy["self_submission_enabled"] else "disabled", self_submission="enabled"
if policy["self_submission_enabled"]
else "disabled",
max_active=policy["max_active_tasks"] or "unlimited", max_active=policy["max_active_tasks"] or "unlimited",
daily_budget=policy["daily_token_budget"] or "unmetered", daily_budget=policy["daily_token_budget"] or "unmetered",
) )
@@ -178,6 +192,7 @@ def _dashboard_body(
<section> <section>
<h2>Current assignment</h2> <h2>Current assignment</h2>
<p id="current-assignment">{escape(assignment_text)}</p> <p id="current-assignment">{escape(assignment_text)}</p>
<p id="current-progress">{escape(progress_text)}</p>
</section> </section>
<section> <section>
<h2>Devices</h2> <h2>Devices</h2>
@@ -197,6 +212,10 @@ def _dashboard_body(
document.getElementById("current-assignment").textContent = current document.getElementById("current-assignment").textContent = current
? current.task_id + " on " + current.device_id + " (started " + current.started_at + ")" ? current.task_id + " on " + current.device_id + " (started " + current.started_at + ")"
: "none"; : "none";
var progress = data.status.progress;
document.getElementById("current-progress").textContent = progress
? "step " + progress.step_index + " \\u2014 " + progress.step_status + ": " + progress.summary
: "";
var policy = data.status.host_policy; var policy = data.status.host_policy;
document.getElementById("host-policy").textContent = policy document.getElementById("host-policy").textContent = policy
? "revision " + policy.revision + "; self-submission " ? "revision " + policy.revision + "; self-submission "
@@ -314,6 +333,88 @@ def _history_body(entries: list[dict[str, Any]]) -> str:
""" """
def _inline_screenshot(record: dict[str, Any]) -> str:
"""Return a ``<img>`` tag with base64-encoded screenshot, or empty string."""
screenshot_path = record.get("screenshot_path")
if not screenshot_path:
return ""
path = Path(str(screenshot_path))
if not path.exists():
return ""
encoded = base64.b64encode(path.read_bytes()).decode("ascii")
return f'<img src="data:image/png;base64,{encoded}" alt="screenshot" style="max-width:100%;border:1px solid #ccc;margin-top:0.5rem;">'
def _tasks_body(tasks: list[dict[str, Any]]) -> str:
if not tasks:
return """
<h1>Tasks</h1>
<p>No tasks recorded.</p>
"""
rows = "".join(
f"<tr>"
f'<td><a href="/tasks/{escape(task["id"])}">{escape(task["id"])}</a></td>'
f"<td>{escape(task.get('status') or '')}</td>"
f"<td>{escape(task.get('device_id') or '')}</td>"
f"<td>{escape(task.get('created_at') or '')}</td>"
f"<td>{escape(task.get('updated_at') or '')}</td>"
f"</tr>"
for task in tasks
)
return f"""
<h1>Tasks</h1>
<table>
<thead><tr><th>Task ID</th><th>Status</th><th>Device</th><th>Created</th><th>Updated</th></tr></thead>
<tbody>{rows}</tbody>
</table>
"""
def _task_detail_body(
task: dict[str, Any], timeline_records: list[dict[str, Any]]
) -> str:
task_rows = "".join(
f"<tr><td>{escape(key)}</td><td>{escape(task[key])}</td></tr>"
for key in ("id", "goal", "device_id", "status", "created_at", "updated_at")
if task.get(key) is not None
)
metadata_html = f"""
<h1>Task {escape(task.get("id") or "")}</h1>
<table>
<thead><tr><th>Field</th><th>Value</th></tr></thead>
<tbody>{task_rows}</tbody>
</table>
"""
if not timeline_records:
timeline_html = "<h2>Timeline</h2><p>No timeline records.</p>"
else:
step_blocks = "".join(
_timeline_step_html(record) for record in timeline_records
)
timeline_html = f"<h2>Timeline</h2>{step_blocks}"
return metadata_html + timeline_html
def _timeline_step_html(record: dict[str, Any]) -> str:
index = record.get("index", "")
timestamp = record.get("timestamp", "")
tool_call = record.get("tool_call")
result = record.get("result")
prompt = record.get("prompt") or ""
tool_call_text = json.dumps(tool_call, ensure_ascii=False) if tool_call else ""
result_text = json.dumps(result, ensure_ascii=False) if result else ""
screenshot_html = _inline_screenshot(record)
return f"""
<div style="border:1px solid #ccc;background:#fff;padding:0.75rem;margin-bottom:0.75rem;">
<p><strong>Step {escape(index)}</strong> — {escape(timestamp)}</p>
<p>Prompt: {escape(prompt)}</p>
<p>Tool call: <code>{escape(tool_call_text)}</code></p>
<p>Result: <code>{escape(result_text)}</code></p>
{screenshot_html}
</div>
"""
def create_console_app( def create_console_app(
*, *,
config: HostAgentConfig, config: HostAgentConfig,
@@ -325,6 +426,9 @@ def create_console_app(
status_tracker: AgentStatusTracker, status_tracker: AgentStatusTracker,
session_manager: SessionManager, session_manager: SessionManager,
enrollment_client: HostAgentEnrollmentClient | None, enrollment_client: HostAgentEnrollmentClient | None,
metadata_store: TaskMetadataStore | None = None,
timeline: Timeline | None = None,
executor: AssignmentExecutor | None = None,
) -> FastAPI: ) -> FastAPI:
app = FastAPI(title="Host Agent Console") app = FastAPI(title="Host Agent Console")
cookie_secure = config.console_bind_host not in _LOOPBACK_BIND_HOSTS cookie_secure = config.console_bind_host not in _LOOPBACK_BIND_HOSTS
@@ -416,6 +520,16 @@ def create_console_app(
session: SessionState = Depends(require_session), session: SessionState = Depends(require_session),
) -> JSONResponse: ) -> JSONResponse:
snapshot = status_tracker.snapshot() snapshot = status_tracker.snapshot()
# Pull live progress from the executor if the tracker has no data yet.
if executor is not None and snapshot.get("progress") is None:
live = executor.latest_progress()
if live is not None:
snapshot["progress"] = {
"step_index": live.step_index,
"step_status": live.step_status,
"summary": live.summary,
"updated_at": live.updated_at.isoformat(),
}
current_assignment = snapshot.get("current_assignment") current_assignment = snapshot.get("current_assignment")
busy_device_id = current_assignment["device_id"] if current_assignment else None busy_device_id = current_assignment["device_id"] if current_assignment else None
devices = [ devices = [
@@ -570,4 +684,34 @@ def create_console_app(
body = _history_body(entries) body = _history_body(entries)
return HTMLResponse(_chrome("History", body, session=session)) return HTMLResponse(_chrome("History", body, session=session))
@app.get("/tasks", response_class=HTMLResponse)
async def tasks_page(
session: SessionState = Depends(require_session),
) -> HTMLResponse:
if metadata_store is None:
raise HTTPException(
status_code=503, detail="task metadata store not configured"
)
tasks_list = await asyncio.to_thread(metadata_store.list_tasks)
body = _tasks_body(tasks_list)
return HTMLResponse(_chrome("Tasks", body, session=session))
@app.get("/tasks/{task_id}", response_class=HTMLResponse)
async def task_detail_page(
task_id: str,
session: SessionState = Depends(require_session),
) -> HTMLResponse:
if metadata_store is None:
raise HTTPException(
status_code=503, detail="task metadata store not configured"
)
task = await asyncio.to_thread(metadata_store.get_task, task_id)
if task is None:
raise HTTPException(status_code=404, detail="task not found")
timeline_records: list[dict[str, Any]] = []
if timeline is not None:
timeline_records = await asyncio.to_thread(timeline.read, task_id)
body = _task_detail_body(task, timeline_records)
return HTMLResponse(_chrome(f"Task {task_id}", body, session=session))
return app return app
+11
View File
@@ -8,6 +8,8 @@ import type {
LlmProviderSettings, LlmProviderSettings,
LlmProviderType, LlmProviderType,
HostRecord, HostRecord,
PlannerDecisionItem,
PlannerDecisionListResponse,
PluginRecord, PluginRecord,
PluginRegistrationPayload, PluginRegistrationPayload,
TaskAttempt, TaskAttempt,
@@ -232,6 +234,15 @@ export function getTaskAttempts(taskId: string): Promise<TaskAttempt[]> {
return request<TaskAttempt[]>(`/v1/tasks/${encodeURIComponent(taskId)}/attempts`); return request<TaskAttempt[]>(`/v1/tasks/${encodeURIComponent(taskId)}/attempts`);
} }
export function getTaskPlannerDecisions(
taskId: string,
attempt: number,
): Promise<PlannerDecisionItem[]> {
return request<PlannerDecisionListResponse>(
`/v1/tasks/${encodeURIComponent(taskId)}/planner-decisions?attempt=${attempt}`,
).then((resp) => resp.items);
}
export function submitTask(payload: TaskSubmissionPayload): Promise<{ task_id: string }> { export function submitTask(payload: TaskSubmissionPayload): Promise<{ task_id: string }> {
return request<{ task_id: string }>("/v1/tasks", { return request<{ task_id: string }>("/v1/tasks", {
method: "POST", method: "POST",
+51
View File
@@ -0,0 +1,51 @@
import { describe, expect, it } from "vitest";
import { computePlannerHistoryState } from "./plannerHistory";
import type { PlannerDecisionItem } from "./types";
function makeDecision(
overrides: Partial<PlannerDecisionItem> = {},
): PlannerDecisionItem {
return {
step_index: 0,
attempt: 0,
system_prompt: "system",
user_prompt: "user",
tool_name: "tap",
arguments: { target: "button" },
created_at: "2026-07-14T00:00:00+00:00",
...overrides,
};
}
describe("computePlannerHistoryState", () => {
it("returns populated when decisions exist (cloud transport)", () => {
const decisions = [
makeDecision({ step_index: 0, tool_name: "tap" }),
makeDecision({ step_index: 1, tool_name: "swipe" }),
];
const state = computePlannerHistoryState(decisions, "cloud");
expect(state).toEqual({ kind: "populated", decisions });
});
it("returns populated when decisions exist even if transport is direct", () => {
const decisions = [makeDecision()];
const state = computePlannerHistoryState(decisions, "direct");
expect(state).toEqual({ kind: "populated", decisions });
});
it("returns empty_cloud_transport when no decisions but host is cloud-transport", () => {
const state = computePlannerHistoryState([], "cloud");
expect(state).toEqual({ kind: "empty_cloud_transport" });
});
it("returns direct_transport_hidden when no decisions and host is direct-transport", () => {
const state = computePlannerHistoryState([], "direct");
expect(state).toEqual({ kind: "direct_transport_hidden" });
});
it("returns unknown_host_transport when host transport is null", () => {
const state = computePlannerHistoryState([], null);
expect(state).toEqual({ kind: "unknown_host_transport" });
});
});
+36
View File
@@ -0,0 +1,36 @@
import type { PlannerDecisionItem } from "./types";
export type PlannerHistoryState =
| { kind: "populated"; decisions: PlannerDecisionItem[] }
| { kind: "empty_cloud_transport" }
| { kind: "direct_transport_hidden" }
| { kind: "unknown_host_transport" };
/**
* Decide which empty-state or populated-state message to show for a task's
* planner decision history, based on whether any decisions were returned and
* the assigned host's configured planner transport.
*
* - **populated**: decisions exist render them.
* - **direct_transport_hidden**: host uses the `direct` transport, which never
* sends prompts to Cloud. Show an explicit "not reported" message instead of
* an empty list.
* - **empty_cloud_transport**: host IS cloud-transport but no decisions have
* been persisted yet (task just started or no steps executed).
* - **unknown_host_transport**: host not found or transport field not reported.
*/
export function computePlannerHistoryState(
decisions: PlannerDecisionItem[],
hostTransport: "direct" | "cloud" | null,
): PlannerHistoryState {
if (decisions.length > 0) {
return { kind: "populated", decisions };
}
if (hostTransport === "direct") {
return { kind: "direct_transport_hidden" };
}
if (hostTransport === "cloud") {
return { kind: "empty_cloud_transport" };
}
return { kind: "unknown_host_transport" };
}
+131
View File
@@ -0,0 +1,131 @@
import { describe, expect, it } from "vitest";
import { formatTaskProgress } from "./taskProgress";
import type { TaskListItem } from "./types";
function makeTask(
overrides: Partial<TaskListItem> & Pick<TaskListItem, "status">,
): TaskListItem {
return {
id: "task-a",
goal: null,
workflow_definition_id: null,
assigned_device_id: null,
assigned_host_id: null,
attempt_count: 0,
failure_reason: null,
target_host_id: null,
target_device_id: null,
created_at: "2026-07-14T00:00:00+00:00",
progress_step_index: null,
progress_step_status: null,
progress_summary: null,
progress_updated_at: null,
...overrides,
};
}
describe("formatTaskProgress", () => {
it("renders step index, status, and summary for an in-progress dispatched task", () => {
const task = makeTask({
status: "dispatched",
progress_step_index: 2,
progress_step_status: "running",
progress_summary: "tapping button",
});
expect(formatTaskProgress(task)).toBe("step 2 \u2014 running: tapping button");
});
it("renders step index and status when summary is missing", () => {
const task = makeTask({
status: "assigned",
progress_step_index: 0,
progress_step_status: "starting",
progress_summary: null,
});
expect(formatTaskProgress(task)).toBe("step 0 \u2014 starting");
});
it("returns null when the task is dispatched but no progress has been reported", () => {
const task = makeTask({
status: "dispatched",
progress_step_index: null,
progress_step_status: null,
progress_summary: null,
});
expect(formatTaskProgress(task)).toBeNull();
});
it("returns null for a dispatched task with undefined progress fields", () => {
const task = makeTask({ status: "dispatched" });
// Fields default to null via makeTask; simulate the undefined case.
delete (task as Partial<TaskListItem>).progress_step_index;
delete (task as Partial<TaskListItem>).progress_step_status;
expect(formatTaskProgress(task)).toBeNull();
});
it("hides stale progress when the task has reached a terminal status (done)", () => {
const task = makeTask({
status: "done",
progress_step_index: 5,
progress_step_status: "running",
progress_summary: "stale snapshot before terminal",
});
expect(formatTaskProgress(task)).toBeNull();
});
it("hides stale progress when the task has reached a terminal status (failed)", () => {
const task = makeTask({
status: "failed",
progress_step_index: 3,
progress_step_status: "running",
progress_summary: "stale snapshot before failure",
});
expect(formatTaskProgress(task)).toBeNull();
});
it("hides progress for a queued task that has not started executing", () => {
const task = makeTask({
status: "queued",
progress_step_index: 1,
progress_step_status: "running",
progress_summary: "should not show while queued",
});
expect(formatTaskProgress(task)).toBeNull();
});
it("ignores a missing step status even when step index is present", () => {
const task = makeTask({
status: "dispatched",
progress_step_index: 1,
progress_step_status: null,
progress_summary: "partial snapshot",
});
expect(formatTaskProgress(task)).toBeNull();
});
it("truncates a long summary with an ellipsis", () => {
const longSummary = "x".repeat(200);
const task = makeTask({
status: "dispatched",
progress_step_index: 1,
progress_step_status: "running",
progress_summary: longSummary,
});
const formatted = formatTaskProgress(task);
expect(formatted).not.toBeNull();
const summaryPart = formatted!.split(": ").slice(1).join(": ");
expect(summaryPart.length).toBeLessThan(longSummary.length);
expect(summaryPart.endsWith("\u2026")).toBe(true);
});
it("trims surrounding whitespace before truncating or rendering", () => {
const task = makeTask({
status: "dispatched",
progress_step_index: 1,
progress_step_status: "running",
progress_summary: " hello world ",
});
expect(formatTaskProgress(task)).toBe("step 1 \u2014 running: hello world");
});
});
+52
View File
@@ -0,0 +1,52 @@
import type { TaskListItem, TaskStatus } from "./types";
/**
* Statuses that represent an actively executing assignment, where live
* in-progress step information is meaningful to surface.
*/
const ACTIVE_STATUSES: ReadonlySet<TaskStatus> = new Set<TaskStatus>([
"assigned",
"dispatched",
]);
/**
* Format a task's latest in-progress step snapshot for inline display.
*
* Returns `null` when:
* - the task is not actively executing (queued, done, failed) terminal/queued
* status always wins over a stale progress snapshot; or
* - no progress has been reported yet (`progress_step_index` is null/undefined
* or `progress_step_status` is missing).
*
* Otherwise returns a compact string like `"step 2 — running: tapping button"`.
* The summary is truncated to a display-friendly length with a Unicode ellipsis
* when it exceeds the limit.
*/
export function formatTaskProgress(
task: Pick<
TaskListItem,
| "status"
| "progress_step_index"
| "progress_step_status"
| "progress_summary"
>,
maxSummaryLength = 120,
): string | null {
if (!ACTIVE_STATUSES.has(task.status)) return null;
if (
task.progress_step_index === null ||
task.progress_step_index === undefined ||
!task.progress_step_status
) {
return null;
}
const summary = task.progress_summary ?? "";
const trimmed = summary.trim();
const display =
trimmed.length > maxSummaryLength
? `${trimmed.slice(0, maxSummaryLength - 1).trimEnd()}\u2026`
: trimmed;
return `step ${task.progress_step_index} \u2014 ${task.progress_step_status}${
display ? `: ${display}` : ""
}`;
}
+18
View File
@@ -17,6 +17,10 @@ export interface TaskListItem {
target_host_id: string | null; target_host_id: string | null;
target_device_id: string | null; target_device_id: string | null;
created_at: string; created_at: string;
progress_step_index?: number | null;
progress_step_status?: string | null;
progress_summary?: string | null;
progress_updated_at?: string | null;
} }
export interface TaskSubmissionPayload { export interface TaskSubmissionPayload {
@@ -178,3 +182,17 @@ export interface LlmProviderProfileListResponse {
settings: LlmProviderSettings; settings: LlmProviderSettings;
items: LlmProviderProfile[]; items: LlmProviderProfile[];
} }
export interface PlannerDecisionItem {
step_index: number;
attempt: number;
system_prompt: string;
user_prompt: string;
tool_name: string;
arguments: Record<string, unknown>;
created_at: string;
}
export interface PlannerDecisionListResponse {
items: PlannerDecisionItem[];
}
+169 -7
View File
@@ -4,6 +4,7 @@ import { LoaderCircle, RefreshCw } from "@lucide/vue";
import { import {
CloudApiError, CloudApiError,
getTaskAttempts, getTaskAttempts,
getTaskPlannerDecisions,
listTasks, listTasks,
listDevices, listDevices,
listHosts, listHosts,
@@ -16,7 +17,10 @@ import type {
TaskStatus, TaskStatus,
DeviceRecord, DeviceRecord,
HostRecord, HostRecord,
PlannerDecisionItem,
} from "../types"; } from "../types";
import { formatTaskProgress } from "../taskProgress";
import { computePlannerHistoryState } from "../plannerHistory";
const props = defineProps<{ canSubmit: boolean }>(); const props = defineProps<{ canSubmit: boolean }>();
@@ -48,6 +52,9 @@ const submitCapabilityTags = ref("");
const submitting = ref(false); const submitting = ref(false);
const hosts = ref<HostRecord[]>([]); const hosts = ref<HostRecord[]>([]);
const devices = ref<DeviceRecord[]>([]); const devices = ref<DeviceRecord[]>([]);
const plannerDecisions = ref<PlannerDecisionItem[]>([]);
const plannerLoading = ref(false);
const plannerError = ref("");
const availableDevices = computed(() => const availableDevices = computed(() =>
devices.value.filter((device) => device.host_id === submitHostId.value), devices.value.filter((device) => device.host_id === submitHostId.value),
); );
@@ -75,6 +82,7 @@ async function refresh() {
if (!stillPresent) { if (!stillPresent) {
selectedTask.value = null; selectedTask.value = null;
attempts.value = []; attempts.value = [];
plannerDecisions.value = [];
} }
} }
} catch (err) { } catch (err) {
@@ -129,21 +137,51 @@ async function selectTask(task: TaskListItem) {
selectedTask.value = task; selectedTask.value = task;
attempts.value = []; attempts.value = [];
attemptsError.value = ""; attemptsError.value = "";
plannerDecisions.value = [];
plannerError.value = "";
attemptsLoading.value = true; attemptsLoading.value = true;
// Fetch attempts first so we know which attempt numbers exist.
let loadedAttempts: TaskAttempt[] = [];
try { try {
attempts.value = await getTaskAttempts(task.id); loadedAttempts = await getTaskAttempts(task.id);
attempts.value = loadedAttempts;
} catch (err) { } catch (err) {
if (err instanceof CloudApiError && err.status === 404) { if (err instanceof CloudApiError && err.status === 404) {
// Task was deleted between list and detail load.
selectedTask.value = null; selectedTask.value = null;
await refresh(); await refresh();
} else { attemptsLoading.value = false;
handleError(err, "failed to load task attempts"); return;
attemptsError.value = errorMessage.value;
errorMessage.value = "";
} }
} finally { handleError(err, "failed to load task attempts");
attemptsError.value = errorMessage.value;
errorMessage.value = "";
attemptsLoading.value = false; attemptsLoading.value = false;
return;
}
attemptsLoading.value = false;
// Fetch planner decisions for the latest attempt (if any).
const latestAttempt = loadedAttempts.length
? Math.max(...loadedAttempts.map((a) => a.attempt))
: task.attempt_count > 0
? task.attempt_count - 1
: null;
if (latestAttempt !== null) {
plannerLoading.value = true;
try {
plannerDecisions.value = await getTaskPlannerDecisions(task.id, latestAttempt);
} catch (err) {
// 404 on the task is already handled above; other errors are non-fatal.
if (!(err instanceof CloudApiError && err.status === 404)) {
handleError(err, "failed to load planner decisions");
plannerError.value = errorMessage.value;
errorMessage.value = "";
}
} finally {
plannerLoading.value = false;
}
} }
} }
@@ -170,6 +208,8 @@ function goNextPage() {
function clearSelection() { function clearSelection() {
selectedTask.value = null; selectedTask.value = null;
attempts.value = []; attempts.value = [];
plannerDecisions.value = [];
plannerError.value = "";
} }
watch(statusFilter, () => { watch(statusFilter, () => {
@@ -212,6 +252,30 @@ function formatTerminalResult(attempt: TaskAttempt): string {
return String(attempt.terminal_result); return String(attempt.terminal_result);
} }
} }
const selectedTaskProgress = computed(() =>
selectedTask.value ? formatTaskProgress(selectedTask.value) : null,
);
const selectedHostTransport = computed<"direct" | "cloud" | null>(() => {
if (!selectedTask.value?.assigned_host_id) return null;
const host = hosts.value.find(
(h) => h.host_id === selectedTask.value?.assigned_host_id,
);
return host?.planner_transport ?? null;
});
const plannerHistoryState = computed(() =>
computePlannerHistoryState(plannerDecisions.value, selectedHostTransport.value),
);
function formatArguments(args: Record<string, unknown>): string {
try {
return JSON.stringify(args, null, 2);
} catch {
return String(args);
}
}
</script> </script>
<template> <template>
@@ -305,6 +369,13 @@ function formatTerminalResult(attempt: TaskAttempt): string {
<div v-if="task.failure_reason" class="dim"> <div v-if="task.failure_reason" class="dim">
{{ task.failure_reason }} {{ task.failure_reason }}
</div> </div>
<div
v-if="formatTaskProgress(task)"
class="task-progress"
:title="formatTaskProgress(task) ?? ''"
>
{{ formatTaskProgress(task) }}
</div>
</td> </td>
<td> <td>
<div v-if="task.assigned_device_id"> <div v-if="task.assigned_device_id">
@@ -363,6 +434,10 @@ function formatTerminalResult(attempt: TaskAttempt): string {
<strong class="text-danger">Failure reason:</strong> <strong class="text-danger">Failure reason:</strong>
{{ selectedTask.failure_reason }} {{ selectedTask.failure_reason }}
</p> </p>
<p v-if="selectedTaskProgress" class="task-progress-detail">
<strong>Current step:</strong>
<code>{{ selectedTaskProgress }}</code>
</p>
<h3>Attempt history</h3> <h3>Attempt history</h3>
<div v-if="attemptsLoading" class="muted">loading attempts</div> <div v-if="attemptsLoading" class="muted">loading attempts</div>
@@ -402,6 +477,93 @@ function formatTerminalResult(attempt: TaskAttempt): string {
</tbody> </tbody>
</table> </table>
<div v-else class="muted">No attempts recorded for this task yet.</div> <div v-else class="muted">No attempts recorded for this task yet.</div>
<h3>LLM interaction history</h3>
<div v-if="plannerLoading" class="muted">loading planner decisions</div>
<div v-else-if="plannerError" class="notice error">{{ plannerError }}</div>
<div v-else-if="plannerHistoryState.kind === 'populated'" class="planner-history">
<div
v-for="decision in plannerHistoryState.decisions"
:key="`${decision.attempt}-${decision.step_index}`"
class="planner-decision"
>
<div class="planner-decision-header">
<span class="status-badge queued">step {{ decision.step_index }}</span>
<code>{{ decision.tool_name }}</code>
<span class="dim">{{ formatTime(decision.created_at) }}</span>
</div>
<details>
<summary>User prompt</summary>
<pre class="planner-prompt">{{ decision.user_prompt }}</pre>
</details>
<details v-if="decision.system_prompt">
<summary>System prompt</summary>
<pre class="planner-prompt">{{ decision.system_prompt }}</pre>
</details>
<details>
<summary>Arguments</summary>
<pre class="planner-prompt">{{ formatArguments(decision.arguments) }}</pre>
</details>
</div>
</div>
<div v-else-if="plannerHistoryState.kind === 'empty_cloud_transport'" class="muted">
No planner decisions reported yet for this task.
</div>
<div v-else-if="plannerHistoryState.kind === 'direct_transport_hidden'" class="muted">
This host uses the <code>direct</code> planner transport and does not report LLM interactions to Cloud.
</div>
<div v-else class="muted">
Host transport unknown; no planner decisions available.
</div>
</div> </div>
</div> </div>
</template> </template>
<style scoped>
.task-progress {
margin-top: 4px;
color: var(--text-dim, #888);
font-size: 12px;
font-family: ui-monospace, SFMono-Regular, "Cascadia Code", Consolas, monospace;
max-width: 320px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.task-progress-detail {
margin: 4px 0;
}
.task-progress-detail code {
font-size: 13px;
}
.planner-history {
display: flex;
flex-direction: column;
gap: 12px;
}
.planner-decision {
border: 1px solid var(--border-color, #e0e0e0);
border-radius: 4px;
padding: 8px 12px;
}
.planner-decision-header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 4px;
}
.planner-prompt {
max-height: 300px;
overflow: auto;
white-space: pre-wrap;
word-break: break-word;
font-size: 12px;
margin: 4px 0 0;
}
</style>
+33
View File
@@ -121,6 +121,39 @@ HOST_AGENT_CONSOLE_HISTORY_LIMIT=200
- `HOST_AGENT_CONSOLE_HISTORY_LIMIT` — number of recent assignment/heartbeat - `HOST_AGENT_CONSOLE_HISTORY_LIMIT` — number of recent assignment/heartbeat
entries the console retains before pruning older ones. entries the console retains before pruning older ones.
### Task progress storage and retention
The Host Agent persists step-by-step task execution state (metadata +
timeline screenshots) to local SQLite/files on the edge machine. These
paths are independent from the Runtime's own `tasks/tasks.sqlite3` and
do not collide when both processes run on the same host.
```text
HOST_AGENT_TASK_PROGRESS_DB_PATH=host_agent_data/task_progress.sqlite3
HOST_AGENT_TASK_ARTIFACT_DIR=host_agent_data/history
HOST_AGENT_TASK_RETENTION_MAX_COUNT=50
HOST_AGENT_TASK_RETENTION_MAX_AGE_DAYS=7
```
- `HOST_AGENT_TASK_PROGRESS_DB_PATH` — SQLite path for the Host Agent's
task metadata store. Default: `host_agent_data/task_progress.sqlite3`.
- `HOST_AGENT_TASK_ARTIFACT_DIR` — directory for timeline screenshots and
step artifacts. Default: `host_agent_data/history`.
- `HOST_AGENT_TASK_RETENTION_MAX_COUNT` — maximum number of completed
tasks to retain. Default: `50`.
- `HOST_AGENT_TASK_RETENTION_MAX_AGE_DAYS` — maximum age in days for
retained tasks. Default: `7`. The more restrictive of count vs. age
always wins.
**Viewing live and historical task progress:**
- **Host Agent console**: Open `http://127.0.0.1:8765/tasks` for the task
list (status, device, timestamps). Click a task ID to see the detail page
with full step-by-step timeline and inlined screenshots.
- **Cloud console**: The Cloud Console task detail page shows the latest
coarse-grained progress badge (step index, status, summary) that the Host
Agent piggybacks on each lease renewal.
Treat `HOST_AGENT_CONSOLE_ALLOW_NON_LOOPBACK` as an explicit, Treat `HOST_AGENT_CONSOLE_ALLOW_NON_LOOPBACK` as an explicit,
operator-accepted risk: the console has no built-in TLS and no rate operator-accepted risk: the console has no built-in TLS and no rate
limiting, so a non-loopback bind exposes an unencrypted login form to limiting, so a non-loopback bind exposes an unencrypted login form to
+23
View File
@@ -339,6 +339,12 @@ Console 默认连接 `http://127.0.0.1:8000`。已由上面启动脚本连接的
`iphone-1` 会出现在设备列表中。不要在 Console 中重复登记同一台设备;当前登记 `iphone-1` 会出现在设备列表中。不要在 Console 中重复登记同一台设备;当前登记
操作只写入配置,不会自动 connect。 操作只写入配置,不会自动 connect。
如果不想为 Console 单独起一个 `npm run dev` 进程,可以改为一次性构建后交给
Runtime API 同源托管,见 `console/README.md` 的「Same-Origin, Single-Process
Mode」一节:设置 `VITE_API_BASE_URL=` 构建,再用 `RUNTIME_CONSOLE_STATIC_DIR`
指向构建产物启动 Runtime API,浏览器访问 `/ui/` 即可;改前端代码后需要重新
`npm run build`,不支持热更新。
## 9. 启动云端受管 Host Agent ## 9. 启动云端受管 Host Agent
完成 Appium/WDA 真机验证后,可以把这台 Mac 作为只发起出站连接的边缘 Host。 完成 Appium/WDA 真机验证后,可以把这台 Mac 作为只发起出站连接的边缘 Host。
@@ -423,11 +429,28 @@ http://127.0.0.1:8765
`DeviceManager` 上生效,无需重启 Host Agent。 `DeviceManager` 上生效,无需重启 Host Agent。
- 修改密码:更新本地操作账号密码,需要先输入当前密码。 - 修改密码:更新本地操作账号密码,需要先输入当前密码。
- 最近历史:近期 assignment 与 heartbeat 的执行记录。 - 最近历史:近期 assignment 与 heartbeat 的执行记录。
- **任务进度页面**`http://127.0.0.1:8765/tasks` 展示本机 Host Agent 上已执行/正在执行
的任务列表(状态、设备、时间戳),点击任务 ID 可查看逐步 timeline 含截图。
完整的 `HOST_AGENT_CONSOLE_*` 环境变量列表(端口、非回环 bind 的显式 opt-in、 完整的 `HOST_AGENT_CONSOLE_*` 环境变量列表(端口、非回环 bind 的显式 opt-in、
session TTL、历史记录条数上限等)参见 `docs/CLOUD_DEPLOYMENT.md`;生产/远程场景下 session TTL、历史记录条数上限等)参见 `docs/CLOUD_DEPLOYMENT.md`;生产/远程场景下
应优先使用 SSH 端口转发访问该 Console,而不是直接把它暴露到非回环地址。 应优先使用 SSH 端口转发访问该 Console,而不是直接把它暴露到非回环地址。
Host Agent 会把每步执行状态与截图持久化到本地 SQLite/文件系统,路径与 Runtime 自身的
`tasks/tasks.sqlite3` 不冲突:
```bash
# 任务进度持久化路径(默认值,可通过环境变量覆盖)
# HOST_AGENT_TASK_PROGRESS_DB_PATH="host_agent_data/task_progress.sqlite3"
# HOST_AGENT_TASK_ARTIFACT_DIR="host_agent_data/history"
# HOST_AGENT_TASK_RETENTION_MAX_COUNT=50 # 最多保留 50 个任务
# HOST_AGENT_TASK_RETENTION_MAX_AGE_DAYS=7 # 超过 7 天的任务自动清理
```
Retention 策略取"数量上限与天数上限中更严格的"——即先按 `max_count` 取最近 N 个、
再按 `max_age_days` 过滤掉过老的,最终保留两者中较小的集合。任务完成后,这些记录
在 Console 的 `/tasks` 页面可查。
### 可选:由 Host Agent 托管 Appium 和 Runtime API ### 可选:由 Host Agent 托管 Appium 和 Runtime API
默认情况下 Host Agent **不会**自动启动 Appium 或本地 Runtime API:必须按 默认情况下 Host Agent **不会**自动启动 Appium 或本地 Runtime API:必须按
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-07-14
@@ -0,0 +1,129 @@
## Context
Three surfaces currently cannot show a task's in-progress status:
1. **Host Agent local console** (`apps/device-host-agent/host_agent/web/app.py`): `HostAgentApplication`'s builder calls `create_execution_factories(resolved_manager, host_agent_config=resolved_config)` without `metadata_store`/`timeline` (`app.py:218-223`), so the in-process `TaskRunner` (`execution.py:39-46`) runs with `metadata_store=None, timeline=None`. `runtime/task.py::TaskRunner._update_task()` only persists when `metadata_store` is truthy, so step transitions vanish. The console's only signal is `AgentStatusTracker.snapshot()` (`status.py:14-88`), a single opaque span: `task_id/device_id/goal/started_at`, cleared on `mark_assignment_finished()`.
2. **Cloud Control Plane / Cloud Console**: the internal protocol (`packages/cloud-platform/cloud/internal_api/models.py`) only has claim, heartbeat, lease-renewal, and terminal-result messages. Lease renewal (`ActiveAssignmentRunner._renew_while_running`, `apps/device-host-agent/host_agent/lease.py:79-106`) already fires periodically (~1/3 of remaining lease) for every in-flight assignment, but carries no task-progress payload.
3. **Runtime `console/` SPA**: `api/rest.py::create_app()` builds its own `TaskMetadataStore`/`Timeline`/`TaskRunner` (`api/rest.py:24-41`), queried by `api/console.py::create_console_router()`'s `/console/tasks*` routes, which `console/src/api.ts` calls against `API_BASE_URL` (default `http://127.0.0.1:8000`). This is a wholly separate process from Host Agent, with no shared store or IPC. `runtime/`-owned packages are forbidden from importing host/cloud concerns (`test_runtime_owned_packages_do_not_import_host_or_cloud_concerns`), and `host-agent-local-console/design.md` already treats Host Agent's local history as "not a system of record," distinct from Cloud's.
## Goals / Non-Goals
**Goals:**
- Host Agent's local console shows live step-level progress for its current assignment (step index, step status, short summary), not just a static started-at snapshot.
- Cloud Control Plane learns step-level progress near-real-time (piggybacked on existing periodic traffic, not a new polling loop) and Cloud Console renders it for an operator watching a remote task.
- An operator can inspect a Host-Agent-executed task's progress/history from a web console, without coupling `runtime/` to host/cloud concerns.
- Reuse existing, already-tested building blocks (`TaskMetadataStore`, `Timeline`, `api/console.py`'s query shape, the lease-renewal cadence) instead of inventing new storage or transport primitives.
- Both the Host Agent's local task history and the Cloud Control Plane persist the *actual* per-step content sent to and received from the LLM (the real prompt text, not just the task's overall goal; the model's resulting decision, not just the parsed tool call) — so a step's record answers "what did we actually ask the model and what did it say," not just "what call did we make." Cloud's copy is the durable, centrally-searchable record for troubleshooting; the Host Agent's local copy remains the on-device record of what actually ran.
**Non-Goals:**
- No SSE/WebSocket push. All three surfaces keep polling (Host Agent console 5s, Cloud Console/`cloud-console` and `console/` at their existing intervals). Nothing here requires push infra, and adding it would be a bigger, separate change.
- The coarse live step index/status/summary reported via lease renewal (D4/D5) stays *latest-snapshot-only* on the Cloud side (overwrite semantics) — it is a "what's happening right now" indicator, not a history mechanism, and is unaffected by the LLM-content decisions below.
- Full step-by-step **LLM interaction** history *is* now synced to and durably persisted by Cloud (see D7/D8) — this is a deliberate reversal of this change's earlier draft, which had scoped Cloud to latest-only. The motivation: Host Agent deployments already route every AI Planner decision through Cloud API's existing `cloud-planner-proxy` endpoint when configured for the `cloud` transport, so Cloud is already on the natural path for this data and centralizing it there (rather than only on whichever edge host happens to still be running) is the more useful place for an operator doing centralized troubleshooting. This durable log is scoped strictly to LLM prompt/response text — it is not a general-purpose duplicate of the Host Agent's full `Timeline` (no screenshots, no scene JSON dumps beyond what's embedded in the prompt text itself).
- No change to the Runtime `console/` SPA or `runtime/`-owned packages. The multi-backend-SPA alternative was considered and rejected (see Decisions).
- No change to `driver/`/`device/`/`core/` device-control internals.
- No screenshot or full-scene data leaves the Host Agent process as part of progress reporting, and no screenshot bytes are ever persisted by the Cloud Control Plane. (Full LLM prompt/response *text* is, by contrast, an explicit Goal below — see D7/D8 — which partially supersedes the original `cloud-planner-proxy` proposal's "does not durably persist ... full prompt text" statement. Screenshots remain excluded; prompts no longer are.)
## Decisions
### D1: Wire a real `metadata_store`/`timeline` into Host Agent's `TaskRunner`, reusing the existing classes
`create_execution_factories()` already accepts `metadata_store: TaskMetadataStore | None` and `timeline: Timeline | None` (`execution.py:28-34`) — these are the same classes `api/rest.py` uses for the Runtime's own console. Reuse them as-is rather than inventing a Host-Agent-specific store: `HostAgentApplication`'s builder constructs a `TaskMetadataStore(db_path=<host-agent-local path>)` and `Timeline(ArtifactStore(<host-agent-local path>))` and passes them into `create_execution_factories()`.
**Alternative considered**: a bespoke in-memory-only step recorder (cheaper, no disk I/O). Rejected because `TaskMetadataStore`/`Timeline` are already proven in production (Runtime's own console has run on them since the original `web-console` change) and reusing them means Host Agent's task/timeline data has the *exact* same shape as `api/console.py` already serializes — a prerequisite for D3 below. A bespoke format would need its own (de)serialization and its own console rendering code for no real benefit.
New config: `HOST_AGENT_TASK_PROGRESS_DB_PATH` (default e.g. `host_agent_data/task_progress.sqlite3`) and a matching artifact directory, kept separate from any local Runtime `tasks/` directory that might exist on the same machine, to avoid two unrelated processes silently sharing or colliding on a path.
### D2: Bounded retention for the Host-Agent-local store
Unlike a Runtime dev session (short-lived, manually cleared), a Host Agent process runs indefinitely and executes many assignments over its lifetime. Unbounded `TaskMetadataStore` rows and `Timeline` screenshot artifacts would grow without limit.
Decision: add a lightweight retention pass (e.g. on a timer, or opportunistically after each assignment finishes) that prunes tasks older than a configurable window or beyond a configurable count, deleting both the `tasks` row and its `Timeline`/`ArtifactStore` files. This is new logic — neither `TaskMetadataStore` nor `Timeline` currently supports deletion — scoped as a small addition to `storage/task_metadata.py` / `storage/timeline.py` (or a Host-Agent-side wrapper if adding delete methods to the shared `storage` package feels too broad; prefer extending `storage` since both Runtime and Host Agent benefit from bounded retention).
### D3: Give Host Agent's own console read-only task/timeline pages instead of making `console/` multi-backend
Two options were evaluated for capability `host-agent-console-task-pages`:
- **(a) Multi-backend Runtime `console/` SPA**: let the existing Vue SPA point at a Host Agent's console origin (a saved/selectable base URL) in addition to the local Runtime. Since D1 makes Host Agent's data shape identical to what `api/console.py` already serializes, the SPA's existing fetch/render code would work unmodified against a Host Agent origin.
- **(b) Extend Host Agent's own server-rendered console** (`host_agent/web/app.py`) with new read-only pages for task list/detail/timeline, reusing `TaskMetadataStore`/`Timeline` query calls directly (the same calls `api/console.py`'s handlers make), rendered as server-side HTML via the existing f-string + `html.escape()` convention (no SPA, no frontend build) established by `host-agent-local-console`.
**Decision: (b).** (a) requires Host Agent's console to serve cross-origin, credentialed requests from whatever origin `console/` is running on (Vite dev server, or a different deployed origin) — meaning either relaxing Host Agent's CORS posture for its session-cookie+CSRF-protected endpoints, or reworking its auth model to tolerate cross-origin fetches. That is real new attack surface on a console whose entire local-only threat model (`console_bind_host` defaulting to loopback, explicit opt-in for non-loopback) was deliberately conservative. (b) reuses an already-accepted pattern (server-rendered, same-origin, same auth) and fully satisfies the actual operator need — a web page showing what a Host Agent is doing — without touching `runtime/`, `console/`, or Host Agent's CORS/auth posture at all. The cost is a small amount of view duplication (Host Agent's console and Runtime's `console/` render similar-shaped data with different templates/frameworks), judged acceptable given they serve different operational contexts (local direct-connect Runtime vs. remote Host Agent fleet).
Any future desire for a unified SPA across both surfaces is left as a follow-up, not blocked by this decision (D1's shared data shape keeps that door open).
### D4: Piggyback progress reporting on the existing lease-renewal call
`ActiveAssignmentRunner._renew_while_running` (`lease.py:79-106`) already makes an authenticated, per-assignment, periodic call (`client.renew(assignment)`, roughly every 1/3 of remaining lease) while a task executes, validated server-side against `host_id`/`task_id`/`attempt`/`lease_id` (`internal_api/api.py:291-320`). This is the natural piggyback point for progress: extend `LeaseRenewalRequest` with an optional `progress: TaskProgressModel | None` field (new small model: `step_index: int`, `step_status: Literal[...]`, `summary: str` bounded length — no screenshot/scene payload) in `cloud/internal_api/models.py`, the single schema both sides already import directly (no parallel schema, matching existing convention noted for this protocol).
A small thread-safe "latest progress" holder (similar in spirit to `LeaseGuard`) is written by the execution thread (via a hook on `TaskRunner`/`AssignmentExecutor`, updated once per step) and read by `_renew_while_running` just before each renewal call, so no new timer/cadence is introduced.
**Alternative considered**: a dedicated `POST /internal/v1/hosts/{host_id}/assignments/{task_id}/progress` endpoint called on its own cadence. Rejected — it would duplicate the auth/validation `renew_assignment` already does, and introduce a second periodic call where one already exists and fires at a reasonable frequency for this purpose.
### D5: Cloud persists only the latest progress snapshot, overwritten alongside lease renewal
`renew_lease()` (`repository.py:407`, `sql_repository.py:1480`) already takes a row lock and writes a lease-expiry update on every renewal; extend it to also accept and store the optional progress fields (few scalar columns — `progress_step_index`, `progress_step_status`, `progress_summary`, `progress_updated_at` — rather than a schema-flexible JSON blob, keeping it queryable and consistent with the rest of the row's typed columns). This requires a new Alembic migration (head is currently `0007_llm_provider_management`) plus the equivalent SQLite schema change, applied to both `repository.py` and `sql_repository.py` to keep the dual-backend contract (`cloud-control-plane` spec's "Deployment and local persistence modes share one contract" requirement).
Progress columns are cleared (or simply superseded and ignored) once a terminal result is recorded — they represent "what's happening right now," not history; Cloud's durable attempt/result history is unaffected and unduplicated.
### D6: Cloud Console reads progress from the existing task/attempt query path, not a new endpoint
Extend whatever response model Cloud Console's task list/detail already uses (Cloud API public router) with the optional latest-progress fields from D5, rather than adding a new endpoint. Cloud Console renders it as a small inline badge/line ("step 4: tapping login button") next to the existing status, refreshed on the SPA's existing polling interval.
### D7: Capture full LLM interaction history for free via the existing `cloud-planner-proxy` decide endpoint, not a new reporting channel
Investigated the Host Agent → Cloud call path in detail: when a Host Agent is configured with `AI_PLANNER_TRANSPORT=cloud`, `AIPlanner.plan()` (`runtime/ai_planner.py`) calls `CloudProxyToolCallingClient.decide()` (`apps/device-host-agent/host_agent/cloud_planner_client.py:46-95`), which `POST`s the *complete* `system_prompt`/`user_prompt` (plus `task_id`/`attempt`/`lease_id` from `current_planner_execution_context()`) to Cloud API's `/hosts/{host_id}/planner/decide` (`packages/cloud-platform/cloud/internal_api/api.py::decide_planner_call`, line ~367). That handler already resolves and returns a `ToolCallDecision` (`tool_name`/`arguments`/`usage`) and already does per-call bookkeeping (`pool.store.settle_host_token_reservation(...)`, line ~463) using the same repository object this change's D5 already touches for lease renewal.
This means **every planning step's actual prompt and resulting decision already flows through a Cloud-owned request handler** when the `cloud` transport is used — no new endpoint, no new protocol field, no queue/batching scheme is needed to get full LLM content to Cloud. The only change needed is to make that handler *persist* what it currently discards.
**Decision**: extend `decide_planner_call` to, immediately after computing `decision` (success path only — a `ToolCallUnavailable`/502 path persists nothing), insert one row into a new log table (D8) keyed by `(task_id, attempt, step_index)`, where `step_index` is assigned by the Cloud side itself (an auto-incrementing counter scoped to `task_id`+`attempt`, e.g. `select count(*) + 1` under the same row lock, or a DB sequence/identity column) — Host Agent does not need to track or send a step counter for this.
**Explicit limitation, called out rather than papered over**: this only captures LLM content for hosts using the `cloud` transport. A host on the (still-default) `direct` transport never sends its prompts to Cloud at all — Cloud has zero LLM content for that host's tasks, and only ever sees the coarse index/status/summary from D4/D5's lease-renewal piggyback (which is transport-agnostic, since it's driven by `TaskRunner` step completion, not by the planner's transport choice). This is a real operational dependency: centralized LLM-interaction troubleshooting via Cloud Console requires the fleet (or the hosts an operator cares about) to run with `AI_PLANNER_TRANSPORT=cloud`. This change does not make `cloud` the new default transport — that remains a separate, already-existing configuration decision outside this change's scope.
**Alternative considered**: extend the D4 lease-renewal piggyback to also carry full prompt/response text (queued, not overwritten, so no step is lost between renewals). Rejected: it would duplicate a transport that already exists for exactly this payload (the decide call itself) whenever `cloud` transport is active, and would still need a *separate* new channel for the `direct`-transport case where Cloud never sees the prompt anyway — i.e., it does not actually solve the `direct`-transport gap, so it only adds complexity without expanding coverage.
### D8: New bounded-retention table for the full per-step LLM decision log, extended on both repository backends
Add a new table (e.g. `planner_decision_log`): `id`, `host_id`, `task_id`, `attempt`, `step_index`, `system_prompt` (text), `user_prompt` (text), `tool_name`, `arguments_json` (text), `created_at`. No screenshot column — screenshots are never sent to this endpoint's persistence path (the request's `screenshot_base64` is used only to call the LLM provider and is never written to this log, consistent with the Non-Goals screenshot exclusion).
Like D5, this needs a new Alembic migration and the equivalent SQLite path, implemented on both `repository.py`'s SQLite-backed implementation and `sql_repository.py::SQLAlchemyCloudRepository` to preserve the existing dual-backend contract. Unlike D5's few-nullable-columns-on-an-existing-row approach, this is an independent append-only table (one row per decide call, not an overwrite), since the whole point is durable per-step history rather than a live snapshot.
**Retention**: this table grows once per planning step across the whole fleet, indefinitely, on a shared multi-tenant Cloud database — unbounded growth is a real risk here in a way D5's single-row-per-assignment overwrite never was. Decision: a scheduled prune job (mirrors D2's Host-Agent-local retention) deletes rows whose owning task reached a terminal state more than a configurable window ago (default: prune 7 days after task terminal, or once the task itself is pruned/archived by whatever existing Cloud task-retention policy applies — reuse that cadence rather than inventing a second one if `cloud-control-plane` already has one; otherwise default to a simple time-based prune).
### D9: Fix the shared `Timeline`/`TaskRunner`/`AIPlanner` path so the *actual* per-step prompt and response are recorded locally, not just the task goal and the parsed tool call
Independent of Cloud persistence, the existing local recording is itself wrong today: `TaskRunner._append_timeline()` (`runtime/task.py:296-319`) calls `Timeline.append(prompt=task.goal, tool_call={"action": ..., "description": ..., "args": ...}, ...)`. `task.goal` is the overall task goal, not the per-step prompt actually sent to the LLM — the real per-step prompt (`planner_user_prompt(goal, scene_json, history_summary)`, built in `ai_planner.py::plan()`) is constructed, sent, and discarded entirely within `AIPlanner.plan()`, never reaching `TaskRunner`. Likewise `ToolCallDecision` (`runtime/tool_calling_client.py:23-27`) carries only the parsed `tool_name`/`arguments`/`usage` — any raw response text/content the model returned is discarded during parsing (`_decision_from_anthropic_response`/`_decision_from_openai_response`).
This is a pre-existing gap in a component shared by Runtime and Host Agent alike (not new to this change), and it undermines the very "step-level detail" goal D1 already committed to — a persisted step whose "prompt" field is just the task's goal repeated on every row is not useful for troubleshooting.
**Decision**: extend `ToolCallDecision` with the actual `user_prompt`/`system_prompt` it was given (or have `AIPlanner.plan()` return a small side-channel result instead of changing the `Planner` interface's return type) so `TaskRunner._append_timeline()` can pass the real per-step prompt into `Timeline.append()`. Rename `Timeline`/`TimelineRecord`'s `prompt` field's meaning (or add a new field) to unambiguously mean "the prompt actually sent to the LLM for this step." This fix lands in the shared `runtime`/`storage` packages, so both Host Agent's local console (D3) and the existing Runtime `console/` automatically benefit — it is not Host-Agent-specific plumbing.
## Risks / Trade-offs
- [Risk] Per-step SQLite writes in a long-lived Host Agent process add I/O overhead → Mitigation: this is the same write pattern Runtime's own console has always used per step; no new proof of acceptability needed. If profiling later shows it matters for very high step-rate tasks, batching/debouncing is a follow-up, not a blocker here.
- [Risk] Unbounded local disk growth from `Timeline` screenshots on an indefinitely-running Host Agent → Mitigation: D2's bounded retention pass; must ship in the same change as D1, not deferred, since D1 alone would otherwise introduce an unbounded-growth regression.
- [Risk] New DB columns/migration touch both `repository.py` (SQLite) and `sql_repository.py` (Postgres) — drift between the two has been a real defect category in this codebase (see `cloud-control-plane-integration` archive notes) → Mitigation: contract/parity tests already exist for this dual-backend boundary; extend them to cover the new progress columns.
- [Risk] View duplication between Host Agent's server-rendered task pages (D3) and Runtime `console/`'s task pages (different frameworks, same data shape) → Mitigation: accepted trade-off (see D3 rationale); shared data shape keeps future unification possible without rework.
- [Risk] Progress payload could accidentally grow to include sensitive data (scene dumps, prompts) if a future change casually extends `TaskProgressModel` → Mitigation: `summary` field is explicitly bounded/plain-text only; code review for this change and future extensions should treat this the same as the existing `cloud-planner-proxy` no-screenshot-persistence rule (screenshots specifically remain excluded from every Cloud-side table this change introduces).
- [Risk] `planner_decision_log` (D8) grows unboundedly across the whole fleet, unlike D5's single-row-per-assignment overwrite → Mitigation: D8's retention/prune job must ship in the same change as D7/D8, not deferred, for the same reason D2 must ship alongside D1.
- [Risk] Full prompt text can itself contain sensitive on-screen content (whatever text was visible in the scene description embedded in the prompt) — persisting it centrally is a deliberate trade-off the user has explicitly requested for centralized troubleshooting, but it is a real expansion of what Cloud stores → Mitigation: no additional mitigation beyond what's already decided (screenshots still excluded); flagged here so it is a visible, intentional decision rather than a silent scope creep.
- [Risk] Full LLM history in Cloud is silently absent for any host on the `direct` transport, which could read as "it's broken" rather than "expected" → Mitigation: Cloud Console should visibly distinguish "no progress reported yet" from "this host does not report LLM content" (e.g. by also surfacing the host's configured transport), rather than just showing an empty history with no explanation.
## Migration Plan
1. Add `TaskProgressModel` and the optional `progress` field to `LeaseRenewalRequest`/response in `cloud/internal_api/models.py`. Backward compatible: `progress` is optional, older Host Agents omit it.
2. Add progress columns + Alembic migration `0008_task_progress_columns`; extend `renew_lease()` in both repository implementations to accept/store them.
3. Extend Cloud Console's task read model and UI to render the new fields (no-op if absent, keeping rollback trivial).
4. Wire `metadata_store`/`timeline` into Host Agent's `create_execution_factories()` call site, add the retention pass (D2), and add the progress-holder hook feeding D4's renewal piggyback.
5. Extend Host Agent's local console with the read-only task/timeline pages (D3).
6. Fix `Timeline`/`TaskRunner`/`AIPlanner`/`ToolCallingClient` to record the real per-step prompt and response locally (D9) — independent of Cloud, benefits both Host Agent and Runtime consoles immediately.
7. Add `planner_decision_log` + Alembic migration for it (D8) on both repository backends, with its retention/prune job.
8. Extend `decide_planner_call` (`internal_api/api.py`) to persist each resolved decision into `planner_decision_log` (D7).
9. Extend Cloud Console with a per-task LLM interaction history view reading the new table, including the "host uses `direct` transport, no LLM content available" distinction from the Risks section.
10. Rollback: each step is independently revertible (optional field, additive columns, additive tables, additive UI, additive Host Agent wiring) — no destructive migration is required; both `0008` and the new decision-log migration's down-revisions drop only what they added.
## Open Questions
- Exact retention window/count defaults for D2 (time-based vs. count-based, or both) — left for tasks.md to pick a concrete, documented default (e.g. keep last 50 tasks or 7 days, whichever is smaller) rather than block design on it.
- Whether Cloud Console's existing task list/detail component can absorb the new fields with a small edit or needs a new sub-component — an implementation detail, not an architectural fork.
- Whether a future change should unify Host Agent's server-rendered task pages and Runtime `console/`'s SPA into one shared frontend, now that D1 gives them an identical underlying data shape — explicitly deferred, not part of this change.
- Exact retention default for D8's `planner_decision_log` (prune-after-terminal window, or reuse an existing Cloud task-retention cadence if one already exists) — left for tasks.md to pick a concrete default rather than block design on it.
- Whether `step_index` in D8 should be assigned via a `SELECT count(*) + 1` under a row lock or a dedicated per-`(task_id, attempt)` counter/sequence — an implementation detail to resolve in tasks.md, not an architectural fork.
@@ -0,0 +1,34 @@
## Why
Nobody can see a task while it is running. Host Agent's local console only shows a coarse "current assignment" snapshot (task id, device id, goal, started-at) because the `TaskRunner` it drives is wired with `metadata_store=None, timeline=None` — every step transition happens in memory and is discarded the instant the assignment finishes. Cloud Control Plane only learns about an assignment at claim, heartbeat, and terminal-result time, so Cloud Console has nothing better to show. The local Runtime (`api/rest.py` + `console/`) is a separate process with its own independent `TaskMetadataStore`/`Timeline`, so it never sees a task that a Host Agent executed at all. An operator debugging a stuck or misbehaving task currently has no live signal anywhere in the system until the task finishes or times out.
## What Changes
- Wire Host Agent's `AssignmentExecutor` / `create_execution_factories()` with a real `metadata_store` and `timeline` (or Host-Agent-local equivalents) so the in-process `TaskRunner` actually records step-by-step status instead of discarding it.
- Expose that step-level detail through Host Agent's local console: extend `AgentStatusTracker`/`/api/status` (or add a focused endpoint) with current step index, step status, and a short in-progress step log; render it in the dashboard's "Current assignment" section instead of the static started-at-only view.
- Add a progress-reporting path from Host Agent to Cloud Control Plane so the control plane learns step-level state near-real-time rather than only at claim/heartbeat/terminal-result. Extend the existing `cloud.internal_api.models` Pydantic schema (the single source of truth Host Agent already imports directly) rather than introducing a parallel schema.
- Persist and expose the latest per-assignment progress on the Cloud Control Plane side, and surface it in Cloud Console so an operator watching a remote task sees live step progress, not just "dispatched" / "succeeded" / "failed".
- Give an operator a web console view of Host-Agent-executed task progress without making the `runtime/`-owned packages import host or cloud concerns (enforced by `test_runtime_owned_packages_do_not_import_host_or_cloud_concerns`). After evaluating the alternative of making the Runtime `console/` SPA multi-backend (pointing its existing JS bundle at a Host Agent's console origin), this change instead extends Host Agent's own server-rendered local console with read-only task list/detail/timeline pages, reusing the same query shape `api/console.py` already exposes to the Runtime SPA. This avoids new cross-origin/session-cookie surface between the SPA and Host Agent, and keeps `runtime/` untouched. See design.md for the full trade-off analysis.
- All three surfaces continue to use polling (matching current behavior); this change does not introduce SSE/WebSocket infrastructure unless design.md finds a compelling reason to.
- Fix the shared `Timeline`/`TaskRunner`/`AIPlanner` recording path so a persisted step's "prompt" is the *actual* prompt sent to the LLM for that step (not the task's overall goal) and the model's resulting decision is captured too — this pre-existing gap affects Runtime and Host Agent alike and undermines the step-level detail this change otherwise adds.
- Persist a durable, per-step log of full LLM prompt/response content on the Cloud Control Plane, for centralized troubleshooting — reusing the already-existing `cloud-planner-proxy` decide endpoint as the capture point (no new protocol/endpoint) rather than the coarse lease-renewal piggyback used for live index/status. This durable log is populated only for hosts using the `cloud` planner transport; hosts on the `direct` transport still get only the coarse index/status via lease renewal. Cloud Console gains a view to browse a task's full LLM interaction history.
## Capabilities
### New Capabilities
- `host-agent-task-progress`: Host Agent captures step-level execution progress for its in-flight assignment (via a wired `metadata_store`/`timeline`) and exposes it through its local console/API.
- `cloud-task-progress-visibility`: Cloud Control Plane receives, persists, and exposes near-real-time step-level progress for assignments it has dispatched to a Host Agent, and Cloud Console renders it.
- `host-agent-console-task-pages`: Host Agent's local server-rendered console gains read-only task list/detail/timeline pages (mirroring `api/console.py`'s task query shape) so an operator can inspect a Host-Agent-executed task's progress and history without needing the separate Runtime `console/` SPA or violating the `runtime/`-package host/cloud isolation boundary.
### Modified Capabilities
- `host-agent-protocol`: add a requirement that the Host Agent reports in-progress step-level status updates to the control plane (in addition to the existing heartbeat/claim/renewal/result operations), and that the control plane accepts and stores them per active assignment.
- `cloud-planner-proxy`: the existing planner-decision endpoint additionally persists each resolved decision's prompt/response into a durable, bounded-retention per-step log, instead of discarding it after the response is returned.
## Impact
- `apps/device-host-agent/host_agent/app.py`, `execution.py`, `assignment.py`, `status.py`, `web/app.py` — wire a real metadata/timeline store into the in-process `TaskRunner`, extend status tracking and the local console UI/API.
- `runtime/task.py`, `runtime/ai_planner.py`, `runtime/tool_calling_client.py`, `storage/timeline.py` — fix the shared step-recording path so the real per-step prompt and the model's response are captured, not just the task goal and parsed tool call (a correctness fix in already-existing, shared code, not new behavior scope).
- `packages/cloud-platform/cloud/internal_api/models.py`, `packages/cloud-platform/cloud/internal_api/api.py` (`decide_planner_call`), `repository.py`/`sql_repository.py`, a new Alembic migration — new progress-reporting request/response models and a persistence + query path for latest per-assignment coarse progress, *and* a new durable per-step `planner_decision_log` table (with its own retention job) populated from the existing planner-decision endpoint.
- `cloud-console/` (Vue3 SPA) — new UI to render live per-assignment progress, and a new view to browse a task's full LLM interaction history.
- `apps/device-host-agent/host_agent/web/app.py` — new read-only task list/detail/timeline pages backed by the Host-Agent-local `TaskMetadataStore`/`Timeline`; no changes anticipated to `console/` (Vue3 SPA).
- No changes anticipated to `driver/`, `device/`, `core/` device-control internals.
@@ -0,0 +1,34 @@
## ADDED Requirements
### Requirement: Cloud Control Plane persists each resolved planner decision for later retrieval
The Cloud Control Plane's planner-decision endpoint SHALL, for each request it successfully resolves to a tool-call decision, persist the request's system prompt, user prompt, the resolved tool name and arguments, and an assigned step index (scoped to the request's `task_id`/`attempt`) to a durable, bounded-retention store, in addition to returning the decision to the requesting Host Agent. The Cloud Control Plane SHALL NOT persist screenshot bytes from these requests.
#### Scenario: A planner-decision request resolves successfully
- **WHEN** the Cloud Control Plane resolves a planner-decision request to a tool-call decision
- **THEN** it persists the request's system prompt, user prompt, the resolved tool name and arguments, and a step index for that `task_id`/`attempt`, before returning the decision to the Host Agent
#### Scenario: A planner-decision request fails
- **WHEN** the configured provider call fails and the endpoint returns a structured failure response
- **THEN** no row is persisted for that request
#### Scenario: A screenshot was included in the request
- **WHEN** a planner-decision request includes a screenshot
- **THEN** the screenshot bytes are used only to call the LLM provider and are not written to the persisted decision log
### Requirement: Persisted planner decisions are retained within a bounded window
The Cloud Control Plane SHALL prune persisted planner decisions once their owning task has been in a terminal state for longer than a configurable retention window, so that indefinite operation does not cause unbounded growth of the decision log.
#### Scenario: A task's retention window has elapsed since reaching a terminal state
- **WHEN** a task reached a terminal state more than the configured retention window ago
- **THEN** the Cloud Control Plane removes that task's persisted planner decisions
#### Scenario: A task is still active or within its retention window
- **WHEN** a task is still active, or reached a terminal state less than the configured retention window ago
- **THEN** its persisted planner decisions remain available for query
### Requirement: Cloud-side planner decision history is scoped to hosts using the cloud-proxy transport
The Cloud Control Plane's persisted planner decision log SHALL only ever contain entries for hosts whose planner calls were routed through the cloud-proxy transport; it SHALL NOT contain entries, synthesized or otherwise, for hosts using the direct-to-provider transport.
#### Scenario: A host uses the direct-to-provider transport
- **WHEN** a Host Agent configured for the direct-to-provider transport executes an assignment
- **THEN** no planner decision entries for that assignment appear in the Cloud Control Plane's persisted decision log
@@ -0,0 +1,38 @@
## ADDED Requirements
### Requirement: Cloud Control Plane exposes the latest in-progress step status for an active assignment
The Cloud Control Plane's existing task query surface SHALL include the latest reported step index, step status, and summary for any assignment that has an in-progress Host Agent execution, alongside the task's existing status fields.
#### Scenario: An assignment has reported progress
- **WHEN** an operator queries a task that has an active, in-progress assignment with previously reported step progress
- **THEN** the response includes that step's index, status, and summary alongside the task's existing fields
#### Scenario: An assignment has no reported progress yet
- **WHEN** an operator queries a task whose active assignment has not yet reported any progress
- **THEN** the response omits progress fields rather than showing stale or default values
#### Scenario: An assignment has reached a terminal state
- **WHEN** an operator queries a task whose assignment has already completed (succeeded or failed)
- **THEN** the response does not present the last in-progress step as current status; the task's terminal status and result take precedence
### Requirement: Cloud Console renders live step progress for in-progress tasks
Cloud Console SHALL display the current step index, status, and summary for a task with an active, in-progress Host Agent execution, refreshed on its existing polling interval, without requiring a new push channel.
#### Scenario: Operator views an in-progress task
- **WHEN** an operator opens a task detail view for a task with an active in-progress assignment
- **THEN** Cloud Console shows the latest known step index, status, and summary, updating on subsequent polls as new progress is reported
#### Scenario: Operator views a task with no in-progress execution
- **WHEN** an operator opens a task detail view for a queued, terminal, or otherwise not-currently-executing task
- **THEN** Cloud Console does not display stale in-progress step information
### Requirement: Cloud Console displays a task's full LLM interaction history
Cloud Console SHALL provide a view, for a given task, listing each persisted planner decision in step order, including its full prompt and resulting decision, sourced from the Cloud Control Plane's persisted planner decision log.
#### Scenario: Task has persisted planner decisions
- **WHEN** an operator opens the LLM interaction history view for a task that has one or more persisted planner decisions
- **THEN** Cloud Console shows each decision in step order with its prompt and resulting tool call
#### Scenario: Task's host used the direct-to-provider transport
- **WHEN** an operator opens the LLM interaction history view for a task whose host used the direct-to-provider transport
- **THEN** Cloud Console indicates that no LLM interaction history is available because the host does not report it, rather than showing an empty history with no explanation
@@ -0,0 +1,34 @@
## ADDED Requirements
### Requirement: Host Agent local console exposes step-level status for the current assignment
The Host Agent's local console SHALL display, for its currently executing assignment, the current step index, step status, and a short summary, sourced from the Host Agent's local task metadata store, refreshed on the console's existing polling interval.
#### Scenario: An assignment is currently executing
- **WHEN** an operator views the Host Agent local console dashboard while an assignment is executing
- **THEN** the dashboard shows the current step index, step status, and a short summary for that assignment, updating on subsequent polls
#### Scenario: No assignment is currently executing
- **WHEN** an operator views the dashboard while the Host Agent is idle
- **THEN** the dashboard shows no in-progress step information
### Requirement: Host Agent local console exposes read-only task history with per-step detail and screenshots
The Host Agent's local console SHALL provide authenticated, read-only pages listing recently executed tasks and, for a selected task, its full per-step history including any captured screenshots, sourced from the Host Agent's local task metadata store and timeline.
#### Scenario: Operator lists recent tasks
- **WHEN** an authenticated operator opens the Host Agent local console's task list page
- **THEN** it shows tasks from the local task metadata store, most recent first, including tasks that have already reached a terminal state
#### Scenario: Operator inspects a completed task's step history
- **WHEN** an authenticated operator opens the detail page for a specific completed task
- **THEN** the page shows each recorded step in order, including its tool call, result, and any captured screenshot
#### Scenario: Unauthenticated request
- **WHEN** a request to the task list or task detail pages is made without a valid Host Agent console session
- **THEN** the Host Agent rejects the request the same way it rejects unauthenticated requests to its other console pages
### Requirement: Host Agent local console task pages require no new cross-origin surface
The Host Agent local console's task pages SHALL be served same-origin from the Host Agent's existing web application, without introducing new CORS allowances or a dependency on the separate Runtime `console/` frontend.
#### Scenario: Task pages are requested
- **WHEN** an operator's browser requests the Host Agent local console's task pages
- **THEN** the pages are served by the Host Agent's own application using its existing session/CSRF protections, with no additional cross-origin configuration required
@@ -0,0 +1,23 @@
## ADDED Requirements
### Requirement: Host Agent reports execution progress alongside lease renewal
The Host Agent SHALL optionally include a bounded, screenshot-free progress summary (current step index, step status, and a short plain-text summary) in its periodic lease-renewal request for an active assignment, and the control plane SHALL accept and store only the most recent such summary per active assignment.
#### Scenario: Progress is available at renewal time
- **WHEN** the Host Agent renews the lease for an in-progress assignment and has a current step index, status, and summary available
- **THEN** the renewal request includes that progress summary and the control plane overwrites any previously stored progress for that assignment with it
#### Scenario: Progress is not available at renewal time
- **WHEN** the Host Agent renews a lease without a progress summary available (e.g. before the first step completes)
- **THEN** the renewal request omits the progress field and any previously stored progress for that assignment is left unchanged
#### Scenario: Assignment reaches a terminal state
- **WHEN** an assignment's terminal result is recorded
- **THEN** the control plane's stored progress for that assignment is no longer treated as current and is not exposed as an in-progress status
### Requirement: Progress reports exclude screenshot and scene payloads
The control plane SHALL reject or ignore any progress field on a renewal request that includes screenshot, scene, or other bulk payload data beyond the bounded step index, status, and short text summary.
#### Scenario: Renewal request includes an oversized or non-text summary
- **WHEN** a Host Agent submits a progress summary exceeding the configured length bound
- **THEN** the control plane truncates or rejects the oversized field without failing the underlying lease renewal
@@ -0,0 +1,30 @@
## ADDED Requirements
### Requirement: Host Agent records step-level execution detail for its in-process TaskRunner
The Host Agent SHALL construct its in-process `TaskRunner` with a durable metadata store and timeline so that every step transition (status, index, the actual prompt submitted to the LLM for that step, the model's resulting decision, result, and screenshot when captured) is persisted as it happens, rather than discarded when the assignment completes. The persisted prompt SHALL be the prompt actually sent to the LLM for that specific step, not the task's overall goal.
#### Scenario: A step completes during goal execution
- **WHEN** the Host Agent's `TaskRunner` completes a step while executing an assigned goal
- **THEN** the step's status, index, the actual per-step LLM prompt and response, tool call, result, and any captured screenshot are persisted to the Host Agent's local task metadata store and timeline before the next step begins
#### Scenario: An assignment finishes
- **WHEN** an assignment reaches a terminal state (succeeded or failed)
- **THEN** its full step history remains queryable from the Host Agent's local store after the in-memory `Task` object is discarded
### Requirement: Host-Agent-local task history is retained within a bounded window
The Host Agent SHALL prune persisted task metadata, timeline records, and associated screenshot artifacts once they exceed a configurable retention window or count, so that indefinite process uptime does not cause unbounded local disk growth.
#### Scenario: Retention window is exceeded
- **WHEN** a persisted task's age or position exceeds the configured retention threshold
- **THEN** the Host Agent removes that task's metadata row, timeline records, and screenshot artifacts from local storage
#### Scenario: Retention has not been exceeded
- **WHEN** a persisted task is within the configured retention threshold
- **THEN** its metadata, timeline records, and screenshot artifacts remain available for query
### Requirement: Host Agent local task storage is isolated from an unrelated local Runtime
The Host Agent SHALL use a configurable, Host-Agent-specific database and artifact path for its task metadata store and timeline, distinct from any local Runtime API's own task storage path, so that the two processes cannot silently collide or share state when run on the same machine.
#### Scenario: Host Agent and local Runtime run on the same machine
- **WHEN** both a Host Agent process and a local Runtime API process run on the same machine with their default configurations
- **THEN** each process reads and writes its own task metadata store and timeline without observing or modifying the other's data
@@ -0,0 +1,68 @@
## 1. Host Agent local task storage (D1, D2)
- [x] 1.1 Add `HOST_AGENT_TASK_PROGRESS_DB_PATH` (and matching artifact directory config) to `HostAgentConfig`/`load_host_agent_config()`, with a Host-Agent-specific default distinct from any local Runtime `tasks/` path.
- [x] 1.2 In `HostAgentApplication`'s builder (`app.py:218-223`), construct a `TaskMetadataStore`/`Timeline` from that config and pass them into `create_execution_factories(..., metadata_store=..., timeline=...)`.
- [x] 1.3 Add delete/prune methods to `storage/task_metadata.py::TaskMetadataStore` and `storage/timeline.py::Timeline` (remove a task's row, timeline records, and screenshot artifacts).
- [x] 1.4 Implement a bounded retention pass in the Host Agent (default: keep the newer of "last 50 tasks" or "7 days", whichever keeps fewer rows) that runs after each assignment finishes, calling the new prune methods.
- [x] 1.5 Add unit tests: step transitions persist during execution; a task's history is queryable after the assignment completes and the in-memory `Task` is discarded; retention prunes tasks beyond the configured threshold; Host Agent and a local Runtime process using default paths on the same machine do not collide.
## 2. Host Agent reports progress during lease renewal (D4)
- [x] 2.1 Add `TaskProgressModel` (`step_index: int`, `step_status`, `summary: str` with a bounded max length) to `packages/cloud-platform/cloud/internal_api/models.py`, and an optional `progress: TaskProgressModel | None` field on `LeaseRenewalRequest`.
- [x] 2.2 Add a thread-safe "latest progress" holder written by the Host Agent's execution thread (hook into `TaskRunner`/`AssignmentExecutor` per-step completion) and read by `ActiveAssignmentRunner._renew_while_running` (`lease.py`) just before each renewal call.
- [x] 2.3 Update `HostAgentClient.renew()` to include the current progress snapshot (if any) in the renewal request.
- [x] 2.4 Add unit tests: renewal includes progress once a step has completed; renewal omits progress before any step completes; an oversized summary is truncated/rejected client-side before sending.
## 3. Cloud Control Plane persists latest progress (D5)
- [x] 3.1 Add progress columns (`progress_step_index`, `progress_step_status`, `progress_summary`, `progress_updated_at`, all nullable) via a new Alembic migration (`0008_task_progress_columns`) with a down-revision that drops them.
- [x] 3.2 Add the equivalent columns/handling to the SQLite schema path.
- [x] 3.3 Extend `renew_lease()` in both `repository.py` and `sql_repository.py` to accept and overwrite the optional progress fields under the same row lock already taken for the lease-expiry update.
- [x] 3.4 Update `renew_assignment` in `internal_api/api.py` to pass the optional `payload.progress` fields through to `renew_lease()`, rejecting/truncating an oversized `summary` without failing the underlying renewal.
- [x] 3.5 Ensure progress fields are treated as stale/ignored once `record_task_result` records a terminal outcome for that task/attempt.
- [x] 3.6 Add/extend repository contract tests (both SQLite and PostgreSQL where the existing test setup covers it) so the two backends stay in parity for the new columns.
## 4. Cloud Console displays live progress (D6)
- [x] 4.1 Extend the Cloud API's existing task list/detail response model with the optional latest-progress fields from section 3.
- [x] 4.2 Extend `cloud-console/`'s task list/detail view to render step index, status, and summary for a task with an active in-progress assignment, refreshed on its existing polling interval; show nothing when no progress is present or the task is terminal.
- [x] 4.3 Add Vitest coverage for the new progress rendering (present, absent, and terminal-task-hides-stale-progress cases).
## 5. Host Agent local console task pages (D3)
- [x] 5.1 Extend `AgentStatusTracker`/`/api/status` (or a small addition alongside it) to surface the current step index/status/summary for the in-progress assignment, sourced from the same progress holder built in section 2, and update the dashboard's "Current assignment" rendering to show it.
- [x] 5.2 Add authenticated, read-only task list and task detail/timeline routes to `host_agent/web/app.py`, querying the Host-Agent-local `TaskMetadataStore`/`Timeline` (reusing the same query calls `api/console.py` makes) and rendering server-side HTML consistent with the existing dashboard's f-string + `html.escape()` convention, including inlined screenshots on the detail/timeline page.
- [x] 5.3 Gate the new routes behind the existing Host Agent console session/CSRF protection; verify no new CORS configuration is introduced.
- [x] 5.4 Add tests: unauthenticated requests to the new routes are rejected the same way as other console routes; task list/detail/timeline pages render expected data including screenshots for a completed task.
## 6. Documentation and verification
- [x] 6.1 Update `docs/MACOS_IPHONE_SETUP.md` and/or `docs/CLOUD_DEPLOYMENT.md` with the new Host Agent config vars (`HOST_AGENT_TASK_PROGRESS_DB_PATH` and retention settings) and a short note on where to view live/historical task progress in each console.
- [x] 6.2 Run `uv run --all-packages pytest -m "not integration"` and targeted Cloud API / Host Agent test suites; run `cloud-console/` and `console/`-equivalent Vitest suites for the touched frontend.
- [x] 6.3 Run Ruff check/format and `compileall` across touched packages.
- [x] 6.4 Run `openspec validate --strict` for this change.
- [ ] 6.5 Manual verification (requires a real Host Agent + Appium/device setup per `docs/MACOS_IPHONE_SETUP.md`): run a real task end-to-end and confirm step progress appears live in the Host Agent console and Cloud Console, and that full step history with screenshots is browsable afterward in the Host Agent console.
## 7. Real per-step LLM prompt/response recorded locally (D9)
- [x] 7.1 Extend `ToolCallDecision` (`runtime/tool_calling_client.py:23-27`) with the actual prompt content given to that call (or return it via a small side-channel from `AIPlanner.plan()`, not by changing the `Planner.plan()` return type used by other planners).
- [x] 7.2 Update `TaskRunner._append_timeline()` (`runtime/task.py:296-319`) to pass the real per-step prompt and the model's resulting decision into `Timeline.append()`, instead of `task.goal`.
- [x] 7.3 Update `storage/timeline.py`'s `Timeline`/`TimelineRecord` field(s) so the persisted meaning is unambiguously "the prompt actually sent to the LLM for this step" (rename or add a field; keep backward-compatible read of any already-persisted rows if a Runtime dev DB might already have old-shaped rows).
- [x] 7.4 Update any renderer of this data (`api/console.py`, Runtime `console/`, and the new Host Agent console task pages from section 5) to show the corrected field.
- [x] 7.5 Add unit tests: a persisted step's prompt matches what `AIPlanner.plan()` actually sent for that step (not the task goal), across at least one multi-step task.
## 8. Cloud persists full per-step LLM decision history via the existing cloud-planner-proxy endpoint (D7, D8)
- [x] 8.1 Add a new `planner_decision_log` table (`id`, `host_id`, `task_id`, `attempt`, `step_index`, `system_prompt`, `user_prompt`, `tool_name`, `arguments_json`, `created_at`) via a new Alembic migration, plus the equivalent SQLite schema/table, on both `repository.py` and `sql_repository.py`.
- [x] 8.2 Add a repository method (e.g. `record_planner_decision(...)`) on both backends that inserts one row, assigning `step_index` as the next value scoped to `(task_id, attempt)`.
- [x] 8.3 Extend `decide_planner_call` (`packages/cloud-platform/cloud/internal_api/api.py:367-488`) to call the new repository method with the resolved decision, immediately after `settle_host_token_reservation`, on the success path only (never on the `ToolCallUnavailable`/502 path).
- [x] 8.4 Add a retention/prune job for `planner_decision_log` (default: prune a task's rows once it has been terminal for longer than a configurable window), mirroring section 1's Host Agent retention approach.
- [x] 8.5 Add/extend repository contract tests so SQLite and PostgreSQL stay in parity for the new table and prune job.
- [x] 8.6 Add unit tests: a successful decision is persisted with the correct prompt/tool_name/arguments and an incrementing step_index; a failed decision persists nothing; screenshot bytes are never written to the log even when the request included one; retention prunes rows for long-terminal tasks.
## 9. Cloud Console browses full LLM interaction history (D7/D8 UI)
- [x] 9.1 Add a Cloud API query endpoint (or extend an existing task-detail endpoint) to list `planner_decision_log` rows for a task in step order.
- [x] 9.2 Add a `cloud-console/` view rendering a task's full LLM interaction history (prompt + resulting decision per step).
- [x] 9.3 When a task's host used the `direct` transport (no persisted decisions and the host's configured transport is known to be `direct`), show an explicit "not reported by this host's transport" state rather than an empty list.
- [x] 9.4 Add Vitest coverage for populated history, empty-but-cloud-transport (task hasn't produced any decisions yet), and direct-transport-hidden cases.
@@ -38,6 +38,8 @@ class CloudControlConfig:
trust_proxy_headers: bool = False trust_proxy_headers: bool = False
planner_token_reservation_ceiling: int = 4096 planner_token_reservation_ceiling: int = 4096
planner_token_reservation_ttl_seconds: int = 300 planner_token_reservation_ttl_seconds: int = 300
planner_decision_log_prune_interval_seconds: float = 3600.0
planner_decision_log_retention_days: int = 7
def load_control_config( def load_control_config(
@@ -127,6 +129,16 @@ def load_control_config(
"CLOUD_PLANNER_TOKEN_RESERVATION_TTL_SECONDS", "CLOUD_PLANNER_TOKEN_RESERVATION_TTL_SECONDS",
300, 300,
), ),
planner_decision_log_prune_interval_seconds=_positive_float(
values,
"CLOUD_PLANNER_DECISION_LOG_PRUNE_INTERVAL_SECONDS",
3600.0,
),
planner_decision_log_retention_days=_positive_int(
values,
"CLOUD_PLANNER_DECISION_LOG_RETENTION_DAYS",
7,
),
) )
validate_control_config(config) validate_control_config(config)
return config return config
@@ -142,9 +154,7 @@ def validate_control_config(config: CloudControlConfig) -> None:
"CLOUD_USER_SESSION_ABSOLUTE_SECONDS must be at least the idle TTL" "CLOUD_USER_SESSION_ABSOLUTE_SECONDS must be at least the idle TTL"
) )
if config.environment == "production" and not config.session_cookie_secure: if config.environment == "production" and not config.session_cookie_secure:
raise CloudConfigurationError( raise CloudConfigurationError("production requires secure user session cookies")
"production requires secure user session cookies"
)
def _positive_float( def _positive_float(
+35 -2
View File
@@ -106,6 +106,10 @@ class ScheduledTaskRow(Base):
) )
lease_id: Mapped[str | None] = mapped_column(String, nullable=True) lease_id: Mapped[str | None] = mapped_column(String, nullable=True)
lease_expires_at: Mapped[str | None] = mapped_column(String, nullable=True) lease_expires_at: Mapped[str | None] = mapped_column(String, nullable=True)
progress_step_index: Mapped[int | None] = mapped_column(Integer, nullable=True)
progress_step_status: Mapped[str | None] = mapped_column(String, nullable=True)
progress_summary: Mapped[str | None] = mapped_column(String, nullable=True)
progress_updated_at: Mapped[str | None] = mapped_column(String, nullable=True)
failure_reason: Mapped[str | None] = mapped_column(Text, nullable=True) failure_reason: Mapped[str | None] = mapped_column(Text, nullable=True)
result_json: Mapped[str | None] = mapped_column(Text, nullable=True) result_json: Mapped[str | None] = mapped_column(Text, nullable=True)
updated_at: Mapped[str | None] = mapped_column(String, nullable=True) updated_at: Mapped[str | None] = mapped_column(String, nullable=True)
@@ -128,6 +132,31 @@ class TaskAttemptRow(Base):
result_json: Mapped[str | None] = mapped_column(Text, nullable=True) result_json: Mapped[str | None] = mapped_column(Text, nullable=True)
class PlannerDecisionLogRow(Base):
__tablename__ = "planner_decision_log"
__table_args__ = (
Index(
"ix_planner_decision_log_task_attempt",
"task_id",
"attempt",
"step_index",
unique=True,
),
Index("ix_planner_decision_log_host_created", "host_id", "created_at"),
)
id: Mapped[str] = mapped_column(String, primary_key=True)
host_id: Mapped[str] = mapped_column(String, nullable=False)
task_id: Mapped[str] = mapped_column(String, nullable=False)
attempt: Mapped[int] = mapped_column(Integer, nullable=False)
step_index: Mapped[int] = mapped_column(Integer, nullable=False)
system_prompt: Mapped[str] = mapped_column(Text, nullable=False)
user_prompt: Mapped[str] = mapped_column(Text, nullable=False)
tool_name: Mapped[str] = mapped_column(String, nullable=False)
arguments_json: Mapped[str] = mapped_column(Text, nullable=False)
created_at: Mapped[str] = mapped_column(String, nullable=False)
class PluginRow(Base): class PluginRow(Base):
__tablename__ = "plugins" __tablename__ = "plugins"
@@ -141,7 +170,9 @@ class PluginRow(Base):
class UserRow(Base): class UserRow(Base):
__tablename__ = "cloud_users" __tablename__ = "cloud_users"
__table_args__ = ( __table_args__ = (
UniqueConstraint("username_normalized", name="uq_cloud_users_username_normalized"), UniqueConstraint(
"username_normalized", name="uq_cloud_users_username_normalized"
),
Index("ix_cloud_users_enabled_role", "enabled", "role"), Index("ix_cloud_users_enabled_role", "enabled", "role"),
) )
@@ -151,7 +182,9 @@ class UserRow(Base):
display_name: Mapped[str] = mapped_column(String, nullable=False) display_name: Mapped[str] = mapped_column(String, nullable=False)
password_hash: Mapped[str] = mapped_column(Text, nullable=False) password_hash: Mapped[str] = mapped_column(Text, nullable=False)
role: Mapped[str] = mapped_column(String, nullable=False) role: Mapped[str] = mapped_column(String, nullable=False)
enabled: Mapped[int] = mapped_column(Integer, nullable=False, default=1, server_default=text("1")) enabled: Mapped[int] = mapped_column(
Integer, nullable=False, default=1, server_default=text("1")
)
must_change_password: Mapped[int] = mapped_column( must_change_password: Mapped[int] = mapped_column(
Integer, Integer,
nullable=False, nullable=False,
@@ -2,6 +2,7 @@ from __future__ import annotations
import asyncio import asyncio
import base64 import base64
import json
import logging import logging
from collections.abc import Awaitable, Callable from collections.abc import Awaitable, Callable
from datetime import timedelta from datetime import timedelta
@@ -43,6 +44,7 @@ from cloud.llm_providers import LlmProviderResolutionError, LlmProviderService
from cloud.planner_config import build_cloud_planner_client from cloud.planner_config import build_cloud_planner_client
from cloud.provider_secrets import ProviderSecretConfigurationError from cloud.provider_secrets import ProviderSecretConfigurationError
from cloud.repository import ( from cloud.repository import (
AssignmentProgressSnapshot,
DeviceEnrollmentConflictError, DeviceEnrollmentConflictError,
HostEnrollmentConflictError, HostEnrollmentConflictError,
) )
@@ -79,8 +81,11 @@ def create_internal_router(
if planner_token_reservation_ceiling <= 0: if planner_token_reservation_ceiling <= 0:
raise ValueError("planner_token_reservation_ceiling must be greater than zero") raise ValueError("planner_token_reservation_ceiling must be greater than zero")
if planner_token_reservation_ttl_seconds <= 0: if planner_token_reservation_ttl_seconds <= 0:
raise ValueError("planner_token_reservation_ttl_seconds must be greater than zero") raise ValueError(
"planner_token_reservation_ttl_seconds must be greater than zero"
)
router = APIRouter(prefix=version_prefix, tags=["host-agent"]) router = APIRouter(prefix=version_prefix, tags=["host-agent"])
def authorize_host(request: Request, host_id: str) -> None: def authorize_host(request: Request, host_id: str) -> None:
principal = auth_provider.authenticate(request) principal = auth_provider.authenticate(request)
if principal is None: if principal is None:
@@ -303,6 +308,14 @@ def create_internal_router(
) )
now = utc_now() now = utc_now()
lease_expires_at = now + timedelta(seconds=lease_duration_seconds) lease_expires_at = now + timedelta(seconds=lease_duration_seconds)
progress_snapshot: AssignmentProgressSnapshot | None = None
if payload.progress is not None:
progress_snapshot = AssignmentProgressSnapshot(
step_index=payload.progress.step_index,
step_status=payload.progress.step_status,
summary=payload.progress.summary[:500],
updated_at=now,
)
renewal_status = pool.store.renew_lease( renewal_status = pool.store.renew_lease(
task_id=task_id, task_id=task_id,
attempt=payload.attempt, attempt=payload.attempt,
@@ -310,6 +323,7 @@ def create_internal_router(
host_id=host_id, host_id=host_id,
lease_expires_at=lease_expires_at, lease_expires_at=lease_expires_at,
now=now, now=now,
progress=progress_snapshot,
) )
if renewal_status == "not_found": if renewal_status == "not_found":
raise HTTPException( raise HTTPException(
@@ -407,7 +421,9 @@ def create_internal_router(
resolved_provider = resolved.profile.provider_type resolved_provider = resolved.profile.provider_type
resolved_model = resolved.profile.model resolved_model = resolved.profile.model
else: else:
raise LlmProviderResolutionError("no database Provider resolver configured") raise LlmProviderResolutionError(
"no database Provider resolver configured"
)
except (LlmProviderResolutionError, ProviderSecretConfigurationError) as exc: except (LlmProviderResolutionError, ProviderSecretConfigurationError) as exc:
logger.info( logger.info(
"planner-decision request failed", "planner-decision request failed",
@@ -429,7 +445,8 @@ def create_internal_router(
task_id=payload.task_id, task_id=payload.task_id,
attempt=payload.attempt, attempt=payload.attempt,
created_at=now, created_at=now,
expires_at=now + timedelta(seconds=planner_token_reservation_ttl_seconds), expires_at=now
+ timedelta(seconds=planner_token_reservation_ttl_seconds),
) )
except TokenBudgetExceededError as exc: except TokenBudgetExceededError as exc:
return JSONResponse( return JSONResponse(
@@ -460,7 +477,11 @@ def create_internal_router(
content=PlannerDecisionError(detail=str(exc)).model_dump(), content=PlannerDecisionError(detail=str(exc)).model_dump(),
) )
usage = decision.usage usage = decision.usage
if reservation is not None and usage is not None and usage.total_tokens is not None: if (
reservation is not None
and usage is not None
and usage.total_tokens is not None
):
pool.store.settle_host_token_reservation( pool.store.settle_host_token_reservation(
reservation_id=reservation.id, reservation_id=reservation.id,
event_id=uuid4().hex, event_id=uuid4().hex,
@@ -471,6 +492,17 @@ def create_internal_router(
total_tokens=usage.total_tokens, total_tokens=usage.total_tokens,
occurred_at=utc_now(), occurred_at=utc_now(),
) )
if payload.task_id and payload.attempt:
pool.store.record_planner_decision(
host_id=host_id,
task_id=payload.task_id,
attempt=payload.attempt,
system_prompt=payload.system_prompt,
user_prompt=payload.user_prompt,
tool_name=decision.tool_name,
arguments_json=json.dumps(decision.arguments),
now=utc_now(),
)
logger.info( logger.info(
"planner-decision request resolved", "planner-decision request resolved",
extra={ extra={
@@ -504,7 +536,9 @@ def _validate_assignment_identity(
) )
def _validate_planner_context(pool, *, host_id: str, payload: PlannerDecisionRequest) -> None: def _validate_planner_context(
pool, *, host_id: str, payload: PlannerDecisionRequest
) -> None:
context_values = (payload.task_id, payload.attempt, payload.lease_id) context_values = (payload.task_id, payload.attempt, payload.lease_id)
if not any(value is not None for value in context_values): if not any(value is not None for value in context_values):
return return
@@ -78,11 +78,18 @@ class ClaimResponse(BaseModel):
timed_out: bool = False timed_out: bool = False
class TaskProgressModel(BaseModel):
step_index: int = Field(ge=0)
step_status: Literal["running", "completed", "failed"]
summary: str = Field(default="", max_length=2000)
class LeaseRenewalRequest(BaseModel): class LeaseRenewalRequest(BaseModel):
host_id: str = Field(min_length=1) host_id: str = Field(min_length=1)
task_id: str = Field(min_length=1) task_id: str = Field(min_length=1)
attempt: int = Field(ge=1) attempt: int = Field(ge=1)
lease_id: str = Field(min_length=1) lease_id: str = Field(min_length=1)
progress: TaskProgressModel | None = None
class LeaseRenewalResponse(BaseModel): class LeaseRenewalResponse(BaseModel):
@@ -0,0 +1,38 @@
"""Add nullable per-assignment progress snapshot columns to scheduled_tasks."""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
revision = "0008_task_progress_columns"
down_revision = "0007_llm_provider_management"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column(
"scheduled_tasks",
sa.Column("progress_step_index", sa.Integer(), nullable=True),
)
op.add_column(
"scheduled_tasks",
sa.Column("progress_step_status", sa.String(), nullable=True),
)
op.add_column(
"scheduled_tasks",
sa.Column("progress_summary", sa.String(), nullable=True),
)
op.add_column(
"scheduled_tasks",
sa.Column("progress_updated_at", sa.String(), nullable=True),
)
def downgrade() -> None:
op.drop_column("scheduled_tasks", "progress_updated_at")
op.drop_column("scheduled_tasks", "progress_summary")
op.drop_column("scheduled_tasks", "progress_step_status")
op.drop_column("scheduled_tasks", "progress_step_index")
@@ -0,0 +1,49 @@
"""Add planner_decision_log table for D7/D8 full LLM interaction history."""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
revision = "0009_planner_decision_log"
down_revision = "0008_task_progress_columns"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
"planner_decision_log",
sa.Column("id", sa.String(), primary_key=True),
sa.Column("host_id", sa.String(), nullable=False),
sa.Column("task_id", sa.String(), nullable=False),
sa.Column("attempt", sa.Integer(), nullable=False),
sa.Column("step_index", sa.Integer(), nullable=False),
sa.Column("system_prompt", sa.Text(), nullable=False),
sa.Column("user_prompt", sa.Text(), nullable=False),
sa.Column("tool_name", sa.String(), nullable=False),
sa.Column("arguments_json", sa.Text(), nullable=False),
sa.Column("created_at", sa.String(), nullable=False),
)
op.create_index(
"ix_planner_decision_log_task_attempt",
"planner_decision_log",
["task_id", "attempt", "step_index"],
unique=True,
)
op.create_index(
"ix_planner_decision_log_host_created",
"planner_decision_log",
["host_id", "created_at"],
)
def downgrade() -> None:
op.drop_index(
"ix_planner_decision_log_host_created", table_name="planner_decision_log"
)
op.drop_index(
"ix_planner_decision_log_task_attempt", table_name="planner_decision_log"
)
op.drop_table("planner_decision_log")
+120 -13
View File
@@ -22,7 +22,11 @@ if TYPE_CHECKING:
TokenUsageEvent, TokenUsageEvent,
UserSubmissionPolicy, UserSubmissionPolicy,
) )
from cloud.llm_providers import LlmProviderProfile, LlmProviderSettings, ProviderType from cloud.llm_providers import (
LlmProviderProfile,
LlmProviderSettings,
ProviderType,
)
AttemptStatus = Literal["assigned", "dispatched", "done", "failed", "expired"] AttemptStatus = Literal["assigned", "dispatched", "done", "failed", "expired"]
@@ -99,6 +103,39 @@ class LeasedAssignment:
workflow_definition_id: str | None workflow_definition_id: str | None
@dataclass(frozen=True)
class AssignmentProgressSnapshot:
"""Latest in-progress step snapshot for an active assignment.
Mirrors the wire ``TaskProgressModel`` fields; carried through the
repository layer without depending on Pydantic models.
``None`` on the ``renew_lease`` parameter means "no progress reported
for this renewal" (leave any previously stored progress untouched).
"""
step_index: int
step_status: str
summary: str
updated_at: datetime
@dataclass(frozen=True)
class PlannerDecisionRecord:
"""One persisted planner decision row (D7/D8)."""
id: str
host_id: str
task_id: str
attempt: int
step_index: int
system_prompt: str
user_prompt: str
tool_name: str
arguments_json: str
created_at: datetime
class CloudRepository(Protocol): class CloudRepository(Protocol):
"""Persistence port for cloud state and atomic scheduling operations.""" """Persistence port for cloud state and atomic scheduling operations."""
@@ -286,13 +323,17 @@ class CloudRepository(Protocol):
block_duration: timedelta, block_duration: timedelta,
) -> LoginThrottle: ... ) -> LoginThrottle: ...
def clear_login_throttle(self, username_normalized: str, client_bucket: str) -> None: ... def clear_login_throttle(
self, username_normalized: str, client_bucket: str
) -> None: ...
def record_auth_audit(self, event: AuthAuditEvent) -> None: ... def record_auth_audit(self, event: AuthAuditEvent) -> None: ...
def cleanup_auth_state(self, *, now: datetime, limit: int) -> int: ... def cleanup_auth_state(self, *, now: datetime, limit: int) -> int: ...
def get_user_submission_policy(self, user_id: str) -> UserSubmissionPolicy | None: ... def get_user_submission_policy(
self, user_id: str
) -> UserSubmissionPolicy | None: ...
def upsert_user_submission_policy( def upsert_user_submission_policy(
self, self,
@@ -305,7 +346,9 @@ class CloudRepository(Protocol):
updated_at: datetime, updated_at: datetime,
) -> UserSubmissionPolicy: ... ) -> UserSubmissionPolicy: ...
def get_host_governance_policy(self, host_id: str) -> HostGovernancePolicy | None: ... def get_host_governance_policy(
self, host_id: str
) -> HostGovernancePolicy | None: ...
def upsert_host_governance_policy( def upsert_host_governance_policy(
self, self,
@@ -321,32 +364,58 @@ class CloudRepository(Protocol):
def count_active_tasks_for_host(self, host_id: str) -> int: ... def count_active_tasks_for_host(self, host_id: str) -> int: ...
def reserve_host_token_budget( def reserve_host_token_budget(
self, *, reservation_id: str, host_id: str, usage_day: str, self,
reserved_tokens: int, task_id: str | None, attempt: int | None, *,
created_at: datetime, expires_at: datetime, reservation_id: str,
host_id: str,
usage_day: str,
reserved_tokens: int,
task_id: str | None,
attempt: int | None,
created_at: datetime,
expires_at: datetime,
) -> TokenReservation | None: ... ) -> TokenReservation | None: ...
def settle_host_token_reservation( def settle_host_token_reservation(
self, *, reservation_id: str, event_id: str, provider: str, model: str, self,
input_tokens: int | None, output_tokens: int | None, total_tokens: int, *,
reservation_id: str,
event_id: str,
provider: str,
model: str,
input_tokens: int | None,
output_tokens: int | None,
total_tokens: int,
occurred_at: datetime, occurred_at: datetime,
) -> TokenUsageEvent | None: ... ) -> TokenUsageEvent | None: ...
def cleanup_expired_token_reservations(self, *, now: datetime, limit: int) -> int: ... def cleanup_expired_token_reservations(
self, *, now: datetime, limit: int
) -> int: ...
def get_host_token_usage_summary( def get_host_token_usage_summary(
self, *, host_id: str, usage_day: str, now: datetime, self,
*,
host_id: str,
usage_day: str,
now: datetime,
) -> TokenUsageSummary: ... ) -> TokenUsageSummary: ...
def list_host_token_usage_events( def list_host_token_usage_events(
self, *, host_id: str, limit: int, offset: int, self,
*,
host_id: str,
limit: int,
offset: int,
) -> list[TokenUsageEvent]: ... ) -> list[TokenUsageEvent]: ...
def get_llm_provider_settings(self) -> LlmProviderSettings: ... def get_llm_provider_settings(self) -> LlmProviderSettings: ...
def list_llm_provider_profiles(self) -> list[LlmProviderProfile]: ... def list_llm_provider_profiles(self) -> list[LlmProviderProfile]: ...
def get_llm_provider_profile(self, profile_id: str) -> LlmProviderProfile | None: ... def get_llm_provider_profile(
self, profile_id: str
) -> LlmProviderProfile | None: ...
def create_llm_provider_profile( def create_llm_provider_profile(
self, profile: LlmProviderProfile self, profile: LlmProviderProfile
@@ -413,6 +482,7 @@ class CloudRepository(Protocol):
host_id: str, host_id: str,
lease_expires_at: datetime, lease_expires_at: datetime,
now: datetime, now: datetime,
progress: AssignmentProgressSnapshot | None = None,
) -> LeaseRenewalStatus: ... ) -> LeaseRenewalStatus: ...
def record_task_result( def record_task_result(
@@ -437,6 +507,43 @@ class CloudRepository(Protocol):
def list_task_attempts(self, task_id: str) -> list[TaskAttemptRecord]: ... def list_task_attempts(self, task_id: str) -> list[TaskAttemptRecord]: ...
def record_planner_decision(
self,
*,
host_id: str,
task_id: str,
attempt: int,
system_prompt: str,
user_prompt: str,
tool_name: str,
arguments_json: str,
now: datetime,
) -> int:
"""Insert one planner-decision log row, returning the assigned step_index.
``step_index`` is assigned as ``max(step_index) + 1`` scoped to
``(task_id, attempt)`` within the same transaction.
"""
def prune_planner_decision_log(
self,
*,
now: datetime,
prune_after_terminal_seconds: int,
) -> int:
"""Delete decision-log rows for tasks terminal more than the window ago.
Returns the count of deleted rows.
"""
def list_planner_decisions(
self,
*,
task_id: str,
attempt: int,
) -> list[PlannerDecisionRecord]:
"""Return all decision-log rows for a (task_id, attempt), ordered by step."""
def health_check(self) -> None: ... def health_check(self) -> None: ...
def close(self) -> None: ... def close(self) -> None: ...
+8 -1
View File
@@ -53,6 +53,10 @@ class ScheduledTask:
failure_reason: str | None = None failure_reason: str | None = None
updated_at: datetime | None = None updated_at: datetime | None = None
created_at: datetime = field(default_factory=utc_now) created_at: datetime = field(default_factory=utc_now)
progress_step_index: int | None = None
progress_step_status: str | None = None
progress_summary: str | None = None
progress_updated_at: datetime | None = None
@runtime_checkable @runtime_checkable
@@ -199,7 +203,10 @@ class TaskScheduler:
def _matches(device: "PooledDevice", constraints: TaskConstraints) -> bool: def _matches(device: "PooledDevice", constraints: TaskConstraints) -> bool:
if constraints.target_host_id and device.host_id != constraints.target_host_id: if constraints.target_host_id and device.host_id != constraints.target_host_id:
return False return False
if constraints.target_device_id and device.device_id != constraints.target_device_id: if (
constraints.target_device_id
and device.device_id != constraints.target_device_id
):
return False return False
if constraints.driver_type and device.driver_type != constraints.driver_type: if constraints.driver_type and device.driver_type != constraints.driver_type:
return False return False
+1 -1
View File
@@ -9,7 +9,7 @@ from alembic.runtime.migration import MigrationContext
from cloud.database import create_database_engine, normalize_database_url from cloud.database import create_database_engine, normalize_database_url
HEAD_REVISION = "0007_llm_provider_management" HEAD_REVISION = "0009_planner_decision_log"
class SchemaVersionError(RuntimeError): class SchemaVersionError(RuntimeError):
+56 -4
View File
@@ -10,6 +10,7 @@ authentication can be added later without changing route signatures.
from __future__ import annotations from __future__ import annotations
import json
from typing import TYPE_CHECKING, Callable, Literal from typing import TYPE_CHECKING, Callable, Literal
from cloud.auth import ( from cloud.auth import (
@@ -32,6 +33,8 @@ from cloud.sdk.models import (
TaskAttemptResponse, TaskAttemptResponse,
TaskListItem, TaskListItem,
TaskListResponse, TaskListResponse,
TaskPlannerDecisionItem,
TaskPlannerDecisionListResponse,
TaskStatusResponse, TaskStatusResponse,
TaskSubmissionRequest, TaskSubmissionRequest,
TaskSubmissionResponse, TaskSubmissionResponse,
@@ -144,14 +147,16 @@ def create_cloud_router(
failure_reason=task.failure_reason, failure_reason=task.failure_reason,
target_host_id=task.constraints.target_host_id, target_host_id=task.constraints.target_host_id,
target_device_id=task.constraints.target_device_id, target_device_id=task.constraints.target_device_id,
progress_step_index=task.progress_step_index,
progress_step_status=task.progress_step_status,
progress_summary=task.progress_summary,
progress_updated_at=task.progress_updated_at,
) )
@router.get("/tasks", response_model=TaskListResponse) @router.get("/tasks", response_model=TaskListResponse)
def list_tasks( def list_tasks(
request: Request, request: Request,
status_filter: Literal[ status_filter: Literal["queued", "assigned", "dispatched", "done", "failed"]
"queued", "assigned", "dispatched", "done", "failed"
]
| None = Query(default=None, alias="status"), | None = Query(default=None, alias="status"),
limit: int = Query(default=50, ge=1, le=100), limit: int = Query(default=50, ge=1, le=100),
offset: int = Query(default=0, ge=0), offset: int = Query(default=0, ge=0),
@@ -177,6 +182,10 @@ def create_cloud_router(
target_host_id=task.constraints.target_host_id, target_host_id=task.constraints.target_host_id,
target_device_id=task.constraints.target_device_id, target_device_id=task.constraints.target_device_id,
created_at=task.created_at, created_at=task.created_at,
progress_step_index=task.progress_step_index,
progress_step_status=task.progress_step_status,
progress_summary=task.progress_summary,
progress_updated_at=task.progress_updated_at,
) )
for task in tasks for task in tasks
], ],
@@ -218,6 +227,47 @@ def create_cloud_router(
for attempt in attempts for attempt in attempts
] ]
@router.get(
"/tasks/{task_id}/planner-decisions",
response_model=TaskPlannerDecisionListResponse,
)
def list_task_planner_decisions(
task_id: str,
request: Request,
attempt: int = Query(ge=0),
) -> TaskPlannerDecisionListResponse:
_authorize(request, TASKS_READ_SCOPE)
task = scheduler.store.get_task(task_id)
if task is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"task {task_id!r} not found",
)
records = scheduler.store.list_planner_decisions(
task_id=task_id,
attempt=attempt,
)
items: list[TaskPlannerDecisionItem] = []
for rec in records:
try:
parsed_args = (
json.loads(rec.arguments_json) if rec.arguments_json else {}
)
except Exception:
parsed_args = {}
items.append(
TaskPlannerDecisionItem(
step_index=rec.step_index,
attempt=rec.attempt,
system_prompt=rec.system_prompt,
user_prompt=rec.user_prompt,
tool_name=rec.tool_name,
arguments=parsed_args,
created_at=rec.created_at,
)
)
return TaskPlannerDecisionListResponse(items=items)
@router.get("/devices", response_model=list[DeviceResponse]) @router.get("/devices", response_model=list[DeviceResponse])
def list_devices(request: Request) -> list[DeviceResponse]: def list_devices(request: Request) -> list[DeviceResponse]:
_authorize(request, POOL_READ_SCOPE) _authorize(request, POOL_READ_SCOPE)
@@ -332,7 +382,9 @@ def _validate_task_target(pool, constraints) -> None:
raise ValueError("target_device_id requires target_host_id") raise ValueError("target_device_id requires target_host_id")
if constraints.target_host_id is None: if constraints.target_host_id is None:
return return
if not any(host.host_id == constraints.target_host_id for host in pool.list_hosts()): if not any(
host.host_id == constraints.target_host_id for host in pool.list_hosts()
):
raise ValueError(f"target host {constraints.target_host_id!r} is not known") raise ValueError(f"target host {constraints.target_host_id!r} is not known")
if constraints.target_device_id is not None and not any( if constraints.target_device_id is not None and not any(
device.host_id == constraints.target_host_id device.host_id == constraints.target_host_id
@@ -37,6 +37,10 @@ class TaskStatusResponse(BaseModel):
failure_reason: str | None = None failure_reason: str | None = None
target_host_id: str | None = None target_host_id: str | None = None
target_device_id: str | None = None target_device_id: str | None = None
progress_step_index: int | None = None
progress_step_status: str | None = None
progress_summary: str | None = None
progress_updated_at: datetime | None = None
class TaskListItem(BaseModel): class TaskListItem(BaseModel):
@@ -51,6 +55,10 @@ class TaskListItem(BaseModel):
target_host_id: str | None = None target_host_id: str | None = None
target_device_id: str | None = None target_device_id: str | None = None
created_at: datetime created_at: datetime
progress_step_index: int | None = None
progress_step_status: str | None = None
progress_summary: str | None = None
progress_updated_at: datetime | None = None
class TaskListResponse(BaseModel): class TaskListResponse(BaseModel):
@@ -263,3 +271,17 @@ class LlmProviderSettingsResponse(BaseModel):
class LlmProviderProfileListResponse(BaseModel): class LlmProviderProfileListResponse(BaseModel):
settings: LlmProviderSettingsResponse settings: LlmProviderSettingsResponse
items: list[LlmProviderProfileResponse] items: list[LlmProviderProfileResponse]
class TaskPlannerDecisionItem(BaseModel):
step_index: int
attempt: int
system_prompt: str
user_prompt: str
tool_name: str
arguments: dict[str, Any] = Field(default_factory=dict)
created_at: datetime
class TaskPlannerDecisionListResponse(BaseModel):
items: list[TaskPlannerDecisionItem]
+107 -1
View File
@@ -4,7 +4,8 @@ import json
import logging import logging
from dataclasses import asdict from dataclasses import asdict
from datetime import datetime, timedelta from datetime import datetime, timedelta
from typing import Any from typing import TYPE_CHECKING, Any
from uuid import uuid4
from sqlalchemy import Engine, delete, func, select from sqlalchemy import Engine, delete, func, select
from sqlalchemy.exc import IntegrityError from sqlalchemy.exc import IntegrityError
@@ -14,6 +15,7 @@ from cloud.db_models import (
Base, Base,
DeviceEnrollmentRow, DeviceEnrollmentRow,
HostRow, HostRow,
PlannerDecisionLogRow,
PluginRow, PluginRow,
PooledDeviceRow, PooledDeviceRow,
ScheduledTaskRow, ScheduledTaskRow,
@@ -32,6 +34,9 @@ from cloud.db_models import (
from cloud.observability import current_correlation_id from cloud.observability import current_correlation_id
from core.models import utc_now from core.models import utc_now
if TYPE_CHECKING:
from cloud.repository import AssignmentProgressSnapshot
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -1486,6 +1491,7 @@ class SQLAlchemyCloudRepository:
host_id: str, host_id: str,
lease_expires_at: datetime, lease_expires_at: datetime,
now: datetime, now: datetime,
progress: AssignmentProgressSnapshot | None = None,
) -> str: ) -> str:
with self._sessions.begin() as session: with self._sessions.begin() as session:
task = session.get( task = session.get(
@@ -1525,6 +1531,11 @@ class SQLAlchemyCloudRepository:
task.lease_expires_at = renewed_until task.lease_expires_at = renewed_until
task.updated_at = _iso(now) task.updated_at = _iso(now)
attempt_row.lease_expires_at = renewed_until attempt_row.lease_expires_at = renewed_until
if progress is not None:
task.progress_step_index = progress.step_index
task.progress_step_status = progress.step_status
task.progress_summary = progress.summary
task.progress_updated_at = _iso(progress.updated_at)
_log_task_lifecycle("renewed", task) _log_task_lifecycle("renewed", task)
return "renewed" return "renewed"
@@ -1590,6 +1601,10 @@ class SQLAlchemyCloudRepository:
task.failure_reason = failure_reason task.failure_reason = failure_reason
task.result_json = result_json task.result_json = result_json
task.updated_at = completed_at_iso task.updated_at = completed_at_iso
task.progress_step_index = None
task.progress_step_status = None
task.progress_summary = None
task.progress_updated_at = None
attempt_row.status = status attempt_row.status = status
attempt_row.completed_at = completed_at_iso attempt_row.completed_at = completed_at_iso
attempt_row.failure_reason = failure_reason attempt_row.failure_reason = failure_reason
@@ -1663,6 +1678,93 @@ class SQLAlchemyCloudRepository:
).all() ).all()
return [_task_attempt_from_row(row) for row in rows] return [_task_attempt_from_row(row) for row in rows]
def record_planner_decision(
self,
*,
host_id: str,
task_id: str,
attempt: int,
system_prompt: str,
user_prompt: str,
tool_name: str,
arguments_json: str,
now: datetime,
) -> int:
with self._sessions.begin() as session:
current_max = session.scalars(
select(func.coalesce(func.max(PlannerDecisionLogRow.step_index), 0))
.where(PlannerDecisionLogRow.task_id == task_id)
.where(PlannerDecisionLogRow.attempt == attempt)
).one()
next_step = current_max + 1
session.add(
PlannerDecisionLogRow(
id=uuid4().hex,
host_id=host_id,
task_id=task_id,
attempt=attempt,
step_index=next_step,
system_prompt=system_prompt,
user_prompt=user_prompt,
tool_name=tool_name,
arguments_json=arguments_json,
created_at=_iso(now),
)
)
session.flush()
return next_step
def prune_planner_decision_log(
self,
*,
now: datetime,
prune_after_terminal_seconds: int,
) -> int:
cutoff_iso = _iso(now - timedelta(seconds=prune_after_terminal_seconds))
with self._sessions.begin() as session:
terminal_task_ids = select(TaskAttemptRow.task_id).where(
TaskAttemptRow.status.in_(("done", "failed")),
TaskAttemptRow.completed_at.is_not(None),
TaskAttemptRow.completed_at <= cutoff_iso,
)
result = session.execute(
delete(PlannerDecisionLogRow).where(
PlannerDecisionLogRow.task_id.in_(terminal_task_ids)
)
)
return result.rowcount or 0
def list_planner_decisions(
self,
*,
task_id: str,
attempt: int,
) -> list[Any]:
from cloud.repository import PlannerDecisionRecord
with self._sessions() as session:
rows = session.scalars(
select(PlannerDecisionLogRow)
.where(PlannerDecisionLogRow.task_id == task_id)
.where(PlannerDecisionLogRow.attempt == attempt)
.order_by(PlannerDecisionLogRow.step_index)
).all()
return [
PlannerDecisionRecord(
id=row.id,
host_id=row.host_id,
task_id=row.task_id,
attempt=row.attempt,
step_index=row.step_index,
system_prompt=row.system_prompt,
user_prompt=row.user_prompt,
tool_name=row.tool_name,
arguments_json=row.arguments_json,
created_at=_parse_dt(row.created_at), # type: ignore[arg-type]
)
for row in rows
]
def health_check(self) -> None: def health_check(self) -> None:
with self._sessions() as session: with self._sessions() as session:
session.execute(select(1)) session.execute(select(1))
@@ -1778,6 +1880,10 @@ def _task_from_row(row: ScheduledTaskRow) -> Any:
failure_reason=row.failure_reason, failure_reason=row.failure_reason,
updated_at=_parse_dt(row.updated_at), updated_at=_parse_dt(row.updated_at),
created_at=_parse_dt(row.created_at) or utc_now(), created_at=_parse_dt(row.created_at) or utc_now(),
progress_step_index=row.progress_step_index,
progress_step_status=row.progress_step_status,
progress_summary=row.progress_summary,
progress_updated_at=_parse_dt(row.progress_updated_at),
) )
+7 -5
View File
@@ -36,13 +36,14 @@ class AIPlanner(Planner):
world: "WorldState | None" = None, world: "WorldState | None" = None,
screenshot: bytes | None = None, screenshot: bytes | None = None,
) -> list[PlannedStep]: ) -> list[PlannedStep]:
user_prompt = planner_user_prompt(
goal=goal,
scene_json=scene.to_dict(),
history_summary=_history_summary(world),
)
decision = self.client.decide( decision = self.client.decide(
system_prompt=PLANNER_SYSTEM_PROMPT, system_prompt=PLANNER_SYSTEM_PROMPT,
user_prompt=planner_user_prompt( user_prompt=user_prompt,
goal=goal,
scene_json=scene.to_dict(),
history_summary=_history_summary(world),
),
screenshot=screenshot, screenshot=screenshot,
tools=ALL_TOOL_SPECS, tools=ALL_TOOL_SPECS,
timeout=self.config.timeout, timeout=self.config.timeout,
@@ -58,6 +59,7 @@ class AIPlanner(Planner):
action=decision.tool_name, action=decision.tool_name,
description=f"AI planner: {decision.tool_name}({decision.arguments})", description=f"AI planner: {decision.tool_name}({decision.arguments})",
args=dict(decision.arguments), args=dict(decision.arguments),
prompt=decision.user_prompt or user_prompt,
) )
] ]
+3
View File
@@ -16,6 +16,9 @@ class PlannedStep:
description: str description: str
args: dict[str, Any] = field(default_factory=dict) args: dict[str, Any] = field(default_factory=dict)
expected_text: str | None = None expected_text: str | None = None
# The actual prompt sent to the LLM for this step (AI planners only).
# ``None`` for non-LLM planners; TaskRunner falls back to the task goal.
prompt: str | None = None
class Planner: class Planner:
+32 -1
View File
@@ -40,6 +40,7 @@ Observer = Callable[[str], Scene]
ScreenshotProvider = Callable[[str], bytes] ScreenshotProvider = Callable[[str], bytes]
TaskSucceededHook = Callable[[str, str, Timeline], None] TaskSucceededHook = Callable[[str, str, Timeline], None]
StopRequested = Callable[[], bool] StopRequested = Callable[[], bool]
StepProgressCallback = Callable[[int, str, str], None]
class TaskRunner: class TaskRunner:
@@ -60,6 +61,7 @@ class TaskRunner:
skill_store: SkillStore | None = None, skill_store: SkillStore | None = None,
skill_embedding_client: EmbeddingClient | None = None, skill_embedding_client: EmbeddingClient | None = None,
planner_config: PlannerConfig | None = None, planner_config: PlannerConfig | None = None,
on_step_progress: StepProgressCallback | None = None,
) -> None: ) -> None:
self.planner_config = planner_config or load_planner_config() self.planner_config = planner_config or load_planner_config()
self.planner = planner or self._default_planner() self.planner = planner or self._default_planner()
@@ -89,6 +91,7 @@ class TaskRunner:
self.on_task_succeeded = self._default_task_succeeded_hook self.on_task_succeeded = self._default_task_succeeded_hook
else: else:
self.on_task_succeeded = None self.on_task_succeeded = None
self.on_step_progress = on_step_progress
def run( def run(
self, self,
@@ -114,6 +117,7 @@ class TaskRunner:
reason = ( reason = (
f"{type(exc).__name__}: {exc}" if str(exc) else type(exc).__name__ f"{type(exc).__name__}: {exc}" if str(exc) else type(exc).__name__
) )
self._emit_step_progress(len(context.step_results), "failed", reason)
self._update_task( self._update_task(
task, task,
status="failed", status="failed",
@@ -140,7 +144,17 @@ class TaskRunner:
self._record_step_result( self._record_step_result(
world_handle, context, task, scene, step, result world_handle, context, task, scene, step, result
) )
self._emit_step_progress(
len(context.step_results),
"running",
f"{step.action}: {step.description}",
)
if not result.success: if not result.success:
self._emit_step_progress(
len(context.step_results),
"failed",
result.error or "step failed",
)
self._update_task( self._update_task(
task, task,
status="failed", status="failed",
@@ -149,6 +163,11 @@ class TaskRunner:
) )
return task return task
self._emit_step_progress(
len(context.step_results),
"failed",
f"max steps exceeded: {self.config.max_steps}",
)
self._update_task( self._update_task(
task, task,
status="failed", status="failed",
@@ -158,6 +177,7 @@ class TaskRunner:
return task return task
def _interrupt_task(self, task: Task) -> Task: def _interrupt_task(self, task: Task) -> Task:
self._emit_step_progress(-1, "failed", "execution interrupted")
self._update_task( self._update_task(
task, task,
status="failed", status="failed",
@@ -194,7 +214,18 @@ class TaskRunner:
self._update_world(world_handle, context, scene, step, result) self._update_world(world_handle, context, scene, step, result)
self._append_timeline(task, scene, step, result) self._append_timeline(task, scene, step, result)
def _emit_step_progress(
self, step_index: int, step_status: str, summary: str
) -> None:
if self.on_step_progress is None:
return
try:
self.on_step_progress(max(step_index, 0), step_status, summary[:200])
except Exception as exc:
logger.debug("step progress callback failed: %s", exc)
def _complete_task(self, task: Task) -> Task: def _complete_task(self, task: Task) -> Task:
self._emit_step_progress(-1, "completed", "task completed")
self._update_task(task, status="completed", completed=True) self._update_task(task, status="completed", completed=True)
self._notify_task_succeeded(task) self._notify_task_succeeded(task)
return task return task
@@ -306,7 +337,7 @@ class TaskRunner:
self.timeline.append( self.timeline.append(
task_id=task.id, task_id=task.id,
scene=scene.to_dict(), scene=scene.to_dict(),
prompt=task.goal, prompt=step.prompt or task.goal,
tool_call={ tool_call={
"action": step.action, "action": step.action,
"description": step.description, "description": step.description,
+31 -4
View File
@@ -25,6 +25,11 @@ class ToolCallDecision:
tool_name: str tool_name: str
arguments: dict[str, Any] arguments: dict[str, Any]
usage: ToolCallUsage | None = None usage: ToolCallUsage | None = None
# The actual prompts sent to the LLM for this call. Populated by the
# built-in Anthropic/OpenAI clients; empty for clients (e.g.
# CloudProxyToolCallingClient) that don't surface them.
system_prompt: str = ""
user_prompt: str = ""
class ToolCallingClient(Protocol): class ToolCallingClient(Protocol):
@@ -72,7 +77,11 @@ class AnthropicToolCallingClient:
tools, tools,
timeout=timeout, timeout=timeout,
) )
return _decision_from_anthropic_response(response) return _decision_from_anthropic_response(
response,
system_prompt=system_prompt,
user_prompt=user_prompt,
)
except ToolCallUnavailable: except ToolCallUnavailable:
raise raise
except Exception as exc: except Exception as exc:
@@ -164,7 +173,11 @@ class OpenAIToolCallingClient:
tools, tools,
timeout=timeout, timeout=timeout,
) )
return _decision_from_openai_response(response) return _decision_from_openai_response(
response,
system_prompt=system_prompt,
user_prompt=user_prompt,
)
except ToolCallUnavailable: except ToolCallUnavailable:
raise raise
except Exception as exc: except Exception as exc:
@@ -250,7 +263,12 @@ def _anthropic_tool(spec: ToolSpec) -> dict[str, Any]:
} }
def _decision_from_anthropic_response(response: Any) -> ToolCallDecision: def _decision_from_anthropic_response(
response: Any,
*,
system_prompt: str = "",
user_prompt: str = "",
) -> ToolCallDecision:
content = _value(response, "content") content = _value(response, "content")
if not isinstance(content, list): if not isinstance(content, list):
raise ValueError("anthropic tool-call response missing content list") raise ValueError("anthropic tool-call response missing content list")
@@ -264,6 +282,8 @@ def _decision_from_anthropic_response(response: Any) -> ToolCallDecision:
tool_name=name, tool_name=name,
arguments=arguments, arguments=arguments,
usage=_anthropic_usage(response), usage=_anthropic_usage(response),
system_prompt=system_prompt,
user_prompt=user_prompt,
) )
raise ValueError("anthropic response did not include a tool_use block") raise ValueError("anthropic response did not include a tool_use block")
@@ -295,7 +315,12 @@ def _openai_tool(spec: ToolSpec) -> dict[str, Any]:
} }
def _decision_from_openai_response(response: Any) -> ToolCallDecision: def _decision_from_openai_response(
response: Any,
*,
system_prompt: str = "",
user_prompt: str = "",
) -> ToolCallDecision:
choices = _value(response, "choices") choices = _value(response, "choices")
if not isinstance(choices, list) or not choices: if not isinstance(choices, list) or not choices:
raise ValueError("openai tool-call response missing choices") raise ValueError("openai tool-call response missing choices")
@@ -312,6 +337,8 @@ def _decision_from_openai_response(response: Any) -> ToolCallDecision:
tool_name=name, tool_name=name,
arguments=arguments, arguments=arguments,
usage=_openai_usage(response), usage=_openai_usage(response),
system_prompt=system_prompt,
user_prompt=user_prompt,
) )
+5 -1
View File
@@ -1,6 +1,7 @@
from __future__ import annotations from __future__ import annotations
import json import json
import shutil
from dataclasses import asdict, is_dataclass from dataclasses import asdict, is_dataclass
from datetime import date, datetime from datetime import date, datetime
from pathlib import Path from pathlib import Path
@@ -53,6 +54,10 @@ class ArtifactStore:
steps.append(json.loads(path.read_text(encoding="utf-8"))) steps.append(json.loads(path.read_text(encoding="utf-8")))
return steps return steps
def delete_task(self, task_id: str) -> None:
"""Remove all on-disk artifacts (JSON + screenshots) for a task."""
shutil.rmtree(self.task_dir(task_id), ignore_errors=True)
def _jsonable(value: Any) -> Any: def _jsonable(value: Any) -> Any:
if hasattr(value, "to_dict"): if hasattr(value, "to_dict"):
@@ -68,4 +73,3 @@ def _jsonable(value: Any) -> Any:
if isinstance(value, (datetime, date)): if isinstance(value, (datetime, date)):
return value.isoformat() return value.isoformat()
return value return value
+15 -1
View File
@@ -73,6 +73,21 @@ class TaskMetadataStore:
).fetchall() ).fetchall()
return [dict(row) for row in rows] return [dict(row) for row in rows]
def list_task_ids(self) -> list[str]:
with self._connect() as connection:
rows = connection.execute(
"select id from tasks order by created_at desc"
).fetchall()
return [row["id"] for row in rows]
def delete_task(self, task_id: str) -> None:
"""Delete a task row. No-op if the task does not exist."""
with self._connect() as connection:
connection.execute(
"delete from tasks where id = ?",
(task_id,),
)
def _ensure_schema(self) -> None: def _ensure_schema(self) -> None:
with self._connect() as connection: with self._connect() as connection:
connection.execute( connection.execute(
@@ -94,4 +109,3 @@ class TaskMetadataStore:
connection = sqlite3.connect(self.db_path) connection = sqlite3.connect(self.db_path)
connection.row_factory = sqlite3.Row connection.row_factory = sqlite3.Row
return connection return connection
+5
View File
@@ -11,6 +11,8 @@ from storage.artifact_store import ArtifactStore
class TimelineRecord: class TimelineRecord:
index: int index: int
scene: dict[str, Any] scene: dict[str, Any]
# The prompt actually sent to the LLM for this step. For non-LLM planners
# (or older records persisted before D9), this falls back to the task goal.
prompt: str prompt: str
tool_call: dict[str, Any] tool_call: dict[str, Any]
result: dict[str, Any] result: dict[str, Any]
@@ -60,3 +62,6 @@ class Timeline:
def read(self, task_id: str) -> list[dict[str, Any]]: def read(self, task_id: str) -> list[dict[str, Any]]:
return self.artifact_store.read_steps(task_id) return self.artifact_store.read_steps(task_id)
def delete_task(self, task_id: str) -> None:
"""Delete all timeline records and screenshots for a task."""
self.artifact_store.delete_task(task_id)
+98 -17
View File
@@ -8,7 +8,6 @@ from core.errors import TaskFailedError
from core.models import Bounds, Scene, SceneElement from core.models import Bounds, Scene, SceneElement
from runtime.ai_planner import AIPlanner from runtime.ai_planner import AIPlanner
from runtime.context import TaskContext from runtime.context import TaskContext
from runtime.planner import PlannedStep
from runtime.planner_config import PlannerConfig from runtime.planner_config import PlannerConfig
from runtime.tool_calling_client import ToolCallDecision from runtime.tool_calling_client import ToolCallDecision
from runtime.tool_specs import ALL_TOOL_SPECS from runtime.tool_specs import ALL_TOOL_SPECS
@@ -44,7 +43,11 @@ def _scene() -> Scene:
return Scene( return Scene(
width=10, width=10,
height=20, height=20,
elements=[SceneElement(id="send", type="button", text="Send", bounds=Bounds(1, 2, 3, 4))], elements=[
SceneElement(
id="send", type="button", text="Send", bounds=Bounds(1, 2, 3, 4)
)
],
) )
@@ -53,23 +56,25 @@ def _context() -> TaskContext:
def test_ai_planner_returns_single_planned_step_for_action_decision() -> None: def test_ai_planner_returns_single_planned_step_for_action_decision() -> None:
client = FakeToolCallingClient(ToolCallDecision(tool_name="tap", arguments={"x": 1, "y": 2})) client = FakeToolCallingClient(
ToolCallDecision(tool_name="tap", arguments={"x": 1, "y": 2})
)
planner = AIPlanner(client=client) planner = AIPlanner(client=client)
steps = planner.plan(goal="send a message", scene=_scene(), context=_context()) steps = planner.plan(goal="send a message", scene=_scene(), context=_context())
assert steps == [ assert len(steps) == 1
PlannedStep( step = steps[0]
action="tap", assert step.action == "tap"
description="AI planner: tap({'x': 1, 'y': 2})", assert step.description == "AI planner: tap({'x': 1, 'y': 2})"
args={"x": 1, "y": 2}, assert step.args == {"x": 1, "y": 2}
)
]
def test_ai_planner_finish_task_success_returns_empty_plan() -> None: def test_ai_planner_finish_task_success_returns_empty_plan() -> None:
client = FakeToolCallingClient( client = FakeToolCallingClient(
ToolCallDecision(tool_name="finish_task", arguments={"success": True, "reason": "done"}) ToolCallDecision(
tool_name="finish_task", arguments={"success": True, "reason": "done"}
)
) )
planner = AIPlanner(client=client) planner = AIPlanner(client=client)
@@ -80,7 +85,10 @@ def test_ai_planner_finish_task_success_returns_empty_plan() -> None:
def test_ai_planner_finish_task_failure_raises_task_failed_error_with_reason() -> None: def test_ai_planner_finish_task_failure_raises_task_failed_error_with_reason() -> None:
client = FakeToolCallingClient( client = FakeToolCallingClient(
ToolCallDecision(tool_name="finish_task", arguments={"success": False, "reason": "stuck on login"}) ToolCallDecision(
tool_name="finish_task",
arguments={"success": False, "reason": "stuck on login"},
)
) )
planner = AIPlanner(client=client) planner = AIPlanner(client=client)
@@ -89,7 +97,9 @@ def test_ai_planner_finish_task_failure_raises_task_failed_error_with_reason() -
def test_ai_planner_finish_task_failure_without_reason_uses_default_message() -> None: def test_ai_planner_finish_task_failure_without_reason_uses_default_message() -> None:
client = FakeToolCallingClient(ToolCallDecision(tool_name="finish_task", arguments={"success": False})) client = FakeToolCallingClient(
ToolCallDecision(tool_name="finish_task", arguments={"success": False})
)
planner = AIPlanner(client=client) planner = AIPlanner(client=client)
with pytest.raises(TaskFailedError, match="task failed"): with pytest.raises(TaskFailedError, match="task failed"):
@@ -97,20 +107,91 @@ def test_ai_planner_finish_task_failure_without_reason_uses_default_message() ->
def test_ai_planner_goal_reached_is_always_false() -> None: def test_ai_planner_goal_reached_is_always_false() -> None:
client = FakeToolCallingClient(ToolCallDecision(tool_name="tap", arguments={"x": 1, "y": 2})) client = FakeToolCallingClient(
ToolCallDecision(tool_name="tap", arguments={"x": 1, "y": 2})
)
planner = AIPlanner(client=client) planner = AIPlanner(client=client)
assert planner.goal_reached(goal="anything", scene=_scene(), context=_context()) is False assert (
planner.goal_reached(goal="anything", scene=_scene(), context=_context())
is False
)
def test_ai_planner_forwards_tools_screenshot_and_timeout_to_client() -> None: def test_ai_planner_forwards_tools_screenshot_and_timeout_to_client() -> None:
client = FakeToolCallingClient(ToolCallDecision(tool_name="tap", arguments={"x": 1, "y": 2})) client = FakeToolCallingClient(
ToolCallDecision(tool_name="tap", arguments={"x": 1, "y": 2})
)
planner = AIPlanner(client=client, config=PlannerConfig(timeout=12.5)) planner = AIPlanner(client=client, config=PlannerConfig(timeout=12.5))
planner.plan(goal="send a message", scene=_scene(), context=_context(), screenshot=b"fake-bytes") planner.plan(
goal="send a message",
scene=_scene(),
context=_context(),
screenshot=b"fake-bytes",
)
call = client.calls[0] call = client.calls[0]
assert call["tools"] == ALL_TOOL_SPECS assert call["tools"] == ALL_TOOL_SPECS
assert call["screenshot"] == b"fake-bytes" assert call["screenshot"] == b"fake-bytes"
assert call["timeout"] == 12.5 assert call["timeout"] == 12.5
assert "send a message" in call["user_prompt"] assert "send a message" in call["user_prompt"]
def test_ai_planner_populates_step_prompt_from_user_prompt() -> None:
"""PlannedStep.prompt should carry the actual user prompt sent to the LLM,
not the bare task goal."""
client = FakeToolCallingClient(
ToolCallDecision(tool_name="tap", arguments={"x": 1, "y": 2})
)
planner = AIPlanner(client=client)
steps = planner.plan(goal="send a message", scene=_scene(), context=_context())
assert len(steps) == 1
assert steps[0].prompt is not None
# The per-step prompt contains the goal but also scene JSON and instruction text
assert "send a message" in steps[0].prompt
assert "Current Scene (JSON)" in steps[0].prompt
assert "Call exactly one tool" in steps[0].prompt
def test_ai_planner_step_prompt_reflects_scene_changes() -> None:
"""Per-step prompts differ when the scene changes, proving they are not
just the repeated task goal."""
from runtime.context import TaskContext
client = FakeToolCallingClient(
ToolCallDecision(tool_name="tap", arguments={"x": 1, "y": 2})
)
planner = AIPlanner(client=client)
scene_a = Scene(
width=10,
height=20,
elements=[
SceneElement(
id="btn_a", type="button", text="Alpha", bounds=Bounds(1, 2, 3, 4)
)
],
)
scene_b = Scene(
width=10,
height=20,
elements=[
SceneElement(
id="btn_b", type="button", text="Beta", bounds=Bounds(5, 6, 7, 8)
)
],
)
steps_a = planner.plan(
goal="test", scene=scene_a, context=TaskContext(task_id="t", goal="test")
)
steps_b = planner.plan(
goal="test", scene=scene_b, context=TaskContext(task_id="t", goal="test")
)
assert steps_a[0].prompt != steps_b[0].prompt
assert "Alpha" in steps_a[0].prompt
assert "Beta" in steps_b[0].prompt
+148 -2
View File
@@ -1,11 +1,16 @@
from __future__ import annotations from __future__ import annotations
from typing import Any
from core.models import Bounds, Scene, SceneElement, Task from core.models import Bounds, Scene, SceneElement, Task
from runtime.ai_planner import AIPlanner from runtime.ai_planner import AIPlanner
from runtime.executor import Executor, ExecutorConfig from runtime.executor import Executor, ExecutorConfig
from runtime.planner import PlannedStep, Planner from runtime.planner import PlannedStep, Planner
from runtime.planner_config import PlannerConfig from runtime.planner_config import PlannerConfig
from runtime.task import TaskRunner, TaskRunnerConfig from runtime.task import TaskRunner, TaskRunnerConfig
from runtime.tool_calling_client import ToolCallDecision
from storage.artifact_store import ArtifactStore
from storage.timeline import Timeline
from tests.fakes import PNG_10X20 from tests.fakes import PNG_10X20
@@ -56,7 +61,11 @@ def _scene() -> Scene:
return Scene( return Scene(
width=10, width=10,
height=20, height=20,
elements=[SceneElement(id="send", type="button", text="Send", bounds=Bounds(1, 2, 3, 4))], elements=[
SceneElement(
id="send", type="button", text="Send", bounds=Bounds(1, 2, 3, 4)
)
],
) )
@@ -126,7 +135,144 @@ def test_task_runner_default_planner_is_stub_when_ai_planner_disabled() -> None:
def test_task_runner_default_planner_is_ai_planner_when_enabled() -> None: def test_task_runner_default_planner_is_ai_planner_when_enabled() -> None:
runner = _runner( runner = _runner(
planner=None, planner=None,
planner_config=PlannerConfig(enabled=True, provider="anthropic", model="test-model"), planner_config=PlannerConfig(
enabled=True, provider="anthropic", model="test-model"
),
) )
assert isinstance(runner.planner, AIPlanner) assert isinstance(runner.planner, AIPlanner)
# ---------------------------------------------------------------------------
# D9: per-step prompt recording
# ---------------------------------------------------------------------------
class ScriptedToolCallingClient:
"""Returns a sequence of decisions, capturing the actual user_prompt each call."""
def __init__(self, decisions: list[ToolCallDecision]) -> None:
self._decisions = list(decisions)
self.calls: list[dict[str, Any]] = []
def decide(
self,
*,
system_prompt: str,
user_prompt: str,
screenshot: bytes | None,
tools: list[Any],
timeout: float,
) -> ToolCallDecision:
index = len(self.calls)
self.calls.append(
{
"system_prompt": system_prompt,
"user_prompt": user_prompt,
}
)
return self._decisions[index]
def _multi_step_scene(element_text: str = "Send") -> Scene:
return Scene(
width=10,
height=20,
elements=[
SceneElement(
id="btn",
type="button",
text=element_text,
bounds=Bounds(1, 2, 3, 4),
)
],
)
def test_multi_step_timeline_records_actual_per_step_prompts(tmp_path) -> None:
"""When AIPlanner is used, each timeline step's prompt is the real
per-step user prompt (containing scene JSON), not the bare task goal."""
decisions = [
ToolCallDecision(tool_name="tap", arguments={"x": 1, "y": 2}),
ToolCallDecision(tool_name="finish_task", arguments={"success": True}),
]
client = ScriptedToolCallingClient(decisions)
planner = AIPlanner(client=client)
executor = Executor(
tools={"tap": lambda **kwargs: {"ok": True, **kwargs}},
config=ExecutorConfig(max_retries=1, backoff_seconds=0),
)
timeline = Timeline(ArtifactStore(tmp_path / "history"))
task = Task(goal="tap the button", device_id="phone")
runner = TaskRunner(
planner=planner,
executor=executor,
timeline=timeline,
config=TaskRunnerConfig(max_steps=5),
observer=lambda device_id: _multi_step_scene("Send"),
screenshot_provider=lambda device_id: PNG_10X20,
)
runner.run(task)
records = timeline.read(task.id)
# Step 1 was recorded (step 2 was finish_task, which returns empty plan
# and completes the task without a timeline append).
assert len(records) == 1
prompt = records[0]["prompt"]
# The per-step prompt is NOT the bare task goal.
assert prompt != "tap the button"
# It contains scene-specific content that only the real planner_user_prompt
# would include.
assert "Current Scene (JSON)" in prompt
assert "Call exactly one tool" in prompt
assert "tap the button" in prompt
def test_non_ai_planner_falls_back_to_task_goal_for_prompt(tmp_path) -> None:
"""A non-LLM planner (no step.prompt) keeps recording task.goal as the
timeline prompt backward compat with pre-D9 behavior."""
scene = _multi_step_scene("Search")
planner = ScriptedPlannerForTimeline(
[PlannedStep(action="tap", description="tap", args={"x": 1, "y": 2})]
)
executor = Executor(
tools={"tap": lambda **kwargs: {"ok": True, **kwargs}},
config=ExecutorConfig(max_retries=1, backoff_seconds=0),
)
timeline = Timeline(ArtifactStore(tmp_path / "history"))
task = Task(goal="search for something", device_id="phone")
runner = TaskRunner(
planner=planner,
executor=executor,
timeline=timeline,
config=TaskRunnerConfig(max_steps=5),
observer=lambda device_id: scene,
screenshot_provider=lambda device_id: PNG_10X20,
)
runner.run(task)
records = timeline.read(task.id)
assert len(records) >= 1
# Non-AI planner: prompt falls back to task goal.
assert records[0]["prompt"] == "search for something"
class ScriptedPlannerForTimeline(Planner):
"""Simple planner that returns a fixed list of steps then signals done."""
def __init__(self, steps: list[PlannedStep]) -> None:
self.steps = steps
def plan(self, *, goal, scene, context):
if len(context.step_results) >= len(self.steps):
return []
return [self.steps[len(context.step_results)]]
def goal_reached(self, *, goal, scene, context):
return len(context.step_results) >= len(self.steps) and all(
r.success for r in context.step_results
)
+51
View File
@@ -195,6 +195,57 @@ def test_schema_readiness_requires_head_revision(tmp_path) -> None:
require_current_schema(database_url) require_current_schema(database_url)
def test_planner_decision_log_migration_upgrades_and_downgrades(tmp_path) -> None:
database_url = _database_url(tmp_path)
upgrade_database(database_url, "0008_task_progress_columns")
engine = create_engine(database_url)
try:
inspector = inspect(engine)
assert "planner_decision_log" not in inspector.get_table_names()
finally:
engine.dispose()
upgrade_database(database_url)
engine = create_engine(database_url)
try:
inspector = inspect(engine)
assert "planner_decision_log" in inspector.get_table_names()
columns = {col["name"] for col in inspector.get_columns("planner_decision_log")}
assert {
"id",
"host_id",
"task_id",
"attempt",
"step_index",
"system_prompt",
"user_prompt",
"tool_name",
"arguments_json",
"created_at",
} <= columns
index_names = {
idx["name"] for idx in inspector.get_indexes("planner_decision_log")
}
assert {
"ix_planner_decision_log_task_attempt",
"ix_planner_decision_log_host_created",
} <= index_names
assert current_revision(database_url) == HEAD_REVISION
finally:
engine.dispose()
downgrade_database(database_url, "0008_task_progress_columns")
engine = create_engine(database_url)
try:
inspector = inspect(engine)
assert "planner_decision_log" not in inspector.get_table_names()
assert current_revision(database_url) == "0008_task_progress_columns"
finally:
engine.dispose()
def _create_legacy_schema(connection) -> None: def _create_legacy_schema(connection) -> None:
connection.exec_driver_sql( connection.exec_driver_sql(
"create table host_registrations (" "create table host_registrations ("
+163 -9
View File
@@ -1,10 +1,14 @@
from __future__ import annotations from __future__ import annotations
from datetime import UTC, datetime, timedelta
from fastapi import FastAPI from fastapi import FastAPI
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
from sqlalchemy.orm import Session
from cloud.auth import BearerCredential, ConfiguredBearerAuthProvider from cloud.auth import BearerCredential, ConfiguredBearerAuthProvider
from cloud.config import CloudConfig from cloud.config import CloudConfig
from cloud.db_models import TaskAttemptRow
from cloud.internal_api.api import create_internal_router from cloud.internal_api.api import create_internal_router
from cloud.pool import DevicePool from cloud.pool import DevicePool
from cloud.store import CloudStore from cloud.store import CloudStore
@@ -49,7 +53,7 @@ class _FakeToolCallingClient:
def _build_client( def _build_client(
tmp_path, *, fake_client: _FakeToolCallingClient tmp_path, *, fake_client: _FakeToolCallingClient
) -> tuple[TestClient, _FakeToolCallingClient]: ) -> tuple[TestClient, _FakeToolCallingClient, DevicePool]:
pool = DevicePool( pool = DevicePool(
CloudStore(tmp_path / "internal.sqlite3"), CloudStore(tmp_path / "internal.sqlite3"),
CloudConfig(stale_after_seconds=60), CloudConfig(stale_after_seconds=60),
@@ -68,7 +72,7 @@ def _build_client(
planner_client_factory=lambda: fake_client, planner_client_factory=lambda: fake_client,
) )
) )
return TestClient(app), fake_client return TestClient(app), fake_client, pool
def _decision_payload(**overrides: object) -> dict[str, object]: def _decision_payload(**overrides: object) -> dict[str, object]:
@@ -94,7 +98,7 @@ def test_authenticated_host_resolves_planner_decision(tmp_path) -> None:
fake_client = _FakeToolCallingClient( fake_client = _FakeToolCallingClient(
decision=ToolCallDecision(tool_name="tap", arguments={"x": 1, "y": 2}) decision=ToolCallDecision(tool_name="tap", arguments={"x": 1, "y": 2})
) )
client, fake_client = _build_client(tmp_path, fake_client=fake_client) client, fake_client, _pool = _build_client(tmp_path, fake_client=fake_client)
response = client.post( response = client.post(
"/internal/v1/hosts/host-a/planner/decide", "/internal/v1/hosts/host-a/planner/decide",
@@ -113,7 +117,7 @@ def test_planner_decision_decodes_screenshot_base64(tmp_path) -> None:
fake_client = _FakeToolCallingClient( fake_client = _FakeToolCallingClient(
decision=ToolCallDecision(tool_name="tap", arguments={}) decision=ToolCallDecision(tool_name="tap", arguments={})
) )
client, fake_client = _build_client(tmp_path, fake_client=fake_client) client, fake_client, _pool = _build_client(tmp_path, fake_client=fake_client)
response = client.post( response = client.post(
"/internal/v1/hosts/host-a/planner/decide", "/internal/v1/hosts/host-a/planner/decide",
@@ -129,7 +133,7 @@ def test_invalid_screenshot_base64_is_rejected(tmp_path) -> None:
fake_client = _FakeToolCallingClient( fake_client = _FakeToolCallingClient(
decision=ToolCallDecision(tool_name="tap", arguments={}) decision=ToolCallDecision(tool_name="tap", arguments={})
) )
client, fake_client = _build_client(tmp_path, fake_client=fake_client) client, fake_client, _pool = _build_client(tmp_path, fake_client=fake_client)
response = client.post( response = client.post(
"/internal/v1/hosts/host-a/planner/decide", "/internal/v1/hosts/host-a/planner/decide",
@@ -145,7 +149,7 @@ def test_unauthenticated_request_is_rejected(tmp_path) -> None:
fake_client = _FakeToolCallingClient( fake_client = _FakeToolCallingClient(
decision=ToolCallDecision(tool_name="tap", arguments={}) decision=ToolCallDecision(tool_name="tap", arguments={})
) )
client, fake_client = _build_client(tmp_path, fake_client=fake_client) client, fake_client, _pool = _build_client(tmp_path, fake_client=fake_client)
response = client.post( response = client.post(
"/internal/v1/hosts/host-a/planner/decide", "/internal/v1/hosts/host-a/planner/decide",
@@ -160,7 +164,7 @@ def test_foreign_host_token_cannot_request_another_hosts_decision(tmp_path) -> N
fake_client = _FakeToolCallingClient( fake_client = _FakeToolCallingClient(
decision=ToolCallDecision(tool_name="tap", arguments={}) decision=ToolCallDecision(tool_name="tap", arguments={})
) )
client, fake_client = _build_client(tmp_path, fake_client=fake_client) client, fake_client, _pool = _build_client(tmp_path, fake_client=fake_client)
response = client.post( response = client.post(
"/internal/v1/hosts/host-a/planner/decide", "/internal/v1/hosts/host-a/planner/decide",
@@ -176,7 +180,7 @@ def test_path_and_payload_host_id_mismatch_is_rejected(tmp_path) -> None:
fake_client = _FakeToolCallingClient( fake_client = _FakeToolCallingClient(
decision=ToolCallDecision(tool_name="tap", arguments={}) decision=ToolCallDecision(tool_name="tap", arguments={})
) )
client, fake_client = _build_client(tmp_path, fake_client=fake_client) client, fake_client, _pool = _build_client(tmp_path, fake_client=fake_client)
response = client.post( response = client.post(
"/internal/v1/hosts/host-a/planner/decide", "/internal/v1/hosts/host-a/planner/decide",
@@ -190,7 +194,7 @@ def test_path_and_payload_host_id_mismatch_is_rejected(tmp_path) -> None:
def test_provider_failure_returns_structured_error_without_crashing(tmp_path) -> None: def test_provider_failure_returns_structured_error_without_crashing(tmp_path) -> None:
fake_client = _FakeToolCallingClient(error="anthropic timed out") fake_client = _FakeToolCallingClient(error="anthropic timed out")
client, fake_client = _build_client(tmp_path, fake_client=fake_client) client, fake_client, _pool = _build_client(tmp_path, fake_client=fake_client)
response = client.post( response = client.post(
"/internal/v1/hosts/host-a/planner/decide", "/internal/v1/hosts/host-a/planner/decide",
@@ -203,3 +207,153 @@ def test_provider_failure_returns_structured_error_without_crashing(tmp_path) ->
"code": "planner_unavailable", "code": "planner_unavailable",
"detail": "anthropic timed out", "detail": "anthropic timed out",
} }
def _seed_attempt(pool: DevicePool, task_id: str, host_id: str = "host-a") -> None:
"""Insert a task_attempts row so _validate_planner_context passes."""
now = datetime.now(tz=UTC)
with Session(pool.store.engine) as session, session.begin():
session.add(
TaskAttemptRow(
task_id=task_id,
attempt=1,
lease_id="lease-x",
host_id=host_id,
device_id="device-x",
status="dispatched",
lease_expires_at=(now + timedelta(minutes=5)).isoformat(),
created_at=now.isoformat(),
completed_at=None,
failure_reason=None,
result_json=None,
)
)
def test_successful_decision_is_persisted_with_correct_fields(tmp_path) -> None:
fake_client = _FakeToolCallingClient(
decision=ToolCallDecision(tool_name="tap", arguments={"x": 10, "y": 20})
)
client, fake_client, pool = _build_client(tmp_path, fake_client=fake_client)
_seed_attempt(pool, "task-log-1")
response = client.post(
"/internal/v1/hosts/host-a/planner/decide",
headers={"Authorization": "Bearer token-a"},
json=_decision_payload(
task_id="task-log-1",
attempt=1,
lease_id="lease-x",
system_prompt="you are a test planner",
user_prompt="tap the button now",
),
)
assert response.status_code == 200
decisions = pool.store.list_planner_decisions(task_id="task-log-1", attempt=1)
assert len(decisions) == 1
row = decisions[0]
assert row.step_index == 1
assert row.system_prompt == "you are a test planner"
assert row.user_prompt == "tap the button now"
assert row.tool_name == "tap"
assert '"x": 10' in row.arguments_json
assert '"y": 20' in row.arguments_json
def test_failed_decision_persists_nothing(tmp_path) -> None:
fake_client = _FakeToolCallingClient(error="model is down")
client, fake_client, pool = _build_client(tmp_path, fake_client=fake_client)
_seed_attempt(pool, "task-log-fail")
response = client.post(
"/internal/v1/hosts/host-a/planner/decide",
headers={"Authorization": "Bearer token-a"},
json=_decision_payload(
task_id="task-log-fail",
attempt=1,
lease_id="lease-x",
),
)
assert response.status_code == 502
assert pool.store.list_planner_decisions(task_id="task-log-fail", attempt=1) == []
def test_screenshot_bytes_are_never_persisted_to_decision_log(tmp_path) -> None:
fake_client = _FakeToolCallingClient(
decision=ToolCallDecision(tool_name="tap", arguments={})
)
client, fake_client, pool = _build_client(tmp_path, fake_client=fake_client)
_seed_attempt(pool, "task-log-screenshot")
response = client.post(
"/internal/v1/hosts/host-a/planner/decide",
headers={"Authorization": "Bearer token-a"},
json=_decision_payload(
task_id="task-log-screenshot",
attempt=1,
lease_id="lease-x",
screenshot_base64="aGVsbG8gd29ybGQ=",
),
)
assert response.status_code == 200
decisions = pool.store.list_planner_decisions(
task_id="task-log-screenshot", attempt=1
)
assert len(decisions) == 1
# The row has no screenshot column at all — verify the raw row text
# does not contain the screenshot bytes.
row_repr = repr(decisions[0])
assert "hello world" not in row_repr
assert "aGVsbG8" not in row_repr
def test_decision_without_task_context_is_not_persisted(tmp_path) -> None:
"""When task_id/attempt are absent, the log insert is skipped."""
fake_client = _FakeToolCallingClient(
decision=ToolCallDecision(tool_name="tap", arguments={})
)
client, fake_client, pool = _build_client(tmp_path, fake_client=fake_client)
response = client.post(
"/internal/v1/hosts/host-a/planner/decide",
headers={"Authorization": "Bearer token-a"},
json=_decision_payload(), # no task_id / attempt / lease_id
)
assert response.status_code == 200
# No task_id to query by — verify no rows exist at all via direct SQL.
from sqlalchemy import func, select
from cloud.db_models import PlannerDecisionLogRow
with Session(pool.store.engine) as session:
count = session.scalar(select(func.count()).select_from(PlannerDecisionLogRow))
assert count == 0
def test_multiple_decisions_get_incrementing_step_index(tmp_path) -> None:
fake_client = _FakeToolCallingClient(
decision=ToolCallDecision(tool_name="tap", arguments={})
)
client, fake_client, pool = _build_client(tmp_path, fake_client=fake_client)
_seed_attempt(pool, "task-log-multi")
for i in range(3):
response = client.post(
"/internal/v1/hosts/host-a/planner/decide",
headers={"Authorization": "Bearer token-a"},
json=_decision_payload(
task_id="task-log-multi",
attempt=1,
lease_id="lease-x",
user_prompt=f"step {i}",
),
)
assert response.status_code == 200
decisions = pool.store.list_planner_decisions(task_id="task-log-multi", attempt=1)
assert [d.step_index for d in decisions] == [1, 2, 3]
assert [d.user_prompt for d in decisions] == ["step 0", "step 1", "step 2"]
+198
View File
@@ -82,6 +82,9 @@ def test_cloud_repository_exposes_crud_and_atomic_lease_operations() -> None:
"record_task_result", "record_task_result",
"reap_expired_leases", "reap_expired_leases",
"list_task_attempts", "list_task_attempts",
"record_planner_decision",
"prune_planner_decision_log",
"list_planner_decisions",
"health_check", "health_check",
"close", "close",
} <= members } <= members
@@ -1553,3 +1556,198 @@ def test_task_lifecycle_logs_structured_identifiers(
assert "secret-image" not in repr(events) assert "secret-image" not in repr(events)
finally: finally:
database.close() database.close()
def test_record_planner_decision_assigns_incrementing_step_index(
database_url: str,
) -> None:
database = CloudDatabase(database_url)
task_id = _unique_id("planner-task")
other_task_id = _unique_id("planner-task-other")
host_id = _unique_id("planner-host")
now = datetime(2026, 7, 14, 0, 0, tzinfo=UTC)
try:
step1 = database.repository.record_planner_decision(
host_id=host_id,
task_id=task_id,
attempt=1,
system_prompt="system",
user_prompt="prompt-1",
tool_name="tap",
arguments_json='{"x": 1}',
now=now,
)
step2 = database.repository.record_planner_decision(
host_id=host_id,
task_id=task_id,
attempt=1,
system_prompt="system",
user_prompt="prompt-2",
tool_name="swipe",
arguments_json='{"y": 2}',
now=now + timedelta(seconds=1),
)
step3 = database.repository.record_planner_decision(
host_id=host_id,
task_id=task_id,
attempt=1,
system_prompt="system",
user_prompt="prompt-3",
tool_name="wait",
arguments_json="{}",
now=now + timedelta(seconds=2),
)
assert [step1, step2, step3] == [1, 2, 3]
# Different (task_id, attempt) gets its own counter starting at 1.
other_step = database.repository.record_planner_decision(
host_id=host_id,
task_id=other_task_id,
attempt=1,
system_prompt="system",
user_prompt="other",
tool_name="tap",
arguments_json="{}",
now=now,
)
assert other_step == 1
# Different attempt on the same task also gets its own counter.
attempt2_step = database.repository.record_planner_decision(
host_id=host_id,
task_id=task_id,
attempt=2,
system_prompt="system",
user_prompt="retry",
tool_name="tap",
arguments_json="{}",
now=now,
)
assert attempt2_step == 1
# Verify stored rows.
decisions = database.repository.list_planner_decisions(
task_id=task_id, attempt=1
)
assert len(decisions) == 3
assert [d.step_index for d in decisions] == [1, 2, 3]
assert [d.user_prompt for d in decisions] == [
"prompt-1",
"prompt-2",
"prompt-3",
]
assert [d.tool_name for d in decisions] == ["tap", "swipe", "wait"]
assert decisions[0].arguments_json == '{"x": 1}'
finally:
database.close()
def test_prune_planner_decision_log_deletes_only_old_terminal_tasks(
database_url: str,
) -> None:
database = CloudDatabase(database_url)
old_terminal_task = _unique_id("old-terminal")
in_flight_task = _unique_id("in-flight")
recent_terminal_task = _unique_id("recent-terminal")
host_id = _unique_id("prune-host")
now = datetime(2026, 7, 14, 12, 0, tzinfo=UTC)
try:
with Session(database.engine) as session, session.begin():
# Old terminal task (completed > window ago).
session.add(
TaskAttemptRow(
task_id=old_terminal_task,
attempt=1,
lease_id="lease-old",
host_id=host_id,
device_id="device-old",
status="done",
lease_expires_at=(now - timedelta(days=10)).isoformat(),
created_at=(now - timedelta(days=11)).isoformat(),
completed_at=(now - timedelta(days=10)).isoformat(),
failure_reason=None,
result_json=None,
)
)
# In-flight task (also old, but NOT terminal).
session.add(
TaskAttemptRow(
task_id=in_flight_task,
attempt=1,
lease_id="lease-flight",
host_id=host_id,
device_id="device-flight",
status="dispatched",
lease_expires_at=(now - timedelta(days=10)).isoformat(),
created_at=(now - timedelta(days=11)).isoformat(),
completed_at=None,
failure_reason=None,
result_json=None,
)
)
# Recently terminal task (within window).
session.add(
TaskAttemptRow(
task_id=recent_terminal_task,
attempt=1,
lease_id="lease-recent",
host_id=host_id,
device_id="device-recent",
status="done",
lease_expires_at=(now - timedelta(hours=1)).isoformat(),
created_at=(now - timedelta(hours=2)).isoformat(),
completed_at=(now - timedelta(hours=1)).isoformat(),
failure_reason=None,
result_json=None,
)
)
# Seed decision log rows for all three tasks.
for task_id in [old_terminal_task, in_flight_task, recent_terminal_task]:
database.repository.record_planner_decision(
host_id=host_id,
task_id=task_id,
attempt=1,
system_prompt="s",
user_prompt="u",
tool_name="tap",
arguments_json="{}",
now=now - timedelta(days=11),
)
# 7-day window.
deleted = database.repository.prune_planner_decision_log(
now=now,
prune_after_terminal_seconds=7 * 86_400,
)
assert deleted == 1
# Old terminal task's rows are gone.
assert (
database.repository.list_planner_decisions(
task_id=old_terminal_task, attempt=1
)
== []
)
# In-flight and recent terminal rows survive.
assert (
len(
database.repository.list_planner_decisions(
task_id=in_flight_task, attempt=1
)
)
== 1
)
assert (
len(
database.repository.list_planner_decisions(
task_id=recent_terminal_task, attempt=1
)
)
== 1
)
finally:
database.close()
+1
View File
@@ -338,6 +338,7 @@ def test_submit_rejects_incomplete_or_foreign_target(tmp_path) -> None:
("get", "/v1/tasks/missing", None, "tasks:read"), ("get", "/v1/tasks/missing", None, "tasks:read"),
("get", "/v1/tasks", None, "tasks:read"), ("get", "/v1/tasks", None, "tasks:read"),
("get", "/v1/tasks/missing/attempts", None, "tasks:read"), ("get", "/v1/tasks/missing/attempts", None, "tasks:read"),
("get", "/v1/tasks/missing/planner-decisions?attempt=0", None, "tasks:read"),
("get", "/v1/devices", None, "pool:read"), ("get", "/v1/devices", None, "pool:read"),
("get", "/v1/hosts", None, "pool:read"), ("get", "/v1/hosts", None, "pool:read"),
("get", "/v1/plugins", None, "plugins:read"), ("get", "/v1/plugins", None, "plugins:read"),
@@ -0,0 +1,316 @@
"""Repository contract tests for cloud task progress columns (task 3.6).
Separate from ``test_cloud_repository_contract.py``; covers the new
``progress_*`` fields added by migration 0008.
"""
from __future__ import annotations
from datetime import UTC, datetime, timedelta
from cloud.database import CloudDatabase
from cloud.pool import PooledDevice
from cloud.repository import AssignmentProgressSnapshot
from cloud.scheduler import ScheduledTask, TaskConstraints
def _device(device_id: str, host_id: str) -> PooledDevice:
return PooledDevice(
device_id=device_id,
host_id=host_id,
driver_type="wda",
status="idle",
capability_tags=["ios"],
synced_at=datetime(2026, 7, 12, tzinfo=UTC),
)
def _task(task_id: str, *, created_at: datetime | None = None) -> ScheduledTask:
return ScheduledTask(
id=task_id,
goal="test progress",
workflow_definition_id=None,
constraints=TaskConstraints(),
created_at=created_at or datetime(2026, 7, 12, tzinfo=UTC),
)
def _setup_assigned_and_claimed(
database: CloudDatabase, host_id: str, device_id: str, task_id: str
):
"""Enqueue, assign, and claim a task. Returns LeasedAssignment."""
now = datetime(2026, 7, 12, 5, 0, tzinfo=UTC)
database.repository.upsert_host(host_id, address=None, last_seen_at=now)
database.repository.replace_host_devices(host_id, [_device(device_id, host_id)])
database.repository.enqueue_task(_task(task_id, created_at=now))
database.repository.assign_task(
task_id=task_id,
host_id=host_id,
device_id=device_id,
lease_id="lease-1",
lease_expires_at=now + timedelta(minutes=10),
now=now,
)
assignment = database.repository.claim_assignment(
host_id=host_id,
now=now + timedelta(seconds=1),
)
assert assignment is not None
return assignment, now
# --------------------------------------------------------------------------- #
# renew_lease writes progress on success
# --------------------------------------------------------------------------- #
def test_renew_lease_writes_progress_on_success(tmp_path) -> None:
database = CloudDatabase(f"sqlite:///{(tmp_path / 'progress.sqlite3').as_posix()}")
host_id = "progress-host"
device_id = "progress-device"
task_id = "progress-task"
try:
assignment, now = _setup_assigned_and_claimed(
database, host_id, device_id, task_id
)
progress = AssignmentProgressSnapshot(
step_index=2,
step_status="running",
summary="executing step 2",
updated_at=now + timedelta(seconds=5),
)
result = database.repository.renew_lease(
task_id=task_id,
attempt=assignment.attempt,
lease_id=assignment.lease_id,
host_id=host_id,
lease_expires_at=now + timedelta(minutes=15),
now=now + timedelta(seconds=5),
progress=progress,
)
assert result == "renewed"
task = database.repository.get_task(task_id)
assert task is not None
assert task.progress_step_index == 2
assert task.progress_step_status == "running"
assert task.progress_summary == "executing step 2"
assert task.progress_updated_at is not None
finally:
database.close()
# --------------------------------------------------------------------------- #
# renew_lease without progress leaves previous untouched
# --------------------------------------------------------------------------- #
def test_renew_lease_without_progress_leaves_previous_untouched(tmp_path) -> None:
database = CloudDatabase(f"sqlite:///{(tmp_path / 'progress2.sqlite3').as_posix()}")
host_id = "retain-host"
device_id = "retain-device"
task_id = "retain-task"
try:
assignment, now = _setup_assigned_and_claimed(
database, host_id, device_id, task_id
)
# First renew WITH progress.
progress = AssignmentProgressSnapshot(
step_index=1,
step_status="completed",
summary="step 1 done",
updated_at=now + timedelta(seconds=2),
)
database.repository.renew_lease(
task_id=task_id,
attempt=assignment.attempt,
lease_id=assignment.lease_id,
host_id=host_id,
lease_expires_at=now + timedelta(minutes=15),
now=now + timedelta(seconds=2),
progress=progress,
)
# Second renew WITHOUT progress (None).
database.repository.renew_lease(
task_id=task_id,
attempt=assignment.attempt,
lease_id=assignment.lease_id,
host_id=host_id,
lease_expires_at=now + timedelta(minutes=20),
now=now + timedelta(seconds=4),
progress=None,
)
task = database.repository.get_task(task_id)
assert task is not None
# Previous values must be retained, not cleared.
assert task.progress_step_index == 1
assert task.progress_step_status == "completed"
assert task.progress_summary == "step 1 done"
finally:
database.close()
# --------------------------------------------------------------------------- #
# record_task_result clears progress on terminal
# --------------------------------------------------------------------------- #
def test_record_task_result_clears_progress_on_terminal(tmp_path) -> None:
database = CloudDatabase(f"sqlite:///{(tmp_path / 'clear.sqlite3').as_posix()}")
host_id = "clear-host"
device_id = "clear-device"
task_id = "clear-task"
try:
assignment, now = _setup_assigned_and_claimed(
database, host_id, device_id, task_id
)
# Write progress first.
progress = AssignmentProgressSnapshot(
step_index=3,
step_status="running",
summary="almost done",
updated_at=now + timedelta(seconds=3),
)
database.repository.renew_lease(
task_id=task_id,
attempt=assignment.attempt,
lease_id=assignment.lease_id,
host_id=host_id,
lease_expires_at=now + timedelta(minutes=15),
now=now + timedelta(seconds=3),
progress=progress,
)
task = database.repository.get_task(task_id)
assert task is not None
assert task.progress_step_index == 3
# Record terminal result.
result = database.repository.record_task_result(
task_id=task_id,
attempt=assignment.attempt,
lease_id=assignment.lease_id,
host_id=host_id,
status="done",
failure_reason=None,
terminal_result={"output": "success"},
completed_at=now + timedelta(seconds=10),
)
assert result == "recorded"
task = database.repository.get_task(task_id)
assert task is not None
assert task.status == "done"
assert task.progress_step_index is None
assert task.progress_step_status is None
assert task.progress_summary is None
assert task.progress_updated_at is None
finally:
database.close()
# --------------------------------------------------------------------------- #
# Task list/detail response includes progress fields
# --------------------------------------------------------------------------- #
def test_list_tasks_includes_progress_fields(tmp_path) -> None:
database = CloudDatabase(f"sqlite:///{(tmp_path / 'list.sqlite3').as_posix()}")
host_id = "list-host"
device_id = "list-device"
task_id = "list-task"
try:
assignment, now = _setup_assigned_and_claimed(
database, host_id, device_id, task_id
)
progress = AssignmentProgressSnapshot(
step_index=1,
step_status="running",
summary="running step 1",
updated_at=now + timedelta(seconds=5),
)
database.repository.renew_lease(
task_id=task_id,
attempt=assignment.attempt,
lease_id=assignment.lease_id,
host_id=host_id,
lease_expires_at=now + timedelta(minutes=15),
now=now + timedelta(seconds=5),
progress=progress,
)
tasks = database.repository.list_tasks()
matching = [t for t in tasks if t.id == task_id]
assert len(matching) == 1
task = matching[0]
assert task.progress_step_index == 1
assert task.progress_step_status == "running"
assert task.progress_summary == "running step 1"
assert task.progress_updated_at is not None
finally:
database.close()
def test_get_task_includes_progress_fields(tmp_path) -> None:
database = CloudDatabase(f"sqlite:///{(tmp_path / 'get.sqlite3').as_posix()}")
host_id = "get-host"
device_id = "get-device"
task_id = "get-task"
try:
assignment, now = _setup_assigned_and_claimed(
database, host_id, device_id, task_id
)
progress = AssignmentProgressSnapshot(
step_index=5,
step_status="failed",
summary="step 5 failed",
updated_at=now + timedelta(seconds=8),
)
database.repository.renew_lease(
task_id=task_id,
attempt=assignment.attempt,
lease_id=assignment.lease_id,
host_id=host_id,
lease_expires_at=now + timedelta(minutes=15),
now=now + timedelta(seconds=8),
progress=progress,
)
task = database.repository.get_task(task_id)
assert task is not None
assert task.progress_step_index == 5
assert task.progress_step_status == "failed"
assert task.progress_summary == "step 5 failed"
finally:
database.close()
def test_progress_fields_default_null_before_any_renewal(tmp_path) -> None:
database = CloudDatabase(f"sqlite:///{(tmp_path / 'null.sqlite3').as_posix()}")
host_id = "null-host"
device_id = "null-device"
task_id = "null-task"
try:
_setup_assigned_and_claimed(database, host_id, device_id, task_id)
task = database.repository.get_task(task_id)
assert task is not None
assert task.progress_step_index is None
assert task.progress_step_status is None
assert task.progress_summary is None
assert task.progress_updated_at is None
finally:
database.close()
+210
View File
@@ -0,0 +1,210 @@
"""Tests for Host Agent console /tasks routes (task 5.4)."""
from __future__ import annotations
import re
from datetime import UTC, datetime
from fastapi.testclient import TestClient
from device.manager import DeviceManager
from host_agent.config import HostAgentConfig
from host_agent.history import ConsoleHistoryStore
from host_agent.identity import HostIdentityStore
from host_agent.local_account import LocalAccountStore
from host_agent.status import AgentStatusTracker
from host_agent.web.app import create_console_app
from host_agent.web.auth import SessionManager
from storage.artifact_store import ArtifactStore
from storage.device_config import DeviceConfigStore
from storage.task_metadata import TaskMetadataStore
from storage.timeline import Timeline
from tests.fakes import PNG_10X20
CSRF_PATTERN = re.compile(r'name="csrf_token" value="([^"]+)"')
def _build_client(
tmp_path,
*,
metadata_store: TaskMetadataStore | None = None,
timeline: Timeline | None = None,
create_account: bool = True,
) -> tuple[TestClient, dict]:
config = HostAgentConfig(
control_plane_url="https://control.example",
host_id="host-a",
token="secret",
enrollment_managed=False,
console_session_ttl_seconds=3600.0,
)
manager = DeviceManager()
config_store = DeviceConfigStore(tmp_path / "devices.sqlite3")
local_account_store = LocalAccountStore(tmp_path / "host_local_account.json")
if create_account:
local_account_store.create("operator", "correct horse battery staple")
identity_store = HostIdentityStore(tmp_path / "host_identity.json")
history_store = ConsoleHistoryStore(tmp_path / "history.sqlite3")
status_tracker = AgentStatusTracker()
session_manager = SessionManager(ttl_seconds=3600.0)
if metadata_store is None:
metadata_store = TaskMetadataStore(tmp_path / "task_progress.sqlite3")
if timeline is None:
timeline = Timeline(ArtifactStore(tmp_path / "history"))
app = create_console_app(
config=config,
manager=manager,
config_store=config_store,
local_account_store=local_account_store,
identity_store=identity_store,
history_store=history_store,
status_tracker=status_tracker,
session_manager=session_manager,
enrollment_client=None,
metadata_store=metadata_store,
timeline=timeline,
)
client = TestClient(app)
context = {
"metadata_store": metadata_store,
"timeline": timeline,
"session_manager": session_manager,
}
return client, context
def _login(client: TestClient) -> str:
response = client.post(
"/login",
data={"username": "operator", "password": "correct horse battery staple"},
)
assert response.status_code == 200
match = CSRF_PATTERN.search(response.text)
assert match is not None
return match.group(1)
# --------------------------------------------------------------------------- #
# Unauthenticated requests rejected
# --------------------------------------------------------------------------- #
def test_unauthenticated_tasks_list_redirects_to_login(tmp_path) -> None:
client, _ = _build_client(tmp_path)
response = client.get("/tasks", follow_redirects=False)
assert response.status_code == 303
assert response.headers["location"] == "/login"
def test_unauthenticated_task_detail_redirects_to_login(tmp_path) -> None:
client, _ = _build_client(tmp_path)
response = client.get("/tasks/some-id", follow_redirects=False)
assert response.status_code == 303
assert response.headers["location"] == "/login"
def test_unauthenticated_devices_page_also_redirects_for_parity(tmp_path) -> None:
"""Confirm /tasks has the same auth behavior as the existing /devices route."""
client, _ = _build_client(tmp_path)
response = client.get("/devices", follow_redirects=False)
assert response.status_code == 303
assert response.headers["location"] == "/login"
# --------------------------------------------------------------------------- #
# Authenticated list/detail render
# --------------------------------------------------------------------------- #
def test_authenticated_tasks_list_renders_completed_task(tmp_path) -> None:
from core.models import Task
metadata_store = TaskMetadataStore(tmp_path / "task_progress.sqlite3")
timeline = Timeline(ArtifactStore(tmp_path / "history"))
task = Task(
id="task-visible",
goal="open settings",
device_id="iphone-1",
status="completed",
created_at=datetime(2026, 7, 10, tzinfo=UTC),
updated_at=datetime(2026, 7, 10, 1, tzinfo=UTC),
completed_at=datetime(2026, 7, 10, 1, tzinfo=UTC),
)
metadata_store.create_task(task)
client, _ = _build_client(
tmp_path, metadata_store=metadata_store, timeline=timeline
)
_login(client)
response = client.get("/tasks")
assert response.status_code == 200
assert "task-visible" in response.text
assert "completed" in response.text
def test_authenticated_task_detail_renders_timeline_with_screenshot(tmp_path) -> None:
from core.models import Task
metadata_store = TaskMetadataStore(tmp_path / "task_progress.sqlite3")
timeline = Timeline(ArtifactStore(tmp_path / "history"))
task = Task(
id="task-detail",
goal="tap search",
device_id="iphone-1",
status="completed",
created_at=datetime(2026, 7, 10, tzinfo=UTC),
updated_at=datetime(2026, 7, 10, 1, tzinfo=UTC),
)
metadata_store.create_task(task)
timeline.append(
task_id="task-detail",
scene={"screen": {"width": 10, "height": 20}, "elements": []},
prompt="find search button",
tool_call={"action": "tap", "x": 5, "y": 10},
result={"ok": True},
screenshot=PNG_10X20,
)
client, _ = _build_client(
tmp_path, metadata_store=metadata_store, timeline=timeline
)
_login(client)
response = client.get("/tasks/task-detail")
assert response.status_code == 200
assert "task-detail" in response.text
# Timeline step visible.
assert "Step 1" in response.text
assert "find search button" in response.text
# Screenshot inlined as base64 data URI.
assert "data:image/png;base64," in response.text
def test_task_detail_404_for_unknown_task(tmp_path) -> None:
client, _ = _build_client(tmp_path)
_login(client)
response = client.get("/tasks/nonexistent")
assert response.status_code == 404
def test_tasks_list_shows_empty_state(tmp_path) -> None:
metadata_store = TaskMetadataStore(tmp_path / "task_progress.sqlite3")
client, _ = _build_client(tmp_path, metadata_store=metadata_store)
_login(client)
response = client.get("/tasks")
assert response.status_code == 200
assert "No tasks recorded" in response.text
+235
View File
@@ -0,0 +1,235 @@
"""Tests for Host Agent progress reporting via lease renewal (task 2.4)."""
from __future__ import annotations
import asyncio
from datetime import UTC, datetime, timedelta
from threading import Event
from typing import Any
import httpx
from cloud.internal_api.models import (
AssignmentModel,
LeaseRenewalResponse,
)
from host_agent.config import HostAgentConfig
from host_agent.lease import ActiveAssignmentRunner
from host_agent.progress import TaskProgressHolder, TaskProgressSnapshot
# --------------------------------------------------------------------------- #
# Helpers
# --------------------------------------------------------------------------- #
def _assignment(
*,
lease_expires_at: datetime | None = None,
) -> AssignmentModel:
return AssignmentModel(
task_id="task-progress",
attempt=1,
lease_id="lease-1",
lease_expires_at=lease_expires_at or datetime.now(UTC) + timedelta(minutes=5),
host_id="host-a",
device_id="device-a",
goal="do something",
)
class _FakeExecutor:
"""Stub executor that optionally fires a progress callback then blocks."""
def __init__(
self,
*,
fire_progress_before_block: bool = False,
progress_payload: tuple[int, str, str] = (1, "running", "step 1 summary"),
block_event: Event | None = None,
) -> None:
self._fire = fire_progress_before_block
self._payload = progress_payload
self._block_event = block_event or Event()
self._progress = TaskProgressHolder()
def execute(
self,
assignment: AssignmentModel,
*,
should_stop: Any | None = None,
) -> Any:
from host_agent.assignment import AssignmentExecutionResult
if self._fire:
self._progress.update(*self._payload)
# Block until test signals completion.
self._block_event.wait(timeout=5)
return AssignmentExecutionResult(status="done")
def latest_progress(self) -> TaskProgressSnapshot | None:
return self._progress.snapshot()
class _RecordingClient:
"""Fake HostAgentClient that records each renew() call's progress argument."""
def __init__(self) -> None:
self.renew_calls: list[TaskProgressSnapshot | None] = []
self._call_count = 0
async def renew(
self,
assignment: AssignmentModel,
*,
progress: TaskProgressSnapshot | None = None,
) -> LeaseRenewalResponse:
self.renew_calls.append(progress)
self._call_count += 1
return LeaseRenewalResponse(
status="renewed",
lease_expires_at=datetime.now(UTC) + timedelta(minutes=5),
)
def _runner(
client: _RecordingClient, executor: _FakeExecutor
) -> ActiveAssignmentRunner:
return ActiveAssignmentRunner(client, executor, now=lambda: datetime.now(UTC))
# --------------------------------------------------------------------------- #
# Renewal includes progress after a step completes
# --------------------------------------------------------------------------- #
def test_renewal_includes_progress_after_step(tmp_path) -> None:
block_event = Event()
executor = _FakeExecutor(
fire_progress_before_block=True,
progress_payload=(1, "running", "step 1 summary"),
block_event=block_event,
)
client = _RecordingClient()
runner = _runner(client, executor)
# Set a very short lease so renewal fires quickly.
assignment = _assignment(
lease_expires_at=datetime.now(UTC) + timedelta(milliseconds=50),
)
async def _drive() -> None:
# Start the runner; execution thread will fire progress then block.
task = asyncio.create_task(runner.run(assignment))
# Wait for at least one renewal to happen.
for _ in range(50):
await asyncio.sleep(0.05)
if client.renew_calls:
break
# Signal the executor to complete.
block_event.set()
await task
asyncio.run(_drive())
assert len(client.renew_calls) > 0
# At least one renewal should have non-None progress.
progress_renewals = [p for p in client.renew_calls if p is not None]
assert len(progress_renewals) > 0
snap = progress_renewals[0]
assert snap.step_index == 1
assert snap.step_status == "running"
assert "step 1 summary" in snap.summary
# --------------------------------------------------------------------------- #
# Renewal omits progress before any step completes
# --------------------------------------------------------------------------- #
def test_renewal_omits_progress_before_any_step(tmp_path) -> None:
block_event = Event()
executor = _FakeExecutor(
fire_progress_before_block=False,
block_event=block_event,
)
client = _RecordingClient()
runner = _runner(client, executor)
assignment = _assignment(
lease_expires_at=datetime.now(UTC) + timedelta(milliseconds=50),
)
async def _drive() -> None:
task = asyncio.create_task(runner.run(assignment))
for _ in range(50):
await asyncio.sleep(0.05)
if client.renew_calls:
break
block_event.set()
await task
asyncio.run(_drive())
assert len(client.renew_calls) > 0
# No progress should have been reported.
assert all(p is None for p in client.renew_calls)
# --------------------------------------------------------------------------- #
# Oversized summary truncated client-side before sending
# --------------------------------------------------------------------------- #
def test_oversized_summary_truncated_client_side() -> None:
"""HostAgentClient.renew truncates summary to <=500 chars before serializing."""
from host_agent.client import HostAgentClient
captured_payload: dict[str, Any] = {}
def _handler(request: httpx.Request) -> httpx.Response:
import json
body = json.loads(request.content.decode("utf-8"))
captured_payload.update(body)
return httpx.Response(
200,
json={
"status": "renewed",
"lease_expires_at": (
datetime.now(UTC) + timedelta(minutes=5)
).isoformat(),
},
)
transport = httpx.MockTransport(_handler)
config = HostAgentConfig(
control_plane_url="https://control.example",
host_id="host-a",
token="secret",
retry_backoff_seconds=0.01,
max_retry_attempts=1,
)
http_client = httpx.AsyncClient(
base_url=config.control_plane_url, transport=transport
)
client = HostAgentClient(config, http_client=http_client)
long_summary = "x" * 2000
snapshot = TaskProgressSnapshot(
step_index=1,
step_status="running",
summary=long_summary,
updated_at=datetime.now(UTC),
)
asyncio.run(client.renew(_assignment(), progress=snapshot))
assert "progress" in captured_payload
assert captured_payload["progress"] is not None
serialized_summary = captured_payload["progress"]["summary"]
assert len(serialized_summary) <= 500
assert serialized_summary == "x" * 500
asyncio.run(http_client.aclose())
+290
View File
@@ -0,0 +1,290 @@
"""Tests for Host Agent task storage, timeline, retention, and collision avoidance (task 1.5)."""
from __future__ import annotations
from datetime import UTC, datetime, timedelta
from pathlib import Path
from core.models import Task
from host_agent.config import HostAgentConfig
from host_agent.retention import prune_task_history
from storage.artifact_store import ArtifactStore
from storage.task_metadata import TaskMetadataStore
from storage.timeline import Timeline
from tests.fakes import PNG_10X20
def _make_task(
task_id: str,
*,
goal: str = "test goal",
device_id: str = "iphone-1",
created_at: datetime | None = None,
status: str = "created",
) -> Task:
ts = created_at or datetime(2026, 7, 1, tzinfo=UTC)
return Task(
id=task_id,
goal=goal,
device_id=device_id,
status=status, # type: ignore[arg-type]
created_at=ts,
updated_at=ts,
)
# --------------------------------------------------------------------------- #
# Persist during execution
# --------------------------------------------------------------------------- #
def test_step_transitions_persist_during_execution(tmp_path) -> None:
metadata_store = TaskMetadataStore(tmp_path / "tasks.sqlite3")
timeline = Timeline(ArtifactStore(tmp_path / "history"))
task = _make_task("task-persist")
metadata_store.create_task(task)
# Simulate the execution loop writing step transitions.
metadata_store.update_task(task.id, status="running")
timeline.append(
task_id=task.id,
scene={"screen": {"width": 1, "height": 1}, "elements": []},
prompt="step 1",
tool_call={"action": "tap"},
result={"ok": True},
screenshot=PNG_10X20,
)
timeline.append(
task_id=task.id,
scene={"screen": {"width": 1, "height": 1}, "elements": []},
prompt="step 2",
tool_call={"action": "swipe"},
result={"ok": True},
screenshot=PNG_10X20,
)
metadata_store.update_task(task.id, status="completed", completed=True)
row = metadata_store.get_task(task.id)
assert row is not None
assert row["status"] == "completed"
assert row["completed_at"] is not None
records = timeline.read(task.id)
assert len(records) == 2
assert [r["index"] for r in records] == [1, 2]
# --------------------------------------------------------------------------- #
# Queryable after completion (in-memory Task discarded)
# --------------------------------------------------------------------------- #
def test_task_history_queryable_after_in_memory_task_discarded(tmp_path) -> None:
db_path = tmp_path / "tasks.sqlite3"
history_dir = tmp_path / "history"
metadata_store = TaskMetadataStore(db_path)
timeline = Timeline(ArtifactStore(history_dir))
task = _make_task("task-survive")
metadata_store.create_task(task)
metadata_store.update_task(task.id, status="running")
timeline.append(
task_id=task.id,
scene={"screen": {"width": 1, "height": 1}, "elements": []},
prompt="do something",
tool_call={"action": "tap"},
result={"ok": True},
screenshot=PNG_10X20,
)
metadata_store.update_task(task.id, status="completed", completed=True)
# Drop ALL in-memory references.
del metadata_store
del timeline
del task
# Re-open from the same paths.
reopened_store = TaskMetadataStore(db_path)
reopened_timeline = Timeline(ArtifactStore(history_dir))
row = reopened_store.get_task("task-survive")
assert row is not None
assert row["status"] == "completed"
assert row["goal"] == "test goal"
records = reopened_timeline.read("task-survive")
assert len(records) == 1
assert records[0]["tool_call"] == {"action": "tap"}
# --------------------------------------------------------------------------- #
# Retention prunes tasks beyond configured threshold
# --------------------------------------------------------------------------- #
def _seed_n_tasks(
store: TaskMetadataStore,
n: int,
*,
base: datetime = datetime(2026, 7, 1, tzinfo=UTC),
) -> list[str]:
ids: list[str] = []
for i in range(n):
task = _make_task(f"task-{i:02d}", created_at=base + timedelta(hours=i))
store.create_task(task)
ids.append(task.id)
return ids
def test_retention_prunes_by_max_count(tmp_path) -> None:
metadata_store = TaskMetadataStore(tmp_path / "tasks.sqlite3")
timeline = Timeline(ArtifactStore(tmp_path / "history"))
ids = _seed_n_tasks(metadata_store, 10)
config = HostAgentConfig(
control_plane_url="https://control.example",
task_retention_max_count=5,
task_retention_max_age_days=365, # large so count is the binding constraint
)
pruned = prune_task_history(
metadata_store,
timeline,
config=config,
now=datetime(2026, 7, 1, tzinfo=UTC) + timedelta(days=1),
)
assert pruned == 5
remaining = metadata_store.list_task_ids()
assert len(remaining) == 5
# list_tasks is ordered by created_at desc, so the 5 newest survive.
expected_survivors = set(ids[5:])
assert set(remaining) == expected_survivors
def test_retention_prunes_by_max_age(tmp_path) -> None:
metadata_store = TaskMetadataStore(tmp_path / "tasks.sqlite3")
timeline = Timeline(ArtifactStore(tmp_path / "history"))
base = datetime(2026, 7, 1, tzinfo=UTC)
_seed_n_tasks(metadata_store, 10, base=base)
config = HostAgentConfig(
control_plane_url="https://control.example",
task_retention_max_count=100, # large so age is the binding constraint
task_retention_max_age_days=3,
)
# "now" is 5 days after base; tasks 0..2 are within 3 days of now-5d... actually:
# tasks created at base+0h .. base+9h. now = base + 5 days.
# cutoff = now - 3 days = base + 2 days. Tasks with created_at >= base+2d survive.
# Only tasks created at base+0h..base+9h -- all are < base+2d, so all pruned.
# Let's use a now that's closer.
now = base + timedelta(hours=5)
pruned = prune_task_history(metadata_store, timeline, config=config, now=now)
# cutoff = now - 3 days. All tasks are within 3 days. None pruned by age.
# Actually max_count=100 so nothing pruned at all.
# Let's redo: use max_age_days to actually prune older tasks.
assert pruned == 0 # all tasks are younger than 3 days
# Now use a stricter age that prunes some.
config_strict = HostAgentConfig(
control_plane_url="https://control.example",
task_retention_max_count=100,
task_retention_max_age_days=1,
)
# cutoff = now - 1 day = base + 5h - 24h = base - 19h. Still all tasks are after that.
# So we need a much later "now".
late_now = base + timedelta(days=10)
pruned2 = prune_task_history(
metadata_store, timeline, config=config_strict, now=late_now
)
# All tasks are ~10 days old, max_age=1 day. All pruned.
assert pruned2 == 10
assert metadata_store.list_task_ids() == []
def test_retention_whichever_keeps_fewer_wins(tmp_path) -> None:
"""The more restrictive bound between max_count and max_age wins."""
metadata_store = TaskMetadataStore(tmp_path / "tasks.sqlite3")
timeline = Timeline(ArtifactStore(tmp_path / "history"))
base = datetime(2026, 7, 1, tzinfo=UTC)
_seed_n_tasks(metadata_store, 10, base=base)
# max_count=8 would keep 8, max_age=2 days from base+3d would keep tasks
# created within 2 days of base+3d = base+1d..base+9h. Only tasks at base+0h..base+9h.
# cutoff = base+3d - 2d = base+1d = base+24h. Tasks created at base+0h..base+9h
# are all < base+24h, so keep_by_age = {} (0 tasks). keep_by_count = newest 8.
# len(keep_by_age)=0 <= len(keep_by_count)=8, so keep_by_age wins -> 0 kept, all pruned.
config = HostAgentConfig(
control_plane_url="https://control.example",
task_retention_max_count=8,
task_retention_max_age_days=2,
)
now = base + timedelta(days=3)
pruned = prune_task_history(metadata_store, timeline, config=config, now=now)
assert pruned == 10
assert metadata_store.list_task_ids() == []
def test_retention_deletes_timeline_alongside_metadata(tmp_path) -> None:
metadata_store = TaskMetadataStore(tmp_path / "tasks.sqlite3")
timeline = Timeline(ArtifactStore(tmp_path / "history"))
# Create a task with timeline records.
task = _make_task("task-to-prune")
metadata_store.create_task(task)
timeline.append(
task_id=task.id,
scene={"screen": {"width": 1, "height": 1}, "elements": []},
prompt="step",
tool_call={"action": "tap"},
result={"ok": True},
screenshot=PNG_10X20,
)
assert len(timeline.read(task.id)) == 1
config = HostAgentConfig(
control_plane_url="https://control.example",
task_retention_max_count=0,
task_retention_max_age_days=365,
)
pruned = prune_task_history(
metadata_store,
timeline,
config=config,
now=datetime(2026, 7, 1, tzinfo=UTC),
)
assert pruned == 1
assert metadata_store.get_task(task.id) is None
assert timeline.read(task.id) == []
# --------------------------------------------------------------------------- #
# No collision between Host Agent and Runtime default paths
# --------------------------------------------------------------------------- #
def test_host_agent_default_db_path_differs_from_runtime_default() -> None:
host_agent_config = HostAgentConfig(control_plane_url="https://control.example")
# Use posix paths for cross-platform comparison.
assert host_agent_config.task_progress_db_path.as_posix() == (
"host_agent_data/task_progress.sqlite3"
)
# Runtime's TaskMetadataStore default.
runtime_default = TaskMetadataStore.__init__.__defaults__[0]
assert Path(runtime_default).as_posix() == "tasks/tasks.sqlite3"
assert host_agent_config.task_progress_db_path != Path(runtime_default)
def test_two_metadata_stores_at_different_paths_are_isolated(tmp_path) -> None:
store_a = TaskMetadataStore(tmp_path / "a.sqlite3")
store_b = TaskMetadataStore(tmp_path / "b.sqlite3")
task_a = _make_task("task-in-a")
store_a.create_task(task_a)
assert store_a.get_task("task-in-a") is not None
assert store_b.get_task("task-in-a") is None
assert "task-in-a" not in store_b.list_task_ids()
assert "task-in-a" in store_a.list_task_ids()
+21
View File
@@ -31,3 +31,24 @@ def test_timeline_records_survive_reopening_store(tmp_path) -> None:
assert [record["index"] for record in records] == [1, 2] assert [record["index"] for record in records] == [1, 2]
assert records[0]["screenshot_path"].endswith("001.png") assert records[0]["screenshot_path"].endswith("001.png")
def test_timeline_records_per_step_prompt_not_task_goal(tmp_path) -> None:
"""The prompt field should persist exactly what was passed to append(),
not a pre-D9 task goal fallback."""
store = ArtifactStore(tmp_path / "history")
timeline = Timeline(store)
per_step_prompt = "Goal:\nsend a message\n\nCurrent Scene (JSON):\n{...}\n\nCall exactly one tool."
timeline.append(
task_id="task-42",
scene={"screen": {"width": 1, "height": 1}, "elements": []},
prompt=per_step_prompt,
tool_call={"action": "tap"},
result={"ok": True},
screenshot=PNG_10X20,
)
records = timeline.read("task-42")
assert len(records) == 1
assert records[0]["prompt"] == per_step_prompt
assert "Call exactly one tool" in records[0]["prompt"]
+139 -29
View File
@@ -20,7 +20,9 @@ from tests.fakes import PNG_10X20
class FakeMessages: class FakeMessages:
def __init__(self, *, response: object | None = None, error: Exception | None = None) -> None: def __init__(
self, *, response: object | None = None, error: Exception | None = None
) -> None:
self.response = response self.response = response
self.error = error self.error = error
self.calls: list[dict[str, Any]] = [] self.calls: list[dict[str, Any]] = []
@@ -38,7 +40,9 @@ class FakeTransport:
class FakeCompletions: class FakeCompletions:
def __init__(self, *, response: object | None = None, error: Exception | None = None) -> None: def __init__(
self, *, response: object | None = None, error: Exception | None = None
) -> None:
self.response = response self.response = response
self.error = error self.error = error
self.calls: list[dict[str, Any]] = [] self.calls: list[dict[str, Any]] = []
@@ -65,9 +69,13 @@ class FakeOpenAITransport:
def test_anthropic_tool_calling_client_sends_forced_single_tool_call_request() -> None: def test_anthropic_tool_calling_client_sends_forced_single_tool_call_request() -> None:
messages = FakeMessages( messages = FakeMessages(
response={"content": [{"type": "tool_use", "name": "tap", "input": {"x": 1, "y": 2}}]} response={
"content": [{"type": "tool_use", "name": "tap", "input": {"x": 1, "y": 2}}]
}
)
client = AnthropicToolCallingClient(
model="test-model", transport=FakeTransport(messages)
) )
client = AnthropicToolCallingClient(model="test-model", transport=FakeTransport(messages))
decision = client.decide( decision = client.decide(
system_prompt="system", system_prompt="system",
@@ -77,14 +85,23 @@ def test_anthropic_tool_calling_client_sends_forced_single_tool_call_request() -
timeout=2.5, timeout=2.5,
) )
assert decision == ToolCallDecision(tool_name="tap", arguments={"x": 1, "y": 2}) assert decision == ToolCallDecision(
tool_name="tap",
arguments={"x": 1, "y": 2},
system_prompt="system",
user_prompt="user",
)
assert len(messages.calls) == 1 assert len(messages.calls) == 1
call = messages.calls[0] call = messages.calls[0]
assert call["model"] == "test-model" assert call["model"] == "test-model"
assert call["timeout"] == 2.5 assert call["timeout"] == 2.5
assert call["tool_choice"] == {"type": "any", "disable_parallel_tool_use": True} assert call["tool_choice"] == {"type": "any", "disable_parallel_tool_use": True}
assert call["tools"] == [ assert call["tools"] == [
{"name": "tap", "description": TAP_SPEC.description, "input_schema": TAP_SPEC.parameters}, {
"name": "tap",
"description": TAP_SPEC.description,
"input_schema": TAP_SPEC.parameters,
},
{ {
"name": "finish_task", "name": "finish_task",
"description": FINISH_TASK_SPEC.description, "description": FINISH_TASK_SPEC.description,
@@ -93,18 +110,28 @@ def test_anthropic_tool_calling_client_sends_forced_single_tool_call_request() -
] ]
assert call["system"][0]["text"] == "system" assert call["system"][0]["text"] == "system"
assert call["system"][0]["cache_control"] == {"type": "ephemeral"} assert call["system"][0]["cache_control"] == {"type": "ephemeral"}
assert call["messages"] == [{"role": "user", "content": [{"type": "text", "text": "user"}]}] assert call["messages"] == [
{"role": "user", "content": [{"type": "text", "text": "user"}]}
]
def test_anthropic_tool_calling_client_includes_image_block_when_screenshot_present() -> None: def test_anthropic_tool_calling_client_includes_image_block_when_screenshot_present() -> (
None
):
messages = FakeMessages( messages = FakeMessages(
response={ response={
"content": [ "content": [
{"type": "tool_use", "name": "finish_task", "input": {"success": True, "reason": "done"}} {
"type": "tool_use",
"name": "finish_task",
"input": {"success": True, "reason": "done"},
}
] ]
} }
) )
client = AnthropicToolCallingClient(model="test-model", transport=FakeTransport(messages)) client = AnthropicToolCallingClient(
model="test-model", transport=FakeTransport(messages)
)
client.decide( client.decide(
system_prompt="system", system_prompt="system",
@@ -157,10 +184,18 @@ def test_anthropic_tool_calling_client_passes_custom_base_url_to_sdk(
def test_anthropic_tool_calling_client_wraps_transport_errors() -> None: def test_anthropic_tool_calling_client_wraps_transport_errors() -> None:
messages = FakeMessages(error=TimeoutError("timed out")) messages = FakeMessages(error=TimeoutError("timed out"))
client = AnthropicToolCallingClient(model="test-model", transport=FakeTransport(messages)) client = AnthropicToolCallingClient(
model="test-model", transport=FakeTransport(messages)
)
with pytest.raises(ToolCallUnavailable): with pytest.raises(ToolCallUnavailable):
client.decide(system_prompt="s", user_prompt="u", screenshot=None, tools=[TAP_SPEC], timeout=1) client.decide(
system_prompt="s",
user_prompt="u",
screenshot=None,
tools=[TAP_SPEC],
timeout=1,
)
@pytest.mark.parametrize( @pytest.mark.parametrize(
@@ -171,12 +206,22 @@ def test_anthropic_tool_calling_client_wraps_transport_errors() -> None:
{"content": [{"type": "tool_use", "name": "tap", "input": "not-a-dict"}]}, {"content": [{"type": "tool_use", "name": "tap", "input": "not-a-dict"}]},
], ],
) )
def test_anthropic_tool_calling_client_wraps_malformed_responses(response: object) -> None: def test_anthropic_tool_calling_client_wraps_malformed_responses(
response: object,
) -> None:
messages = FakeMessages(response=response) messages = FakeMessages(response=response)
client = AnthropicToolCallingClient(model="test-model", transport=FakeTransport(messages)) client = AnthropicToolCallingClient(
model="test-model", transport=FakeTransport(messages)
)
with pytest.raises(ToolCallUnavailable): with pytest.raises(ToolCallUnavailable):
client.decide(system_prompt="s", user_prompt="u", screenshot=None, tools=[TAP_SPEC], timeout=1) client.decide(
system_prompt="s",
user_prompt="u",
screenshot=None,
tools=[TAP_SPEC],
timeout=1,
)
# --- OpenAI -------------------------------------------------------------- # --- OpenAI --------------------------------------------------------------
@@ -186,11 +231,24 @@ def test_openai_tool_calling_client_sends_forced_single_tool_call_request() -> N
completions = FakeCompletions( completions = FakeCompletions(
response={ response={
"choices": [ "choices": [
{"message": {"tool_calls": [{"function": {"name": "tap", "arguments": '{"x": 1, "y": 2}'}}]}} {
"message": {
"tool_calls": [
{
"function": {
"name": "tap",
"arguments": '{"x": 1, "y": 2}',
}
}
]
}
}
] ]
} }
) )
client = OpenAIToolCallingClient(model="test-model", transport=FakeOpenAITransport(completions)) client = OpenAIToolCallingClient(
model="test-model", transport=FakeOpenAITransport(completions)
)
decision = client.decide( decision = client.decide(
system_prompt="system", system_prompt="system",
@@ -200,7 +258,12 @@ def test_openai_tool_calling_client_sends_forced_single_tool_call_request() -> N
timeout=2.5, timeout=2.5,
) )
assert decision == ToolCallDecision(tool_name="tap", arguments={"x": 1, "y": 2}) assert decision == ToolCallDecision(
tool_name="tap",
arguments={"x": 1, "y": 2},
system_prompt="system",
user_prompt="user",
)
assert len(completions.calls) == 1 assert len(completions.calls) == 1
call = completions.calls[0] call = completions.calls[0]
assert call["model"] == "test-model" assert call["model"] == "test-model"
@@ -233,7 +296,9 @@ def test_openai_tool_calling_client_sends_forced_single_tool_call_request() -> N
] ]
def test_openai_tool_calling_client_includes_image_block_when_screenshot_present() -> None: def test_openai_tool_calling_client_includes_image_block_when_screenshot_present() -> (
None
):
completions = FakeCompletions( completions = FakeCompletions(
response={ response={
"choices": [ "choices": [
@@ -252,7 +317,9 @@ def test_openai_tool_calling_client_includes_image_block_when_screenshot_present
] ]
} }
) )
client = OpenAIToolCallingClient(model="test-model", transport=FakeOpenAITransport(completions)) client = OpenAIToolCallingClient(
model="test-model", transport=FakeOpenAITransport(completions)
)
client.decide( client.decide(
system_prompt="system", system_prompt="system",
@@ -275,22 +342,47 @@ def test_openai_tool_calling_client_includes_image_block_when_screenshot_present
def test_openai_tool_calling_client_accepts_arguments_already_as_dict() -> None: def test_openai_tool_calling_client_accepts_arguments_already_as_dict() -> None:
completions = FakeCompletions( completions = FakeCompletions(
response={ response={
"choices": [{"message": {"tool_calls": [{"function": {"name": "tap", "arguments": {"x": 1, "y": 2}}}]}}] "choices": [
{
"message": {
"tool_calls": [
{"function": {"name": "tap", "arguments": {"x": 1, "y": 2}}}
]
}
}
]
} }
) )
client = OpenAIToolCallingClient(model="test-model", transport=FakeOpenAITransport(completions)) client = OpenAIToolCallingClient(
model="test-model", transport=FakeOpenAITransport(completions)
)
decision = client.decide(system_prompt="s", user_prompt="u", screenshot=None, tools=[TAP_SPEC], timeout=1) decision = client.decide(
system_prompt="s", user_prompt="u", screenshot=None, tools=[TAP_SPEC], timeout=1
)
assert decision == ToolCallDecision(tool_name="tap", arguments={"x": 1, "y": 2}) assert decision == ToolCallDecision(
tool_name="tap",
arguments={"x": 1, "y": 2},
system_prompt="s",
user_prompt="u",
)
def test_openai_tool_calling_client_wraps_transport_errors() -> None: def test_openai_tool_calling_client_wraps_transport_errors() -> None:
completions = FakeCompletions(error=TimeoutError("timed out")) completions = FakeCompletions(error=TimeoutError("timed out"))
client = OpenAIToolCallingClient(model="test-model", transport=FakeOpenAITransport(completions)) client = OpenAIToolCallingClient(
model="test-model", transport=FakeOpenAITransport(completions)
)
with pytest.raises(ToolCallUnavailable): with pytest.raises(ToolCallUnavailable):
client.decide(system_prompt="s", user_prompt="u", screenshot=None, tools=[TAP_SPEC], timeout=1) client.decide(
system_prompt="s",
user_prompt="u",
screenshot=None,
tools=[TAP_SPEC],
timeout=1,
)
@pytest.mark.parametrize( @pytest.mark.parametrize(
@@ -298,15 +390,33 @@ def test_openai_tool_calling_client_wraps_transport_errors() -> None:
[ [
{"choices": []}, {"choices": []},
{"choices": [{"message": {"tool_calls": []}}]}, {"choices": [{"message": {"tool_calls": []}}]},
{"choices": [{"message": {"tool_calls": [{"function": {"name": "tap", "arguments": "not-json"}}]}}]}, {
"choices": [
{
"message": {
"tool_calls": [
{"function": {"name": "tap", "arguments": "not-json"}}
]
}
}
]
},
], ],
) )
def test_openai_tool_calling_client_wraps_malformed_responses(response: object) -> None: def test_openai_tool_calling_client_wraps_malformed_responses(response: object) -> None:
completions = FakeCompletions(response=response) completions = FakeCompletions(response=response)
client = OpenAIToolCallingClient(model="test-model", transport=FakeOpenAITransport(completions)) client = OpenAIToolCallingClient(
model="test-model", transport=FakeOpenAITransport(completions)
)
with pytest.raises(ToolCallUnavailable): with pytest.raises(ToolCallUnavailable):
client.decide(system_prompt="s", user_prompt="u", screenshot=None, tools=[TAP_SPEC], timeout=1) client.decide(
system_prompt="s",
user_prompt="u",
screenshot=None,
tools=[TAP_SPEC],
timeout=1,
)
# --- build_client ---------------------------------------------------------- # --- build_client ----------------------------------------------------------