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:
@@ -187,6 +187,19 @@ def create_app(
|
||||
),
|
||||
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.startup_complete = True
|
||||
@@ -442,3 +455,28 @@ async def _wait_for_stop(stop: asyncio.Event, interval_seconds: float) -> bool:
|
||||
except TimeoutError:
|
||||
return False
|
||||
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
|
||||
|
||||
@@ -22,10 +22,14 @@ from host_agent.lease import ActiveAssignmentRunner
|
||||
from host_agent.local_account import LocalAccountStore
|
||||
from host_agent.policy_cache import HostPolicyCacheStore
|
||||
from host_agent.processor import AssignmentProcessingResult, AssignmentProcessor
|
||||
from host_agent.retention import prune_task_history
|
||||
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
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -178,6 +182,16 @@ def create_application(
|
||||
console_enrollment_client: HostAgentEnrollmentClient | None = None
|
||||
if resolved_config.enrollment_managed:
|
||||
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(
|
||||
config=resolved_config,
|
||||
manager=resolved_manager,
|
||||
@@ -190,6 +204,9 @@ def create_application(
|
||||
ttl_seconds=resolved_config.console_session_ttl_seconds
|
||||
),
|
||||
enrollment_client=console_enrollment_client,
|
||||
metadata_store=metadata_store,
|
||||
timeline=timeline,
|
||||
executor=executor,
|
||||
)
|
||||
console_server = _EmbeddedConsoleServer(
|
||||
uvicorn.Config(
|
||||
@@ -215,19 +232,18 @@ def create_application(
|
||||
revision=revision
|
||||
),
|
||||
)
|
||||
executor = AssignmentExecutor(
|
||||
create_execution_factories(
|
||||
resolved_manager,
|
||||
host_agent_config=resolved_config,
|
||||
)
|
||||
)
|
||||
active_runner = ActiveAssignmentRunner(client, executor)
|
||||
processor = AssignmentProcessor(
|
||||
client,
|
||||
active_runner,
|
||||
status_tracker=status_tracker,
|
||||
on_result=lambda assignment, result: _record_assignment_history(
|
||||
history_store, assignment, result
|
||||
on_result=lambda assignment, result: _on_assignment_finished(
|
||||
history_store,
|
||||
metadata_store,
|
||||
timeline,
|
||||
resolved_config,
|
||||
assignment,
|
||||
result,
|
||||
),
|
||||
)
|
||||
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(
|
||||
history_store: ConsoleHistoryStore,
|
||||
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(
|
||||
config_store: DeviceConfigStore,
|
||||
*,
|
||||
|
||||
@@ -8,6 +8,7 @@ from cloud.internal_api.models import AssignmentModel
|
||||
from core.models import Task
|
||||
from host_agent.execution import ExecutionFactories
|
||||
from host_agent.planner_context import bind_planner_execution_context
|
||||
from host_agent.progress import TaskProgressHolder, TaskProgressSnapshot
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -20,6 +21,11 @@ class AssignmentExecutionResult:
|
||||
class AssignmentExecutor:
|
||||
def __init__(self, factories: ExecutionFactories) -> None:
|
||||
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(
|
||||
self,
|
||||
@@ -27,6 +33,7 @@ class AssignmentExecutor:
|
||||
*,
|
||||
should_stop: Callable[[], bool] | None = None,
|
||||
) -> AssignmentExecutionResult:
|
||||
self._progress.clear()
|
||||
with bind_planner_execution_context(assignment):
|
||||
if should_stop is not None and should_stop():
|
||||
return AssignmentExecutionResult(
|
||||
@@ -50,6 +57,7 @@ class AssignmentExecutor:
|
||||
) -> AssignmentExecutionResult:
|
||||
task = Task(goal=assignment.goal or "", device_id=assignment.device_id)
|
||||
runner = self.factories.task_runner_factory()
|
||||
runner.on_step_progress = self._progress.update
|
||||
if should_stop is None:
|
||||
completed = runner.run(task)
|
||||
else:
|
||||
|
||||
@@ -16,9 +16,13 @@ from cloud.internal_api.models import (
|
||||
HostEnrollmentResponse,
|
||||
HostTaskSubmissionResponse,
|
||||
LeaseRenewalResponse,
|
||||
TaskProgressModel,
|
||||
TerminalResultResponse,
|
||||
)
|
||||
from host_agent.config import HostAgentConfig
|
||||
from host_agent.progress import TaskProgressSnapshot
|
||||
|
||||
_VALID_STEP_STATUSES = frozenset({"running", "completed", "failed"})
|
||||
|
||||
|
||||
class HostAgentAPIError(RuntimeError):
|
||||
@@ -194,19 +198,33 @@ class HostAgentClient:
|
||||
async def renew(
|
||||
self,
|
||||
assignment: AssignmentModel,
|
||||
*,
|
||||
progress: TaskProgressSnapshot | None = None,
|
||||
) -> 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(
|
||||
"POST",
|
||||
(
|
||||
f"/internal/v1/hosts/{self.config.host_id}/assignments/"
|
||||
f"{assignment.task_id}/renew"
|
||||
),
|
||||
json={
|
||||
"host_id": self.config.host_id,
|
||||
"task_id": assignment.task_id,
|
||||
"attempt": assignment.attempt,
|
||||
"lease_id": assignment.lease_id,
|
||||
},
|
||||
json=payload,
|
||||
)
|
||||
return LeaseRenewalResponse.model_validate(response.json())
|
||||
|
||||
|
||||
@@ -43,6 +43,10 @@ class HostAgentConfig:
|
||||
runtime_host: str = "127.0.0.1"
|
||||
runtime_port: int = 8000
|
||||
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(
|
||||
@@ -135,6 +139,23 @@ def load_host_agent_config(
|
||||
dependency_restart_max_attempts=_positive_int(
|
||||
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:
|
||||
raise HostAgentConfigurationError(
|
||||
|
||||
@@ -11,6 +11,7 @@ import httpx
|
||||
from cloud.internal_api.models import AssignmentModel
|
||||
from host_agent.assignment import AssignmentExecutionResult
|
||||
from host_agent.client import HostAgentAPIError, HostAgentClient, StaleLeaseError
|
||||
from host_agent.progress import TaskProgressSnapshot
|
||||
|
||||
|
||||
class InterruptibleAssignmentExecutor(Protocol):
|
||||
@@ -21,6 +22,8 @@ class InterruptibleAssignmentExecutor(Protocol):
|
||||
should_stop: Callable[[], bool] | None = None,
|
||||
) -> AssignmentExecutionResult: ...
|
||||
|
||||
def latest_progress(self) -> TaskProgressSnapshot | None: ...
|
||||
|
||||
|
||||
class LeaseGuard:
|
||||
def __init__(self) -> None:
|
||||
@@ -92,7 +95,9 @@ class ActiveAssignmentRunner:
|
||||
if done:
|
||||
return
|
||||
try:
|
||||
response = await self.client.renew(assignment)
|
||||
response = await self.client.renew(
|
||||
assignment, progress=self.executor.latest_progress()
|
||||
)
|
||||
except StaleLeaseError:
|
||||
guard.mark_lost("lease rejected by control plane")
|
||||
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 cloud.internal_api.models import AssignmentModel
|
||||
from host_agent.progress import TaskProgressSnapshot
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Callable
|
||||
@@ -34,6 +35,7 @@ class AgentStatusTracker:
|
||||
self._current_assignment: _CurrentAssignment | None = None
|
||||
self._last_heartbeat: _LastHeartbeat | None = None
|
||||
self._host_policy: dict[str, Any] | None = None
|
||||
self._latest_progress: TaskProgressSnapshot | None = None
|
||||
|
||||
def mark_assignment_started(self, assignment: AssignmentModel) -> None:
|
||||
with self._lock:
|
||||
@@ -48,6 +50,11 @@ class AgentStatusTracker:
|
||||
def mark_assignment_finished(self) -> None:
|
||||
with self._lock:
|
||||
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:
|
||||
with self._lock:
|
||||
@@ -74,6 +81,7 @@ class AgentStatusTracker:
|
||||
with self._lock:
|
||||
current_assignment = self._current_assignment
|
||||
last_heartbeat = self._last_heartbeat
|
||||
progress = self._latest_progress
|
||||
return {
|
||||
"current_assignment": (
|
||||
{
|
||||
@@ -96,4 +104,14 @@ class AgentStatusTracker:
|
||||
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
|
||||
),
|
||||
}
|
||||
|
||||
@@ -1,14 +1,17 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
import json
|
||||
from html import escape as _escape
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from fastapi import Depends, FastAPI, HTTPException, Request
|
||||
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse, Response
|
||||
|
||||
from device.manager import DeviceManager
|
||||
from host_agent.assignment import AssignmentExecutor
|
||||
from host_agent.client import HostAgentEnrollmentClient
|
||||
from host_agent.config import HostAgentConfig
|
||||
from host_agent.devices import register_local_device, unregister_local_device
|
||||
@@ -23,6 +26,8 @@ from host_agent.web.auth import (
|
||||
change_password,
|
||||
)
|
||||
from storage.device_config import DeviceConfigStore
|
||||
from storage.task_metadata import TaskMetadataStore
|
||||
from storage.timeline import Timeline
|
||||
|
||||
SESSION_COOKIE_NAME = "host_console_session"
|
||||
CSRF_HEADER_NAME = "X-CSRF-Token"
|
||||
@@ -58,6 +63,7 @@ def _chrome(title: str, body_html: str, *, session: SessionState | None) -> str:
|
||||
<nav>
|
||||
<a href="/">Status</a>
|
||||
<a href="/devices">Devices</a>
|
||||
<a href="/tasks">Tasks</a>
|
||||
<a href="/account">Account</a>
|
||||
<a href="/history">History</a>
|
||||
<form class="inline" method="post" action="/logout">
|
||||
@@ -142,11 +148,19 @@ def _dashboard_body(
|
||||
if assignment
|
||||
else "none"
|
||||
)
|
||||
progress = snapshot.get("progress")
|
||||
progress_text = (
|
||||
f"step {progress['step_index']} — {progress['step_status']}: {progress['summary']}"
|
||||
if progress
|
||||
else ""
|
||||
)
|
||||
policy_text = (
|
||||
"revision {revision}; self-submission {self_submission}; "
|
||||
"max active tasks {max_active}; daily token budget {daily_budget}".format(
|
||||
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",
|
||||
daily_budget=policy["daily_token_budget"] or "unmetered",
|
||||
)
|
||||
@@ -178,6 +192,7 @@ def _dashboard_body(
|
||||
<section>
|
||||
<h2>Current assignment</h2>
|
||||
<p id="current-assignment">{escape(assignment_text)}</p>
|
||||
<p id="current-progress">{escape(progress_text)}</p>
|
||||
</section>
|
||||
<section>
|
||||
<h2>Devices</h2>
|
||||
@@ -197,6 +212,10 @@ def _dashboard_body(
|
||||
document.getElementById("current-assignment").textContent = current
|
||||
? current.task_id + " on " + current.device_id + " (started " + current.started_at + ")"
|
||||
: "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;
|
||||
document.getElementById("host-policy").textContent = policy
|
||||
? "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(
|
||||
*,
|
||||
config: HostAgentConfig,
|
||||
@@ -325,6 +426,9 @@ def create_console_app(
|
||||
status_tracker: AgentStatusTracker,
|
||||
session_manager: SessionManager,
|
||||
enrollment_client: HostAgentEnrollmentClient | None,
|
||||
metadata_store: TaskMetadataStore | None = None,
|
||||
timeline: Timeline | None = None,
|
||||
executor: AssignmentExecutor | None = None,
|
||||
) -> FastAPI:
|
||||
app = FastAPI(title="Host Agent Console")
|
||||
cookie_secure = config.console_bind_host not in _LOOPBACK_BIND_HOSTS
|
||||
@@ -416,6 +520,16 @@ def create_console_app(
|
||||
session: SessionState = Depends(require_session),
|
||||
) -> JSONResponse:
|
||||
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")
|
||||
busy_device_id = current_assignment["device_id"] if current_assignment else None
|
||||
devices = [
|
||||
@@ -570,4 +684,34 @@ def create_console_app(
|
||||
body = _history_body(entries)
|
||||
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
|
||||
|
||||
@@ -8,6 +8,8 @@ import type {
|
||||
LlmProviderSettings,
|
||||
LlmProviderType,
|
||||
HostRecord,
|
||||
PlannerDecisionItem,
|
||||
PlannerDecisionListResponse,
|
||||
PluginRecord,
|
||||
PluginRegistrationPayload,
|
||||
TaskAttempt,
|
||||
@@ -232,6 +234,15 @@ export function getTaskAttempts(taskId: string): Promise<TaskAttempt[]> {
|
||||
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 }> {
|
||||
return request<{ task_id: string }>("/v1/tasks", {
|
||||
method: "POST",
|
||||
|
||||
@@ -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" });
|
||||
});
|
||||
});
|
||||
@@ -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" };
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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}` : ""
|
||||
}`;
|
||||
}
|
||||
@@ -17,6 +17,10 @@ export interface TaskListItem {
|
||||
target_host_id: string | null;
|
||||
target_device_id: string | null;
|
||||
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 {
|
||||
@@ -178,3 +182,17 @@ export interface LlmProviderProfileListResponse {
|
||||
settings: LlmProviderSettings;
|
||||
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[];
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { LoaderCircle, RefreshCw } from "@lucide/vue";
|
||||
import {
|
||||
CloudApiError,
|
||||
getTaskAttempts,
|
||||
getTaskPlannerDecisions,
|
||||
listTasks,
|
||||
listDevices,
|
||||
listHosts,
|
||||
@@ -16,7 +17,10 @@ import type {
|
||||
TaskStatus,
|
||||
DeviceRecord,
|
||||
HostRecord,
|
||||
PlannerDecisionItem,
|
||||
} from "../types";
|
||||
import { formatTaskProgress } from "../taskProgress";
|
||||
import { computePlannerHistoryState } from "../plannerHistory";
|
||||
|
||||
const props = defineProps<{ canSubmit: boolean }>();
|
||||
|
||||
@@ -48,6 +52,9 @@ const submitCapabilityTags = ref("");
|
||||
const submitting = ref(false);
|
||||
const hosts = ref<HostRecord[]>([]);
|
||||
const devices = ref<DeviceRecord[]>([]);
|
||||
const plannerDecisions = ref<PlannerDecisionItem[]>([]);
|
||||
const plannerLoading = ref(false);
|
||||
const plannerError = ref("");
|
||||
const availableDevices = computed(() =>
|
||||
devices.value.filter((device) => device.host_id === submitHostId.value),
|
||||
);
|
||||
@@ -75,6 +82,7 @@ async function refresh() {
|
||||
if (!stillPresent) {
|
||||
selectedTask.value = null;
|
||||
attempts.value = [];
|
||||
plannerDecisions.value = [];
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
@@ -129,21 +137,51 @@ async function selectTask(task: TaskListItem) {
|
||||
selectedTask.value = task;
|
||||
attempts.value = [];
|
||||
attemptsError.value = "";
|
||||
plannerDecisions.value = [];
|
||||
plannerError.value = "";
|
||||
attemptsLoading.value = true;
|
||||
|
||||
// Fetch attempts first so we know which attempt numbers exist.
|
||||
let loadedAttempts: TaskAttempt[] = [];
|
||||
try {
|
||||
attempts.value = await getTaskAttempts(task.id);
|
||||
loadedAttempts = await getTaskAttempts(task.id);
|
||||
attempts.value = loadedAttempts;
|
||||
} catch (err) {
|
||||
if (err instanceof CloudApiError && err.status === 404) {
|
||||
// Task was deleted between list and detail load.
|
||||
selectedTask.value = null;
|
||||
await refresh();
|
||||
} else {
|
||||
handleError(err, "failed to load task attempts");
|
||||
attemptsError.value = errorMessage.value;
|
||||
errorMessage.value = "";
|
||||
attemptsLoading.value = false;
|
||||
return;
|
||||
}
|
||||
} finally {
|
||||
handleError(err, "failed to load task attempts");
|
||||
attemptsError.value = errorMessage.value;
|
||||
errorMessage.value = "";
|
||||
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() {
|
||||
selectedTask.value = null;
|
||||
attempts.value = [];
|
||||
plannerDecisions.value = [];
|
||||
plannerError.value = "";
|
||||
}
|
||||
|
||||
watch(statusFilter, () => {
|
||||
@@ -212,6 +252,30 @@ function formatTerminalResult(attempt: TaskAttempt): string {
|
||||
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>
|
||||
|
||||
<template>
|
||||
@@ -305,6 +369,13 @@ function formatTerminalResult(attempt: TaskAttempt): string {
|
||||
<div v-if="task.failure_reason" class="dim">
|
||||
{{ task.failure_reason }}
|
||||
</div>
|
||||
<div
|
||||
v-if="formatTaskProgress(task)"
|
||||
class="task-progress"
|
||||
:title="formatTaskProgress(task) ?? ''"
|
||||
>
|
||||
{{ formatTaskProgress(task) }}
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<div v-if="task.assigned_device_id">
|
||||
@@ -363,6 +434,10 @@ function formatTerminalResult(attempt: TaskAttempt): string {
|
||||
<strong class="text-danger">Failure reason:</strong>
|
||||
{{ selectedTask.failure_reason }}
|
||||
</p>
|
||||
<p v-if="selectedTaskProgress" class="task-progress-detail">
|
||||
<strong>Current step:</strong>
|
||||
<code>{{ selectedTaskProgress }}</code>
|
||||
</p>
|
||||
|
||||
<h3>Attempt history</h3>
|
||||
<div v-if="attemptsLoading" class="muted">loading attempts…</div>
|
||||
@@ -402,6 +477,93 @@ function formatTerminalResult(attempt: TaskAttempt): string {
|
||||
</tbody>
|
||||
</table>
|
||||
<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>
|
||||
</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>
|
||||
|
||||
@@ -121,6 +121,39 @@ HOST_AGENT_CONSOLE_HISTORY_LIMIT=200
|
||||
- `HOST_AGENT_CONSOLE_HISTORY_LIMIT` — number of recent assignment/heartbeat
|
||||
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,
|
||||
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
|
||||
|
||||
@@ -339,6 +339,12 @@ Console 默认连接 `http://127.0.0.1:8000`。已由上面启动脚本连接的
|
||||
`iphone-1` 会出现在设备列表中。不要在 Console 中重复登记同一台设备;当前登记
|
||||
操作只写入配置,不会自动 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
|
||||
|
||||
完成 Appium/WDA 真机验证后,可以把这台 Mac 作为只发起出站连接的边缘 Host。
|
||||
@@ -423,11 +429,28 @@ http://127.0.0.1:8765
|
||||
`DeviceManager` 上生效,无需重启 Host Agent。
|
||||
- 修改密码:更新本地操作账号密码,需要先输入当前密码。
|
||||
- 最近历史:近期 assignment 与 heartbeat 的执行记录。
|
||||
- **任务进度页面**:`http://127.0.0.1:8765/tasks` 展示本机 Host Agent 上已执行/正在执行
|
||||
的任务列表(状态、设备、时间戳),点击任务 ID 可查看逐步 timeline 含截图。
|
||||
|
||||
完整的 `HOST_AGENT_CONSOLE_*` 环境变量列表(端口、非回环 bind 的显式 opt-in、
|
||||
session TTL、历史记录条数上限等)参见 `docs/CLOUD_DEPLOYMENT.md`;生产/远程场景下
|
||||
应优先使用 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:必须按
|
||||
|
||||
@@ -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
|
||||
+38
@@ -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
|
||||
+34
@@ -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
|
||||
+30
@@ -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
|
||||
planner_token_reservation_ceiling: int = 4096
|
||||
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(
|
||||
@@ -127,6 +129,16 @@ def load_control_config(
|
||||
"CLOUD_PLANNER_TOKEN_RESERVATION_TTL_SECONDS",
|
||||
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)
|
||||
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"
|
||||
)
|
||||
if config.environment == "production" and not config.session_cookie_secure:
|
||||
raise CloudConfigurationError(
|
||||
"production requires secure user session cookies"
|
||||
)
|
||||
raise CloudConfigurationError("production requires secure user session cookies")
|
||||
|
||||
|
||||
def _positive_float(
|
||||
|
||||
@@ -106,6 +106,10 @@ class ScheduledTaskRow(Base):
|
||||
)
|
||||
lease_id: 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)
|
||||
result_json: Mapped[str | None] = mapped_column(Text, 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)
|
||||
|
||||
|
||||
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):
|
||||
__tablename__ = "plugins"
|
||||
|
||||
@@ -141,7 +170,9 @@ class PluginRow(Base):
|
||||
class UserRow(Base):
|
||||
__tablename__ = "cloud_users"
|
||||
__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"),
|
||||
)
|
||||
|
||||
@@ -151,7 +182,9 @@ class UserRow(Base):
|
||||
display_name: Mapped[str] = mapped_column(String, nullable=False)
|
||||
password_hash: Mapped[str] = mapped_column(Text, 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(
|
||||
Integer,
|
||||
nullable=False,
|
||||
|
||||
@@ -2,6 +2,7 @@ from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
import json
|
||||
import logging
|
||||
from collections.abc import Awaitable, Callable
|
||||
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.provider_secrets import ProviderSecretConfigurationError
|
||||
from cloud.repository import (
|
||||
AssignmentProgressSnapshot,
|
||||
DeviceEnrollmentConflictError,
|
||||
HostEnrollmentConflictError,
|
||||
)
|
||||
@@ -79,8 +81,11 @@ def create_internal_router(
|
||||
if planner_token_reservation_ceiling <= 0:
|
||||
raise ValueError("planner_token_reservation_ceiling must be greater than zero")
|
||||
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"])
|
||||
|
||||
def authorize_host(request: Request, host_id: str) -> None:
|
||||
principal = auth_provider.authenticate(request)
|
||||
if principal is None:
|
||||
@@ -303,6 +308,14 @@ def create_internal_router(
|
||||
)
|
||||
now = utc_now()
|
||||
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(
|
||||
task_id=task_id,
|
||||
attempt=payload.attempt,
|
||||
@@ -310,6 +323,7 @@ def create_internal_router(
|
||||
host_id=host_id,
|
||||
lease_expires_at=lease_expires_at,
|
||||
now=now,
|
||||
progress=progress_snapshot,
|
||||
)
|
||||
if renewal_status == "not_found":
|
||||
raise HTTPException(
|
||||
@@ -407,7 +421,9 @@ def create_internal_router(
|
||||
resolved_provider = resolved.profile.provider_type
|
||||
resolved_model = resolved.profile.model
|
||||
else:
|
||||
raise LlmProviderResolutionError("no database Provider resolver configured")
|
||||
raise LlmProviderResolutionError(
|
||||
"no database Provider resolver configured"
|
||||
)
|
||||
except (LlmProviderResolutionError, ProviderSecretConfigurationError) as exc:
|
||||
logger.info(
|
||||
"planner-decision request failed",
|
||||
@@ -429,7 +445,8 @@ def create_internal_router(
|
||||
task_id=payload.task_id,
|
||||
attempt=payload.attempt,
|
||||
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:
|
||||
return JSONResponse(
|
||||
@@ -460,7 +477,11 @@ def create_internal_router(
|
||||
content=PlannerDecisionError(detail=str(exc)).model_dump(),
|
||||
)
|
||||
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(
|
||||
reservation_id=reservation.id,
|
||||
event_id=uuid4().hex,
|
||||
@@ -471,6 +492,17 @@ def create_internal_router(
|
||||
total_tokens=usage.total_tokens,
|
||||
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(
|
||||
"planner-decision request resolved",
|
||||
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)
|
||||
if not any(value is not None for value in context_values):
|
||||
return
|
||||
|
||||
@@ -78,11 +78,18 @@ class ClaimResponse(BaseModel):
|
||||
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):
|
||||
host_id: str = Field(min_length=1)
|
||||
task_id: str = Field(min_length=1)
|
||||
attempt: int = Field(ge=1)
|
||||
lease_id: str = Field(min_length=1)
|
||||
progress: TaskProgressModel | None = None
|
||||
|
||||
|
||||
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")
|
||||
@@ -22,7 +22,11 @@ if TYPE_CHECKING:
|
||||
TokenUsageEvent,
|
||||
UserSubmissionPolicy,
|
||||
)
|
||||
from cloud.llm_providers import LlmProviderProfile, LlmProviderSettings, ProviderType
|
||||
from cloud.llm_providers import (
|
||||
LlmProviderProfile,
|
||||
LlmProviderSettings,
|
||||
ProviderType,
|
||||
)
|
||||
|
||||
|
||||
AttemptStatus = Literal["assigned", "dispatched", "done", "failed", "expired"]
|
||||
@@ -99,6 +103,39 @@ class LeasedAssignment:
|
||||
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):
|
||||
"""Persistence port for cloud state and atomic scheduling operations."""
|
||||
|
||||
@@ -286,13 +323,17 @@ class CloudRepository(Protocol):
|
||||
block_duration: timedelta,
|
||||
) -> 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 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(
|
||||
self,
|
||||
@@ -305,7 +346,9 @@ class CloudRepository(Protocol):
|
||||
updated_at: datetime,
|
||||
) -> 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(
|
||||
self,
|
||||
@@ -321,32 +364,58 @@ class CloudRepository(Protocol):
|
||||
def count_active_tasks_for_host(self, host_id: str) -> int: ...
|
||||
|
||||
def reserve_host_token_budget(
|
||||
self, *, 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,
|
||||
self,
|
||||
*,
|
||||
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: ...
|
||||
|
||||
def settle_host_token_reservation(
|
||||
self, *, reservation_id: str, event_id: str, provider: str, model: str,
|
||||
input_tokens: int | None, output_tokens: int | None, total_tokens: int,
|
||||
self,
|
||||
*,
|
||||
reservation_id: str,
|
||||
event_id: str,
|
||||
provider: str,
|
||||
model: str,
|
||||
input_tokens: int | None,
|
||||
output_tokens: int | None,
|
||||
total_tokens: int,
|
||||
occurred_at: datetime,
|
||||
) -> 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(
|
||||
self, *, host_id: str, usage_day: str, now: datetime,
|
||||
self,
|
||||
*,
|
||||
host_id: str,
|
||||
usage_day: str,
|
||||
now: datetime,
|
||||
) -> TokenUsageSummary: ...
|
||||
|
||||
def list_host_token_usage_events(
|
||||
self, *, host_id: str, limit: int, offset: int,
|
||||
self,
|
||||
*,
|
||||
host_id: str,
|
||||
limit: int,
|
||||
offset: int,
|
||||
) -> list[TokenUsageEvent]: ...
|
||||
|
||||
def get_llm_provider_settings(self) -> LlmProviderSettings: ...
|
||||
|
||||
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(
|
||||
self, profile: LlmProviderProfile
|
||||
@@ -413,6 +482,7 @@ class CloudRepository(Protocol):
|
||||
host_id: str,
|
||||
lease_expires_at: datetime,
|
||||
now: datetime,
|
||||
progress: AssignmentProgressSnapshot | None = None,
|
||||
) -> LeaseRenewalStatus: ...
|
||||
|
||||
def record_task_result(
|
||||
@@ -437,6 +507,43 @@ class CloudRepository(Protocol):
|
||||
|
||||
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 close(self) -> None: ...
|
||||
|
||||
@@ -53,6 +53,10 @@ class ScheduledTask:
|
||||
failure_reason: str | None = None
|
||||
updated_at: datetime | None = None
|
||||
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
|
||||
@@ -199,7 +203,10 @@ class TaskScheduler:
|
||||
def _matches(device: "PooledDevice", constraints: TaskConstraints) -> bool:
|
||||
if constraints.target_host_id and device.host_id != constraints.target_host_id:
|
||||
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
|
||||
if constraints.driver_type and device.driver_type != constraints.driver_type:
|
||||
return False
|
||||
|
||||
@@ -9,7 +9,7 @@ from alembic.runtime.migration import MigrationContext
|
||||
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):
|
||||
|
||||
@@ -10,6 +10,7 @@ authentication can be added later without changing route signatures.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import TYPE_CHECKING, Callable, Literal
|
||||
|
||||
from cloud.auth import (
|
||||
@@ -32,6 +33,8 @@ from cloud.sdk.models import (
|
||||
TaskAttemptResponse,
|
||||
TaskListItem,
|
||||
TaskListResponse,
|
||||
TaskPlannerDecisionItem,
|
||||
TaskPlannerDecisionListResponse,
|
||||
TaskStatusResponse,
|
||||
TaskSubmissionRequest,
|
||||
TaskSubmissionResponse,
|
||||
@@ -144,14 +147,16 @@ def create_cloud_router(
|
||||
failure_reason=task.failure_reason,
|
||||
target_host_id=task.constraints.target_host_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)
|
||||
def list_tasks(
|
||||
request: Request,
|
||||
status_filter: Literal[
|
||||
"queued", "assigned", "dispatched", "done", "failed"
|
||||
]
|
||||
status_filter: Literal["queued", "assigned", "dispatched", "done", "failed"]
|
||||
| None = Query(default=None, alias="status"),
|
||||
limit: int = Query(default=50, ge=1, le=100),
|
||||
offset: int = Query(default=0, ge=0),
|
||||
@@ -177,6 +182,10 @@ def create_cloud_router(
|
||||
target_host_id=task.constraints.target_host_id,
|
||||
target_device_id=task.constraints.target_device_id,
|
||||
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
|
||||
],
|
||||
@@ -218,6 +227,47 @@ def create_cloud_router(
|
||||
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])
|
||||
def list_devices(request: Request) -> list[DeviceResponse]:
|
||||
_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")
|
||||
if constraints.target_host_id is None:
|
||||
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")
|
||||
if constraints.target_device_id is not None and not any(
|
||||
device.host_id == constraints.target_host_id
|
||||
|
||||
@@ -37,6 +37,10 @@ class TaskStatusResponse(BaseModel):
|
||||
failure_reason: str | None = None
|
||||
target_host_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):
|
||||
@@ -51,6 +55,10 @@ class TaskListItem(BaseModel):
|
||||
target_host_id: str | None = None
|
||||
target_device_id: str | None = None
|
||||
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):
|
||||
@@ -263,3 +271,17 @@ class LlmProviderSettingsResponse(BaseModel):
|
||||
class LlmProviderProfileListResponse(BaseModel):
|
||||
settings: LlmProviderSettingsResponse
|
||||
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]
|
||||
|
||||
@@ -4,7 +4,8 @@ import json
|
||||
import logging
|
||||
from dataclasses import asdict
|
||||
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.exc import IntegrityError
|
||||
@@ -14,6 +15,7 @@ from cloud.db_models import (
|
||||
Base,
|
||||
DeviceEnrollmentRow,
|
||||
HostRow,
|
||||
PlannerDecisionLogRow,
|
||||
PluginRow,
|
||||
PooledDeviceRow,
|
||||
ScheduledTaskRow,
|
||||
@@ -32,6 +34,9 @@ from cloud.db_models import (
|
||||
from cloud.observability import current_correlation_id
|
||||
from core.models import utc_now
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from cloud.repository import AssignmentProgressSnapshot
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -1486,6 +1491,7 @@ class SQLAlchemyCloudRepository:
|
||||
host_id: str,
|
||||
lease_expires_at: datetime,
|
||||
now: datetime,
|
||||
progress: AssignmentProgressSnapshot | None = None,
|
||||
) -> str:
|
||||
with self._sessions.begin() as session:
|
||||
task = session.get(
|
||||
@@ -1525,6 +1531,11 @@ class SQLAlchemyCloudRepository:
|
||||
task.lease_expires_at = renewed_until
|
||||
task.updated_at = _iso(now)
|
||||
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)
|
||||
return "renewed"
|
||||
|
||||
@@ -1590,6 +1601,10 @@ class SQLAlchemyCloudRepository:
|
||||
task.failure_reason = failure_reason
|
||||
task.result_json = result_json
|
||||
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.completed_at = completed_at_iso
|
||||
attempt_row.failure_reason = failure_reason
|
||||
@@ -1663,6 +1678,93 @@ class SQLAlchemyCloudRepository:
|
||||
).all()
|
||||
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:
|
||||
with self._sessions() as session:
|
||||
session.execute(select(1))
|
||||
@@ -1778,6 +1880,10 @@ def _task_from_row(row: ScheduledTaskRow) -> Any:
|
||||
failure_reason=row.failure_reason,
|
||||
updated_at=_parse_dt(row.updated_at),
|
||||
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),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -36,13 +36,14 @@ class AIPlanner(Planner):
|
||||
world: "WorldState | None" = None,
|
||||
screenshot: bytes | None = None,
|
||||
) -> list[PlannedStep]:
|
||||
user_prompt = planner_user_prompt(
|
||||
goal=goal,
|
||||
scene_json=scene.to_dict(),
|
||||
history_summary=_history_summary(world),
|
||||
)
|
||||
decision = self.client.decide(
|
||||
system_prompt=PLANNER_SYSTEM_PROMPT,
|
||||
user_prompt=planner_user_prompt(
|
||||
goal=goal,
|
||||
scene_json=scene.to_dict(),
|
||||
history_summary=_history_summary(world),
|
||||
),
|
||||
user_prompt=user_prompt,
|
||||
screenshot=screenshot,
|
||||
tools=ALL_TOOL_SPECS,
|
||||
timeout=self.config.timeout,
|
||||
@@ -58,6 +59,7 @@ class AIPlanner(Planner):
|
||||
action=decision.tool_name,
|
||||
description=f"AI planner: {decision.tool_name}({decision.arguments})",
|
||||
args=dict(decision.arguments),
|
||||
prompt=decision.user_prompt or user_prompt,
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
@@ -16,6 +16,9 @@ class PlannedStep:
|
||||
description: str
|
||||
args: dict[str, Any] = field(default_factory=dict)
|
||||
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:
|
||||
|
||||
+32
-1
@@ -40,6 +40,7 @@ Observer = Callable[[str], Scene]
|
||||
ScreenshotProvider = Callable[[str], bytes]
|
||||
TaskSucceededHook = Callable[[str, str, Timeline], None]
|
||||
StopRequested = Callable[[], bool]
|
||||
StepProgressCallback = Callable[[int, str, str], None]
|
||||
|
||||
|
||||
class TaskRunner:
|
||||
@@ -60,6 +61,7 @@ class TaskRunner:
|
||||
skill_store: SkillStore | None = None,
|
||||
skill_embedding_client: EmbeddingClient | None = None,
|
||||
planner_config: PlannerConfig | None = None,
|
||||
on_step_progress: StepProgressCallback | None = None,
|
||||
) -> None:
|
||||
self.planner_config = planner_config or load_planner_config()
|
||||
self.planner = planner or self._default_planner()
|
||||
@@ -89,6 +91,7 @@ class TaskRunner:
|
||||
self.on_task_succeeded = self._default_task_succeeded_hook
|
||||
else:
|
||||
self.on_task_succeeded = None
|
||||
self.on_step_progress = on_step_progress
|
||||
|
||||
def run(
|
||||
self,
|
||||
@@ -114,6 +117,7 @@ class TaskRunner:
|
||||
reason = (
|
||||
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(
|
||||
task,
|
||||
status="failed",
|
||||
@@ -140,7 +144,17 @@ class TaskRunner:
|
||||
self._record_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:
|
||||
self._emit_step_progress(
|
||||
len(context.step_results),
|
||||
"failed",
|
||||
result.error or "step failed",
|
||||
)
|
||||
self._update_task(
|
||||
task,
|
||||
status="failed",
|
||||
@@ -149,6 +163,11 @@ class TaskRunner:
|
||||
)
|
||||
return task
|
||||
|
||||
self._emit_step_progress(
|
||||
len(context.step_results),
|
||||
"failed",
|
||||
f"max steps exceeded: {self.config.max_steps}",
|
||||
)
|
||||
self._update_task(
|
||||
task,
|
||||
status="failed",
|
||||
@@ -158,6 +177,7 @@ class TaskRunner:
|
||||
return task
|
||||
|
||||
def _interrupt_task(self, task: Task) -> Task:
|
||||
self._emit_step_progress(-1, "failed", "execution interrupted")
|
||||
self._update_task(
|
||||
task,
|
||||
status="failed",
|
||||
@@ -194,7 +214,18 @@ class TaskRunner:
|
||||
self._update_world(world_handle, context, 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:
|
||||
self._emit_step_progress(-1, "completed", "task completed")
|
||||
self._update_task(task, status="completed", completed=True)
|
||||
self._notify_task_succeeded(task)
|
||||
return task
|
||||
@@ -306,7 +337,7 @@ class TaskRunner:
|
||||
self.timeline.append(
|
||||
task_id=task.id,
|
||||
scene=scene.to_dict(),
|
||||
prompt=task.goal,
|
||||
prompt=step.prompt or task.goal,
|
||||
tool_call={
|
||||
"action": step.action,
|
||||
"description": step.description,
|
||||
|
||||
@@ -25,6 +25,11 @@ class ToolCallDecision:
|
||||
tool_name: str
|
||||
arguments: dict[str, Any]
|
||||
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):
|
||||
@@ -72,7 +77,11 @@ class AnthropicToolCallingClient:
|
||||
tools,
|
||||
timeout=timeout,
|
||||
)
|
||||
return _decision_from_anthropic_response(response)
|
||||
return _decision_from_anthropic_response(
|
||||
response,
|
||||
system_prompt=system_prompt,
|
||||
user_prompt=user_prompt,
|
||||
)
|
||||
except ToolCallUnavailable:
|
||||
raise
|
||||
except Exception as exc:
|
||||
@@ -164,7 +173,11 @@ class OpenAIToolCallingClient:
|
||||
tools,
|
||||
timeout=timeout,
|
||||
)
|
||||
return _decision_from_openai_response(response)
|
||||
return _decision_from_openai_response(
|
||||
response,
|
||||
system_prompt=system_prompt,
|
||||
user_prompt=user_prompt,
|
||||
)
|
||||
except ToolCallUnavailable:
|
||||
raise
|
||||
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")
|
||||
if not isinstance(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,
|
||||
arguments=arguments,
|
||||
usage=_anthropic_usage(response),
|
||||
system_prompt=system_prompt,
|
||||
user_prompt=user_prompt,
|
||||
)
|
||||
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")
|
||||
if not isinstance(choices, list) or not choices:
|
||||
raise ValueError("openai tool-call response missing choices")
|
||||
@@ -312,6 +337,8 @@ def _decision_from_openai_response(response: Any) -> ToolCallDecision:
|
||||
tool_name=name,
|
||||
arguments=arguments,
|
||||
usage=_openai_usage(response),
|
||||
system_prompt=system_prompt,
|
||||
user_prompt=user_prompt,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import shutil
|
||||
from dataclasses import asdict, is_dataclass
|
||||
from datetime import date, datetime
|
||||
from pathlib import Path
|
||||
@@ -53,6 +54,10 @@ class ArtifactStore:
|
||||
steps.append(json.loads(path.read_text(encoding="utf-8")))
|
||||
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:
|
||||
if hasattr(value, "to_dict"):
|
||||
@@ -68,4 +73,3 @@ def _jsonable(value: Any) -> Any:
|
||||
if isinstance(value, (datetime, date)):
|
||||
return value.isoformat()
|
||||
return value
|
||||
|
||||
|
||||
@@ -73,6 +73,21 @@ class TaskMetadataStore:
|
||||
).fetchall()
|
||||
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:
|
||||
with self._connect() as connection:
|
||||
connection.execute(
|
||||
@@ -94,4 +109,3 @@ class TaskMetadataStore:
|
||||
connection = sqlite3.connect(self.db_path)
|
||||
connection.row_factory = sqlite3.Row
|
||||
return connection
|
||||
|
||||
|
||||
@@ -11,6 +11,8 @@ from storage.artifact_store import ArtifactStore
|
||||
class TimelineRecord:
|
||||
index: int
|
||||
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
|
||||
tool_call: dict[str, Any]
|
||||
result: dict[str, Any]
|
||||
@@ -60,3 +62,6 @@ class Timeline:
|
||||
def read(self, task_id: str) -> list[dict[str, Any]]:
|
||||
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
@@ -8,7 +8,6 @@ from core.errors import TaskFailedError
|
||||
from core.models import Bounds, Scene, SceneElement
|
||||
from runtime.ai_planner import AIPlanner
|
||||
from runtime.context import TaskContext
|
||||
from runtime.planner import PlannedStep
|
||||
from runtime.planner_config import PlannerConfig
|
||||
from runtime.tool_calling_client import ToolCallDecision
|
||||
from runtime.tool_specs import ALL_TOOL_SPECS
|
||||
@@ -44,7 +43,11 @@ def _scene() -> Scene:
|
||||
return Scene(
|
||||
width=10,
|
||||
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:
|
||||
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)
|
||||
|
||||
steps = planner.plan(goal="send a message", scene=_scene(), context=_context())
|
||||
|
||||
assert steps == [
|
||||
PlannedStep(
|
||||
action="tap",
|
||||
description="AI planner: tap({'x': 1, 'y': 2})",
|
||||
args={"x": 1, "y": 2},
|
||||
)
|
||||
]
|
||||
assert len(steps) == 1
|
||||
step = steps[0]
|
||||
assert step.action == "tap"
|
||||
assert step.description == "AI planner: tap({'x': 1, 'y': 2})"
|
||||
assert step.args == {"x": 1, "y": 2}
|
||||
|
||||
|
||||
def test_ai_planner_finish_task_success_returns_empty_plan() -> None:
|
||||
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)
|
||||
|
||||
@@ -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:
|
||||
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)
|
||||
|
||||
@@ -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:
|
||||
client = FakeToolCallingClient(ToolCallDecision(tool_name="finish_task", arguments={"success": False}))
|
||||
client = FakeToolCallingClient(
|
||||
ToolCallDecision(tool_name="finish_task", arguments={"success": False})
|
||||
)
|
||||
planner = AIPlanner(client=client)
|
||||
|
||||
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:
|
||||
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)
|
||||
|
||||
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:
|
||||
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.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]
|
||||
assert call["tools"] == ALL_TOOL_SPECS
|
||||
assert call["screenshot"] == b"fake-bytes"
|
||||
assert call["timeout"] == 12.5
|
||||
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
|
||||
|
||||
@@ -1,11 +1,16 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from core.models import Bounds, Scene, SceneElement, Task
|
||||
from runtime.ai_planner import AIPlanner
|
||||
from runtime.executor import Executor, ExecutorConfig
|
||||
from runtime.planner import PlannedStep, Planner
|
||||
from runtime.planner_config import PlannerConfig
|
||||
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
|
||||
|
||||
|
||||
@@ -56,7 +61,11 @@ def _scene() -> Scene:
|
||||
return Scene(
|
||||
width=10,
|
||||
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:
|
||||
runner = _runner(
|
||||
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)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 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
|
||||
)
|
||||
|
||||
@@ -195,6 +195,57 @@ def test_schema_readiness_requires_head_revision(tmp_path) -> None:
|
||||
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:
|
||||
connection.exec_driver_sql(
|
||||
"create table host_registrations ("
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from cloud.auth import BearerCredential, ConfiguredBearerAuthProvider
|
||||
from cloud.config import CloudConfig
|
||||
from cloud.db_models import TaskAttemptRow
|
||||
from cloud.internal_api.api import create_internal_router
|
||||
from cloud.pool import DevicePool
|
||||
from cloud.store import CloudStore
|
||||
@@ -49,7 +53,7 @@ class _FakeToolCallingClient:
|
||||
|
||||
def _build_client(
|
||||
tmp_path, *, fake_client: _FakeToolCallingClient
|
||||
) -> tuple[TestClient, _FakeToolCallingClient]:
|
||||
) -> tuple[TestClient, _FakeToolCallingClient, DevicePool]:
|
||||
pool = DevicePool(
|
||||
CloudStore(tmp_path / "internal.sqlite3"),
|
||||
CloudConfig(stale_after_seconds=60),
|
||||
@@ -68,7 +72,7 @@ def _build_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]:
|
||||
@@ -94,7 +98,7 @@ def test_authenticated_host_resolves_planner_decision(tmp_path) -> None:
|
||||
fake_client = _FakeToolCallingClient(
|
||||
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(
|
||||
"/internal/v1/hosts/host-a/planner/decide",
|
||||
@@ -113,7 +117,7 @@ def test_planner_decision_decodes_screenshot_base64(tmp_path) -> None:
|
||||
fake_client = _FakeToolCallingClient(
|
||||
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(
|
||||
"/internal/v1/hosts/host-a/planner/decide",
|
||||
@@ -129,7 +133,7 @@ def test_invalid_screenshot_base64_is_rejected(tmp_path) -> None:
|
||||
fake_client = _FakeToolCallingClient(
|
||||
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(
|
||||
"/internal/v1/hosts/host-a/planner/decide",
|
||||
@@ -145,7 +149,7 @@ def test_unauthenticated_request_is_rejected(tmp_path) -> None:
|
||||
fake_client = _FakeToolCallingClient(
|
||||
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(
|
||||
"/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(
|
||||
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(
|
||||
"/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(
|
||||
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(
|
||||
"/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:
|
||||
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(
|
||||
"/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",
|
||||
"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"]
|
||||
|
||||
@@ -82,6 +82,9 @@ def test_cloud_repository_exposes_crud_and_atomic_lease_operations() -> None:
|
||||
"record_task_result",
|
||||
"reap_expired_leases",
|
||||
"list_task_attempts",
|
||||
"record_planner_decision",
|
||||
"prune_planner_decision_log",
|
||||
"list_planner_decisions",
|
||||
"health_check",
|
||||
"close",
|
||||
} <= members
|
||||
@@ -1553,3 +1556,198 @@ def test_task_lifecycle_logs_structured_identifiers(
|
||||
assert "secret-image" not in repr(events)
|
||||
finally:
|
||||
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()
|
||||
|
||||
@@ -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", 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/hosts", None, "pool: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()
|
||||
@@ -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
|
||||
@@ -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())
|
||||
@@ -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()
|
||||
@@ -31,3 +31,24 @@ def test_timeline_records_survive_reopening_store(tmp_path) -> None:
|
||||
assert [record["index"] for record in records] == [1, 2]
|
||||
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"]
|
||||
|
||||
@@ -20,7 +20,9 @@ from tests.fakes import PNG_10X20
|
||||
|
||||
|
||||
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.error = error
|
||||
self.calls: list[dict[str, Any]] = []
|
||||
@@ -38,7 +40,9 @@ class FakeTransport:
|
||||
|
||||
|
||||
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.error = error
|
||||
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:
|
||||
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(
|
||||
system_prompt="system",
|
||||
@@ -77,14 +85,23 @@ def test_anthropic_tool_calling_client_sends_forced_single_tool_call_request() -
|
||||
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
|
||||
call = messages.calls[0]
|
||||
assert call["model"] == "test-model"
|
||||
assert call["timeout"] == 2.5
|
||||
assert call["tool_choice"] == {"type": "any", "disable_parallel_tool_use": True}
|
||||
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",
|
||||
"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]["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(
|
||||
response={
|
||||
"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(
|
||||
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:
|
||||
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):
|
||||
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(
|
||||
@@ -171,12 +206,22 @@ def test_anthropic_tool_calling_client_wraps_transport_errors() -> None:
|
||||
{"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)
|
||||
client = AnthropicToolCallingClient(model="test-model", transport=FakeTransport(messages))
|
||||
client = AnthropicToolCallingClient(
|
||||
model="test-model", transport=FakeTransport(messages)
|
||||
)
|
||||
|
||||
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 --------------------------------------------------------------
|
||||
@@ -186,11 +231,24 @@ def test_openai_tool_calling_client_sends_forced_single_tool_call_request() -> N
|
||||
completions = FakeCompletions(
|
||||
response={
|
||||
"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(
|
||||
system_prompt="system",
|
||||
@@ -200,7 +258,12 @@ def test_openai_tool_calling_client_sends_forced_single_tool_call_request() -> N
|
||||
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
|
||||
call = completions.calls[0]
|
||||
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(
|
||||
response={
|
||||
"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(
|
||||
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:
|
||||
completions = FakeCompletions(
|
||||
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:
|
||||
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):
|
||||
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(
|
||||
@@ -298,15 +390,33 @@ def test_openai_tool_calling_client_wraps_transport_errors() -> None:
|
||||
[
|
||||
{"choices": []},
|
||||
{"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:
|
||||
completions = FakeCompletions(response=response)
|
||||
client = OpenAIToolCallingClient(model="test-model", transport=FakeOpenAITransport(completions))
|
||||
client = OpenAIToolCallingClient(
|
||||
model="test-model", transport=FakeOpenAITransport(completions)
|
||||
)
|
||||
|
||||
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 ----------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user