Compare commits

...
106 Commits
Author SHA1 Message Date
showtan001 9076f8ddb0 feat: discover and add connected iOS devices
Tests / Test apps.device-host-agent.tests.test_mcp_token.test_load_or_create_concurrent_calls_do_not_corrupt failed
2026-09-07 18:33:49 +08:00
showtan001 8c99dc015a feat: add on-demand device screenshots
Tests / Test apps.device-host-agent.tests.test_mcp_token.test_load_or_create_concurrent_calls_do_not_corrupt failed
2026-08-31 10:43:37 +08:00
showtan001 60ee157e97 feat: preserve planner context across task steps
Tests / Test apps.device-host-agent.tests.test_mcp_token.test_load_or_create_concurrent_calls_do_not_corrupt failed
2026-08-30 22:59:19 +08:00
showtan001 dd8df33910 Log task planner conversations locally
Tests / Test apps.device-host-agent.tests.test_mcp_token.test_load_or_create_concurrent_calls_do_not_corrupt failed
2026-08-30 22:38:28 +08:00
showtan001 5458f3b8a4 Support Anthropic conversation logging and retries
Tests / Test apps.device-host-agent.tests.test_mcp_token.test_load_or_create_concurrent_calls_do_not_corrupt failed
2026-08-30 22:29:19 +08:00
showtan001 44e1a6651a Support multimodal planner scenes without OCR 2026-08-30 21:56:11 +08:00
showtan001 5b8daab457 Recover task metadata store schema on startup 2026-08-30 21:50:57 +08:00
showtan001 697e54427b Bind chat agent sessions to individual devices 2026-08-30 21:48:39 +08:00
showtan001 fdaca7539b Show local conversation activity in web console 2026-08-30 21:46:50 +08:00
showtan001 3c9e65c78e Record local agent reasoning and tool activity
Tests / Test apps.device-host-agent.tests.test_mcp_token.test_load_or_create_concurrent_calls_do_not_corrupt failed
2026-08-29 21:19:30 +08:00
showtan001 71fd182f50 Support multimodal images in local chat agent 2026-08-29 21:17:20 +08:00
showtan001 a315c62f3a Improve local agent chat and device health detection 2026-08-29 16:03:15 +08:00
showtan001 050d1329c4 Add local host mode and configurable LLM providers 2026-08-24 08:49:18 +08:00
q792602257 433ab41f95 Merge branch 'worktree-host-agent-mcp-server' — Host-Agent MCP Server
Tests / Test apps.device-host-agent.tests.test_mcp_token.test_load_or_create_concurrent_calls_do_not_corrupt failed
Adds a Streamable HTTP MCP server (mount /mcp, port 8765) to the
device-host-agent process so Hermes Agent (or any MCP client) can
drive devices directly, coexisting with the Cloud Control Plane
worker path. Per-device session-level locking with 20s TTL,
independent bearer-token auth, and bidirectional cloud ↔ MCP
coordination via a new heartbeat field.

Implementation:
- 4 new modules (mcp_token, mcp_lock, web/mcp_auth, web/mcp)
- Console mount at /mcp with bearer auth sub-app
- Cloud heartbeat payload + scheduler skip MCP-busy devices
- AssignmentExecutor fail-fast reverse check
- CLI mcp-token subcommand
- docs/MCP_INTEGRATION.md + MACOS_IPHONE_SETUP.md section

Spec: docs/superpowers/specs/2026-07-21-host-agent-mcp-server-design.md
Plan: docs/superpowers/plans/2026-07-21-host-agent-mcp-server.md

18 implementation commits ( Tasks 1-15 + final fix wave).
Spec/plan cherry-picks are detected as already-applied via patch-id.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

# Conflicts:
#	docs/superpowers/specs/2026-07-21-host-agent-mcp-server-design.md
2026-07-21 17:00:29 +08:00
q792602257andClaude Opus 4.6 70e0624a47 fix(host-agent): align MCP integration with mcp SDK 1.28.1 realities
Three final-review deviations closed:

I1 (session-end release): mcp SDK 1.28.1 exposes no per-session
shutdown callback (only a server-level lifespan). Lower the
McpBusyTracker default TTL from 60s to 20s and update spec §6.5,
Q5/R3, D9, and docs/MCP_INTEGRATION.md concurrency section to
document the TTL-only recovery path. 20s is short enough to recover
within one 30s heartbeat interval but long enough that an active
session does not lose its lease during normal operator pauses.

I2 (JSON-RPC error shape): FastMCP Tool.run wraps every non-
UrlElicitationRequiredError exception (including McpError with typed
ErrorData) into ToolError, which the lowlevel call_tool handler
serializes as CallToolResult(isError=true, content=[TextContent(...)]).
There is no public path that surfaces JSON-RPC -32000 with structured
data.busy_owner from a tool call site. Update spec §7 error matrix
and docs/MCP_INTEGRATION.md error table to document the actual wire
shape; busy_owner now lives in the text content.

I3 (typing): mcp_server: Any = None -> FastMCP | None = None via
TYPE_CHECKING, keeping the mcp import lazy (matches precedent
elsewhere in the codebase) while adding static type checking at the
create_console_app boundary.

Tests added (4):
- test_default_ttl_is_20_seconds — locks I1's new default TTL
- test_default_ttl_recovers_dead_session_within_one_window — locks
  I1's recovery semantics (lease sweeped on next read after 20s)
- test_busy_error_wire_shape_is_calltoolresult_iserror — pins I2's
  wire envelope via Tool.run + lowlevel Server._make_error_result
- test_busy_error_text_includes_cloud_assignment_owner — same for
  the cloud_assignment busy_owner branch

Full non-integration suite: 697 passed / 54 deselected (was 693 / 54).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-07-21 16:25:35 +08:00
q792602257andClaude Opus 4.6 e69cea0245 style: ruff format after MCP server integration
Reformat the files touched by Tasks 1-14 of the host-agent MCP server
plan. No semantic changes; pre-existing format issues in unrelated
files (test_templates, test_skill_sync_wiring, 0010_skill_management,
test_skill_catalog_mcp) left untouched for a separate housekeeping
pass.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-07-21 15:52:32 +08:00
q792602257andClaude Opus 4.6 6d9237a592 test: align skill catalog and migration tests with new MCP API
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-07-21 15:31:51 +08:00
q792602257 6241fb9d6d docs: add MCP integration guide 2026-07-21 15:24:16 +08:00
q792602257andClaude Opus 4.6 2d0c740c88 feat(host-agent): add mcp-token CLI subcommand
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-07-21 15:21:32 +08:00
q792602257 ce64c4eb47 feat(host-agent): wire MCP server into create_application 2026-07-21 15:17:28 +08:00
q792602257 ce2469616e feat(host-agent): mount /mcp + surface MCP status in console 2026-07-21 15:09:41 +08:00
q792602257andClaude Opus 4.6 dcb4798408 feat(host-agent): fail-fast cloud assignment when MCP holds device
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-07-21 15:00:54 +08:00
q792602257 ab15218b27 feat(host-agent): include mcp_busy_device_ids in heartbeat payload 2026-07-21 14:56:21 +08:00
q792602257 b0932dd398 feat(cloud): skip MCP-busy devices in scheduler 2026-07-21 14:37:21 +08:00
q792602257 9e3007e7f6 feat(cloud): accept mcp_busy_device_ids in heartbeat payload 2026-07-21 14:32:44 +08:00
q792602257andClaude Opus 4.6 98089b6748 fix(host-agent): use stable ServerSession id for MCP lock identity
The previous _current_session_id() implementation tried to import a
non-existent get_context() helper, so the production code path always
fell through to the empty _TEST_SESSION_ID ContextVar — meaning every
MCP client shared the empty-string identity and there was no per-session
isolation in production.

Use Context.session (the long-lived ServerSession object) as the source
of identity. id(ctx.session) is stable across every tool call the same
client makes within a Streamable HTTP session, which is exactly what the
busy tracker needs to renew leases.

Wire FastMCP to inject the Context into the wrapper by setting
tool.context_kwarg = "ctx" after swapping tool.fn; wrap the swap in a
defensive try/except that surfaces a FastMcpSdkIncompatibilityError on
future SDK layout drift.

Add 4 tests covering the production path: stability across calls in the
same session, isolation between sessions, fallback to _TEST_SESSION_ID
when no Context is supplied, and verification that the registered tool
declares context_kwarg="ctx".

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-07-21 14:28:50 +08:00
q792602257 b73db01626 feat(host-agent): wrap tool_handlers with busy check + status mapping 2026-07-21 14:09:42 +08:00
q792602257 cf8affe4d7 feat(host-agent): add BearerAuthMiddleware for MCP server 2026-07-21 14:02:20 +08:00
q792602257 61c923b92b feat(host-agent): add McpBusyTracker for per-device session locks 2026-07-21 13:58:37 +08:00
q792602257 c7faee8da3 feat(host-agent): add McpTokenStore for MCP bearer token 2026-07-21 13:55:24 +08:00
q792602257 29b9a8c39a refactor(api): make tool_handlers require a DeviceManager
Eliminates the silent fallback to DEFAULT_MANAGER that produced the
DeviceNotFoundError incident. All existing callers already pass
manager explicitly.
2026-07-21 13:52:01 +08:00
q792602257 d1b0fffabb build(host-agent): add mcp as direct dependency 2026-07-21 13:49:17 +08:00
q792602257andClaude Opus 4.6 47eac0f2a7 docs(superpowers): add host-agent MCP server implementation plan
15-task TDD plan implementing the spec committed in 3b62195. Covers
the four new host-agent modules (mcp_token, mcp_lock, web/mcp_auth,
web/mcp), cloud heartbeat + scheduler coordination, console mount
wiring, CLI subcommand, and documentation.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-07-21 13:47:15 +08:00
q792602257andClaude Opus 4.6 2f8f4a36c6 docs(superpowers): add host-agent MCP server design spec
Design for mounting a Streamable HTTP MCP server inside the host-agent
process so Hermes Agent (or any MCP client) can drive devices directly.
Reuses the existing console FastAPI + uvicorn on port 8765, adds bearer-
token auth, per-device session-level locks with 60s TTL, and cloud
coordination via a new heartbeat field. Cloud scheduler skips devices
reported as MCP-busy.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-07-21 13:47:15 +08:00
q792602257andClaude Opus 4.6 28dccb908c docs(superpowers): add host-agent MCP server implementation plan
15-task TDD plan implementing the spec committed in 3b62195. Covers
the four new host-agent modules (mcp_token, mcp_lock, web/mcp_auth,
web/mcp), cloud heartbeat + scheduler coordination, console mount
wiring, CLI subcommand, and documentation.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-07-21 13:46:27 +08:00
q792602257andClaude Opus 4.6 3b62195a36 docs(superpowers): add host-agent MCP server design spec
Design for mounting a Streamable HTTP MCP server inside the host-agent
process so Hermes Agent (or any MCP client) can drive devices directly.
Reuses the existing console FastAPI + uvicorn on port 8765, adds bearer-
token auth, per-device session-level locks with 60s TTL, and cloud
coordination via a new heartbeat field. Cloud scheduler skips devices
reported as MCP-busy.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-07-21 13:18:10 +08:00
q792602257 358f4623ba feat(planner): add execution context to prompts
Tests / Test passed: 977
2026-07-16 10:25:20 +08:00
q792602257 240b7be7b8 fix(cloud-console): refresh task planner history 2026-07-16 09:35:31 +08:00
q792602257 9a17297f1e feat(perception): expose active app metadata in UI tree
Tests / Test passed: 971
2026-07-16 08:13:04 +08:00
q792602257 059fb272bb test(device-host-agent): add conftest.py to disable humanize in tests
Tests / Test passed: 966
The root tests/conftest.py fixture disables APEX_HUMANIZE_ENABLED by default for deterministic assertions, but apps/device-host-agent/tests/ was outside its scope. test_e2e.py::test_public_sdk_reports_fake_device_success_and_runtime_failure failed because tap coordinates were jittered (2.67..., 3.37...) instead of exact (2, 3).

Add the same autouse fixture to apps/device-host-agent/tests/conftest.py so all tests in that directory inherit the deterministic behavior.
2026-07-15 21:19:20 +08:00
q792602257 41006b098a feat(perception): sample OCR text foreground/background colors
Tests / Test apps.device-host-agent.tests.test_e2e.test_public_sdk_reports_fake_device_success_and_runtime_failure failed
PaddleOCR itself returns no color info, only text/bounds/confidence.
Add pixel-level post-processing in perception/ocr.py: crop the
screenshot to each OCR box, split pixels into two luminance clusters
via Otsu threshold, and treat the minority cluster as the text stroke
(foreground) and the majority as the background. New
SceneElement.foreground_color/background_color fields ("#rrggbb",
None when not OCR-sourced or sampling fails) round-trip through
to_dict/from_dict alongside the existing accessibility-state fields.
Planner system prompt documents the new fields as a secondary signal.

pillow is promoted from an implicit paddleocr transitive dependency to
an explicit direct dependency since perception/ocr.py now imports PIL
directly; uv.lock re-resolved with no version change (already locked
at 12.3.0).
2026-07-15 20:58:47 +08:00
q792602257andClaude Opus 4.6 f64f98834f Merge branch 'worktree-gesture-humanize': gesture primitives + humanize
Tests / Test apps.device-host-agent.tests.test_e2e.test_public_sdk_reports_fake_device_success_and_runtime_failure failed
Adds long_press/double_tap atomic gestures, a centralized humanize layer
(coordinate jitter, curved W3C-Actions swipe, timing jitter) gated by
APEX_HUMANIZE_ENABLED, and planner integration. 651 non-integration tests
pass on the branch.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-07-15 20:11:05 +08:00
q792602257 9da73cc6e3 fix(humanize): preserve gaussian magnitude in jitter_point; unify swipe return shape 2026-07-15 19:58:02 +08:00
q792602257 5a93651db7 feat(planner): expose long_press/double_tap to the AI planner 2026-07-15 19:45:06 +08:00
q792602257 bd6b7e64e2 Merge branch 'worktree-task-cancellation': task cancellation feature
Tests / Test passed: 926
# Conflicts:
#	packages/cloud-platform/cloud/schema.py
2026-07-15 19:39:28 +08:00
q792602257 dda70940c0 feat(tools): humanize swipe into curved W3C path when enabled 2026-07-15 19:38:07 +08:00
q792602257 865c163683 test(tools): assert tap humanize actually jitters coords 2026-07-15 19:33:46 +08:00
q792602257 85f0d6e188 feat(tools): humanize tap coordinates; default off in tests 2026-07-15 19:29:57 +08:00
q792602257 fd6365cf6e Add end-to-end cancellation path test and verify no regressions (task-cancellation 9.1-9.2)
- test_cancellation_full_path_queued_immediate_and_dispatched_collaborative
  exercises the full public-API cancellation path: immediate cancel of a
  queued task, collaborative cancel of a dispatched task surfaced through
  lease renewal and a cancelled terminal report, and visibility of the
  cancelled status via both the get and list endpoints.
- Full backend suite (869 passed, 50 skipped) and cloud-console frontend
  suite (27 passed) + typecheck show no regressions; the only failures are
  4 pre-existing live-LLM integration tests unrelated to this change.
2026-07-15 19:23:35 +08:00
q792602257andClaude Opus 4.6 d8e7be4ccb feat(tools): add double_tap tool with humanize hook
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-07-15 19:20:35 +08:00
q792602257andClaude Opus 4.6 c4ee4279ef feat(tools): add long_press tool with humanize hook
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-07-15 19:16:25 +08:00
q792602257 1dd24825ca Document task cancellation latency in CLOUD_DEPLOYMENT.md (task-cancellation 8.1) 2026-07-15 19:14:52 +08:00
q792602257 18f053e64b Add Host Agent local console task cancellation (task-cancellation 7.1-7.3)
- New internal API route POST /internal/v1/hosts/{host_id}/tasks/{task_id}/cancel,
  authenticated via the host's own bearer credential (authorize_host) with an
  ownership check, since host tokens carry no scopes and cannot reach the
  public SDK's tasks:submit-scoped cancel endpoint.
- HostAgentClient.cancel_task() calls the new internal route directly.
- create_console_app() gains a cancel_task callable with automatic default
  wiring from host_client, so production app.py needs no changes.
- Local console: POST /tasks/{task_id}/cancel route resolves the local
  execution id to its Cloud source_task_id before cancelling, and the task
  detail page/template show a Cancel button plus notice/error banners.
- Tests across all three layers: internal API route, Jinja2 template
  rendering, and FastAPI console route behavior.
2026-07-15 19:13:40 +08:00
q792602257 8a73edf4db feat(driver): add Driver.double_tap via W3C actions 2026-07-15 19:12:10 +08:00
q792602257 c25ccb491d feat(driver): add W3C actions helper and Driver.swipe_path 2026-07-15 19:04:57 +08:00
q792602257 ff91bd4f70 feat(driver): add Driver.long_press on WDA and Android 2026-07-15 18:57:53 +08:00
q792602257 7d79f677fe feat(humanize): add coordinate/duration/swipe-path jitter module 2026-07-15 18:51:15 +08:00
q792602257 4d04d7ac83 Add Cloud Console cancel action and cancelled status
- Widen TaskStatus to include "cancelled"; add it to TasksView's
  STATUSES filter dropdown.
- Add TaskCancellationResponse type and cancelTask(taskId) to api.ts.
- Add a Cancel button to TasksView's task detail panel, gated on
  tasks:submit and a non-terminal task status; updates the displayed
  status on success and surfaces errors via the existing error path.
- Extract the cancellability rule into a pure taskCancellation.ts
  module (mirroring taskProgress.ts/plannerHistory.ts) with unit
  tests, since the project has no Vue component-mounting test setup.

Task 6/9 of task-cancellation change.
2026-07-15 18:43:55 +08:00
q792602257 6776ac2f2d Add public SDK cancel endpoint and CloudClient method
- POST /v1/tasks/{task_id}/cancel: tasks:submit scoped, 200 for
  immediate/idempotent cancellation, 202 for newly recorded pending
  cancellation, 404 for unknown task, 409 for terminal task.
- TaskCancellationResponse{task_id, status} model.
- Widen list_tasks status_filter Literal to include "cancelled".
- CloudClient.cancel_task(task_id).
- SDK-level tests covering queued/assigned/idempotent/404/409/scope
  cases for both the router and CloudClient.

Task 5/9 of task-cancellation change.
2026-07-15 18:39:00 +08:00
q792602257 d3024b4810 feat(host-agent): stop assignment execution collaboratively on cancellation
- LeaseGuard gains an is_cancellation convenience property
- ActiveAssignmentRunner marks the lease lost with a cancellation
  reason when a renewal response reports cancel_requested
- AssignmentExecutor threads stop_reason through to TaskRunner/
  WorkflowRunner and maps a cancellation-flavored stop to
  AssignmentExecutionResult.status = "cancelled" instead of "failed"
- AssignmentProcessor forwards a three-way done/cancelled/failed
  status when reporting the terminal result
- Add/extend tests across lease, assignment, processor, and client
2026-07-15 18:28:21 +08:00
q792602257andClaude Opus 4.6 a58ded055e docs(superpowers): add gesture primitives + humanize implementation plan
9-task TDD plan: humanize module, Driver.long_press/swipe_path/double_tap
on WDA+Android (+ all Fake subclasses), tool wrappers, tap/swipe hooks,
and planner integration.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-07-15 18:26:34 +08:00
q792602257andClaude Opus 4.6 557c8a25ba docs(superpowers): add gesture primitives + humanize design spec
Design for long_press/double_tap atomic gestures and a centralized
humanize layer (coordinate jitter, curved W3C-Actions swipe, timing
jitter) gated by APEX_HUMANIZE_ENABLED.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-07-15 18:15:43 +08:00
q792602257 d69be48f96 feat(planner): persist reusable action semantics
Tests / Test passed: 879
2026-07-15 18:14:28 +08:00
q792602257 8a0d48eada feat(cloud): surface cancellation over the internal Host<->Cloud protocol
- LeaseRenewalResponse gains cancel_requested (populated from the
  repository's renew_lease result)
- TerminalResultRequest.status widened to accept "cancelled"
- Add internal API tests for a renewal surfacing cancel_requested=True
  and a cancelled terminal report being accepted/idempotent
2026-07-15 18:13:43 +08:00
q792602257 361dada276 feat(perception): surface accessibility interaction state on UI-tree elements
SceneElement gains enabled/clickable/selected/checked/focused (bool | None),
populated from the literal attributes Appium's XCUITest and UiAutomator2
page_source already emit (iOS: enabled only; Android: all five). None means
"not reported by this platform", not false. to_dict() omits unset fields to
keep the LLM-facing scene JSON compact; planner_prompts.py documents the new
fields so the AI planner knows how to use them (e.g. don't tap disabled
elements, use selected/checked to judge whether a toggle already matches the
goal).
2026-07-15 18:10:53 +08:00
q792602257 19c6669800 feat(cloud): add durable cancellation support to task repository
- Add nullable cancel_requested_at column (migration 0012)
- Widen ScheduledTaskStatus/TerminalTaskStatus to include cancelled
- Add CancellationRequestStatus + request_task_cancellation() to
  CloudRepository protocol and SQLAlchemy implementation
- renew_lease() now returns LeaseRenewalResult, surfacing whether
  cancellation is pending, instead of a bare status string
- reap_expired_leases() resolves pending-cancellation tasks to
  cancelled instead of requeuing/failing them
- record_task_result() accepts cancelled and clears
  cancel_requested_at on any terminal write

Note: internal_api/api.py's renew_assignment route still compares
renew_lease()'s return value against a bare string; it will be
updated in the next task (Internal Host<->Cloud protocol) to consume
LeaseRenewalResult and populate the new cancel_requested wire field.
2026-07-15 18:08:48 +08:00
q792602257 7c6cdc5b67 feat(host-agent): persist OCR/UI overlay toggle preference
Tests / Test passed: 868
Save the "Show OCR/UI-tree bounding boxes" checkbox state to localStorage so it persists across page refreshes in the task detail view.
2026-07-15 18:01:10 +08:00
q792602257 88189770ff feat(runtime): add cancellation-aware stop_reason to TaskRunner and WorkflowRunner
- TaskRunner.run() and WorkflowRunner.run()/resume() accept an optional
  stop_reason callable alongside should_stop, distinguishing a genuine
  cancellation from other stop conditions (e.g. lost lease).
- is_cancellation_reason() shared helper added to runtime/task.py.
- WorkflowRunner._stop_status() now branches cancelled/failed based on
  stop_reason, correcting a prior blanket cancelled-on-any-stop behavior
  that conflicted with the host-agent-protocol spec's requirement to
  distinguish cancellation from lease-loss stops.
- Default behavior (stop_reason=None) is preserved exactly for both
  runners so existing callers/tests are unaffected.
- Task 1 of openspec change task-cancellation.
2026-07-15 17:52:33 +08:00
q792602257 947434b65a docs(openspec): add task-cancellation proposal, design, specs, tasks 2026-07-15 17:38:02 +08:00
q792602257 a25542694d fix test
Tests / Test passed: 868
2026-07-15 17:04:59 +08:00
q792602257 17a709c92f fix(perception): disable PaddleOCR doc-unwarping for screenshots
Tests / Test tests.test_device_config.test_device_config_store_settings_get_set_and_defaults failed
PaddleOCR's OCR.yaml pipeline defaults to use_doc_orientation_classify
and use_doc_unwarping enabled, which are meant for photographed paper
documents. Applied to a flat, upright device screenshot, UVDoc
geometrically warps the image before detection, and returns box
coordinates in that warped space with no inverse mapping back to the
original image.

Verified on a real screenshot: with unwarping on, the same detected
element ("新项目") shifts from y=158 to y=71 versus the original image,
and 2 boxes near the top edge (status bar time/battery) are dropped
entirely. Disabling both flags by default (still overridable via
explicit kwargs) makes detected boxes match the original screenshot.
2026-07-15 16:51:36 +08:00
q792602257 4046c9452d fix(deps): pin paddlepaddle below 3.3.0 to avoid OCR text corruption
paddlepaddle 3.3.1 silently corrupts non-ASCII (CJK) recognized text
into literal U+FFFD replacement characters during rec postprocessing,
while leaving confidence scores high and ASCII/digit text unaffected.
The same release also breaks CPU oneDNN inference on Windows entirely
(NotImplementedError in onednn_instruction.cc). Verified on a real
task screenshot that downgrading to 3.2.x eliminates the corruption
with no other environment changes (same GBK-locale machine).
2026-07-15 16:41:58 +08:00
q792602257 7f439f0db5 fix(perception): reconcile points/pixels scale and stale overlay screenshot
Tests / Test tests.test_device_config.test_device_config_store_settings_get_set_and_defaults failed
Host-agent console showed OCR/UI-tree overlay boxes misaligned with the
displayed screenshot. Two independent causes, both confirmed with real
task data and pixel-level measurement of a user-provided screenshot:

1. perception/ui_parser.py parses XCUITest UI-tree bounds as iOS logical
   points, while scene_builder.py's Scene.width/height (via infer_png_size)
   and OCR bounds are in screenshot pixels, never reconciled (2.0x on
   Retina devices). build_scene() now detects the scale from the first
   x==0,y==0 UI element and rescales OCR bounds down to points-space,
   reporting Scene.width/height in points too. No-op for Android, where
   UiAutomator2 bounds already match pixels 1:1. This also fixes tap()
   landing at the wrong location for OCR-matched text, and lets the IOU
   fusion between UI-tree and OCR elements actually fire on iOS.

2. runtime/task.py captured `scene` (OCR/UI-tree data) before the LLM
   planning call, but re-captured `before_screenshot` for each step
   afterward - a real time gap during which on-screen content (e.g. a
   keyboard) could shift, producing a directional drift between the
   overlay and the displayed image. The first step of each plan batch
   now reuses the screenshot already taken for planning instead of
   capturing a new one; later steps in a multi-step batch still take a
   fresh capture (left unresolved, scoped out by request).

Regression tests added for both the scale reconciliation (using real
828x1792 vs 414x896 numbers) and the screenshot reuse behavior.
2026-07-15 16:12:53 +08:00
q792602257 c50ce1faec OCR不展开
Tests / Test tests.test_device_config.test_device_config_store_settings_get_set_and_defaults failed
2026-07-15 15:06:06 +08:00
q792602257 701983ccdd config: increase max_steps limit from 20 to 999999
Remove the 20-step execution limit that was causing "max steps exceeded" errors for long-running tasks. Increase the default max_steps to 999999 in all configurations, effectively removing the practical limit while maintaining the safety mechanism.

Changes:

- runtime/task.py: TaskRunnerConfig.max_steps 20 → 999999

- agents/collab_runner.py: CollaborativeTaskRunnerConfig.max_steps 20 → 999999

- storage/device_config.py: DEFAULT_MAX_STEPS 20 → 999999
2026-07-15 14:58:06 +08:00
q792602257 8162509158 feat(host-agent): persist UI-tree evidence and add overlay/action visualization
Tests / Test passed: 863
Fixes issue 3: the host-agent console showed OCR results but never real
UI-tree data, because _ui_tree_nodes() checked for a get_ui_tree/ui_tree
tool action that has never existed anywhere in the codebase.

- storage/timeline.py: add a ui_tree_results field to TimelineRecord and
  Timeline.append(), mirroring the existing ocr_results field.
- runtime/task.py: _append_timeline() now extracts scene.elements with
  source == "ui" into ui_tree_results (scene_builder.build_scene() already
  preserved these; they were just never persisted).
- host_agent/web/app.py: _ui_tree_nodes() reads the new field directly
  instead of the dead tool-action check. New _overlay_payload() exposes
  each step's scene dimensions and fused element list for client-side
  rendering.
- task_detail.html: adds a toggle to overlay OCR (orange) and UI-tree
  (blue) bounding boxes on the before-action screenshot, plus a visual
  marker for the actually executed action (tap circle, or an animated
  swipe path) using an SVG viewBox so no manual coordinate-scaling JS is
  needed. Legacy/incomplete records degrade to no overlay, never an error.

Also corrects openspec/specs/runtime-task-evidence and
host-agent-console-task-pages, which had encoded the same nonexistent-tool
assumption, via the new host-agent-console-visual-evidence change.

600 tests passing; ruff/compileall/openspec validate all clean.
2026-07-15 14:39:28 +08:00
q792602257 367fd0d412 fix(planner): allow rationale/thinking by using tool_choice=auto
Tests / Test passed: 855
Forced tool_choice ("any"/"required") makes both Anthropic and OpenAI
skip any text/thinking block before the tool call, which silently made
rationale and thinking always None despite the planner-reflection-history
change's capture code being correct. Switch the primary call to
tool_choice="auto" (Anthropic: type=auto, disable_parallel_tool_use=true;
OpenAI: "auto") so the model can emit its reflection text, and add a
one-time forced retry (Anthropic "any", OpenAI "required", thinking
disabled) if the model responds without a tool call, guaranteeing a step
never stalls. Also add OpenAI text_output capture from message.content,
which was never extracted before (Anthropic-only gap).

Update planner-reflection-history design.md/tasks.md to document the bug
found during the pending manual smoke test (task 8.5) and the fix (new
section 9).
2026-07-15 13:43:39 +08:00
q792602257 24992fc9fb fix test
Tests / Test passed: 851
2026-07-15 12:53:50 +08:00
q792602257 a5aeb8889c feat(runtime): add planner reflection history with rationale and thinking
Tests / Test failed: 2, passed: 849
- ToolCallDecision captures thinking blocks and pre-tool text output
- AnthropicToolCallingClient supports optional extended thinking (budget_tokens + beta header)
- PlannedStep carries rationale and thinking from each LLM decision
- WorldEvent replaces scene_summary with rationale/thinking/page fields (backward-compatible)
- AI planner system prompt instructs reflection before each tool call
- _history_summary() emits compact {page, rationale, action, success} dicts
- Cloud DB migration 0011 adds nullable rationale/thinking columns to planner_decision_log
- OpenAI client extracts reasoning_content into thinking field
2026-07-15 12:43:22 +08:00
q792602257 96e403ee47 chore(openspec): archive task execution visibility 2026-07-15 12:10:14 +08:00
q792602257 77d4813bb2 feat(host-agent): make execution history authoritative
Tests / Test failed: 2, passed: 830
2026-07-15 11:46:27 +08:00
q792602257 ccde30e378 feat(runtime): capture step evidence in console
Tests / Test passed: 862
2026-07-15 10:12:09 +08:00
q792602257 8d5b02e37f fix(perception): degrade to OCR-only when UI tree is unavailable
Tests / Test passed: 858
driver.tree() failures (WDA/Appium session errors) previously raised
uncaught, killing describe_screen() before OCR ever ran. Malformed
tree content (invalid XML) had the same problem inside
parse_ui_tree(). Both are now caught and logged, falling back to an
empty ui_elements list so the scene degrades to OCR-only, mirroring
the existing OCR-failure fallback in run_ocr().
2026-07-15 10:03:30 +08:00
q792602257 778af2da53 fix(cloud): align planner configuration and records
Tests / Test passed: 856
2026-07-15 09:43:14 +08:00
q792602257andClaude Opus 4.6 f8054cb58c chore(skills): docs + ruff format for skill-management-console
Tests / Test passed: 855
Documents Skill Management in CLOUD_DEPLOYMENT.md (cloud-skill store,
per-host entitlement, incremental sync, local authoring/override,
inventory report, skills:admin scope) and applies ruff check/format to
all touched modules. All tasks complete; full non-integration suite
green (593 passed) and openspec validate --strict passes.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-07-15 08:10:20 +08:00
q792602257andClaude Opus 4.6 fd0ea3a066 feat(console): Cloud Console Skills management view
Tests / Test passed: 855
Adds SkillsView.vue (cloud-skill CRUD, per-host entitlement grant/revoke,
read-only host local-skill inventory), skill API client methods + types,
and wires it into App.vue behind the skills:admin scope. Console
typecheck/build/tests green (20 passed).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-07-15 08:06:49 +08:00
q792602257andClaude Opus 4.6 8300c3b6b7 Merge branch 'worktree-runtime-console-jinja2-templates'
Server-rendered Jinja2 Runtime console at /ui/, replacing the Vue/Vite SPA.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-07-15 08:05:02 +08:00
q792602257andClaude Opus 4.6 e00c50e703 feat(api): server-rendered Jinja2 Runtime console at /ui/
Replaces the separate Vue/Vite `console/` SPA with a same-origin,
server-rendered console built on a module-level Jinja2 Environment
with select_autoescape(["html","xml"]).

- Add api/console_web.py with /ui/ routes (dashboard, tasks, task
  detail/timeline, config) and a _status_fragment polled every 10s.
- Refactor api/console.py into a typed ConsoleService shared by the
  JSON and HTML routers so validation/persistence cannot drift.
- Remove RUNTIME_CONSOLE_STATIC_DIR, SpaStaticFiles, and the wildcard
  CORS middleware from api/rest.py; GET / now redirects to /ui/.
- Delete the top-level console/ project; add jinja2 and python-multipart
  as direct dependencies and ship templates/CSS/JS via package-data.
- Add 31 tests (XSS probes, PRG flows, fragment refresh, no-static-dir
  and no-CORS regressions, wheel-packaging smoke test).

/console/* JSON endpoints remain unchanged. The console keeps the
trusted-network-only boundary; auth/CSRF is intentionally deferred.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-07-15 08:03:13 +08:00
q792602257andClaude Opus 4.6 0d944ec97d feat(host-agent): wire skill sync + inventory report into app lifecycle
Adds host_agent/skill_sync.py (HostAgentSkillSync) which constructs the
synced + local skill stores, the Cloud API sync client, and the runner,
then drives them on the host-agent lifecycle: incremental per-host pull
into the synced catalog, fork-on-revocation, and a best-effort local-
skill inventory report to the Cloud (design D7). Wired into
create_application + run_async start/stop. Host-agent suite green
(219 passed).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-07-15 08:02:31 +08:00
q792602257andClaude Opus 4.6 a8ba2312fc feat(skills): cloud sync client + incremental sync + fork-on-revocation
Adds CloudApiSkillClient (Cloud API per-host sync endpoint + inventory
report), forwards since_version for incremental sync (full-replace on
first/stale), and forks a local override into a standalone local skill
when its cloud skill is revoked (design D9). Skill-side tests green (87
passed).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-07-15 07:55:08 +08:00
q792602257andClaude Opus 4.6 cbfdb2ae39 feat(cloud): skill management + host-scoped sync REST endpoints
Adds the skills:admin router (cloud/sdk/skill_api.py) for cloud-skill CRUD
and per-host entitlement grant/revoke with CSRF/scope/audit, and a
host-scoped router serving incremental per-host sync deltas plus the
agent local-skill inventory report/readback. Both composed into the
Cloud API app. cloud-api suite green (46 passed).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-07-15 07:51:27 +08:00
q792602257andClaude Opus 4.6 52e442790a feat(cloud): cloud-managed skill store + per-host entitlement + sync versioning
Tests / Test passed: 819
Adds cloud/skills.py (domain + service), SQLAlchemy models and Alembic
migration 0010_skill_management (cloud_skills, cloud_skill_entitlements,
cloud_skill_sync_state, a per-host changelog, and host_skill_inventory),
and repository methods with a monotonic per-host entitlement_version that
drives correct incremental fetch_host_delta. cloud-api suite green (41
passed); HEAD_REVISION bumped to 0010.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-07-15 07:45:22 +08:00
q792602257andClaude Opus 4.6 8baf3a6a8b feat(skills): unified read-merge + authoring/override MCP tools
Adds the merged read surface (api/skill_catalog_view.py) over synced +
local stores with origin discrimination and override precedence, and
extends the skill MCP tools with create_skill/update_skill/delete_skill
that dispatch by origin (edit local skills; create/update/remove local
overrides for cloud skills). Wired into api.mcp.create_mcp_server.
Full non-integration suite green (564 passed).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-07-15 07:38:12 +08:00
q792602257andClaude Opus 4.6 dd03abbbb0 feat(skills): open skill-management-console change + local skill store
Opens the skill-management-console openspec change (cloud/local skill split
with local override) with proposal, design (D1-D11), four delta specs, and
tasks. Implements the agent-side persistent local skill store
(storage/local_skills.py): authored local skills + cloud-skill overrides in
a physically separate SQLite file, with fork-on-revocation. 10 tests pass.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-07-15 07:28:42 +08:00
q792602257andClaude Opus 4.6 e5a12f9b74 chore(openspec): archive skill-catalog-subscription
Change is complete (24/24 tasks) per its declared scope (read-only local
catalog + MCP tools + sync client contract). Management UI and the
upstream Subscription Platform were explicitly out of scope. Deltas
synced into three new main specs: skill-catalog, skill-mcp-tools,
skill-subscription-sync.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-07-15 00:09:24 +08:00
q792602257andClaude Opus 4.6 56f3f96363 chore(openspec): archive database-llm-provider-management
Tests / Test passed: 794
Change is complete (17/17 tasks). Deltas synced: MODIFIED the
cloud-planner-proxy "Endpoint resolves exactly one tool-call decision"
requirement to resolve provider config from the active database profile,
and created a new main spec openspec/specs/llm-provider-management/spec.md.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-07-14 23:32:08 +08:00
q792602257andClaude Opus 4.6 8e37b965aa chore(openspec): archive cloud-planner-proxy
Change is complete (20/20 tasks). Deltas synced: MODIFIED the
agent-runtime "Pluggable dual-provider tool-calling abstraction"
requirement (added transport selection), and created a new main spec
openspec/specs/cloud-planner-proxy/spec.md.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-07-14 23:30:25 +08:00
q792602257andClaude Opus 4.6 c01dd4c6b2 chore(openspec): archive ai-planner-runtime
Change is complete (22/22 tasks) and its delta spec has been synced
into a new main spec openspec/specs/agent-runtime/spec.md.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-07-14 23:28:17 +08:00
q792602257andClaude Opus 4.6 9669b52498 chore(openspec): archive uv-workspace-packaging
Change is complete (17/17 tasks) and its delta spec has been synced
into a new main spec openspec/specs/workspace-packaging/spec.md.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-07-14 21:24:59 +08:00
q792602257andClaude Opus 4.6 fa10cccf71 chore(openspec): archive downgrade-python-3-13-paddleocr
Change is complete (19/19 tasks) and its delta spec has been synced
into a new main spec openspec/specs/python-runtime-baseline/spec.md.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-07-14 21:18:57 +08:00
q792602257andClaude Opus 4.6 989fdbf878 chore(openspec): archive edge-host-enrollment
Tests / Test passed: 794
Sync delta specs into main specs before archiving: modified
cloud-control-plane, device-pool, and host-agent-protocol; created
new edge-host-enrollment capability spec. openspec validate --specs
reports 18/18 passing.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-07-14 21:01:47 +08:00
q792602257 30f09b6268 feat(host-agent): default planner transport to cloud
Tests / Test passed: 794
2026-07-14 20:42:27 +08:00
q792602257 6e511111c4 fix(perception): harden PaddleOCR result handling
Tests / Test passed: 793
2026-07-14 20:07:49 +08:00
q792602257 25ebc10a8a feat: downgrade Python baseline to 3.13 for PaddleOCR compatibility
Tests / Test passed: 789
paddlepaddle has no Python 3.14 (cp314) wheel on PyPI, so host-agent
deployments on 3.14 can never install it, causing OCR to fail at
runtime with RuntimeError. Pin the workspace to Python 3.13 across
all pyproject.toml files, the Docker base image, and the Jenkins CI
image; regenerate uv.lock against 3.13.

Also fixes a pre-existing Python-2-style `except X, Y:` syntax error
(invalid in all Python 3.x) in runtime/task.py and
packages/cloud-platform/cloud/{sql_repository,internal_api/api}.py,
introduced in 22d37ca9 and unrelated to this change's scope, which
blocked the full test suite from collecting on any interpreter
version.

openspec change: downgrade-python-3-13-paddleocr
2026-07-14 18:05:49 +08:00
q792602257 ecb1dba9ff chore(openspec): archive host-agent-console-task-submission
Tests / Test passed: 789
2026-07-14 17:55:27 +08:00
q792602257 a883903b66 style(host-agent): format Console task submission files 2026-07-14 17:52:35 +08:00
332 changed files with 23823 additions and 5089 deletions
-1
View File
@@ -7,6 +7,5 @@ __pycache__
*.py[cod]
*.sqlite3
tasks
console/node_modules
cloud-console/node_modules
cloud-console/dist
+10
View File
@@ -14,3 +14,13 @@ CLOUD_API_PORT=8001
# undecryptable):
# python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
CLOUD_LLM_PROVIDER_ENCRYPTION_KEY=change-me-generate-a-fernet-key
# Cloud-proxy planner budget reservations. These apply only to Hosts reporting
# AI_PLANNER_TRANSPORT=cloud.
CLOUD_PLANNER_TOKEN_RESERVATION_CEILING=4096
CLOUD_PLANNER_TOKEN_RESERVATION_TTL_SECONDS=300
# Successful Cloud-proxy planner decisions with task context retain prompt and
# tool-call history until their terminal task exceeds this retention window.
CLOUD_PLANNER_DECISION_LOG_PRUNE_INTERVAL_SECONDS=3600
CLOUD_PLANNER_DECISION_LOG_RETENTION_DAYS=7
+1 -1
View File
@@ -23,7 +23,7 @@ COPY cloud-console/ ./
RUN npm run build
# Stage 2: the existing Python image, now carrying the SPA build output.
FROM registry-ghcr.jerryyan.top/astral-sh/uv:python3.14-bookworm-slim
FROM registry-ghcr.jerryyan.top/astral-sh/uv:python3.13-bookworm-slim
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
Vendored
+1 -1
View File
@@ -55,7 +55,7 @@ pipeline {
when { expression { return !params.SKIP_TESTS } }
agent {
docker {
image 'registry-ghcr.jerryyan.top/astral-sh/uv:python3.14-bookworm-slim'
image 'registry-ghcr.jerryyan.top/astral-sh/uv:python3.13-bookworm-slim'
reuseNode true
// The Docker Pipeline plugin runs the container as the Jenkins host
// user (non-root), so /root isn't writable. Cache under /tmp, which is
+86 -6
View File
@@ -16,10 +16,10 @@ contracts.
- `driver/`: the `Driver` contract, concrete driver adapters, and driver-type
registry.
- `device/`: device lifecycle and active driver management.
- `tools/`: device capabilities exposed to runtime and API layers.
- `tools/`: device capabilities exposed to Runtime and adapter layers.
- `perception/`: screen-to-`Scene` perception behind `PerceptionProvider`.
- `runtime/`: planning and execution orchestration.
- `api/`: REST/MCP transport adapters.
- `api/`: MCP and supporting integration adapters.
- `storage/`: timeline, task, and device configuration persistence.
- `packages/cloud-platform/`: cloud scheduling, device pooling, plugins, and
the Python cloud SDK as the `device-cloud-platform` workspace member.
@@ -54,8 +54,88 @@ uv build --package device-agent-runtime
uv build --package device-cloud-platform
```
The Vue/Vite application under `console/` remains an independent npm project;
uv does not install or modify its JavaScript dependencies.
Runtime is an in-process execution library, not a standalone HTTP service. The
Host Agent console at `http://127.0.0.1:8765/tasks` is the authenticated
operator view for the tasks that actually execute on that Host, including
per-step screenshots, OCR observations, and UI-tree results. The Cloud Console
remains the fleet-level view for dispatch status and Cloud-proxy planner history.
## Local-only Host Agent
Run without a Cloud Control Plane by setting `HOST_AGENT_MODE=local`. Tasks
submitted in the Host console are queued and executed in the same process:
```bash
export HOST_AGENT_MODE=local
export AI_PLANNER_ENABLED=true
export AI_PLANNER_PROVIDER=openai-compatible
export AI_PLANNER_MODEL=qwen2.5
export AI_PLANNER_API_KEY=local-key
export AI_PLANNER_BASE_URL=http://127.0.0.1:11434/v1
export AI_PLANNER_MULTIMODAL=true
uv run --package device-host-agent device-host-agent setup
uv run --package device-host-agent device-host-agent
```
`openai-compatible` works with Ollama, LM Studio, vLLM, or another server that
implements OpenAI `/chat/completions`. Hosted `openai` and `anthropic` providers
also accept `AI_PLANNER_API_KEY` and their conventional API key variables.
The local Host console also exposes an authenticated conversational Agent API:
```text
POST http://127.0.0.1:8765/api/chat
```
Send a JSON body containing `device_id` and `messages` (`user`/`assistant` roles). The Agent can
return ordinary assistant text or call the same device-operation tool contracts
used by the Runtime; each tool result is fed back to the model before the final
reply is returned. The endpoint uses the Host console session cookie, so it is
not an unauthenticated device-control endpoint.
Every chat request is bound to exactly one registered `device_id`. Keep a
separate message history and client session for each phone; the Agent injects
the bound device into device tools and rejects cross-device tool arguments.
For vision-capable OpenAI models, a user message may contain standard OpenAI
multimodal blocks:
```json
{
"messages": [{
"role": "user",
"content": [
{"type": "text", "text": "点击图片中的登录按钮"},
{"type": "image_url", "image_url": {"url": "data:image/png;base64,..."}}
]
}]
}
```
The compact form `{ "role": "user", "text": "...", "image_base64": "..." }`
is also accepted. The model can inspect the supplied image and then call a
phone tool such as `tap` in the same conversation.
Set `AI_PLANNER_MULTIMODAL=true` for a vision model. The Planner sends the
screenshot and omits OCR-only elements and OCR metadata from the structured
scene payload, avoiding duplicate OCR text.
Local mode records LLM responses, reasoning fields, tool calls, tool results,
and final replies in a local SQLite database. View them at
`http://127.0.0.1:8765/conversations`; image bytes are excluded. Set
`HOST_AGENT_CONVERSATION_LOG_PATH` to change the database path.
The authenticated `Devices` page has an on-demand `Get screenshot` button for
each connected device. Screenshots are captured only after the operator clicks
the button; the page does not auto-refresh or capture screenshots as part of
heartbeat synchronization.
In local mode, Appium supervision is enabled by default. Host Agent probes
`/status`, adopts a healthy existing Appium instance, starts Appium when no
listener exists, restarts only processes it started if they crash, and stops
those child processes on shutdown. Override `HOST_AGENT_APPIUM_*` or set
`HOST_AGENT_DEPENDENCY_SUPERVISOR_ENABLED=false` when an external process
manager owns Appium.
## Project Direction
@@ -69,5 +149,5 @@ invariants future changes must preserve are in
or deployed PostgreSQL, configure credentials and Runtime AI planning, and
perform orderly shutdown or rollback.
- [macOS migration and real iPhone setup](docs/MACOS_IPHONE_SETUP.md): install
Xcode, Appium/XCUITest, sign WebDriverAgent, verify a real device, and start a
connected Runtime API.
Xcode, Appium/XCUITest, sign WebDriverAgent, verify a real device, and run a
connected Host Agent.
+27 -4
View File
@@ -4,7 +4,6 @@ import logging
from dataclasses import dataclass, replace
from agents.config import CollaborationConfig, load_config
from agents.models import Observation, VerificationVerdict
from agents.observer import Observer
from agents.reflector import Reflector
from agents.verifier import Verifier
@@ -21,7 +20,7 @@ logger = logging.getLogger(__name__)
@dataclass
class CollaborativeTaskRunnerConfig:
max_steps: int = 20
max_steps: int = 999999
max_recovery_attempts: int = 3
@@ -94,10 +93,21 @@ class CollaborativeTaskRunner:
for step in steps:
executable_step = self._step_for_device(step, task.device_id)
before_screenshot = self.task_runner._planning_screenshot(
task.device_id
)
result = self.executor.execute(executable_step, context=context)
after_screenshot = self.task_runner._planning_screenshot(task.device_id)
context.add_step_result(result)
self.task_runner._record_step_result(
world_handle, context, task, scene, step, result
world_handle,
context,
task,
scene,
step,
result,
before_screenshot=before_screenshot,
after_screenshot=after_screenshot,
)
post_scene = self._observe_scene(task.device_id)
@@ -146,13 +156,26 @@ class CollaborativeTaskRunner:
description=outcome.action.description,
args=outcome.action.args,
)
before_screenshot = self.task_runner._planning_screenshot(
task.device_id
)
recovery_result = self.executor.execute(
self._step_for_device(recovery_step, task.device_id),
context=context,
)
after_screenshot = self.task_runner._planning_screenshot(
task.device_id
)
context.add_step_result(recovery_result)
self.task_runner._record_step_result(
world_handle, context, task, post_scene, recovery_step, recovery_result
world_handle,
context,
task,
post_scene,
recovery_step,
recovery_result,
before_screenshot=before_screenshot,
after_screenshot=after_screenshot,
)
if not recovery_result.success:
return self._fail(
-148
View File
@@ -1,148 +0,0 @@
from __future__ import annotations
import base64
from pathlib import Path
from typing import Any
from uuid import uuid4
from device.manager import DeviceManager
from driver.registry import build_driver_factory
from pydantic import BaseModel, Field
from runtime.task import TaskRunner
from storage.device_config import DeviceConfigStore
from storage.task_metadata import TaskMetadataStore
from storage.timeline import Timeline
class RegisterDeviceRequest(BaseModel):
driver_type: str
connection_info: dict[str, Any] = Field(default_factory=dict)
name: str | None = None
class RuntimeConfigRequest(BaseModel):
max_steps: int
def create_console_router(
*,
device_manager: DeviceManager,
metadata_store: TaskMetadataStore,
timeline: Timeline,
config_store: DeviceConfigStore,
task_runner: TaskRunner,
) -> Any:
from fastapi import APIRouter, HTTPException, Response, status
router = APIRouter(prefix="/console", tags=["console"])
@router.get("/devices")
def devices() -> list[dict[str, Any]]:
return [device.to_dict() for device in device_manager.list_devices()]
@router.post("/devices", status_code=status.HTTP_201_CREATED)
def register_device(request: RegisterDeviceRequest) -> dict[str, Any]:
try:
driver_factory = build_driver_factory(
request.driver_type,
request.connection_info,
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
device_id = uuid4().hex
device = device_manager.register_device(
device_id,
driver_factory,
name=request.name,
driver_type=request.driver_type,
connection_info=request.connection_info,
)
config_store.add(
device_id=device_id,
name=request.name,
driver_type=request.driver_type,
connection_info=request.connection_info,
)
return device.to_dict()
@router.delete(
"/devices/{device_id}",
status_code=status.HTTP_204_NO_CONTENT,
response_model=None,
)
def unregister_device(device_id: str) -> Response:
if not _has_device(device_manager, device_id):
raise HTTPException(status_code=404, detail="device not found")
device_manager.unregister_device(device_id)
config_store.remove(device_id)
return Response(status_code=status.HTTP_204_NO_CONTENT)
@router.get("/tasks")
def tasks(
device_id: str | None = None,
status: str | None = None,
) -> list[dict[str, Any]]:
rows = metadata_store.list_tasks()
if device_id is not None:
rows = [row for row in rows if row["device_id"] == device_id]
if status is not None:
rows = [row for row in rows if row["status"] == status]
return rows
@router.get("/tasks/{task_id}")
def task_detail(task_id: str) -> dict[str, Any]:
task = metadata_store.get_task(task_id)
if task is None:
raise HTTPException(status_code=404, detail="task not found")
return task
@router.get("/tasks/{task_id}/timeline")
def task_timeline(task_id: str) -> list[dict[str, Any]]:
if metadata_store.get_task(task_id) is None:
raise HTTPException(status_code=404, detail="task not found")
return [_inline_screenshot(record) for record in timeline.read(task_id)]
@router.get("/config")
def runtime_config() -> dict[str, int]:
return {"max_steps": _runner_max_steps(task_runner)}
@router.put("/config")
def update_runtime_config(request: RuntimeConfigRequest) -> dict[str, int]:
if request.max_steps <= 0:
raise HTTPException(status_code=400, detail="max_steps must be positive")
_set_runner_max_steps(task_runner, request.max_steps)
config_store.set_setting("max_steps", request.max_steps)
return {"max_steps": request.max_steps}
return router
def _inline_screenshot(record: dict[str, Any]) -> dict[str, Any]:
payload = dict(record)
screenshot_path = payload.get("screenshot_path")
if screenshot_path:
path = Path(str(screenshot_path))
if path.exists():
payload["image_base64"] = base64.b64encode(path.read_bytes()).decode(
"ascii"
)
return payload
def _has_device(device_manager: DeviceManager, device_id: str) -> bool:
return any(device.id == device_id for device in device_manager.list_devices())
def _runner_max_steps(task_runner: TaskRunner) -> int:
config = getattr(task_runner, "config", None)
if config is None or not hasattr(config, "max_steps"):
raise RuntimeError("task runner config unavailable")
return int(config.max_steps)
def _set_runner_max_steps(task_runner: TaskRunner, max_steps: int) -> None:
config = getattr(task_runner, "config", None)
if config is None or not hasattr(config, "max_steps"):
raise RuntimeError("task runner config unavailable")
config.max_steps = max_steps
+45 -30
View File
@@ -3,7 +3,7 @@ from collections.abc import Callable
from typing import Any
from api.errors import call_with_semantic_errors
from device.manager import DEFAULT_MANAGER, DeviceManager
from device.manager import DeviceManager
from tools.describe_screen import describe_screen
from tools.find_icon import find_icon_on_screen
from tools.find_text import find_text_on_screen
@@ -17,12 +17,11 @@ from tools.ui_tree import get_ui_tree
def tool_handlers(
*,
manager: DeviceManager | None = None,
manager: DeviceManager,
) -> dict[str, Callable[..., Any]]:
device_manager = manager or DEFAULT_MANAGER
def _screenshot(device_id: str | None = None) -> dict[str, Any]:
image = take_screenshot(device_id, manager=device_manager)
image = take_screenshot(device_id, manager=manager)
return {
"ok": True,
"image_base64": base64.b64encode(image).decode("ascii"),
@@ -39,70 +38,77 @@ def tool_handlers(
x,
y,
device_id=device_id,
manager=device_manager,
manager=manager,
),
"swipe": lambda start_x, start_y, end_x, end_y, duration_ms=500, device_id=None: call_with_semantic_errors(
swipe,
start_x,
start_y,
end_x,
end_y,
duration_ms=duration_ms,
device_id=device_id,
manager=device_manager,
"swipe": lambda start_x, start_y, end_x, end_y, duration_ms=500, device_id=None: (
call_with_semantic_errors(
swipe,
start_x,
start_y,
end_x,
end_y,
duration_ms=duration_ms,
device_id=device_id,
manager=manager,
)
),
"input_text": lambda text, device_id=None: call_with_semantic_errors(
input_text,
text,
device_id=device_id,
manager=device_manager,
manager=manager,
),
"launch_app": lambda app_id, device_id=None: call_with_semantic_errors(
launch_app,
app_id,
device_id=device_id,
manager=device_manager,
manager=manager,
),
"find_text": lambda query, device_id=None: call_with_semantic_errors(
find_text_on_screen,
query,
device_id=device_id,
manager=device_manager,
manager=manager,
),
"find_icon": lambda name, device_id=None: call_with_semantic_errors(
find_icon_on_screen,
name,
device_id=device_id,
manager=device_manager,
manager=manager,
),
"get_ui_tree": lambda device_id=None: call_with_semantic_errors(
get_ui_tree,
device_id,
manager=device_manager,
"get_ui_tree": lambda device_id=None, include_app_info=False: (
call_with_semantic_errors(
get_ui_tree,
device_id,
manager=manager,
include_app_info=include_app_info,
)
),
"describe_screen": lambda device_id=None: call_with_semantic_errors(
lambda: describe_screen(device_id, manager=device_manager).to_dict()
lambda: describe_screen(device_id, manager=manager).to_dict()
),
"list_devices": lambda: [
device.to_dict() for device in device_manager.list_devices()
],
"list_devices": lambda: [device.to_dict() for device in manager.list_devices()],
"device_status": lambda device_id: call_with_semantic_errors(
lambda: {"device_id": device_id, "status": device_manager.status(device_id)}
lambda: {"device_id": device_id, "status": manager.status(device_id)}
),
}
def create_mcp_server(
*,
manager: DeviceManager | None = None,
manager: DeviceManager,
skill_catalog_store: Any | None = None,
skill_active_subscriptions: set[str] | None = None,
skill_local_store: Any | None = None,
) -> Any:
try:
from mcp.server.fastmcp import FastMCP
except ImportError as exc:
raise RuntimeError("mcp SDK is not installed") from exc
if manager is None:
raise ValueError("create_mcp_server requires a non-None manager")
handlers = tool_handlers(manager=manager)
server = FastMCP("apex-agent")
@@ -149,8 +155,14 @@ def create_mcp_server(
return handlers["find_icon"](name=name, device_id=device_id)
@server.tool(name="get_ui_tree")
def _get_ui_tree(device_id: str | None = None) -> Any:
return handlers["get_ui_tree"](device_id=device_id)
def _get_ui_tree(
device_id: str | None = None,
include_app_info: bool = False,
) -> Any:
return handlers["get_ui_tree"](
device_id=device_id,
include_app_info=include_app_info,
)
@server.tool(name="describe_screen")
def _describe_screen(device_id: str | None = None) -> dict[str, Any]:
@@ -166,10 +178,13 @@ def create_mcp_server(
if skill_catalog_store is not None:
from api.skill_catalog_mcp import register_skill_catalog_tools
from storage.local_skills import LocalSkillStore
local_store = skill_local_store or LocalSkillStore()
register_skill_catalog_tools(
server,
store=skill_catalog_store,
local_store=local_store,
get_active_subscriptions=lambda: set(skill_active_subscriptions or set()),
get_registered_tools=lambda: set(handlers.keys()),
)
-210
View File
@@ -1,210 +0,0 @@
import os
from pathlib import Path
from typing import Any
from api.console import create_console_router
from api.errors import semantic_error
from core.models import Task
from device.manager import DEFAULT_MANAGER, DeviceManager
from driver.registry import build_driver_factory
from runtime.executor import Executor, default_tool_registry
from runtime.task import TaskRunner, TaskRunnerConfig
from storage.device_config import DEFAULT_MAX_STEPS, DeviceConfigStore
from storage.task_metadata import TaskMetadataStore
from storage.timeline import Timeline
from tools.launch_app import launch_app
from tools.screenshot import take_screenshot
from tools.tap import tap
def create_app(
*,
manager: DeviceManager | None = None,
task_runner: TaskRunner | None = None,
metadata_store: TaskMetadataStore | None = None,
device_config_store: DeviceConfigStore | None = None,
timeline: Timeline | None = None,
) -> Any:
from fastapi import BackgroundTasks, FastAPI, HTTPException
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import RedirectResponse
from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel
from starlette.exceptions import HTTPException as StarletteHTTPException
from starlette.types import Scope
class SpaStaticFiles(StaticFiles):
"""``StaticFiles`` variant that falls back to ``index.html`` for SPA routes.
Mirrors ``apps/cloud-api/cloud_api/app.py``'s implementation: an unknown
path like ``/ui/tasks/abc`` would otherwise 404 instead of letting the
SPA's client-side router handle it.
"""
async def get_response(self, path: str, scope: Scope) -> Any:
try:
return await super().get_response(path, scope)
except StarletteHTTPException as exc:
if exc.status_code == 404 and path != "index.html":
return await super().get_response("index.html", scope)
raise
device_manager = manager or DEFAULT_MANAGER
store = metadata_store or TaskMetadataStore()
config_store = device_config_store or DeviceConfigStore()
timeline_store = timeline or Timeline()
max_steps = _load_max_steps(config_store)
_reload_device_configs(device_manager, config_store)
runner = task_runner or TaskRunner(
metadata_store=store,
executor=Executor(tools=default_tool_registry(manager=device_manager)),
timeline=timeline_store,
config=TaskRunnerConfig(max_steps=max_steps),
)
_apply_max_steps(runner, max_steps)
app = FastAPI(title="Apex Agent API")
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_methods=["*"],
allow_headers=["*"],
)
class TapRequest(BaseModel):
x: float
y: float
class ScreenshotResponse(BaseModel):
image_base64: str
mime_type: str = "image/png"
class LaunchRequest(BaseModel):
app_id: str
class AgentTaskRequest(BaseModel):
goal: str
device_id: str
@app.get("/devices")
def devices() -> list[dict[str, Any]]:
return [device.to_dict() for device in device_manager.list_devices()]
@app.post("/devices/{device_id}/tap")
def tap_device(device_id: str, request: TapRequest) -> dict[str, Any]:
return _raise_semantic(
lambda: tap(
request.x,
request.y,
device_id=device_id,
manager=device_manager,
)
)
@app.post("/devices/{device_id}/screenshot")
def screenshot_device(device_id: str) -> dict[str, str]:
import base64
image = _raise_semantic(
lambda: take_screenshot(device_id, manager=device_manager)
)
response = ScreenshotResponse(
image_base64=base64.b64encode(image).decode("ascii")
)
return response.model_dump()
@app.post("/devices/{device_id}/launch")
def launch_device(device_id: str, request: LaunchRequest) -> dict[str, Any]:
return _raise_semantic(
lambda: launch_app(
request.app_id,
device_id=device_id,
manager=device_manager,
)
)
@app.post("/agent/task")
def start_task(
request: AgentTaskRequest,
background_tasks: BackgroundTasks,
) -> dict[str, str]:
task = Task(goal=request.goal, device_id=request.device_id)
store.create_task(task)
background_tasks.add_task(runner.run, task)
return {"task_id": task.id, "status": task.status}
@app.get("/task/{task_id}")
def get_task(task_id: str) -> dict[str, Any]:
task = store.get_task(task_id)
if task is None:
raise HTTPException(status_code=404, detail="task not found")
return task
app.include_router(
create_console_router(
device_manager=device_manager,
metadata_store=store,
timeline=timeline_store,
config_store=config_store,
task_runner=runner,
)
)
console_static_dir = os.environ.get("RUNTIME_CONSOLE_STATIC_DIR")
if console_static_dir:
dist_dir = Path(console_static_dir)
if not dist_dir.is_dir():
raise ValueError(
f"RUNTIME_CONSOLE_STATIC_DIR is not a directory: {dist_dir}"
)
@app.get("/", include_in_schema=False)
async def _redirect_to_console() -> RedirectResponse:
return RedirectResponse(url="/ui/")
app.mount(
"/ui",
SpaStaticFiles(directory=str(dist_dir), html=True),
name="console",
)
return app
def _raise_semantic(func: Any) -> Any:
from fastapi import HTTPException
try:
return func()
except Exception as exc:
raise HTTPException(status_code=400, detail=semantic_error(exc)) from exc
def _load_max_steps(config_store: DeviceConfigStore) -> int:
raw_value = config_store.get_setting("max_steps")
try:
max_steps = int(raw_value) if raw_value is not None else DEFAULT_MAX_STEPS
except ValueError:
return DEFAULT_MAX_STEPS
if max_steps <= 0:
return DEFAULT_MAX_STEPS
return max_steps
def _apply_max_steps(task_runner: Any, max_steps: int) -> None:
config = getattr(task_runner, "config", None)
if config is not None and hasattr(config, "max_steps"):
config.max_steps = max_steps
def _reload_device_configs(
device_manager: DeviceManager,
config_store: DeviceConfigStore,
) -> None:
for config in config_store.list():
device_manager.register_device(
config["device_id"],
build_driver_factory(config["driver_type"], config["connection_info"]),
name=config["name"],
driver_type=config["driver_type"],
connection_info=config["connection_info"],
)
+168 -26
View File
@@ -1,22 +1,31 @@
"""MCP tool surface for the Skill Catalog.
API layer per CONSTITUTION.md: MCP dependencies (FastMCP) live here.
Reads from :mod:`storage.skill_catalog`; flow-template parameter resolution
delegates to the existing :func:`workflow.skill_exec.resolve_skill_steps`.
Reads go through the unified merge surface in :mod:`api.skill_catalog_view`
(synced + local, with override precedence); flow-template parameter
resolution delegates to the existing :func:`workflow.skill_exec.resolve_skill_steps`.
No server-side execution primitive is exposed — flow templates are resolved
here but executed step-by-step by the LLM via the existing device-capability
tools (design D5).
Authoring tools (``create_skill``/``update_skill``/``delete_skill``) dispatch by
origin (design D10): they edit/delete local skills and create/update/remove
local overrides for cloud skills, never writing to the synced store.
"""
from __future__ import annotations
from collections.abc import Callable
from typing import Any
from skills_learning.models import (
FlowStep,
FlowTemplateSkill,
KnowledgeSkill,
SkillMetadata,
)
from api.skill_catalog_view import SkillCatalogView, SkillSummary, SkillView
from storage.local_skills import LocalSkillStore
from storage.skill_catalog import SkillCatalogStore
from workflow.skill_exec import SkillExecutionError, resolve_skill_steps
@@ -27,6 +36,9 @@ SKILL_TOOL_NAMES = (
"search_skills",
"get_skill",
"resolve_flow_template",
"create_skill",
"update_skill",
"delete_skill",
)
@@ -51,9 +63,15 @@ class MissingParameterError(SkillCatalogError):
"""Raised when required flow-template parameters are missing/invalid."""
class SkillAuthoringError(SkillCatalogError):
"""Raised when an authoring operation cannot be applied (e.g., deleting a
cloud skill that has no local override)."""
def skill_tool_handlers(
*,
store: SkillCatalogStore,
local_store: LocalSkillStore,
get_active_subscriptions: Callable[[], set[str]],
get_registered_tools: Callable[[], set[str]] | None = None,
) -> dict[str, Callable[..., dict[str, Any]]]:
@@ -62,44 +80,41 @@ def skill_tool_handlers(
Decoupled from FastMCP so handlers can be tested directly without
standing up a server (mirrors :func:`api.mcp.tool_handlers`).
"""
view = SkillCatalogView(store, local_store)
local = local_store
tools_getter = get_registered_tools or (lambda: set())
def _list_skills() -> dict[str, Any]:
metas = store.list_skills(get_active_subscriptions())
return {
"ok": True,
"skills": [_metadata_to_summary(m) for m in metas],
}
summaries = view.list_skills(get_active_subscriptions())
return {"ok": True, "skills": [_summary_to_dict(s) for s in summaries]}
def _search_skills(query: str) -> dict[str, Any]:
metas = store.search_skills(query, get_active_subscriptions())
return {
"ok": True,
"skills": [_metadata_to_summary(m) for m in metas],
}
summaries = view.search_skills(query, get_active_subscriptions())
return {"ok": True, "skills": [_summary_to_dict(s) for s in summaries]}
def _get_skill(skill_id: str) -> dict[str, Any]:
skill = store.get_skill(
result = view.get_skill(
skill_id,
get_active_subscriptions(),
registered_tools=tools_getter(),
)
if skill is None:
if result is None:
return _error_response(SkillNotFoundError(skill_id))
return {"ok": True, "skill": _skill_to_full_dict(skill)}
return {"ok": True, "skill": _view_to_dict(result)}
def _resolve_flow_template(
skill_id: str,
params: dict[str, Any] | None = None,
) -> dict[str, Any]:
params = params or {}
skill = store.get_skill(
result = view.get_skill(
skill_id,
get_active_subscriptions(),
registered_tools=tools_getter(),
)
if skill is None:
if result is None:
return _error_response(SkillNotFoundError(skill_id))
skill = result.skill
if not isinstance(skill, FlowTemplateSkill):
return _error_response(
InvalidFlowTemplateError(
@@ -112,11 +127,60 @@ def skill_tool_handlers(
return _error_response(MissingParameterError(str(exc)))
return {"ok": True, "steps": steps}
def _create_skill(payload: dict[str, Any]) -> dict[str, Any]:
try:
skill = _build_skill(payload)
except ValueError as exc:
return _error_response(SkillAuthoringError(str(exc)))
stored = local.create_local(skill)
return {"ok": True, "skill": _skill_to_full_dict(stored), "origin": "local"}
def _update_skill(skill_id: str, payload: dict[str, Any]) -> dict[str, Any]:
try:
skill = _build_skill(payload)
except ValueError as exc:
return _error_response(SkillAuthoringError(str(exc)))
if view.is_local_skill(skill_id):
stored = local.update_local(_with_id(skill, skill_id))
return {
"ok": True,
"skill": _skill_to_full_dict(stored),
"origin": "local",
}
# Cloud skill id (or anticipated one): create/update a local override.
stored = local.upsert_override(skill_id, skill)
return {
"ok": True,
"skill": _skill_to_full_dict(stored),
"origin": "cloud",
"locally_overridden": True,
}
def _delete_skill(skill_id: str) -> dict[str, Any]:
if view.is_local_skill(skill_id):
local.delete_local(skill_id)
return {"ok": True, "deleted": skill_id, "origin": "local"}
if view.has_override(skill_id):
local.remove_override(skill_id)
return {
"ok": True,
"deleted_override": skill_id,
"origin": "cloud",
}
return _error_response(
SkillAuthoringError(
f"skill {skill_id} is a cloud skill with no local override to remove"
)
)
return {
"list_skills": _list_skills,
"search_skills": _search_skills,
"get_skill": _get_skill,
"resolve_flow_template": _resolve_flow_template,
"create_skill": _create_skill,
"update_skill": _update_skill,
"delete_skill": _delete_skill,
}
@@ -124,18 +188,20 @@ def register_skill_catalog_tools(
server: Any,
*,
store: SkillCatalogStore,
local_store: LocalSkillStore,
get_active_subscriptions: Callable[[], set[str]],
get_registered_tools: Callable[[], set[str]] | None = None,
) -> Any:
"""Register ``list_skills``/``search_skills``/``get_skill``/
``resolve_flow_template`` as MCP tools on ``server``.
"""Register the skill MCP tools (read + authoring) on ``server``.
Returns the server so the caller can chain. No batch-execute tool is
registered (design D5): the LLM issues each resulting device-capability
tool call itself, preserving the Observe-Think-Act loop.
tool call itself, preserving the Observe-Think-Act loop. Authoring tools
are always registered (design D6): there is no enable/disable gate.
"""
handlers = skill_tool_handlers(
store=store,
local_store=local_store,
get_active_subscriptions=get_active_subscriptions,
get_registered_tools=get_registered_tools,
)
@@ -159,25 +225,58 @@ def register_skill_catalog_tools(
) -> dict[str, Any]:
return handlers["resolve_flow_template"](skill_id=skill_id, params=params)
@server.tool(name="create_skill")
def _create_skill(payload: dict[str, Any]) -> dict[str, Any]:
return handlers["create_skill"](payload=payload)
@server.tool(name="update_skill")
def _update_skill(skill_id: str, payload: dict[str, Any]) -> dict[str, Any]:
return handlers["update_skill"](skill_id=skill_id, payload=payload)
@server.tool(name="delete_skill")
def _delete_skill(skill_id: str) -> dict[str, Any]:
return handlers["delete_skill"](skill_id=skill_id)
return server
def _metadata_to_summary(meta: SkillMetadata) -> dict[str, Any]:
"""Compact metadata for list/search — no Subscription-Platform-specific fields."""
# ----------------------------------------------------------------------
# Serialization helpers
# ----------------------------------------------------------------------
def _summary_to_dict(summary: SkillSummary) -> dict[str, Any]:
meta = summary.metadata
return {
"id": meta.id,
"name": meta.name,
"description": meta.description,
"kind": meta.kind,
"tags": list(meta.tags),
"origin": summary.origin,
"locally_overridden": summary.locally_overridden,
}
def _view_to_dict(view: SkillView) -> dict[str, Any]:
base = _skill_to_full_dict(view.skill)
base["origin"] = view.origin
base["locally_overridden"] = view.locally_overridden
return base
def _skill_to_full_dict(skill: Any) -> dict[str, Any]:
"""Full skill payload for ``get_skill``."""
base = _metadata_to_summary(skill.metadata)
base["version"] = skill.metadata.version
base["updated_at"] = skill.metadata.updated_at.isoformat()
"""Full skill payload for ``get_skill`` / authoring responses."""
meta: SkillMetadata = skill.metadata
base = {
"id": meta.id,
"name": meta.name,
"description": meta.description,
"kind": meta.kind,
"tags": list(meta.tags),
"version": meta.version,
"updated_at": meta.updated_at.isoformat(),
}
if isinstance(skill, KnowledgeSkill):
base["content"] = skill.content
elif isinstance(skill, FlowTemplateSkill):
@@ -188,6 +287,46 @@ def _skill_to_full_dict(skill: Any) -> dict[str, Any]:
return base
def _build_skill(payload: dict[str, Any]) -> KnowledgeSkill | FlowTemplateSkill:
"""Construct a Skill from an authoring payload."""
kind = str(payload.get("kind") or "").strip()
name = str(payload.get("name") or "").strip()
if not name:
raise ValueError("skill name must not be empty")
if kind not in ("knowledge", "flow_template"):
raise ValueError(f"unsupported skill kind: {kind!r}")
tags = [str(tag) for tag in payload.get("tags") or []]
meta = SkillMetadata(name=name, kind=kind, tags=tags) # type: ignore[arg-type]
if kind == "knowledge":
return KnowledgeSkill(metadata=meta, content=str(payload.get("content") or ""))
steps = [
FlowStep(
tool_name=str(step.get("tool_name") or step.get("action") or ""),
args=dict(step.get("args") or {}),
)
for step in (payload.get("steps") or [])
]
parameters = {
str(name): dict(schema)
for name, schema in (payload.get("parameters") or {}).items()
}
return FlowTemplateSkill(metadata=meta, steps=steps, parameters=parameters)
def _with_id(skill: KnowledgeSkill | FlowTemplateSkill, skill_id: str):
meta = skill.metadata
from dataclasses import replace
new_meta = replace(meta, id=skill_id)
if isinstance(skill, KnowledgeSkill):
return KnowledgeSkill(metadata=new_meta, content=skill.content)
return FlowTemplateSkill(
metadata=new_meta,
steps=list(skill.steps),
parameters={n: dict(s) for n, s in skill.parameters.items()},
)
def _error_response(exc: Exception) -> dict[str, Any]:
return {"ok": False, "error": _semantic_skill_error(exc)}
@@ -200,4 +339,7 @@ def _semantic_skill_error(exc: Exception) -> str:
if isinstance(exc, MissingParameterError):
message = str(exc)
return f"missing parameter: {message}" if message else "invalid parameter"
if isinstance(exc, SkillAuthoringError):
message = str(exc)
return f"authoring error: {message}" if message else "authoring error"
return "operation failed"
+159
View File
@@ -0,0 +1,159 @@
"""Unified read-merge surface over the synced catalog and the local skill store.
API layer per CONSTITUTION.md: this module reads from both
:mod:`storage.skill_catalog` (cloud-synced, read-only-except-sync) and
:mod:`storage.local_skills` (agent-authored + overrides) and presents a single
origin-discriminated catalog. It is the only module that reads both stores.
Override semantics (design D8/D9/D11): a local override shadows the cloud
skill at read time, wins against sync updates, and is reported with
``origin = "cloud"`` and ``locally_overridden = true`` while it shadows.
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import Literal
from skills_learning.models import (
FlowTemplateSkill,
KnowledgeSkill,
SkillMetadata,
)
from storage.local_skills import LocalSkillStore
from storage.skill_catalog import SkillCatalogStore
Origin = Literal["cloud", "local"]
@dataclass(frozen=True)
class SkillSummary:
"""Metadata + origin for list/search results."""
metadata: SkillMetadata
origin: Origin
locally_overridden: bool
@dataclass(frozen=True)
class SkillView:
"""Full skill + origin for get_skill results."""
skill: KnowledgeSkill | FlowTemplateSkill
origin: Origin
locally_overridden: bool
class SkillCatalogView:
"""Merged read surface over synced + local skills, with override precedence."""
def __init__(self, synced: SkillCatalogStore, local: LocalSkillStore) -> None:
self._synced = synced
self._local = local
# ------------------------------------------------------------------
# Merged reads
# ------------------------------------------------------------------
def list_skills(
self,
active_subscriptions: set[str],
) -> list[SkillSummary]:
override_ids = self._local.list_override_cloud_ids()
summaries: list[SkillSummary] = []
# Local authored skills.
for meta in self._local.list_local():
summaries.append(SkillSummary(meta, "local", False))
# Cloud-synced skills, applying overrides where present.
for meta in self._synced.list_skills(active_subscriptions):
if meta.id in override_ids:
override = self._local.get_override(meta.id)
if override is not None:
summaries.append(SkillSummary(override.metadata, "cloud", True))
continue
summaries.append(SkillSummary(meta, "cloud", False))
summaries.sort(key=lambda s: s.metadata.name)
return summaries
def search_skills(
self,
query: str,
active_subscriptions: set[str],
) -> list[SkillSummary]:
override_ids = self._local.list_override_cloud_ids()
summaries: list[SkillSummary] = []
for meta in self._local.search_local(query):
summaries.append(SkillSummary(meta, "local", False))
for meta in self._synced.search_skills(query, active_subscriptions):
if meta.id in override_ids:
override = self._local.get_override(meta.id)
if override is not None:
summaries.append(SkillSummary(override.metadata, "cloud", True))
continue
summaries.append(SkillSummary(meta, "cloud", False))
return summaries
def get_skill(
self,
skill_id: str,
active_subscriptions: set[str],
*,
registered_tools: set[str] | None = None,
) -> SkillView | None:
# 1. Local authored skill.
local_skill = self._local.get_local(skill_id)
if local_skill is not None:
return SkillView(local_skill, "local", False)
# 2. Override shadowing a cloud skill id.
override = self._local.get_override(skill_id)
if override is not None:
if (
isinstance(override, FlowTemplateSkill)
and registered_tools is not None
and not _tools_valid(override, registered_tools)
):
return None
return SkillView(override, "cloud", True)
# 3. Cloud-synced skill (None for unknown AND not-visible — no leak).
cloud_skill = self._synced.get_skill(
skill_id,
active_subscriptions,
registered_tools=registered_tools,
)
if cloud_skill is not None:
return SkillView(cloud_skill, "cloud", False)
return None
# ------------------------------------------------------------------
# Origin classification for authoring dispatch (D10)
# ------------------------------------------------------------------
def is_local_skill(self, skill_id: str) -> bool:
return self._local.get_local(skill_id) is not None
def has_override(self, cloud_skill_id: str) -> bool:
return self._local.has_override(cloud_skill_id)
def _tools_valid(skill: FlowTemplateSkill, registered_tools: set[str]) -> bool:
return all(step.tool_name in registered_tools for step in skill.steps)
def make_view_from_stores(
synced: SkillCatalogStore,
local: LocalSkillStore,
) -> SkillCatalogView:
return SkillCatalogView(synced, local)
def default_local_store() -> LocalSkillStore:
"""Lazy default local store (created on first use)."""
return LocalSkillStore()
+106 -4
View File
@@ -7,6 +7,7 @@ truth" contract is enforced by that import boundary.
Push (webhook) is optional; the baseline pull loop is correct standalone.
"""
from __future__ import annotations
import logging
@@ -21,6 +22,7 @@ from skills_learning.models import (
KnowledgeSkill,
Skill,
)
from storage.local_skills import LocalSkillStore
from storage.skill_catalog import SkillCatalogStore
log = logging.getLogger(__name__)
@@ -144,6 +146,98 @@ def _parse_sync_payload(payload: dict[str, Any]) -> SyncDelta:
)
class CloudApiSkillClient:
"""Concrete client for this project's Cloud API per-host skill sync endpoint.
The ``subscription_id`` passed to :meth:`fetch_entitled_skills` is the
agent's host identifier; the endpoint is
``GET /internal/v1/hosts/{host_id}/skills/sync`` authenticated with the
same host-scoped bearer used for heartbeat/planner-decision.
"""
def __init__(
self,
base_url: str,
host_token: str,
*,
timeout: float = 30.0,
client: httpx.Client | None = None,
) -> None:
self.base_url = base_url.rstrip("/")
self.host_token = host_token
self.timeout = timeout
self._client = client
def fetch_entitled_skills(
self,
subscription_id: str,
since_version: int | None = None,
) -> SyncDelta:
url = f"{self.base_url}/internal/v1/hosts/{subscription_id}/skills/sync"
params: dict[str, Any] = {}
if since_version is not None:
params["since_version"] = str(since_version)
response = self._ensure_client().get(
url,
params=params or None,
headers={"Authorization": f"Bearer {self.host_token}"},
timeout=self.timeout,
)
response.raise_for_status()
return _parse_cloud_sync_payload(response.json())
def report_inventory(self, host_id: str, inventory: list[dict[str, Any]]) -> None:
"""Best-effort local-skill inventory report to the Cloud (design D7)."""
response = self._ensure_client().post(
f"{self.base_url}/internal/v1/hosts/{host_id}/skills/inventory",
json={"skills": inventory},
headers={"Authorization": f"Bearer {self.host_token}"},
timeout=self.timeout,
)
response.raise_for_status()
def close(self) -> None:
if self._client is not None:
self._client.close()
self._client = None
def _ensure_client(self) -> httpx.Client:
if self._client is None:
self._client = httpx.Client()
return self._client
def _parse_cloud_sync_payload(payload: dict[str, Any]) -> SyncDelta:
"""Parse the Cloud API sync response into a SyncDelta.
The Cloud skill payloads carry ``revision`` (mapped to the local
``version``) and kind-specific ``content``/``steps``/``parameters`` fields
that line up with :class:`skills_learning.models` ``from_dict``.
"""
skills: list[Skill] = []
for item in payload.get("skills") or []:
normalized = dict(item)
if "version" not in normalized and "revision" in normalized:
normalized["version"] = normalized["revision"]
source = "cloud"
kind = normalized.get("kind", "knowledge")
normalized["source"] = source
if kind == "flow_template":
skills.append(FlowTemplateSkill.from_dict(normalized))
else:
skills.append(KnowledgeSkill.from_dict(normalized))
removed_ids = [str(rid) for rid in payload.get("removed_ids") or []]
latest_raw = payload.get("latest_version")
latest_version = int(latest_raw) if latest_raw is not None else None
is_full_replace = bool(payload.get("is_full_replace", True))
return SyncDelta(
skills=skills,
removed_ids=removed_ids,
latest_version=latest_version,
is_full_replace=is_full_replace,
)
class SkillSyncRunner:
"""Drives periodic sync between the Subscription Platform and local catalog.
@@ -160,11 +254,13 @@ class SkillSyncRunner:
client: SubscriptionClient,
subscriptions: list[str],
poll_interval: float = 300.0,
local_store: LocalSkillStore | None = None,
) -> None:
self.store = store
self.client = client
self.subscriptions = list(subscriptions)
self.poll_interval = poll_interval
self.local_store = local_store
self._stop = threading.Event()
self._thread: threading.Thread | None = None
self._tick_lock = threading.Lock()
@@ -209,12 +305,13 @@ class SkillSyncRunner:
self._stop.wait(self.poll_interval)
def _sync_one(self, subscription_id: str) -> SyncOutcome:
since_version = self.store._get_subscription_version(subscription_id)
try:
delta = self.client.fetch_entitled_skills(subscription_id)
except Exception as exc:
log.warning(
"skill sync fetch failed for %s: %s", subscription_id, exc
delta = self.client.fetch_entitled_skills(
subscription_id, since_version=since_version
)
except Exception as exc:
log.warning("skill sync fetch failed for %s: %s", subscription_id, exc)
self.store._set_subscription_state(
subscription_id,
last_error=f"{type(exc).__name__}: {exc}",
@@ -233,6 +330,11 @@ class SkillSyncRunner:
for skill in delta.skills:
self.store._apply_sync_upsert(skill, subscription_id)
for skill_id in delta.removed_ids:
# Fork-on-revocation (design D9): if a local override shadows
# this cloud skill, promote it to a standalone local skill
# before the cloud id disappears from the synced store.
if self.local_store is not None:
self.local_store.fork_override_to_local(skill_id)
self.store._apply_sync_remove(skill_id)
self.store._set_subscription_state(
subscription_id,
+21
View File
@@ -32,6 +32,7 @@ from cloud.control_config import (
from cloud.database import CloudDatabase
from cloud.internal_api.api import create_internal_router
from cloud.llm_providers import LlmProviderService
from cloud.skills import CloudSkillService
from cloud.plugins import PluginRegistry
from cloud.observability import (
CORRELATION_HEADER,
@@ -46,6 +47,7 @@ from cloud.schema import require_current_schema
from cloud.sdk.api import create_cloud_router
from cloud.sdk.governance_api import create_governance_router
from cloud.sdk.llm_provider_api import create_llm_provider_router
from cloud.sdk.skill_api import create_skill_host_router, create_skill_management_router
from cloud.sdk.user_api import create_user_auth_router
from cloud.user_auth import USER_CSRF_COOKIE, USER_SESSION_COOKIE, UserAuthService, UserAuthSettings
from core.models import utc_now
@@ -130,6 +132,7 @@ def create_app(
),
)
llm_provider_service = LlmProviderService(repository)
cloud_skill_service = CloudSkillService(repository)
auth_provider = ChainedAuthProvider(
(
configured_auth_provider,
@@ -325,6 +328,24 @@ def create_app(
),
)
)
app.include_router(
create_skill_management_router(
service=cloud_skill_service,
repository=repository,
auth_provider=auth_provider,
csrf_validator=lambda request, principal: _valid_csrf_request(
request,
principal,
user_auth_service,
),
)
)
app.include_router(
create_skill_host_router(
service=cloud_skill_service,
auth_provider=auth_provider,
)
)
app.include_router(
create_internal_router(
pool=pool,
+1 -1
View File
@@ -2,7 +2,7 @@
name = "device-cloud-api"
version = "0.1.0"
description = "Deployable Cloud Control Plane API for Device Agent Runtime."
requires-python = ">=3.14"
requires-python = ">=3.13,<3.14"
dependencies = [
"device-cloud-platform==0.1.0",
"fastapi>=0.115.0",
@@ -0,0 +1,143 @@
"""Repository + service tests for Cloud-managed skills and per-host sync.
Uses an in-memory SQLite engine. Covers skill CRUD, per-host entitlement, the
monotonic entitlement_version bump, and incremental vs full-replace
fetch_host_delta semantics (design D2/D3).
"""
from __future__ import annotations
import json
import pytest
from sqlalchemy import create_engine
from sqlalchemy.pool import StaticPool
from cloud.db_models import Base
from cloud.skills import (
CloudSkillConflictError,
CloudSkillValidationError,
CloudSkillService,
)
from cloud.sql_repository import SQLAlchemyCloudRepository
from core.models import utc_now
@pytest.fixture
def service():
engine = create_engine(
"sqlite://",
connect_args={"check_same_thread": False},
poolclass=StaticPool,
)
Base.metadata.create_all(engine)
repo = SQLAlchemyCloudRepository(engine=engine, create_schema=False)
return CloudSkillService(repo)
def _knowledge_payload(name: str, content: str = "body") -> dict:
return dict(
name=name,
kind="knowledge",
description="d",
tags=["t"],
content=content,
steps_json="[]",
parameters_json="{}",
)
def test_create_list_get_skill(service):
now = utc_now()
created = service.create_skill(now=now, **_knowledge_payload("Alpha"))
assert created.name == "Alpha"
assert created.kind == "knowledge"
[fetched] = service.list_skills()
assert fetched.id == created.id
assert service.get_skill(created.id).content == "body"
def test_create_rejects_duplicate_name(service):
service.create_skill(now=utc_now(), **_knowledge_payload("Alpha"))
with pytest.raises(CloudSkillConflictError):
service.create_skill(now=utc_now(), **_knowledge_payload("Alpha"))
def test_create_rejects_blank_content_for_knowledge(service):
payload = _knowledge_payload("Alpha", content=" ")
with pytest.raises(CloudSkillValidationError):
service.create_skill(now=utc_now(), **payload)
def test_grant_revoke_entitlement_drives_delta(service):
now = utc_now()
skill = service.create_skill(now=now, **_knowledge_payload("Alpha"))
host = "host-1"
# First sync: no entitlements yet -> empty full replace.
delta = service.fetch_host_delta(host, since_version=None)
assert delta.is_full_replace is True
assert delta.skills == []
assert delta.latest_version == 0
# Grant -> version bumps, next full sync sees the skill.
service.grant_entitlement(skill.id, host, now=now)
full = service.fetch_host_delta(host, since_version=None)
assert [s.id for s in full.skills] == [skill.id]
assert full.latest_version == 1
# Incremental from 0 returns the grant.
incr = service.fetch_host_delta(host, since_version=0)
assert incr.is_full_replace is False
assert [s.id for s in incr.skills] == [skill.id]
assert incr.removed_ids == []
# Revoke -> version bumps, incremental reports removal.
service.revoke_entitlement(skill.id, host, now=now)
after = service.fetch_host_delta(host, since_version=incr.latest_version)
assert after.removed_ids == [skill.id]
assert after.skills == []
def test_skill_content_update_notifies_entitled_hosts(service):
now = utc_now()
skill = service.create_skill(now=now, **_knowledge_payload("Alpha", "v1"))
service.grant_entitlement(skill.id, "host-1", now=now)
baseline = service.fetch_host_delta("host-1", since_version=None).latest_version
updated = service.update_skill(
skill.id, now=utc_now(), **_knowledge_payload("Alpha", "v2")
)
assert updated.revision == 2
incr = service.fetch_host_delta("host-1", since_version=baseline)
assert [s.id for s in incr.skills] == [skill.id]
assert incr.skills[0].content == "v2"
def test_delete_skill_removes_and_notifies_entitled_hosts(service):
now = utc_now()
skill = service.create_skill(now=now, **_knowledge_payload("Alpha"))
service.grant_entitlement(skill.id, "host-1", now=now)
baseline = service.fetch_host_delta("host-1", since_version=None).latest_version
service.delete_skill(skill.id)
assert service.get_skill(skill.id) is None
after = service.fetch_host_delta("host-1", since_version=baseline)
assert after.removed_ids == [skill.id]
def test_stale_since_version_falls_back_to_full_replace(service):
now = utc_now()
skill = service.create_skill(now=now, **_knowledge_payload("Alpha"))
service.grant_entitlement(skill.id, "host-1", now=now)
# A version older than anything in the changelog must yield a full replace.
delta = service.fetch_host_delta("host-1", since_version=-5)
assert delta.is_full_replace is True
def test_inventory_record_and_readback(service):
now = utc_now()
payload = json.dumps([{"id": "local-1", "name": "My Note", "origin": "local"}])
service.record_host_inventory("host-1", payload, now=now)
entry = service.get_host_inventory("host-1")
assert entry is not None
assert json.loads(entry.payload_json)[0]["name"] == "My Note"
@@ -10,10 +10,10 @@ from cloud_api.app import create_app
class _FakePlannerClient:
def __init__(self) -> None:
self.calls = 0
self.calls: list[dict[str, object]] = []
def decide(self, **_kwargs) -> ToolCallDecision:
self.calls += 1
def decide(self, **kwargs) -> ToolCallDecision:
self.calls.append(kwargs)
return ToolCallDecision(tool_name="tap", arguments={"x": 1, "y": 2})
@@ -38,7 +38,12 @@ def _login_admin(client: TestClient) -> dict[str, str]:
def _create_profile(
client: TestClient, headers: dict[str, str], *, name: str, model: str
client: TestClient,
headers: dict[str, str],
*,
name: str,
model: str,
timeout_seconds: float,
) -> dict:
response = client.post(
"/v1/planner/providers",
@@ -48,7 +53,7 @@ def _create_profile(
"provider_type": "openai-compatible",
"model": model,
"base_url": "https://compat.example/v1",
"timeout_seconds": 30,
"timeout_seconds": timeout_seconds,
"api_key": f"key-for-{name}",
},
)
@@ -95,7 +100,11 @@ def test_planner_uses_the_newly_activated_database_profile(monkeypatch) -> None:
with TestClient(app) as client:
admin_headers = _login_admin(client)
first = _create_profile(
client, admin_headers, name="First", model="first-model"
client,
admin_headers,
name="First",
model="first-model",
timeout_seconds=41,
)
assert (
client.post(
@@ -115,9 +124,14 @@ def test_planner_uses_the_newly_activated_database_profile(monkeypatch) -> None:
assert first_decision.status_code == 200, first_decision.text
assert resolved_profiles[-1].profile.model == "first-model"
assert resolved_profiles[-1].api_key == "key-for-First"
assert fake.calls[-1]["timeout"] == 41
second = _create_profile(
client, admin_headers, name="Second", model="second-model"
client,
admin_headers,
name="Second",
model="second-model",
timeout_seconds=57,
)
settings = client.get("/v1/planner/providers").json()["settings"]
activated = client.post(
@@ -134,7 +148,8 @@ def test_planner_uses_the_newly_activated_database_profile(monkeypatch) -> None:
)
assert second_decision.status_code == 200, second_decision.text
assert resolved_profiles[-1].profile.model == "second-model"
assert fake.calls == 2
assert fake.calls[-1]["timeout"] == 57
assert len(fake.calls) == 2
def test_planner_fails_closed_without_an_active_database_profile(monkeypatch) -> None:
@@ -0,0 +1,128 @@
"""HTTP tests for the Cloud skill management admin router.
Mirrors the llm-provider management test setup (in-memory DB, admin login,
CSRF). Covers skill CRUD, per-host entitlement grant/revoke, authorization
(non-admin rejected), and a basic sync-endpoint auth guard.
"""
from __future__ import annotations
from fastapi.testclient import TestClient
from cloud.control_config import CloudControlConfig
from cloud_api.app import create_app
def _create_admin(client: TestClient) -> None:
client.app.state.cloud_services.user_auth_service.create_user(
username="admin",
display_name="Administrator",
role="admin",
password="correct-horse-battery-staple",
must_change_password=False,
)
response = client.post(
"/v1/auth/login",
json={"username": "admin", "password": "correct-horse-battery-staple"},
)
assert response.status_code == 200
def _csrf_headers(client: TestClient) -> dict[str, str]:
token = client.cookies.get("amcp_csrf")
assert token is not None
return {"X-CSRF-Token": token}
def _skill_payload(**overrides: object) -> dict[str, object]:
payload: dict[str, object] = {
"name": "Search Notes",
"kind": "knowledge",
"description": "how to search",
"tags": ["search"],
"content": "type and press enter",
"steps": [],
"parameters": {},
}
payload.update(overrides)
return payload
def _client() -> TestClient:
app = create_app(config=CloudControlConfig(database_url="sqlite:///:memory:"))
return TestClient(app)
def test_admin_can_create_list_get_update_delete_skill():
with _client() as client:
_create_admin(client)
headers = _csrf_headers(client)
created = client.post("/v1/skills", json=_skill_payload(), headers=headers)
assert created.status_code == 201, created.text
skill_id = created.json()["id"]
listed = client.get("/v1/skills", headers=headers)
assert listed.status_code == 200
assert any(s["id"] == skill_id for s in listed.json()["items"])
fetched = client.get(f"/v1/skills/{skill_id}", headers=headers)
assert fetched.status_code == 200
assert fetched.json()["content"] == "type and press enter"
updated = client.patch(
f"/v1/skills/{skill_id}",
json=_skill_payload(content="new content"),
headers=headers,
)
assert updated.status_code == 200, updated.text
assert updated.json()["content"] == "new content"
deleted = client.delete(f"/v1/skills/{skill_id}", headers=headers)
assert deleted.status_code == 204
assert client.get(f"/v1/skills/{skill_id}", headers=headers).status_code == 404
def test_duplicate_skill_name_conflicts():
with _client() as client:
_create_admin(client)
headers = _csrf_headers(client)
first = client.post("/v1/skills", json=_skill_payload(), headers=headers)
assert first.status_code == 201
second = client.post("/v1/skills", json=_skill_payload(), headers=headers)
assert second.status_code == 409
def test_entitlement_grant_revoke_lists_hosts():
with _client() as client:
_create_admin(client)
headers = _csrf_headers(client)
skill_id = client.post(
"/v1/skills", json=_skill_payload(), headers=headers
).json()["id"]
grant = client.post(
f"/v1/skills/{skill_id}/entitlements/host-1", headers=headers
)
assert grant.status_code == 204
listed = client.get(f"/v1/skills/{skill_id}/entitlements", headers=headers)
assert listed.json()["host_ids"] == ["host-1"]
revoke = client.delete(
f"/v1/skills/{skill_id}/entitlements/host-1", headers=headers
)
assert revoke.status_code == 204
listed = client.get(f"/v1/skills/{skill_id}/entitlements", headers=headers)
assert listed.json()["host_ids"] == []
def test_unauthenticated_request_is_rejected():
with _client() as client:
response = client.get("/v1/skills")
assert response.status_code == 401
def test_sync_endpoint_requires_host_credentials():
with _client() as client:
# No host credentials -> 401 (no skill content leaked).
response = client.get("/internal/v1/hosts/host-1/skills/sync")
assert response.status_code == 401
+81 -17
View File
@@ -1,8 +1,9 @@
from __future__ import annotations
import asyncio
import logging
from contextlib import suppress
from dataclasses import dataclass
from dataclasses import dataclass, replace
import uvicorn
@@ -11,6 +12,8 @@ from device.manager import DeviceManager
from host_agent.assignment import AssignmentExecutor
from host_agent.client import HostAgentClient, HostAgentEnrollmentClient
from host_agent.config import HostAgentConfig, load_host_agent_config
from host_agent.conversation import ConversationAgent
from host_agent.conversation_log import ConversationLogStore
from host_agent.dependency_supervisor import DependencySupervisor
from host_agent.devices import register_local_device
from host_agent.enrollment import resolve_host_identity
@@ -21,12 +24,19 @@ from host_agent.identity import HostIdentityStore
from host_agent.instance_lock import InstanceLock
from host_agent.lease import ActiveAssignmentRunner
from host_agent.local_account import LocalAccountStore
from host_agent.local_client import LocalHostAgentClient
from host_agent.mcp_lock import McpBusyTracker
from host_agent.mcp_token import McpTokenStore
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.skill_sync import HostAgentSkillSync
from host_agent.status import AgentStatusTracker
from host_agent.web.app import create_console_app
from host_agent.web.auth import SessionManager
from host_agent.web.mcp import build_mcp_server
from runtime.executor import default_tool_registry
from runtime.planner_config import load_config as load_planner_config
from storage.artifact_store import ArtifactStore
from storage.device_config import DeviceConfigStore
from storage.task_metadata import TaskMetadataStore
@@ -42,6 +52,7 @@ class HostAgentApplication:
console_enrollment_client: HostAgentEnrollmentClient | None = None
dependency_supervisor: DependencySupervisor | None = None
instance_lock: InstanceLock | None = None
skill_sync: HostAgentSkillSync | None = None
def run(self) -> None:
asyncio.run(self.run_async())
@@ -60,6 +71,8 @@ class HostAgentApplication:
)
heartbeat_stop = asyncio.Event()
heartbeat_task = asyncio.create_task(self.heartbeat.run(heartbeat_stop))
if self.skill_sync is not None:
self.skill_sync.start()
console_task = (
asyncio.create_task(self.console_server.serve())
if self.console_server is not None
@@ -110,6 +123,8 @@ class HostAgentApplication:
finally:
if self.console_enrollment_client is not None:
self.console_enrollment_client.close()
if self.skill_sync is not None:
self.skill_sync.stop()
await self.client.aclose()
if self.instance_lock is not None:
self.instance_lock.release()
@@ -163,25 +178,37 @@ def create_application(
startup_config.identity_path
)
owned_enrollment_client = enrollment_client is None
bootstrap_client = enrollment_client or HostAgentEnrollmentClient(
startup_config
)
bootstrap_client = enrollment_client or HostAgentEnrollmentClient(startup_config)
try:
resolved_config = resolve_host_identity(
startup_config,
identity_store=resolved_identity_store,
client=bootstrap_client,
)
bootstrap_client.config = resolved_config
resolved_manager = manager or _configured_device_manager(
config_store,
config=resolved_config,
enrollment_client=bootstrap_client,
)
if startup_config.mode == "local":
resolved_config = replace(
startup_config, control_plane_url="", host_id="local-host"
)
resolved_manager = manager or _configured_device_manager(
config_store, config=resolved_config, enrollment_client=None
)
else:
resolved_config = resolve_host_identity(
startup_config,
identity_store=resolved_identity_store,
client=bootstrap_client,
)
bootstrap_client.config = resolved_config
resolved_manager = manager or _configured_device_manager(
config_store,
config=resolved_config,
enrollment_client=bootstrap_client,
)
finally:
if owned_enrollment_client:
bootstrap_client.close()
client = HostAgentClient(resolved_config)
if resolved_config.mode == "local":
client = LocalHostAgentClient(
host_id="local-host",
device_ids=lambda: [device.id for device in resolved_manager.list_devices()],
)
else:
client = HostAgentClient(resolved_config)
history_store = ConsoleHistoryStore(
resolved_config.identity_path.parent / "host_console_history.sqlite3",
@@ -195,13 +222,40 @@ def create_application(
db_path=resolved_config.task_progress_db_path
)
timeline = Timeline(ArtifactStore(root=resolved_config.task_artifact_dir))
mcp_token_path = resolved_config.identity_path.parent / "host_mcp_token.json"
mcp_token_existed = mcp_token_path.exists()
mcp_token_store = McpTokenStore(mcp_token_path)
mcp_token_store.load_or_create()
if not mcp_token_existed:
logging.getLogger(__name__).info(
"MCP token generated at %s", mcp_token_path
)
mcp_busy_tracker = McpBusyTracker(ttl_seconds=20.0)
conversation_log = ConversationLogStore(resolved_config.conversation_log_path) if resolved_config.mode == "local" else None
executor = AssignmentExecutor(
create_execution_factories(
resolved_manager,
metadata_store=metadata_store,
timeline=timeline,
host_agent_config=resolved_config,
conversation_log=conversation_log,
),
mcp_busy_tracker=mcp_busy_tracker,
)
mcp_server = build_mcp_server(
manager=resolved_manager,
mcp_busy_tracker=mcp_busy_tracker,
status_tracker=status_tracker,
)
planner_config = load_planner_config()
conversation_agent = (
ConversationAgent(
config=planner_config,
tools=default_tool_registry(manager=resolved_manager),
event_logger=conversation_log.append if conversation_log else None,
)
if resolved_config.ai_planner_transport == "direct"
else None
)
console_app = create_console_app(
config=resolved_config,
@@ -219,6 +273,11 @@ def create_application(
metadata_store=metadata_store,
timeline=timeline,
executor=executor,
mcp_server=mcp_server,
mcp_token_store=mcp_token_store,
mcp_busy_tracker=mcp_busy_tracker,
conversation_agent=conversation_agent,
conversation_log=conversation_log,
)
console_server = _EmbeddedConsoleServer(
uvicorn.Config(
@@ -234,6 +293,7 @@ def create_application(
client,
resolved_config,
status_tracker=status_tracker,
mcp_busy_tracker=mcp_busy_tracker,
on_sync=lambda device_count: history_store.record_heartbeat(
device_count=device_count
),
@@ -263,6 +323,9 @@ def create_application(
dependency_supervisor = DependencySupervisor.from_host_agent_config(
resolved_config
)
skill_sync: HostAgentSkillSync | None = None
if resolved_config.host_id and resolved_config.token:
skill_sync = HostAgentSkillSync(resolved_config)
return HostAgentApplication(
client=client,
heartbeat=heartbeat,
@@ -271,6 +334,7 @@ def create_application(
console_enrollment_client=console_enrollment_client,
dependency_supervisor=dependency_supervisor,
instance_lock=instance_lock,
skill_sync=skill_sync,
)
except BaseException:
instance_lock.release()
@@ -319,7 +383,7 @@ def _configured_device_manager(
config_store: DeviceConfigStore,
*,
config: HostAgentConfig,
enrollment_client: HostAgentEnrollmentClient,
enrollment_client: HostAgentEnrollmentClient | None,
) -> DeviceManager:
manager = DeviceManager()
for device in config_store.list():
@@ -2,13 +2,17 @@ from __future__ import annotations
from collections.abc import Callable
from dataclasses import dataclass, field
from typing import Any
from typing import TYPE_CHECKING, Any
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
from runtime.task import is_cancellation_reason
if TYPE_CHECKING:
from host_agent.mcp_lock import McpBusyTracker
@dataclass(frozen=True)
@@ -19,9 +23,15 @@ class AssignmentExecutionResult:
class AssignmentExecutor:
def __init__(self, factories: ExecutionFactories) -> None:
def __init__(
self,
factories: ExecutionFactories,
*,
mcp_busy_tracker: McpBusyTracker | None = None,
) -> None:
self.factories = factories
self._progress = TaskProgressHolder()
self._mcp_busy_tracker = mcp_busy_tracker
def latest_progress(self) -> TaskProgressSnapshot | None:
"""Latest step progress reported by the currently-running assignment."""
@@ -32,18 +42,33 @@ class AssignmentExecutor:
assignment: AssignmentModel,
*,
should_stop: Callable[[], bool] | None = None,
stop_reason: Callable[[], str | None] | None = None,
) -> AssignmentExecutionResult:
self._progress.clear()
if self._mcp_busy_tracker is not None and (
assignment.device_id in self._mcp_busy_tracker.busy_device_ids()
):
return AssignmentExecutionResult(
status="failed",
failure_reason=(
f"device {assignment.device_id} is held by an active MCP session"
),
)
with bind_planner_execution_context(assignment):
if should_stop is not None and should_stop():
reason = stop_reason() if stop_reason is not None else None
return AssignmentExecutionResult(
status="failed",
failure_reason="execution interrupted",
status="cancelled" if is_cancellation_reason(reason) else "failed",
failure_reason=reason or "execution interrupted",
)
if assignment.workflow_definition_id is not None:
return self._execute_workflow(assignment, should_stop=should_stop)
return self._execute_workflow(
assignment, should_stop=should_stop, stop_reason=stop_reason
)
if assignment.goal is not None:
return self._execute_goal(assignment, should_stop=should_stop)
return self._execute_goal(
assignment, should_stop=should_stop, stop_reason=stop_reason
)
return AssignmentExecutionResult(
status="failed",
failure_reason="assignment has neither goal nor workflow definition",
@@ -54,16 +79,25 @@ class AssignmentExecutor:
assignment: AssignmentModel,
*,
should_stop: Callable[[], bool] | None,
stop_reason: Callable[[], str | None] | None,
) -> AssignmentExecutionResult:
task = Task(goal=assignment.goal or "", device_id=assignment.device_id)
if self.factories.metadata_store is not None:
self.factories.metadata_store.create_task(
task,
source_task_id=assignment.task_id,
source_attempt=assignment.attempt,
)
runner = self.factories.task_runner_factory()
runner.on_step_progress = self._progress.update
if should_stop is None:
completed = runner.run(task)
else:
completed = runner.run(task, should_stop=should_stop)
completed = runner.run(
task, should_stop=should_stop, stop_reason=stop_reason
)
return AssignmentExecutionResult(
status="done" if completed.status == "completed" else "failed",
status=_terminal_status(completed.status),
failure_reason=completed.failure_reason,
metadata={
"runtime_task_id": completed.id,
@@ -76,6 +110,7 @@ class AssignmentExecutor:
assignment: AssignmentModel,
*,
should_stop: Callable[[], bool] | None,
stop_reason: Callable[[], str | None] | None,
) -> AssignmentExecutionResult:
definition_id = assignment.workflow_definition_id or ""
definition = self.factories.workflow_store.get_definition(definition_id)
@@ -92,9 +127,10 @@ class AssignmentExecutor:
definition,
device_id=assignment.device_id,
should_stop=should_stop,
stop_reason=stop_reason,
)
return AssignmentExecutionResult(
status="done" if run.status == "completed" else "failed",
status=_terminal_status(run.status),
failure_reason=(
None if run.status == "completed" else f"workflow ended as {run.status}"
),
@@ -103,3 +139,11 @@ class AssignmentExecutor:
"workflow_status": run.status,
},
)
def _terminal_status(runtime_status: str) -> str:
if runtime_status == "completed":
return "done"
if runtime_status == "cancelled":
return "cancelled"
return "failed"
+34
View File
@@ -2,7 +2,9 @@ from __future__ import annotations
import argparse
import getpass
import os
import sys
from pathlib import Path
from collections.abc import Sequence
from dataclasses import replace
@@ -10,6 +12,7 @@ from host_agent.app import create_application
from host_agent.config import load_host_agent_config
from host_agent.instance_lock import InstanceAlreadyRunningError
from host_agent.local_account import LocalAccountStore
from host_agent.mcp_token import McpTokenStore
class LocalAccountSetupError(RuntimeError):
@@ -17,11 +20,20 @@ class LocalAccountSetupError(RuntimeError):
def main(argv: Sequence[str] | None = None) -> None:
_load_dotenv()
parser = argparse.ArgumentParser(description="Run the Device Host Agent")
subparsers = parser.add_subparsers(dest="command")
subparsers.add_parser("setup", help="Create the local operator account")
subparsers.add_parser(
"mcp-token",
help="Print the MCP server bearer token (generating if missing)",
)
args = parser.parse_args(argv)
if args.command == "mcp-token":
_print_mcp_token()
return
try:
if args.command == "setup":
_run_setup()
@@ -54,6 +66,12 @@ def _run_setup() -> None:
print(f"Local account '{account.username}' created.")
def _print_mcp_token() -> None:
config = load_host_agent_config()
store = McpTokenStore(config.identity_path.parent / "host_mcp_token.json")
print(store.load_or_create().token)
def _resolve_config_with_local_account():
config = load_host_agent_config()
store = LocalAccountStore(config.local_account_path)
@@ -81,3 +99,19 @@ def _prompt_and_create(store: LocalAccountStore):
if password != confirm:
raise LocalAccountSetupError("passwords do not match")
return store.create(username, password)
def _load_dotenv() -> None:
"""Load a simple repository-root .env without overriding shell values."""
path = Path.cwd() / ".env"
if not path.is_file():
return
for line in path.read_text(encoding="utf-8").splitlines():
line = line.strip()
if not line or line.startswith("#") or "=" not in line:
continue
name, value = line.split("=", 1)
name = name.strip()
value = value.strip()
if name and name not in os.environ:
os.environ[name] = value
+23 -7
View File
@@ -14,6 +14,7 @@ from cloud.internal_api.models import (
DeviceSnapshotModel,
HeartbeatResponse,
HostEnrollmentResponse,
HostTaskCancellationResponse,
HostTaskSubmissionResponse,
LeaseRenewalResponse,
TaskProgressModel,
@@ -167,17 +168,21 @@ class HostAgentClient:
*,
address: str | None = None,
policy_revision: int = 0,
mcp_busy_device_ids: list[str] | None = None,
) -> HeartbeatResponse:
payload: dict[str, Any] = {
"host_id": self.config.host_id,
"address": address,
"devices": [device.model_dump(mode="json") for device in devices],
"policy_revision": policy_revision,
"planner_transport": self.config.ai_planner_transport,
}
if mcp_busy_device_ids:
payload["mcp_busy_device_ids"] = list(mcp_busy_device_ids)
response = await self._request(
"PUT",
f"/internal/v1/hosts/{self.config.host_id}/heartbeat",
json={
"host_id": self.config.host_id,
"address": address,
"devices": [device.model_dump(mode="json") for device in devices],
"policy_revision": policy_revision,
"planner_transport": self.config.ai_planner_transport,
},
json=payload,
)
return HeartbeatResponse.model_validate(response.json())
@@ -219,6 +224,17 @@ class HostAgentClient:
"control plane returned malformed success payload"
) from exc
async def cancel_task(self, task_id: str) -> HostTaskCancellationResponse:
response = await self._client.request(
"POST",
f"/internal/v1/hosts/{self.config.host_id}/tasks/{task_id}/cancel",
json={"host_id": self.config.host_id},
headers={"Authorization": f"Bearer {self.config.token}"},
)
if not response.is_success:
_raise_api_error(response)
return HostTaskCancellationResponse.model_validate(response.json())
async def claim(self) -> AssignmentModel | None:
response = await self._request(
"POST",
@@ -28,10 +28,21 @@ import httpx
from cloud.internal_api.models import PlannerDecisionError, PlannerDecisionResponse
from host_agent.config import HostAgentConfig
from host_agent.planner_context import current_planner_execution_context
from runtime.tool_calling_client import ToolCallDecision, ToolCallUnavailable, ToolCallUsage
from runtime.tool_calling_client import (
ToolCallDecision,
ToolCallUnavailable,
ToolCallUsage,
)
from runtime.tool_specs import ToolSpec
_CLOUD_PROVIDER_MAX_TIMEOUT_SECONDS = 120.0
_CLOUD_PROXY_TRANSPORT_GRACE_SECONDS = 5.0
_CLOUD_PROXY_HTTP_TIMEOUT_SECONDS = (
_CLOUD_PROVIDER_MAX_TIMEOUT_SECONDS + _CLOUD_PROXY_TRANSPORT_GRACE_SECONDS
)
class CloudProxyToolCallingClient:
def __init__(
self,
@@ -51,11 +62,13 @@ class CloudProxyToolCallingClient:
screenshot: bytes | None,
tools: list[ToolSpec],
timeout: float,
history: list[dict[str, Any]] | None = None,
) -> ToolCallDecision:
payload: dict[str, Any] = {
"host_id": self.config.host_id,
"system_prompt": system_prompt,
"user_prompt": user_prompt,
"history": history or [],
"screenshot_base64": (
base64.b64encode(screenshot).decode("ascii")
if screenshot is not None
@@ -69,7 +82,9 @@ class CloudProxyToolCallingClient:
}
for spec in tools
],
"timeout_seconds": timeout,
# Legacy Cloud API versions use this field. Current Cloud API versions
# resolve the provider timeout from the active Provider profile.
"timeout_seconds": min(timeout, _CLOUD_PROVIDER_MAX_TIMEOUT_SECONDS),
}
context = current_planner_execution_context()
if context is not None:
@@ -85,7 +100,7 @@ class CloudProxyToolCallingClient:
f"/internal/v1/hosts/{self.config.host_id}/planner/decide",
json=payload,
headers={"Authorization": f"Bearer {self.config.token}"},
timeout=timeout + 5,
timeout=_CLOUD_PROXY_HTTP_TIMEOUT_SECONDS,
)
except httpx.HTTPError as exc:
raise ToolCallUnavailable(str(exc)) from exc
@@ -111,6 +126,10 @@ class CloudProxyToolCallingClient:
)
else None
),
text_output=decoded.rationale,
thinking=decoded.thinking,
purpose=decoded.purpose,
expected_outcome=decoded.expected_outcome,
)
raise ToolCallUnavailable(_error_detail(response))
+45 -17
View File
@@ -13,11 +13,18 @@ class HostAgentConfigurationError(ValueError):
_LOOPBACK_BIND_HOSTS = frozenset({"127.0.0.1", "localhost", "::1"})
_AI_PLANNER_TRANSPORTS = frozenset({"direct", "cloud"})
_HOST_AGENT_MODES = frozenset({"cloud", "local"})
_REMOVED_RUNTIME_SUPERVISION_SETTINGS = (
"HOST_AGENT_RUNTIME_SUPERVISED",
"HOST_AGENT_RUNTIME_HOST",
"HOST_AGENT_RUNTIME_PORT",
)
@dataclass(frozen=True)
class HostAgentConfig:
control_plane_url: str
control_plane_url: str = ""
mode: str = "cloud"
host_id: str = ""
token: str = field(default="", repr=False)
identity_path: Path = Path("tasks/host_identity.json")
@@ -34,35 +41,38 @@ class HostAgentConfig:
console_allow_non_loopback: bool = False
console_session_ttl_seconds: float = 43200.0
console_history_limit: int = 200
ai_planner_transport: str = "direct"
ai_planner_transport: str = "cloud"
dependency_supervisor_enabled: bool = False
appium_supervised: bool = False
appium_host: str = "127.0.0.1"
appium_port: int = 4723
runtime_supervised: bool = False
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")
conversation_log_path: Path = Path("host_agent_data/conversations.sqlite3")
task_retention_max_count: int = 50
task_retention_max_age_days: int = 7
skill_sync_interval_seconds: float = 300.0
def load_host_agent_config(
env: Mapping[str, str] | None = None,
) -> HostAgentConfig:
values = os.environ if env is None else env
_reject_removed_runtime_supervision_settings(values)
mode = values.get("HOST_AGENT_MODE", "cloud").strip().lower()
if mode not in _HOST_AGENT_MODES:
raise HostAgentConfigurationError("HOST_AGENT_MODE must be cloud or local")
control_plane_url = (
values.get(
"HOST_AGENT_CONTROL_PLANE_URL",
"https://amcp.home.jerryyan.top",
"https://amcp.home.jerryyan.top" if mode == "cloud" else "",
)
.strip()
.rstrip("/")
)
parsed_url = urlparse(control_plane_url)
if parsed_url.scheme not in {"http", "https"} or not parsed_url.netloc:
if mode == "cloud" and (parsed_url.scheme not in {"http", "https"} or not parsed_url.netloc):
raise HostAgentConfigurationError(
"HOST_AGENT_CONTROL_PLANE_URL must be an HTTP(S) URL"
)
@@ -78,9 +88,10 @@ def load_host_agent_config(
config = HostAgentConfig(
control_plane_url=control_plane_url,
mode=mode,
identity_path=identity_path,
local_account_path=local_account_path,
enrollment_managed=True,
enrollment_managed=mode == "cloud",
display_name=values.get("HOST_AGENT_DISPLAY_NAME") or None,
heartbeat_interval_seconds=_positive_float(
values,
@@ -124,18 +135,17 @@ def load_host_agent_config(
"HOST_AGENT_CONSOLE_HISTORY_LIMIT",
200,
),
ai_planner_transport=_parse_ai_planner_transport(
values.get("AI_PLANNER_TRANSPORT")
),
ai_planner_transport=("direct" if mode == "local" else _parse_ai_planner_transport(values.get("AI_PLANNER_TRANSPORT"))),
dependency_supervisor_enabled=_truthy(
values, "HOST_AGENT_DEPENDENCY_SUPERVISOR_ENABLED", False
values,
"HOST_AGENT_DEPENDENCY_SUPERVISOR_ENABLED",
mode == "local",
),
appium_supervised=_truthy(
values, "HOST_AGENT_APPIUM_SUPERVISED", mode == "local"
),
appium_supervised=_truthy(values, "HOST_AGENT_APPIUM_SUPERVISED", False),
appium_host=values.get("HOST_AGENT_APPIUM_HOST", "127.0.0.1").strip(),
appium_port=_positive_int(values, "HOST_AGENT_APPIUM_PORT", 4723),
runtime_supervised=_truthy(values, "HOST_AGENT_RUNTIME_SUPERVISED", False),
runtime_host=values.get("HOST_AGENT_RUNTIME_HOST", "127.0.0.1").strip(),
runtime_port=_positive_int(values, "HOST_AGENT_RUNTIME_PORT", 8000),
dependency_restart_max_attempts=_positive_int(
values, "HOST_AGENT_DEPENDENCY_RESTART_MAX_ATTEMPTS", 5
),
@@ -150,12 +160,16 @@ def load_host_agent_config(
"HOST_AGENT_TASK_ARTIFACT_DIR", "host_agent_data/history"
).strip()
),
conversation_log_path=Path(values.get("HOST_AGENT_CONVERSATION_LOG_PATH", "host_agent_data/conversations.sqlite3").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
),
skill_sync_interval_seconds=_positive_float(
values, "HOST_AGENT_SKILL_SYNC_INTERVAL_SECONDS", 300.0
),
)
if config.max_retry_backoff_seconds < config.retry_backoff_seconds:
raise HostAgentConfigurationError(
@@ -173,9 +187,23 @@ def load_host_agent_config(
return config
def _reject_removed_runtime_supervision_settings(values: Mapping[str, str]) -> None:
configured = [
setting
for setting in _REMOVED_RUNTIME_SUPERVISION_SETTINGS
if setting in values
]
if configured:
raise HostAgentConfigurationError(
f"{', '.join(configured)} has been removed with the standalone "
"Runtime service. Use the Host Agent console for task evidence "
"and HOST_AGENT_APPIUM_SUPERVISED for optional Appium supervision."
)
def _parse_ai_planner_transport(value: str | None) -> str:
if value is None:
return "direct"
return "cloud"
transport = value.strip().lower()
if transport not in _AI_PLANNER_TRANSPORTS:
raise HostAgentConfigurationError(
@@ -0,0 +1,291 @@
from __future__ import annotations
import json
from dataclasses import dataclass
from typing import Any, Callable
from runtime.planner_config import PlannerConfig
from runtime.tool_specs import ACTION_TOOL_SPECS, ToolSpec
READ_TOOL_SPECS = [
ToolSpec("take_screenshot", "Capture the current device screen.", {"type": "object", "properties": {"device_id": {"type": "string"}}}),
ToolSpec("describe_screen", "Inspect the current screen and UI.", {"type": "object", "properties": {"device_id": {"type": "string"}}}),
ToolSpec("find_text", "Find visible text on the current screen.", {"type": "object", "required": ["query"], "properties": {"query": {"type": "string"}, "device_id": {"type": "string"}}}),
ToolSpec("get_ui_tree", "Read the current accessibility/UI tree.", {"type": "object", "properties": {"device_id": {"type": "string"}, "include_app_info": {"type": "boolean"}}}),
ToolSpec("list_devices", "List configured devices.", {"type": "object", "properties": {}}),
ToolSpec("device_status", "Read one device status.", {"type": "object", "required": ["device_id"], "properties": {"device_id": {"type": "string"}}}),
]
@dataclass(frozen=True)
class ChatResult:
content: str
tool_calls: int
class ConversationAgent:
"""Small OpenAI-compatible agent loop backed by Host Agent tools."""
def __init__(
self,
*,
config: PlannerConfig,
tools: dict[str, Callable[..., Any]],
max_rounds: int = 8,
event_logger: Callable[[dict[str, Any]], None] | None = None,
) -> None:
self.config = config
self.tools = tools
self.max_rounds = max_rounds
self.event_logger = event_logger
self._client: Any | None = None
def chat(self, messages: list[dict[str, Any]], *, tools: dict[str, Callable[..., Any]] | None = None) -> ChatResult:
if self.config.provider == "anthropic":
return self._chat_anthropic(messages, tools=tools)
if self.config.provider not in {"openai", "openai_compatible"}:
raise RuntimeError("unsupported conversation provider")
client = self._get_client()
active_tools = tools or self.tools
history: list[dict[str, Any]] = [
{
"role": "system",
"content": (
"You are a mobile device assistant. Reply naturally when no "
"device action is needed. Call a tool when the user asks you "
"to inspect or operate a device. Never claim an action was "
"completed unless the tool result confirms it."
),
},
*[_normalize_message(message) for message in messages],
]
calls = 0
for _ in range(self.max_rounds):
response = client.chat.completions.create(
model=self.config.resolved_model(),
messages=history,
tools=[_openai_tool(spec) for spec in [*ACTION_TOOL_SPECS, *READ_TOOL_SPECS]],
tool_choice="auto",
parallel_tool_calls=False,
timeout=self.config.timeout,
)
message = response.choices[0].message
text = getattr(message, "content", None)
thinking = getattr(message, "reasoning_content", None) or getattr(message, "reasoning", None)
tool_calls = getattr(message, "tool_calls", None) or []
self._log({"type": "llm_response", "content": text, "thinking": thinking,
"tool_calls": [{"id": c.id, "name": c.function.name, "arguments": c.function.arguments} for c in tool_calls]})
if not tool_calls:
self._log({"type": "final_reply", "content": str(text or "")})
return ChatResult(content=str(text or ""), tool_calls=calls)
history.append(_message_dict(message))
for tool_call in tool_calls:
name = tool_call.function.name
arguments: dict[str, Any] = {}
try:
decoded_arguments = json.loads(tool_call.function.arguments or "{}")
if not isinstance(decoded_arguments, dict):
raise ValueError("tool arguments must be an object")
arguments = decoded_arguments
arguments.pop("purpose", None)
arguments.pop("expected_outcome", None)
result = active_tools[name](**arguments)
except Exception as exc:
result = {"ok": False, "error": str(exc)}
calls += 1
self._log({"type": "tool_result", "tool_call_id": tool_call.id,
"tool_name": name, "arguments": arguments, "result": result})
history.append(
{
"role": "tool",
"tool_call_id": tool_call.id,
"content": json.dumps(result, ensure_ascii=True, default=str),
}
)
raise RuntimeError("conversation exceeded the maximum tool-call rounds")
def _chat_anthropic(self, messages: list[dict[str, Any]], *, tools: dict[str, Callable[..., Any]] | None = None) -> ChatResult:
import anthropic
kwargs: dict[str, Any] = {}
if self.config.api_key:
kwargs["api_key"] = self.config.api_key
if self.config.base_url:
kwargs["base_url"] = self.config.base_url
client = anthropic.Anthropic(**kwargs)
history = [_normalize_anthropic_message(message) for message in messages]
active_tools = tools or self.tools
calls = 0
for _ in range(self.max_rounds):
response = client.messages.create(
model=self.config.resolved_model(),
max_tokens=2048,
system="You are a mobile device assistant. Reply naturally, or call one device tool when needed. Never claim an action succeeded unless its tool result confirms it.",
messages=history,
tools=[_anthropic_tool(spec) for spec in [*ACTION_TOOL_SPECS, *READ_TOOL_SPECS]],
timeout=self.config.timeout,
)
blocks = getattr(response, "content", []) or []
text_parts = [getattr(block, "text", "") for block in blocks if getattr(block, "type", None) == "text"]
thinking_parts = [getattr(block, "thinking", "") for block in blocks if getattr(block, "type", None) == "thinking"]
uses = [block for block in blocks if getattr(block, "type", None) == "tool_use"]
self._log({"type": "llm_response", "content": "\n".join(x for x in text_parts if x), "thinking": "\n".join(x for x in thinking_parts if x), "tool_calls": [{"id": u.id, "name": u.name, "arguments": u.input} for u in uses]})
if not uses:
content = "\n".join(x for x in text_parts if x)
self._log({"type": "final_reply", "content": content})
return ChatResult(content=content, tool_calls=calls)
history.append({"role": "assistant", "content": [_anthropic_block_dict(block) for block in blocks if getattr(block, "type", None) != "thinking"]})
results = []
for use in uses:
arguments = dict(use.input) if isinstance(use.input, dict) else {}
try:
result = active_tools[use.name](**arguments)
except Exception as exc:
result = {"ok": False, "error": str(exc)}
calls += 1
self._log({"type": "tool_result", "tool_call_id": use.id, "tool_name": use.name, "arguments": arguments, "result": result})
results.append({"type": "tool_result", "tool_use_id": use.id, "content": json.dumps(result, ensure_ascii=True, default=str)})
history.append({"role": "user", "content": results})
raise RuntimeError("conversation exceeded the maximum tool-call rounds")
def chat_for_device(self, device_id: str, messages: list[dict[str, Any]]) -> ChatResult:
"""Run a conversation bound to exactly one device."""
device_tools = {
name: _bind_device(tool, device_id, name=name)
for name, tool in self.tools.items()
}
return self.chat(messages, tools=device_tools)
def _log(self, event: dict[str, Any]) -> None:
if self.event_logger is not None:
try:
self.event_logger(event)
except Exception:
pass
def _get_client(self) -> Any:
if self._client is None:
from openai import OpenAI
kwargs: dict[str, Any] = {}
if self.config.api_key:
kwargs["api_key"] = self.config.api_key
if self.config.base_url:
kwargs["base_url"] = self.config.base_url
self._client = OpenAI(**kwargs)
return self._client
def _openai_tool(spec: ToolSpec) -> dict[str, Any]:
parameters = dict(spec.parameters)
properties = dict(parameters.get("properties") or {})
properties.setdefault(
"device_id",
{"type": "string", "description": "Target device ID when needed."},
)
parameters["properties"] = properties
return {
"type": "function",
"function": {
"name": spec.name,
"description": spec.description,
"parameters": parameters,
},
}
def _anthropic_tool(spec: ToolSpec) -> dict[str, Any]:
return {"name": spec.name, "description": spec.description, "input_schema": spec.parameters}
def _anthropic_block_dict(block: Any) -> dict[str, Any]:
block_type = getattr(block, "type", "")
if block_type == "text":
return {"type": "text", "text": getattr(block, "text", "")}
if block_type == "thinking":
return {"type": "thinking", "thinking": getattr(block, "thinking", "")}
return {"type": "tool_use", "id": block.id, "name": block.name, "input": block.input}
def _normalize_anthropic_message(message: dict[str, Any]) -> dict[str, Any]:
normalized = _normalize_message(message)
content = normalized["content"]
if isinstance(content, str):
return normalized
blocks = []
for block in content:
if block.get("type") == "text":
blocks.append(block)
elif block.get("type") == "image_url":
url = block.get("image_url", {}).get("url", "")
if isinstance(url, str) and url.startswith("data:"):
header, data = url.split(",", 1)
media_type = header[5:].split(";", 1)[0]
blocks.append({"type": "image", "source": {"type": "base64", "media_type": media_type, "data": data}})
return {"role": normalized["role"], "content": blocks}
def _message_dict(message: Any) -> dict[str, Any]:
result: dict[str, Any] = {"role": "assistant"}
content = getattr(message, "content", None)
if content is not None:
result["content"] = content
calls = getattr(message, "tool_calls", None) or []
result["tool_calls"] = [
{
"id": call.id,
"type": "function",
"function": {
"name": call.function.name,
"arguments": call.function.arguments,
},
}
for call in calls
]
return result
def _normalize_message(message: dict[str, Any]) -> dict[str, Any]:
"""Accept OpenAI text/image content blocks and a compact image_base64 form."""
role = message.get("role")
content = message.get("content")
if isinstance(content, str):
return {"role": role, "content": content}
if isinstance(content, list):
blocks: list[dict[str, Any]] = []
for block in content:
if not isinstance(block, dict):
continue
if block.get("type") == "text" and isinstance(block.get("text"), str):
blocks.append({"type": "text", "text": block["text"]})
elif block.get("type") == "image_url":
image_url = block.get("image_url")
if isinstance(image_url, str):
image_url = {"url": image_url}
if isinstance(image_url, dict) and isinstance(image_url.get("url"), str):
blocks.append({"type": "image_url", "image_url": {"url": image_url["url"]}})
if blocks:
return {"role": role, "content": blocks}
image = message.get("image_base64")
if isinstance(image, str) and image:
mime = str(message.get("mime_type") or "image/png")
text = message.get("text")
blocks = []
if isinstance(text, str) and text:
blocks.append({"type": "text", "text": text})
blocks.append({"type": "image_url", "image_url": {"url": f"data:{mime};base64,{image}"}})
return {"role": role, "content": blocks}
raise ValueError("message content must be text, image blocks, or image_base64")
def _bind_device(tool: Callable[..., Any], device_id: str, *, name: str) -> Callable[..., Any]:
def bound(**arguments: Any) -> Any:
if name == "list_devices":
return tool(**arguments)
requested = arguments.get("device_id")
if requested is not None and requested != device_id:
raise ValueError(f"conversation is bound to device {device_id}")
arguments["device_id"] = device_id
return tool(**arguments)
return bound
@@ -0,0 +1,48 @@
from __future__ import annotations
import json
import sqlite3
from datetime import UTC, datetime
from pathlib import Path
from threading import Lock
from typing import Any
class ConversationLogStore:
"""SQLite-backed local audit log for chat and tool activity."""
def __init__(self, path: str | Path) -> None:
self.path = Path(path)
self._lock = Lock()
self.path.parent.mkdir(parents=True, exist_ok=True)
with self._connect() as connection:
connection.execute("CREATE TABLE IF NOT EXISTS conversation_events (id INTEGER PRIMARY KEY AUTOINCREMENT, occurred_at TEXT NOT NULL, event_type TEXT NOT NULL, payload_json TEXT NOT NULL)")
def append(self, event: dict[str, Any]) -> None:
with self._lock, self._connect() as connection:
connection.execute("INSERT INTO conversation_events (occurred_at, event_type, payload_json) VALUES (?, ?, ?)", (datetime.now(UTC).isoformat(), str(event.get("type") or "event"), json.dumps(_safe(event), ensure_ascii=True, default=str)))
def list_recent(self, *, limit: int = 200) -> list[dict[str, Any]]:
with self._connect() as connection:
rows = connection.execute("SELECT id, occurred_at, event_type, payload_json FROM conversation_events ORDER BY id DESC LIMIT ?", (max(1, min(limit, 1000)),)).fetchall()
result = []
for row in rows:
try:
payload = json.loads(row["payload_json"])
except (TypeError, ValueError):
payload = {}
result.append({"id": row["id"], "occurred_at": row["occurred_at"], "event_type": row["event_type"], **payload})
return result
def _connect(self) -> sqlite3.Connection:
connection = sqlite3.connect(self.path)
connection.row_factory = sqlite3.Row
return connection
def _safe(value: Any) -> Any:
if isinstance(value, dict):
return {str(k): _safe(v) for k, v in value.items()}
if isinstance(value, list):
return [_safe(v) for v in value]
return value if isinstance(value, (str, int, float, bool)) or value is None else str(value)
@@ -1,7 +1,6 @@
"""Optional supervisor for the two local external processes the Host Agent
depends on for the macOS single-machine real-device workflow: the Appium
server (which gates real ``Driver.connect()``) and the local Runtime API
(used for local inspection).
"""Optional supervisor for Appium in the macOS single-machine real-device
workflow. Appium gates real driver connections; task inspection is provided by
the Host Agent's own console.
Lives in ``host_agent`` because it spawns and monitors host-level processes
alongside the heartbeat/claim loop. Off by default; see ``HostAgentConfig``.
@@ -56,18 +55,6 @@ def probe_appium(host: str, port: int) -> ProbeResult:
return _probe_http(host, port, path="/status")
def probe_runtime(host: str, port: int) -> ProbeResult:
"""Probe the local Runtime API at ``host:port``. Healthy iff ``GET /devices``
returns 200 with a JSON body.
``api.rest.create_app`` does not expose a dedicated ``/health`` endpoint;
``/devices`` is the stable read-only GET that proves the FastAPI app is
mounted and the device manager is reachable. Per design.md Decision 2 this
is the "equivalent existing endpoint" used for the readiness check.
"""
return _probe_http(host, port, path="/devices")
def _probe_http(host: str, port: int, *, path: str) -> ProbeResult:
# Step 1: plain TCP connect — distinguish "nothing listening" (→ spawn)
# from "something is there but wrong" (→ port conflict, skip).
@@ -98,18 +85,6 @@ def appium_argv_factory(host: str, port: int) -> list[str]:
return ["appium", "--address", host, "--port", str(port)]
def runtime_argv_factory(host: str, port: int) -> list[str]:
return [
"uvicorn",
"api.rest:create_app",
"--factory",
"--host",
host,
"--port",
str(port),
]
@dataclass
class SupervisedDependency:
"""Config + mutable runtime state for one supervised external process."""
@@ -190,16 +165,6 @@ class DependencySupervisor:
probe=probe_appium,
)
)
if ha_config.runtime_supervised:
deps.append(
SupervisedDependency(
name="runtime",
host=ha_config.runtime_host,
port=ha_config.runtime_port,
argv_factory=runtime_argv_factory,
probe=probe_runtime,
)
)
return cls(
deps,
max_attempts=ha_config.dependency_restart_max_attempts,
+28 -4
View File
@@ -3,6 +3,7 @@ from __future__ import annotations
import os
from collections.abc import Callable
from dataclasses import dataclass, replace
from typing import Any
from device.manager import DeviceManager
from host_agent.cloud_planner_client import CloudProxyToolCallingClient
@@ -25,6 +26,7 @@ class ExecutionFactories:
task_runner_factory: Callable[[], TaskRunner]
workflow_runner_factory: Callable[[], WorkflowRunner]
workflow_store: WorkflowStore
metadata_store: TaskMetadataStore | None = None
def create_execution_factories(
@@ -34,6 +36,7 @@ def create_execution_factories(
metadata_store: TaskMetadataStore | None = None,
timeline: Timeline | None = None,
host_agent_config: HostAgentConfig | None = None,
conversation_log: Any | None = None,
) -> ExecutionFactories:
shared_workflow_store = workflow_store or WorkflowStore()
resolved_host_agent_config = host_agent_config
@@ -47,8 +50,14 @@ def create_execution_factories(
),
metadata_store=metadata_store,
timeline=timeline,
planner=_host_agent_planner(resolved_host_agent_config),
planner=_host_agent_planner(
resolved_host_agent_config,
event_logger=conversation_log.append if conversation_log is not None else None,
),
planner_config=_host_agent_planner_config(),
device_platform_provider=lambda device_id: _device_platform(
manager, device_id
),
)
def create_workflow_runner() -> WorkflowRunner:
@@ -61,6 +70,7 @@ def create_execution_factories(
task_runner_factory=create_task_runner,
workflow_runner_factory=create_workflow_runner,
workflow_store=shared_workflow_store,
metadata_store=metadata_store,
)
@@ -81,14 +91,15 @@ def _host_agent_planner_config() -> PlannerConfig:
def _host_agent_planner(
host_agent_config: HostAgentConfig | None,
*,
event_logger: Callable[[dict[str, Any]], None] | None = None,
) -> Planner | None:
"""Build the `AIPlanner` explicitly when the cloud-proxy transport is
selected, so its `ToolCallingClient` is a `CloudProxyToolCallingClient`
instead of a local Anthropic/OpenAI SDK client.
Returns `None` (letting `TaskRunner` fall back to its own
`_default_planner()`) for the `direct` transport, which preserves the
existing default-enabled/direct-to-provider behavior unchanged.
`_default_planner()`) only for the explicit `direct` transport.
"""
planner_config = _host_agent_planner_config()
if not planner_config.enabled:
@@ -96,9 +107,22 @@ def _host_agent_planner(
resolved_config = host_agent_config or load_host_agent_config()
if resolved_config.ai_planner_transport != "cloud":
return None
return AIPlanner(config=planner_config, event_logger=event_logger)
return AIPlanner(
client=CloudProxyToolCallingClient(resolved_config),
config=planner_config,
event_logger=event_logger,
)
def _device_platform(manager: DeviceManager, device_id: str) -> str | None:
for device in manager.list_devices():
if device.id != device_id:
continue
if device.driver_type == "wda":
return "ios"
if device.driver_type == "uiautomator2":
return "android"
return None
return None
+23 -1
View File
@@ -14,6 +14,8 @@ from host_agent.status import AgentStatusTracker
if TYPE_CHECKING:
from collections.abc import Awaitable, Callable
from host_agent.mcp_lock import McpBusyTracker
def build_device_snapshot(manager: DeviceManager) -> list[DeviceSnapshotModel]:
return [
@@ -40,6 +42,7 @@ class HeartbeatSynchronizer:
on_sync: Callable[[int], None] | None = None,
policy_cache: HostPolicyCacheStore | None = None,
on_policy_sync: Callable[[int], None] | None = None,
mcp_busy_tracker: McpBusyTracker | None = None,
) -> None:
self.manager = manager
self.client = client
@@ -50,17 +53,26 @@ class HeartbeatSynchronizer:
self.on_sync = on_sync
self.policy_cache = policy_cache
self.on_policy_sync = on_policy_sync
self.mcp_busy_tracker = mcp_busy_tracker
self.policy = policy_cache.load() if policy_cache is not None else None
self.policy_revision = self.policy.revision if self.policy is not None else 0
if self.status_tracker is not None:
self.status_tracker.mark_host_policy(self.policy)
async def sync_once(self) -> HeartbeatResponse:
self.probe_connected_devices()
self.connect_devices()
snapshot = build_device_snapshot(self.manager)
mcp_busy_ids = (
self.mcp_busy_tracker.busy_device_ids()
if self.mcp_busy_tracker is not None
else []
)
response = await self.client.heartbeat(
snapshot,
address=self.address,
policy_revision=self.policy_revision,
mcp_busy_device_ids=mcp_busy_ids,
)
self.policy_revision = response.policy_revision
if response.policy is not None:
@@ -98,9 +110,19 @@ class HeartbeatSynchronizer:
def connect_devices(self) -> None:
for device in self.manager.list_devices():
if device.status != "idle":
if device.status not in {"idle", "offline", "error"}:
continue
try:
self.manager.connect(device.id)
except DeviceRuntimeError:
continue
def probe_connected_devices(self) -> None:
active_id: str | None = None
if self.status_tracker is not None:
current = self.status_tracker.snapshot().get("current_assignment")
if isinstance(current, dict) and isinstance(current.get("device_id"), str):
active_id = current["device_id"]
for device in self.manager.list_devices():
if device.id != active_id and device.status == "busy":
self.manager.probe(device.id)
@@ -0,0 +1,79 @@
from __future__ import annotations
import json
import subprocess
import tempfile
from pathlib import Path
from typing import Any
class IOSDiscoveryError(RuntimeError):
pass
def discover_connected_ios_devices(*, timeout_seconds: int = 10) -> list[dict[str, str]]:
"""Return paired, currently connected physical iOS devices from CoreDevice."""
with tempfile.TemporaryDirectory(prefix="ios-device-discovery-") as temp_dir:
output_path = Path(temp_dir) / "devices.json"
try:
completed = subprocess.run(
[
"xcrun",
"devicectl",
"list",
"devices",
"--json-output",
str(output_path),
"--timeout",
str(timeout_seconds),
"--quiet",
],
capture_output=True,
text=True,
timeout=timeout_seconds + 2,
check=False,
)
except FileNotFoundError as exc:
raise IOSDiscoveryError("xcrun is unavailable; install Xcode command line tools") from exc
except subprocess.TimeoutExpired as exc:
raise IOSDiscoveryError("iOS device discovery timed out") from exc
if completed.returncode != 0:
detail = completed.stderr.strip() or completed.stdout.strip()
raise IOSDiscoveryError(detail or "devicectl failed to discover iOS devices")
try:
payload = json.loads(output_path.read_text(encoding="utf-8"))
except (OSError, ValueError) as exc:
raise IOSDiscoveryError("devicectl returned invalid device data") from exc
raw_devices = payload.get("result", {}).get("devices", [])
devices: list[dict[str, str]] = []
for item in raw_devices if isinstance(raw_devices, list) else []:
if not isinstance(item, dict):
continue
hardware = item.get("hardwareProperties", {})
properties = item.get("deviceProperties", {})
connection = item.get("connectionProperties", {})
if not all(isinstance(value, dict) for value in (hardware, properties, connection)):
continue
udid = hardware.get("udid")
if (
hardware.get("platform") != "iOS"
or hardware.get("reality") != "physical"
or connection.get("pairingState") != "paired"
or connection.get("tunnelState") != "connected"
or not isinstance(udid, str)
or not udid
):
continue
devices.append(
{
"udid": udid,
"name": str(properties.get("name") or hardware.get("marketingName") or "iPhone"),
"model": str(hardware.get("marketingName") or hardware.get("productType") or "iPhone"),
"os_version": str(properties.get("osVersionNumber") or ""),
"transport": str(connection.get("transportType") or "unknown"),
}
)
return sorted(devices, key=lambda device: (device["name"], device["udid"]))
@@ -12,6 +12,7 @@ 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
from runtime.task import is_cancellation_reason
class InterruptibleAssignmentExecutor(Protocol):
@@ -20,6 +21,7 @@ class InterruptibleAssignmentExecutor(Protocol):
assignment: AssignmentModel,
*,
should_stop: Callable[[], bool] | None = None,
stop_reason: Callable[[], str | None] | None = None,
) -> AssignmentExecutionResult: ...
def latest_progress(self) -> TaskProgressSnapshot | None: ...
@@ -36,6 +38,10 @@ class LeaseGuard:
with self._lock:
return self._reason
@property
def is_cancellation(self) -> bool:
return is_cancellation_reason(self.reason)
def is_lost(self) -> bool:
return self._lost.is_set()
@@ -69,6 +75,7 @@ class ActiveAssignmentRunner:
self.executor.execute,
assignment,
should_stop=lambda: guard.is_lost() or self._stop_requested.is_set(),
stop_reason=lambda: guard.reason,
)
)
renewal = asyncio.create_task(
@@ -108,4 +115,7 @@ class ActiveAssignmentRunner:
guard.mark_lost("lease renewal failed after transport retries")
return
else:
if response.cancel_requested:
guard.mark_lost("cancellation requested by control plane")
return
lease_expires_at = response.lease_expires_at
@@ -0,0 +1,72 @@
from __future__ import annotations
import asyncio
import uuid
from collections.abc import Callable
from datetime import UTC, datetime, timedelta
from cloud.internal_api.models import (
ClaimResponse,
HeartbeatResponse,
HostTaskCancellationResponse,
HostTaskSubmissionResponse,
LeaseRenewalResponse,
TerminalResultResponse,
)
from host_agent.progress import TaskProgressSnapshot
class LocalHostAgentClient:
"""In-process task broker used when the Host Agent runs without Cloud."""
def __init__(self, *, host_id: str, device_ids: Callable[[], list[str]]) -> None:
self.host_id = host_id
self._device_ids = device_ids
self._queue: asyncio.Queue[tuple[str, str, str | None]] = asyncio.Queue()
self._cancelled: set[str] = set()
async def submit_self_task(self, *, goal: str, device_id: str | None = None) -> HostTaskSubmissionResponse:
task_id = f"local-{uuid.uuid4().hex}"
await self._queue.put((task_id, goal, device_id))
return HostTaskSubmissionResponse(task_id=task_id)
async def claim(self):
task_id, goal, requested_device = await self._queue.get()
devices = self._device_ids()
device_id = requested_device or (devices[0] if devices else "")
if not device_id:
return None
from cloud.internal_api.models import AssignmentModel
return AssignmentModel(
task_id=task_id,
attempt=1,
lease_id=f"local-lease-{uuid.uuid4().hex}",
lease_expires_at=datetime.now(UTC) + timedelta(days=3650),
host_id=self.host_id,
device_id=device_id,
goal=goal,
)
async def heartbeat(self, *args, **kwargs) -> HeartbeatResponse:
return HeartbeatResponse(
host_id=self.host_id,
accepted_devices=len(self._device_ids()),
received_at=datetime.now(UTC),
)
async def renew(self, assignment, *, progress: TaskProgressSnapshot | None = None) -> LeaseRenewalResponse:
return LeaseRenewalResponse(
status="renewed",
lease_expires_at=datetime.now(UTC) + timedelta(days=3650),
)
async def report_result(self, assignment, *, status: str, failure_reason: str | None = None, result: dict | None = None) -> TerminalResultResponse:
return TerminalResultResponse(status="recorded")
async def cancel_task(self, task_id: str) -> HostTaskCancellationResponse:
self._cancelled.add(task_id)
return HostTaskCancellationResponse(task_id=task_id, status="cancel_requested")
async def aclose(self) -> None:
return None
@@ -0,0 +1,155 @@
"""Per-device MCP session-level busy tracker.
The cloud-side assignment path and the MCP-driven path both drive devices
through the same in-process ``DeviceManager``. This tracker records which
devices are currently held by an MCP session so that:
- MCP tool calls against a device held by another session (or by a cloud
assignment checked separately by the caller via ``AgentStatusTracker``)
can fail fast with a busy error.
- The heartbeat payload can advertise ``mcp_busy_device_ids`` so the cloud
scheduler won't dispatch conflicting assignments to the same device.
Leases expire ``ttl_seconds`` after the last ``renew()`` call (set on every
tool call from the holding session). Expired leases are lazy-swept on read.
"""
from __future__ import annotations
import time
from dataclasses import dataclass
from datetime import UTC, datetime
from threading import Lock
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from collections.abc import Callable
@dataclass(frozen=True)
class McpDeviceLease:
device_id: str
session_id: str
acquired_at: datetime
last_seen_at: datetime
class McpBusyTracker:
def __init__(
self,
*,
ttl_seconds: float = 20.0,
now: Callable[[], datetime] | None = None,
) -> None:
self._ttl = float(ttl_seconds)
self._now = now or (lambda: datetime.now(UTC))
self._lock = Lock()
# device_id -> McpDeviceLease
self._leases: dict[str, McpDeviceLease] = {}
def acquire(self, device_id: str, session_id: str) -> bool:
with self._lock:
self._sweep_locked()
existing = self._leases.get(device_id)
if existing is not None and existing.session_id != session_id:
return False
now = self._now()
lease = McpDeviceLease(
device_id=device_id,
session_id=session_id,
acquired_at=(existing.acquired_at if existing is not None else now),
last_seen_at=now,
)
self._leases[device_id] = lease
return True
def renew(self, device_id: str, session_id: str) -> bool:
with self._lock:
self._sweep_locked()
existing = self._leases.get(device_id)
# Tolerate boundary: lease may have been swept, but if the caller
# is the legitimate previous holder, re-acquire on their behalf.
if existing is None:
now = self._now()
self._leases[device_id] = McpDeviceLease(
device_id=device_id,
session_id=session_id,
acquired_at=now,
last_seen_at=now,
)
return True
if existing.session_id != session_id:
return False
self._leases[device_id] = McpDeviceLease(
device_id=device_id,
session_id=session_id,
acquired_at=existing.acquired_at,
last_seen_at=self._now(),
)
return True
def release(self, session_id: str) -> list[str]:
with self._lock:
freed = [
device_id
for device_id, lease in self._leases.items()
if lease.session_id == session_id
]
for device_id in freed:
del self._leases[device_id]
return freed
def release_device(self, device_id: str, session_id: str) -> bool:
with self._lock:
existing = self._leases.get(device_id)
if existing is None or existing.session_id != session_id:
return False
del self._leases[device_id]
return True
def busy_device_ids(self) -> list[str]:
with self._lock:
self._sweep_locked()
return sorted(self._leases)
def snapshot(self) -> list[McpDeviceLease]:
with self._lock:
self._sweep_locked()
return sorted(self._leases.values(), key=lambda lease: lease.device_id)
def wait_until_usable(
self,
device_id: str,
session_id: str,
*,
timeout: float,
poll_interval: float = 1.0,
cloud_busy_check: Callable[[], bool] | None = None,
) -> bool:
"""Block until ``device_id`` is acquirable by ``session_id`` or timeout.
Reserved capability. MVP callers use try-acquire (``acquire`` -> False
means busy). This method exists for future wiring where the cloud
assignment path or an explicit MCP tool may opt to wait.
"""
deadline = time.monotonic() + timeout
while True:
cloud_busy = cloud_busy_check() if cloud_busy_check else False
if not cloud_busy:
if self.acquire(device_id, session_id):
return True
if time.monotonic() >= deadline:
return False
remaining = deadline - time.monotonic()
time.sleep(max(0.0, min(poll_interval, remaining)))
def _sweep_locked(self) -> None:
"""Caller holds ``self._lock``. Drops leases past their TTL."""
cutoff = self._now()
expired = [
device_id
for device_id, lease in self._leases.items()
if (cutoff - lease.last_seen_at).total_seconds() > self._ttl
]
for device_id in expired:
del self._leases[device_id]
@@ -0,0 +1,116 @@
"""Bearer-token persistence for the host-agent MCP server.
The token is generated on first start and persisted to a JSON file with
0o600 permissions (POSIX) alongside the host identity. Rotation = delete
the file and restart host-agent.
"""
from __future__ import annotations
import json
import os
import secrets
import tempfile
from dataclasses import dataclass
from datetime import UTC, datetime
from pathlib import Path
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from collections.abc import Callable
_TOKEN_BYTES = 32
class McpTokenStoreError(RuntimeError):
"""Raised when the MCP token file cannot be read or written."""
@dataclass(frozen=True)
class McpToken:
version: int
token: str
created_at: datetime
class McpTokenStore:
def __init__(
self,
path: Path,
*,
now: Callable[[], datetime] | None = None,
) -> None:
self._path = Path(path)
self._now = now or (lambda: datetime.now(UTC))
def load_or_create(self) -> McpToken:
if self._path.exists():
return self._read_existing()
return self._generate_and_write()
def verify(self, presented: str) -> bool:
try:
token = self.load_or_create()
except McpTokenStoreError:
return False
import hmac
return hmac.compare_digest(token.token, presented)
def _read_existing(self) -> McpToken:
try:
data = json.loads(self._path.read_text())
except (OSError, json.JSONDecodeError) as exc:
raise McpTokenStoreError(
f"cannot read MCP token file {self._path}: {exc}"
) from exc
if not isinstance(data, dict):
raise McpTokenStoreError("MCP token file is not a JSON object")
try:
return McpToken(
version=int(data["version"]),
token=str(data["token"]),
created_at=datetime.fromisoformat(str(data["created_at"])),
)
except (KeyError, TypeError, ValueError) as exc:
raise McpTokenStoreError(f"MCP token file schema invalid: {exc}") from exc
def _generate_and_write(self) -> McpToken:
token = McpToken(
version=1,
token=secrets.token_urlsafe(_TOKEN_BYTES),
created_at=self._now(),
)
payload = {
"version": token.version,
"token": token.token,
"created_at": token.created_at.isoformat(),
}
try:
self._atomic_write(json.dumps(payload, indent=2))
except OSError as exc:
raise McpTokenStoreError(
f"cannot write MCP token file {self._path}: {exc}"
) from exc
return token
def _atomic_write(self, content: str) -> None:
self._path.parent.mkdir(parents=True, exist_ok=True)
# Atomic on POSIX; on Windows os.replace is also atomic per docs.
fd, tmp_name = tempfile.mkstemp(
prefix=".host_mcp_token.",
suffix=".tmp",
dir=str(self._path.parent),
)
try:
with os.fdopen(fd, "w", encoding="utf-8") as fh:
fh.write(content)
os.chmod(tmp_name, 0o600)
os.replace(tmp_name, self._path)
except BaseException:
try:
os.unlink(tmp_name)
except OSError:
pass
raise
@@ -47,8 +47,11 @@ class AssignmentProcessor:
self.status_tracker.mark_assignment_started(assignment)
try:
execution = await self.active_executor.run(assignment)
status = "done" if execution.status == "done" else "failed"
failure_reason = execution.failure_reason if status == "failed" else None
if execution.status in {"done", "cancelled"}:
status = execution.status
else:
status = "failed"
failure_reason = execution.failure_reason if status != "done" else None
response = await self.client.report_result(
assignment,
status=status,
@@ -0,0 +1,105 @@
"""Host-agent wiring for skill sync against the Cloud Control Plane.
Constructs the synced catalog store, the local skill store, the Cloud API
sync client, and the :class:`SkillSyncRunner`, then drives them on the host
agent's lifecycle: the runner pulls incremental per-host skill deltas into the
synced catalog (forking local overrides on revocation, design D9), and a
best-effort inventory of the agent's local skills is reported to the Cloud
(design D7). Lives in ``host_agent`` (not ``runtime``) for the same boundary
reasons as :mod:`host_agent.cloud_planner_client`.
"""
from __future__ import annotations
import logging
import threading
from host_agent.config import HostAgentConfig
log = logging.getLogger(__name__)
class HostAgentSkillSync:
"""Owns the skill stores, sync runner, and inventory reporting thread."""
def __init__(
self,
config: HostAgentConfig,
*,
poll_interval: float | None = None,
http_client=None,
) -> None:
from api.skill_sync import CloudApiSkillClient, SkillSyncRunner
from storage.local_skills import LocalSkillStore
from storage.skill_catalog import SkillCatalogStore
state_dir = config.identity_path.parent
self.config = config
self.synced_store = SkillCatalogStore(db_path=state_dir / "skills.sqlite3")
self.local_store = LocalSkillStore(db_path=state_dir / "local_skills.sqlite3")
self.client = CloudApiSkillClient(
config.control_plane_url,
host_token=config.token,
client=http_client,
)
self.runner = SkillSyncRunner(
store=self.synced_store,
client=self.client,
subscriptions=[config.host_id],
poll_interval=poll_interval or config.skill_sync_interval_seconds,
local_store=self.local_store,
)
self._inventory_stop = threading.Event()
self._inventory_thread: threading.Thread | None = None
def start(self) -> None:
"""Start the sync poll loop + periodic inventory reporting."""
self.runner.start_background()
self._inventory_stop.clear()
self._inventory_thread = threading.Thread(
target=self._report_inventory_forever, daemon=True
)
self._inventory_thread.start()
log.info("skill sync started for host %s", self.config.host_id)
def stop(self) -> None:
"""Stop the sync loop + inventory thread and close the HTTP client."""
self.runner.stop_background()
self._inventory_stop.set()
if self._inventory_thread is not None:
self._inventory_thread.join(timeout=5.0)
self._inventory_thread = None
self.client.close()
def report_inventory_once(self) -> None:
"""Report the current local-skill inventory to the Cloud (best-effort)."""
try:
inventory = [
{
"id": meta.id,
"name": meta.name,
"kind": meta.kind,
"origin": "local",
}
for meta in self.local_store.list_local()
]
for override in self.local_store.list_overrides():
inventory.append(
{
"id": override.metadata.id,
"name": override.metadata.name,
"kind": override.metadata.kind,
"origin": "cloud",
"locally_overridden": True,
}
)
self.client.report_inventory(self.config.host_id, inventory)
except Exception: # best-effort: never impair local operation
log.debug("skill inventory report failed", exc_info=True)
def _report_inventory_forever(self) -> None:
# Report once at startup, then on the sync cadence.
self.report_inventory_once()
interval = self.config.skill_sync_interval_seconds
while not self._inventory_stop.wait(interval):
self.report_inventory_once()
+366 -39
View File
@@ -5,13 +5,15 @@ import base64
import json
from collections.abc import Awaitable, Callable
from pathlib import Path
from typing import Any
from typing import TYPE_CHECKING, Any
import jinja2
from fastapi import Depends, FastAPI, HTTPException, Request
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse, Response
from core.errors import DeviceNotFoundError, DeviceOfflineError, DeviceRuntimeError
from device.manager import DeviceManager
from driver.registry import build_driver_factory
from host_agent.assignment import AssignmentExecutor
from host_agent.client import (
HostAgentClient,
@@ -20,10 +22,15 @@ from host_agent.client import (
HostTaskSubmissionUnknownError,
)
from host_agent.config import HostAgentConfig
from host_agent.conversation import ConversationAgent
from host_agent.conversation_log import ConversationLogStore
from host_agent.devices import register_local_device, unregister_local_device
from host_agent.history import ConsoleHistoryStore
from host_agent.identity import HostIdentityStore
from host_agent.ios_discovery import IOSDiscoveryError, discover_connected_ios_devices
from host_agent.local_account import LocalAccountStore
from host_agent.mcp_lock import McpBusyTracker
from host_agent.mcp_token import McpTokenStore
from host_agent.status import AgentStatusTracker
from host_agent.web.auth import (
SessionManager,
@@ -31,7 +38,11 @@ from host_agent.web.auth import (
attempt_login,
change_password,
)
from host_agent.web.mcp_auth import BearerAuthMiddleware
from storage.device_config import DeviceConfigStore
if TYPE_CHECKING:
from mcp.server.fastmcp import FastMCP
from storage.task_metadata import TaskMetadataStore
from storage.timeline import Timeline
@@ -41,7 +52,9 @@ CSRF_FORM_FIELD = "csrf_token"
_LOOPBACK_BIND_HOSTS = frozenset({"127.0.0.1", "localhost", "::1"})
TaskSubmissionCallable = Callable[..., Awaitable[str]]
TaskCancellationCallable = Callable[..., Awaitable[Any]]
AUTOMATIC_DEVICE_VALUE = "__automatic__"
_TERMINAL_LOCAL_TASK_STATUSES = frozenset({"completed", "failed", "cancelled"})
_ENV = jinja2.Environment(
loader=jinja2.FileSystemLoader(Path(__file__).parent / "templates"),
@@ -70,9 +83,13 @@ def _device_display_status(device: Any, *, busy_device_id: str | None) -> str:
return device.status
def _screenshot_data_uri(record: dict[str, Any]) -> str | None:
"""Return a ``data:`` URI for the step's screenshot, or ``None``."""
screenshot_path = record.get("screenshot_path")
def _screenshot_data_uri(
record: dict[str, Any],
*,
path_key: str = "screenshot_path",
) -> str | None:
"""Return a ``data:`` URI for one step screenshot, or ``None``."""
screenshot_path = record.get(path_key)
if not screenshot_path:
return None
path = Path(str(screenshot_path))
@@ -82,6 +99,60 @@ def _screenshot_data_uri(record: dict[str, Any]) -> str | None:
return f"data:image/png;base64,{encoded}"
def _ocr_results(record: dict[str, Any]) -> list[dict[str, Any]]:
raw_results = record.get("ocr_results")
if not isinstance(raw_results, list):
return []
return [result for result in raw_results if isinstance(result, dict)]
def _ui_tree_nodes(record: dict[str, Any]) -> list[dict[str, Any]]:
raw_nodes = record.get("ui_tree_results")
if not isinstance(raw_nodes, list):
return []
return [node for node in raw_nodes if isinstance(node, dict)]
def _overlay_payload(record: dict[str, Any]) -> dict[str, Any]:
"""Combined perception elements + screen size for client-side bounding-box
overlay and action-effect rendering on the before-screenshot.
"""
scene = record.get("scene")
screen = scene.get("screen") if isinstance(scene, dict) else None
width = screen.get("width") if isinstance(screen, dict) else None
height = screen.get("height") if isinstance(screen, dict) else None
elements = scene.get("elements") if isinstance(scene, dict) else None
return {
"width": width if isinstance(width, (int, float)) else 0,
"height": height if isinstance(height, (int, float)) else 0,
"elements": [element for element in elements if isinstance(element, dict)]
if isinstance(elements, list)
else [],
}
def _timeline_step_context(record: dict[str, Any]) -> dict[str, Any]:
tool_call = record.get("tool_call")
result = record.get("result")
return {
"index": record.get("index", ""),
"timestamp": record.get("timestamp", ""),
"prompt": record.get("prompt") or "",
"tool_call": tool_call if isinstance(tool_call, dict) else {},
"result": result if isinstance(result, dict) else {},
"before_screenshot_src": _screenshot_data_uri(
record, path_key="before_screenshot_path"
),
"after_screenshot_src": _screenshot_data_uri(
record, path_key="after_screenshot_path"
)
or _screenshot_data_uri(record),
"ocr_results": _ocr_results(record),
"ui_tree_nodes": _ui_tree_nodes(record),
"overlay": _overlay_payload(record),
}
def _safe_submission_error(detail: str) -> str:
"""Return a safe, single-line error message for the operator.
@@ -159,15 +230,35 @@ def create_console_app(
enrollment_client: HostAgentEnrollmentClient | None,
host_client: HostAgentClient | None = None,
submit_self_task: TaskSubmissionCallable | None = None,
cancel_task: TaskCancellationCallable | None = None,
metadata_store: TaskMetadataStore | None = None,
timeline: Timeline | None = None,
executor: AssignmentExecutor | None = None,
mcp_server: FastMCP | None = None,
mcp_token_store: McpTokenStore | None = None,
mcp_busy_tracker: McpBusyTracker | None = None,
conversation_agent: ConversationAgent | None = None,
conversation_log: ConversationLogStore | None = None,
) -> FastAPI:
app = FastAPI(title="Host Agent Console")
cookie_secure = config.console_bind_host not in _LOOPBACK_BIND_HOSTS
if submit_self_task is None and host_client is not None:
submit_self_task = host_client.submit_self_task
if cancel_task is None and host_client is not None:
cancel_task = host_client.cancel_task
submission_available = submit_self_task is not None
mcp_mounted = mcp_server is not None and mcp_token_store is not None
if mcp_mounted:
from starlette.applications import Starlette
from starlette.middleware import Middleware
mcp_asgi = mcp_server.streamable_http_app()
authed = Starlette(
routes=[],
middleware=[Middleware(BearerAuthMiddleware, token_store=mcp_token_store)],
)
authed.router.mount("/", mcp_asgi)
app.mount("/mcp", authed)
def _running_devices() -> list[dict[str, str]]:
return [
@@ -277,6 +368,10 @@ def create_console_app(
for d in manager.list_devices()
]
texts = _dashboard_texts(snapshot=snapshot)
mcp_endpoint = "/mcp" if mcp_mounted else None
mcp_busy_devices = (
mcp_busy_tracker.busy_device_ids() if mcp_busy_tracker is not None else []
)
return _render(
"dashboard.html",
title="Status",
@@ -284,6 +379,8 @@ def create_console_app(
identity=identity,
devices=devices,
config=config,
mcp_endpoint=mcp_endpoint,
mcp_busy_devices=mcp_busy_devices,
**texts,
)
@@ -312,7 +409,63 @@ def create_console_app(
}
for device in manager.list_devices()
]
return JSONResponse({"status": snapshot, "devices": devices})
return JSONResponse(
{
"status": snapshot,
"devices": devices,
"mcp_endpoint": "/mcp" if mcp_mounted else None,
"mcp_busy_devices": (
mcp_busy_tracker.busy_device_ids()
if mcp_busy_tracker is not None
else []
),
}
)
@app.post("/api/chat")
async def api_chat(
request: Request,
session: SessionState = Depends(require_csrf),
) -> JSONResponse:
if conversation_agent is None:
raise HTTPException(status_code=503, detail="chat agent is not configured")
payload = await request.json()
device_id = payload.get("device_id") if isinstance(payload, dict) else None
if not isinstance(device_id, str) or not device_id.strip():
raise HTTPException(status_code=400, detail="device_id is required")
if device_id not in {device.id for device in manager.list_devices()}:
raise HTTPException(status_code=404, detail="unknown device")
raw_messages = payload.get("messages") if isinstance(payload, dict) else None
if not isinstance(raw_messages, list) or not raw_messages:
raise HTTPException(status_code=400, detail="messages must be a non-empty list")
messages = [
{key: value for key, value in item.items() if key in {"role", "content", "image_base64", "mime_type", "text"}}
for item in raw_messages
if isinstance(item, dict)
and item.get("role") in {"user", "assistant"}
and (isinstance(item.get("content"), (str, list)) or isinstance(item.get("image_base64"), str))
]
if not messages:
raise HTTPException(status_code=400, detail="messages are invalid")
if conversation_log is not None:
await asyncio.to_thread(
conversation_log.append,
{"type": "user_request", "device_id": device_id.strip(), "messages": messages},
)
try:
result = await asyncio.to_thread(
conversation_agent.chat_for_device, device_id.strip(), messages
)
except Exception as exc:
if conversation_log is not None:
await asyncio.to_thread(
conversation_log.append,
{"type": "agent_error", "device_id": device_id.strip(), "error": str(exc)},
)
raise HTTPException(status_code=502, detail=str(exc)) from exc
return JSONResponse(
{"content": result.content, "tool_calls": result.tool_calls}
)
@app.get("/devices", response_class=HTMLResponse)
async def devices_page(
@@ -338,6 +491,124 @@ def create_console_app(
error=None,
)
@app.post("/api/devices/{device_id}/screenshot")
async def api_device_screenshot(
device_id: str,
session: SessionState = Depends(require_csrf),
) -> Response:
"""Capture one on-demand screenshot for a connected local device."""
try:
screenshot = await asyncio.to_thread(
lambda: manager.active_driver(device_id).screenshot()
)
except DeviceNotFoundError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
except DeviceOfflineError as exc:
raise HTTPException(status_code=503, detail=str(exc)) from exc
except DeviceRuntimeError as exc:
raise HTTPException(status_code=502, detail=str(exc)) from exc
except Exception as exc:
raise HTTPException(
status_code=502,
detail=str(exc) or "failed to capture device screenshot",
) from exc
if not isinstance(screenshot, bytes) or not screenshot:
raise HTTPException(
status_code=502,
detail="device returned an empty screenshot",
)
return Response(
content=screenshot,
media_type="image/png",
headers={
"Cache-Control": "no-store",
"Pragma": "no-cache",
"X-Content-Type-Options": "nosniff",
},
)
@app.get("/api/devices/discover-ios")
async def api_discover_ios_devices(
session: SessionState = Depends(require_session),
) -> JSONResponse:
try:
discovered = await asyncio.to_thread(discover_connected_ios_devices)
except IOSDiscoveryError as exc:
raise HTTPException(status_code=502, detail=str(exc)) from exc
configured = await asyncio.to_thread(config_store.list)
configured_udids = {
str(record["connection_info"].get("udid"))
for record in configured
if record["driver_type"] == "wda"
}
used_wda_ports = {
record["connection_info"].get("wda_local_port") for record in configured
}
used_mjpeg_ports = {
record["connection_info"].get("mjpegServerPort") for record in configured
}
next_wda_port = 8100
next_mjpeg_port = 9100
result = []
for device in discovered:
while next_wda_port in used_wda_ports:
next_wda_port += 1
while next_mjpeg_port in used_mjpeg_ports:
next_mjpeg_port += 1
result.append(
{
**device,
"configured": device["udid"] in configured_udids,
"suggested_wda_port": next_wda_port,
"suggested_mjpeg_port": next_mjpeg_port,
}
)
used_wda_ports.add(next_wda_port)
used_mjpeg_ports.add(next_mjpeg_port)
next_wda_port += 1
next_mjpeg_port += 1
return JSONResponse({"devices": result})
@app.post("/api/devices/test-connection")
async def api_device_test_connection(
request: Request,
session: SessionState = Depends(require_csrf),
) -> JSONResponse:
payload = await request.json()
if not isinstance(payload, dict):
raise HTTPException(status_code=400, detail="Request must be an object.")
driver_type = str(payload.get("driver_type", "")).strip()
connection_info = payload.get("connection_info")
if not driver_type or not isinstance(connection_info, dict):
raise HTTPException(
status_code=400,
detail="Driver type and connection info are required.",
)
driver = None
connected = False
try:
driver = build_driver_factory(driver_type, connection_info)()
await asyncio.to_thread(driver.connect)
connected = True
await asyncio.to_thread(driver.health_check)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
except Exception as exc:
raise HTTPException(
status_code=502,
detail=str(exc) or "device connection test failed",
) from exc
finally:
if connected and driver is not None:
try:
await asyncio.to_thread(driver.disconnect)
except Exception:
pass
return JSONResponse({"ok": True, "driver_type": driver_type})
@app.post("/devices/save")
async def devices_save(
request: Request,
@@ -364,6 +635,26 @@ def create_console_app(
else:
connection_info = parsed
if error is None:
# The console exposes the common Appium settings as regular form
# fields. Advanced JSON remains available for uncommon capabilities.
field_map = {
"server_url": "server_url",
"udid": "udid",
"device_name": "device_name",
}
for form_key, config_key in field_map.items():
value = str(form.get(form_key, "")).strip()
if value:
connection_info[config_key] = value
port_key = "wda_local_port" if driver_type == "wda" else "system_port"
port_value = str(form.get(port_key, "")).strip()
if port_value:
try:
connection_info[port_key] = int(port_value)
except ValueError:
error = f"{port_key} must be an integer."
if error is None:
try:
await asyncio.to_thread(
@@ -477,6 +768,15 @@ def create_console_app(
entries=entries,
)
@app.get("/conversations", response_class=HTMLResponse)
async def conversations_page(
session: SessionState = Depends(require_session),
) -> HTMLResponse:
events = await asyncio.to_thread(
conversation_log.list_recent if conversation_log is not None else (lambda: [])
)
return _render("conversations.html", title="Conversations", session=session, events=events)
def _tasks_list_context(
session: SessionState,
*,
@@ -492,9 +792,7 @@ def create_console_app(
"title": "Tasks",
"session": session,
"csrf_token": session.csrf_token,
"tasks": metadata_store.list_tasks()
if metadata_store is not None
else [],
"tasks": metadata_store.list_tasks() if metadata_store is not None else [],
"metadata_store_missing": metadata_store is None,
"devices": devices,
"automatic_device_value": AUTOMATIC_DEVICE_VALUE,
@@ -527,7 +825,7 @@ def create_console_app(
if task_id:
notice = (
f"Task submitted. Cloud task ID: {task_id}. "
"Track it from the Cloud console for execution progress."
"It will appear below when this Host begins executing it."
)
elif request.query_params.get("outcome") == "unknown":
unknown = True
@@ -603,9 +901,7 @@ def create_console_app(
)
try:
response = await submit_self_task(
goal=goal, device_id=explicit_device_id
)
response = await submit_self_task(goal=goal, device_id=explicit_device_id)
except HostAgentAPIError as exc:
context = _tasks_list_context(
session,
@@ -655,6 +951,7 @@ def create_console_app(
@app.get("/tasks/{task_id}", response_class=HTMLResponse)
async def task_detail_page(
task_id: str,
request: Request,
session: SessionState = Depends(require_session),
) -> HTMLResponse:
if metadata_store is None:
@@ -668,43 +965,73 @@ def create_console_app(
if timeline is not None:
timeline_records = await asyncio.to_thread(timeline.read, task_id)
task_rows = [
(key, task[key])
for key in (
"id",
"goal",
"device_id",
"status",
"created_at",
"updated_at",
(label, task[key])
for key, label in (
("source_task_id", "Cloud task ID"),
("source_attempt", "Cloud attempt"),
("id", "Execution ID"),
("goal", "Goal"),
("device_id", "Device"),
("status", "Status"),
("created_at", "Created"),
("updated_at", "Updated"),
("completed_at", "Completed"),
("failure_reason", "Failure reason"),
)
if task.get(key) is not None
]
timeline_steps = [
{
"index": record.get("index", ""),
"timestamp": record.get("timestamp", ""),
"prompt": record.get("prompt") or "",
"tool_call_text": (
json.dumps(record.get("tool_call"), ensure_ascii=False)
if record.get("tool_call")
else ""
),
"result_text": (
json.dumps(record.get("result"), ensure_ascii=False)
if record.get("result")
else ""
),
"screenshot_src": _screenshot_data_uri(record),
}
for record in timeline_records
]
timeline_steps = [_timeline_step_context(record) for record in timeline_records]
can_cancel = (
cancel_task is not None
and task.get("source_task_id") is not None
and task.get("status") not in _TERMINAL_LOCAL_TASK_STATUSES
)
cancel_notice = (
"Cancellation requested. It may take a moment to take effect."
if request.query_params.get("cancelled") == "1"
else None
)
cancel_error = (
"Failed to request cancellation. Try again."
if request.query_params.get("cancel_error") == "1"
else None
)
return _render(
"task_detail.html",
title=f"Task {task_id}",
session=session,
csrf_token=session.csrf_token,
task=task,
task_rows=task_rows,
timeline_steps=timeline_steps,
can_cancel=can_cancel,
cancel_notice=cancel_notice,
cancel_error=cancel_error,
)
@app.post("/tasks/{task_id}/cancel")
async def tasks_cancel(
task_id: str,
session: SessionState = Depends(require_csrf),
) -> Response:
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")
source_task_id = task.get("source_task_id")
if cancel_task is None or source_task_id is None:
return RedirectResponse(
url=f"/tasks/{task_id}?cancel_error=1", status_code=303
)
try:
await cancel_task(source_task_id)
except HostAgentAPIError:
return RedirectResponse(
url=f"/tasks/{task_id}?cancel_error=1", status_code=303
)
return RedirectResponse(url=f"/tasks/{task_id}?cancelled=1", status_code=303)
return app
@@ -0,0 +1,255 @@
"""FastMCP server builder for the host-agent MCP endpoint.
Wraps ``api.mcp.tool_handlers(manager=...)`` with:
- Cloud-busy and MCP-busy checks (per-device, fail-fast on conflict).
- Lazy session-level device lock acquire / renew.
- Display-status mapping for ``list_devices`` / ``device_status`` so
connected-but-idle devices don't appear "busy" (which they do at the
``DeviceManager`` layer because an Appium/WDA session is open).
The builder returns a ``FastMCP`` instance. The caller
(``create_console_app``) is responsible for wrapping it in
``BearerAuthMiddleware`` and mounting at ``/mcp``.
"""
from __future__ import annotations
import contextvars
from collections.abc import Callable
from typing import Any
from device.manager import DeviceManager
from host_agent.mcp_lock import McpBusyTracker
from host_agent.status import AgentStatusTracker
from mcp.server.fastmcp import Context, FastMCP
# Tool names that don't target a specific device — skip busy check.
_NON_DEVICE_TOOLS = frozenset({"list_devices", "device_status"})
# Tools that report status and should use the display-status mapping.
_STATUS_TOOLS = frozenset({"list_devices", "device_status"})
# Name of the wrapper kwarg FastMCP injects the live ``Context`` into.
# We set ``tool.context_kwarg = _CONTEXT_KWARG`` after swapping the tool's
# ``fn`` (see ``build_mcp_server``) so FastMCP passes ``ctx`` into our
# wrapper alongside the validated arguments.
_CONTEXT_KWARG = "ctx"
class McpDeviceBusyError(Exception):
"""Raised by the wrapper when the target device is held by the cloud
assignment path or another MCP session."""
def __init__(self, device_id: str, busy_owner: str) -> None:
super().__init__(f"device {device_id} is busy (held by {busy_owner})")
self.device_id = device_id
self.busy_owner = busy_owner
class FastMcpSdkIncompatibilityError(RuntimeError):
"""Raised when the FastMCP SDK layout diverges from what this module
expects (e.g. ``Tool.fn`` rename or ``Tool.context_kwarg`` removal)."""
# contextvars fallback used by tests and any call that originates outside a
# live FastMCP request lifecycle. Production handlers run inside an MCP
# request whose context exposes ``request_id`` and the underlying
# ``session``; ``_current_session_id`` reads from that context first and
# falls back to this ContextVar.
_TEST_SESSION_ID: contextvars.ContextVar[str] = contextvars.ContextVar(
"_TEST_SESSION_ID", default=""
)
def _current_session_id(ctx: Context | None = None) -> str:
"""Extract a stable per-MCP-session identifier from the live context.
The mcp SDK 1.28.1 ``Context`` exposes ``session`` (a long-lived
``ServerSession`` instance per Streamable HTTP session). Its Python
object identity (``id(ctx.session)``) is stable across every tool call
the same client makes within that session, which is exactly the
identity the busy tracker needs to renew leases.
Falls back to ``_TEST_SESSION_ID`` when no Context is supplied (i.e.
when invoked outside a FastMCP request lifecycle, as ``_call_tool_sync``
does in tests).
"""
if ctx is not None:
session_obj = getattr(ctx, "session", None)
if session_obj is not None:
return f"mcp_session:{id(session_obj)}"
return _TEST_SESSION_ID.get("")
def build_mcp_server(
*,
manager: DeviceManager,
mcp_busy_tracker: McpBusyTracker,
status_tracker: AgentStatusTracker,
) -> FastMCP:
"""Construct the FastMCP server wrapping ``tool_handlers``."""
# Imported lazily to keep the package import graph flat.
from api.mcp import tool_handlers
handlers = tool_handlers(manager=manager)
server = FastMCP("apex-host-agent")
for tool_name, raw_handler in handlers.items():
wrapped = _wrap_tool(
tool_name,
raw_handler,
mcp_busy_tracker=mcp_busy_tracker,
status_tracker=status_tracker,
)
# Register the raw handler so FastMCP captures its signature (the
# MCP wire schema is derived from the function signature). Then
# swap ``tool.fn`` for our busy-check / status-mapping wrapper.
# Using ``*args, **kwargs`` directly breaks the schema, so we have
# to keep the signature and only replace the underlying callable.
server._tool_manager.add_tool( # type: ignore[attr-defined]
raw_handler, name=tool_name
)
try:
tool = server._tool_manager._tools[tool_name] # type: ignore[attr-defined]
tool.fn = wrapped
# FastMCP injects the live Context into the kwarg named by
# ``tool.context_kwarg``. The raw handler doesn't declare one,
# so the cached value is None; we override it so the wrapper
# receives the Context via its ``ctx`` kwarg.
tool.context_kwarg = _CONTEXT_KWARG
except AttributeError as exc:
raise FastMcpSdkIncompatibilityError(
"FastMCP SDK layout changed: cannot swap Tool.fn or set "
f"context_kwarg (tool={tool_name!r}). Underlying error: {exc}"
) from exc
return server
def _wrap_tool(
tool_name: str,
handler: Callable[..., Any],
*,
mcp_busy_tracker: McpBusyTracker,
status_tracker: AgentStatusTracker,
) -> Callable[..., Any]:
def wrapped(*args: Any, **kwargs: Any) -> Any:
ctx = kwargs.pop(_CONTEXT_KWARG, None)
session_id = _current_session_id(ctx)
device_id = kwargs.get("device_id")
if tool_name in _STATUS_TOOLS:
return _with_display_status(handler, status_tracker, *args, **kwargs)
if device_id is not None and tool_name not in _NON_DEVICE_TOOLS:
_check_and_acquire(device_id, session_id, mcp_busy_tracker, status_tracker)
return handler(*args, **kwargs)
return wrapped
def _check_and_acquire(
device_id: str,
session_id: str,
mcp_busy_tracker: McpBusyTracker,
status_tracker: AgentStatusTracker,
) -> None:
cloud_busy = _cloud_busy_device_id(status_tracker)
if cloud_busy == device_id:
raise McpDeviceBusyError(device_id, "cloud_assignment")
if device_id in mcp_busy_tracker.busy_device_ids():
existing = next(
(
lease
for lease in mcp_busy_tracker.snapshot()
if lease.device_id == device_id
),
None,
)
if existing is not None and existing.session_id != session_id:
prefix = existing.session_id[:8]
raise McpDeviceBusyError(device_id, f"mcp_session:{prefix}")
if not mcp_busy_tracker.acquire(device_id, session_id):
# Race: someone else got it between check and acquire.
raise McpDeviceBusyError(device_id, "another_session")
mcp_busy_tracker.renew(device_id, session_id)
def _cloud_busy_device_id(status_tracker: AgentStatusTracker) -> str | None:
"""Return the device_id currently bound to the cloud assignment, if any."""
snap = status_tracker.snapshot()
current = snap.get("current_assignment")
if not isinstance(current, dict):
return None
device_id = current.get("device_id")
return device_id if isinstance(device_id, str) else None
def _with_display_status(
handler: Callable[..., Any],
status_tracker: AgentStatusTracker,
*args: Any,
**kwargs: Any,
) -> Any:
busy_device_id = _cloud_busy_device_id(status_tracker)
result = handler(*args, **kwargs)
if isinstance(result, list):
for item in result:
if isinstance(item, dict) and "status" in item:
item["status"] = _display_status(
item["status"], item.get("id"), busy_device_id
)
return result
if isinstance(result, dict) and "status" in result:
result["status"] = _display_status(
result["status"], result.get("device_id"), busy_device_id
)
return result
def _display_status(raw: str, device_id: Any, busy_device_id: str | None) -> str:
"""Mirror ``host_agent.web.app._device_display_status`` semantics.
A device that's locally "busy" because it's connected-but-idle reports
"connected" instead, unless it's the device currently running a cloud
assignment (in which case "busy" is the truthful status).
"""
if raw == "busy" and device_id != busy_device_id:
return "connected"
return raw
def _call_tool_sync(
server: FastMCP,
tool_name: str,
arguments: dict[str, Any],
*,
session_id: str,
) -> Any:
"""Test helper: invoke a registered tool synchronously with a forced
``session_id``. Bypasses the HTTP/MCP transport layer (and the live
FastMCP Context) so tests don't need an MCP client.
Walks FastMCP's tool registry (``_tool_manager._tools[tool_name].fn``) —
the exact attribute path follows mcp SDK 1.28.1's
``ToolManager._tools`` layout.
"""
token = _TEST_SESSION_ID.set(session_id)
try:
manager = getattr(server, "_tool_manager", None)
if manager is None:
raise KeyError(f"tool {tool_name!r} not registered (no tool manager)")
registry = getattr(manager, "_tools", None) or getattr(manager, "tools", None)
if isinstance(registry, dict):
tool = registry.get(tool_name)
else:
tool = manager.get_tool(tool_name) # type: ignore[union-attr]
if tool is None:
raise KeyError(f"tool {tool_name!r} not registered")
# FastMCP Tool wraps a callable; our wrappers are sync, so unwrap.
fn = getattr(tool, "fn", None) or getattr(tool, "func", None)
if fn is None:
raise KeyError(f"tool {tool_name!r} has no callable")
return fn(**arguments)
finally:
_TEST_SESSION_ID.reset(token)
@@ -0,0 +1,36 @@
"""Bearer-token auth middleware for the MCP sub-app.
Mounted on the FastMCP ``streamable_http_app()`` (NOT the console FastAPI),
so cookie-session auth on console routes is unaffected.
"""
from __future__ import annotations
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.requests import Request
from starlette.responses import JSONResponse, Response
from host_agent.mcp_token import McpTokenStore
class BearerAuthMiddleware(BaseHTTPMiddleware):
def __init__(self, app, token_store: McpTokenStore) -> None:
super().__init__(app)
self._store = token_store
async def dispatch(self, request: Request, call_next) -> Response: # type: ignore[no-untyped-def]
header = request.headers.get("Authorization")
if not header or not header.lower().startswith("bearer "):
return _unauthorized()
presented = header.split(" ", 1)[1].strip()
if not self._store.verify(presented):
return _unauthorized()
return await call_next(request)
def _unauthorized() -> JSONResponse:
return JSONResponse(
status_code=401,
content={"error": "invalid token"},
headers={"WWW-Authenticate": "Bearer"},
)
@@ -26,6 +26,7 @@ form.inline { display: inline; margin: 0; }
<a href="/tasks">Tasks</a>
<a href="/account">Account</a>
<a href="/history">History</a>
<a href="/conversations">Conversations</a>
<form class="inline" method="post" action="/logout">
<input type="hidden" name="csrf_token" value="{{ session.csrf_token }}">
<button type="submit">Logout</button>
@@ -0,0 +1,14 @@
{% extends "base.html" %}
{% block body %}
<h1>Conversations</h1>
{% if not events %}<p>No conversation activity recorded yet.</p>{% endif %}
{% for event in events %}
<article>
<h2>{{ event["event_type"] }} <small>{{ event["occurred_at"] }}</small></h2>
{% if event.get("content") %}<pre>{{ event["content"] }}</pre>{% endif %}
{% if event.get("thinking") %}<details><summary>LLM reasoning</summary><pre>{{ event["thinking"] }}</pre></details>{% endif %}
{% if event.get("tool_calls") %}<details open><summary>Tool calls</summary><pre>{{ event["tool_calls"] | tojson(indent=2) }}</pre></details>{% endif %}
{% if event.get("tool_name") %}<p><strong>{{ event["tool_name"] }}</strong></p><pre>{{ event.get("arguments") | tojson(indent=2) }}</pre><pre>{{ event.get("result") | tojson(indent=2) }}</pre>{% endif %}
</article>
{% endfor %}
{% endblock %}
@@ -20,6 +20,24 @@
<p id="current-assignment">{{ assignment_text }}</p>
<p id="current-progress">{{ progress_text }}</p>
</section>
<section>
<h2>MCP</h2>
<table>
<tbody>
<tr>
<td>MCP</td>
<td>
{% if mcp_endpoint %}
endpoint <code>{{ mcp_endpoint }}</code>;
{% if mcp_busy_devices %}busy: {{ mcp_busy_devices|join(", ") }}{% else %}idle{% endif %}
{% else %}
not configured
{% endif %}
</td>
</tr>
</tbody>
</table>
</section>
<section>
<h2>Devices</h2>
<table>
@@ -5,13 +5,20 @@
<p class="error">{{ error }}</p>
{% endif %}
<table>
<thead><tr><th>ID</th><th>Name</th><th>Driver</th><th>Cloud ID</th><th></th></tr></thead>
<thead><tr><th>ID</th><th>Name</th><th>Driver</th><th>Cloud ID</th><th>Screenshot</th><th></th></tr></thead>
<tbody>{% for device in devices %}
<tr>
<td>{{ device["device_id"] }}</td>
<td>{{ device["name"] or "" }}</td>
<td>{{ device["driver_type"] }}</td>
<td>{{ device["cloud_device_id"] or "" }}</td>
<td>
<button type="button" class="screenshot-button" data-device-id="{{ device["device_id"] }}">Get screenshot</button>
<div class="screenshot-preview" data-screenshot-preview hidden>
<p class="screenshot-status" data-screenshot-status></p>
<img alt="Current screen for {{ device["device_id"] }}" data-screenshot-image hidden>
</div>
</td>
<td>
<a href="/devices?edit={{ device["device_id"] }}">Edit</a>
<form class="inline" method="post" action="/devices/remove">
@@ -24,14 +31,173 @@
{% endfor %}</tbody>
</table>
<h2>{{ "Edit device" if edit_record else "Add device" }}</h2>
<button type="button" id="discover-ios">Scan connected iPhones</button>
<p id="discovery-status" class="screenshot-status"></p>
<div id="discovered-devices"></div>
<form method="post" action="/devices/save">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<label>Device ID <input type="text" name="device_id" value="{{ edit_record["device_id"] if edit_record else "" }}" required></label><br>
<label>Name <input type="text" name="name" value="{{ edit_record["name"] if edit_record else "" }}"></label><br>
<label>Driver type <input type="text" name="driver_type" value="{{ edit_record["driver_type"] if edit_record else "wda" }}" required></label><br>
<label>Connection info (JSON)<br>
<textarea name="connection_info" rows="3" cols="50">{{ connection_info_json }}</textarea>
<label>Platform and protocol
<select name="driver_type" id="driver-type" required>
<option value="wda"{% if not edit_record or edit_record["driver_type"] == "wda" %} selected{% endif %}>iOS - Appium / XCUITest (WDA)</option>
<option value="uiautomator2"{% if edit_record and edit_record["driver_type"] == "uiautomator2" %} selected{% endif %}>Android - Appium / UiAutomator2</option>
</select>
</label><br>
<label>Appium server URL <input type="url" name="server_url" value="{{ edit_record["connection_info"].get("server_url", "http://127.0.0.1:4723") if edit_record else "http://127.0.0.1:4723" }}" required></label><br>
<label>Device UDID <input type="text" name="udid" value="{{ edit_record["connection_info"].get("udid", "") if edit_record else "" }}" required></label><br>
<label>Device name <input type="text" name="device_name" value="{{ edit_record["connection_info"].get("device_name", "") if edit_record else "" }}" placeholder="iPhone"></label><br>
<label class="ios-setting">WDA local port <input type="number" min="1" max="65535" name="wda_local_port" value="{{ edit_record["connection_info"].get("wda_local_port", "") if edit_record else "" }}" placeholder="8100"></label>
<label class="android-setting">UiAutomator2 system port <input type="number" min="1" max="65535" name="system_port" value="{{ edit_record["connection_info"].get("system_port", "") if edit_record else "" }}" placeholder="8200"></label><br>
<details>
<summary>Advanced connection capabilities (JSON)</summary>
<textarea name="connection_info" rows="4" cols="60">{{ connection_info_json }}</textarea>
</details>
<p id="connection-test-status" class="screenshot-status"></p>
<button type="button" id="test-connection">Test connection</button>
<button type="submit">Save</button>
</form>
<style>
.screenshot-preview { margin-top: 0.5rem; max-width: 260px; }
.screenshot-preview img { display: block; width: 100%; height: auto; border: 1px solid #c8d0d6; }
.screenshot-status { margin: 0 0 0.35rem; color: #5e6b73; }
.screenshot-status.error { color: #b00020; }
</style>
<script>
(() => {
const driverType = document.getElementById("driver-type");
const syncPlatformFields = () => {
const ios = driverType.value === "wda";
document.querySelectorAll(".ios-setting").forEach((el) => { el.hidden = !ios; });
document.querySelectorAll(".android-setting").forEach((el) => { el.hidden = ios; });
};
driverType.addEventListener("change", syncPlatformFields);
syncPlatformFields();
const csrfInput = document.querySelector('input[name="csrf_token"]');
const csrfToken = csrfInput ? csrfInput.value : "";
const form = document.querySelector('form[action="/devices/save"]');
const discoverButton = document.getElementById("discover-ios");
const discoveryStatus = document.getElementById("discovery-status");
const discoveredDevices = document.getElementById("discovered-devices");
discoverButton.addEventListener("click", async () => {
discoverButton.disabled = true;
discoveryStatus.classList.remove("error");
discoveryStatus.textContent = "Scanning...";
discoveredDevices.replaceChildren();
try {
const response = await fetch("/api/devices/discover-ios");
const payload = await response.json();
if (!response.ok) throw new Error(payload.detail || "Discovery failed.");
discoveryStatus.textContent = payload.devices.length
? `Found ${payload.devices.length} connected iPhone(s).`
: "No connected, paired iPhones found.";
payload.devices.forEach((device, index) => {
const row = document.createElement("p");
const description = document.createElement("span");
description.textContent = `${device.name} - ${device.model} - iOS ${device.os_version} (${device.transport}) `;
const select = document.createElement("button");
select.type = "button";
select.textContent = device.configured ? "Already added" : "Use this iPhone";
select.disabled = device.configured;
select.addEventListener("click", () => {
form.elements.driver_type.value = "wda";
form.elements.udid.value = device.udid;
form.elements.device_name.value = device.name;
form.elements.wda_local_port.value = device.suggested_wda_port;
form.elements.device_id.value ||= `ios-phone-${index + 1}`;
form.elements.name.value ||= device.name;
const advanced = JSON.parse(form.elements.connection_info.value || "{}");
advanced.mjpegServerPort = device.suggested_mjpeg_port;
advanced.derivedDataPath = `/tmp/wda-${device.udid}`;
form.elements.connection_info.value = JSON.stringify(advanced, null, 2);
syncPlatformFields();
form.scrollIntoView({ behavior: "smooth", block: "start" });
});
row.append(description, select);
discoveredDevices.append(row);
});
} catch (error) {
discoveryStatus.classList.add("error");
discoveryStatus.textContent = error.message || "Discovery failed.";
} finally {
discoverButton.disabled = false;
}
});
const testButton = document.getElementById("test-connection");
const testStatus = document.getElementById("connection-test-status");
const connectionInfo = () => {
const data = new FormData(form);
let info = {};
const advanced = String(data.get("connection_info") || "{}");
info = JSON.parse(advanced);
["server_url", "udid", "device_name"].forEach((key) => {
const value = String(data.get(key) || "").trim();
if (value) info[key] = value;
});
const portKey = data.get("driver_type") === "wda" ? "wda_local_port" : "system_port";
const port = String(data.get(portKey) || "").trim();
if (port) info[portKey] = Number(port);
return { driver_type: data.get("driver_type"), connection_info: info };
};
testButton.addEventListener("click", async () => {
testButton.disabled = true;
testStatus.classList.remove("error");
testStatus.textContent = "Testing connection...";
try {
const response = await fetch("/api/devices/test-connection", {
method: "POST",
headers: { "Content-Type": "application/json", "X-CSRF-Token": csrfToken },
body: JSON.stringify(connectionInfo()),
});
const payload = await response.json();
if (!response.ok) throw new Error(payload.detail || "Connection test failed.");
testStatus.textContent = "Connection successful.";
} catch (error) {
testStatus.classList.add("error");
testStatus.textContent = error.message || "Connection test failed.";
} finally {
testButton.disabled = false;
}
});
document.querySelectorAll(".screenshot-button").forEach((button) => {
button.addEventListener("click", async () => {
const deviceId = button.dataset.deviceId;
const preview = button.parentElement.querySelector("[data-screenshot-preview]");
const status = preview.querySelector("[data-screenshot-status]");
const image = preview.querySelector("[data-screenshot-image]");
const previousUrl = image.dataset.objectUrl;
if (previousUrl) URL.revokeObjectURL(previousUrl);
button.disabled = true;
preview.hidden = false;
image.hidden = true;
status.classList.remove("error");
status.textContent = "Capturing...";
try {
const response = await fetch(
"/api/devices/" + encodeURIComponent(deviceId) + "/screenshot",
{ method: "POST", headers: { "X-CSRF-Token": csrfToken } }
);
if (!response.ok) {
let detail = "Screenshot failed.";
try {
const payload = await response.json();
if (payload.detail) detail = payload.detail;
} catch (_) {}
throw new Error(detail);
}
const objectUrl = URL.createObjectURL(await response.blob());
image.src = objectUrl;
image.dataset.objectUrl = objectUrl;
image.hidden = false;
status.textContent = "Captured.";
} catch (error) {
status.classList.add("error");
status.textContent = error.message || "Screenshot failed.";
} finally {
button.disabled = false;
}
});
});
})();
</script>
{% endblock %}
@@ -1,22 +1,259 @@
{% extends "base.html" %}
{% block styles %}
{{ super() }}
.task-back { margin-top: 0; }
.timeline-step { border: 1px solid #c8d0d6; background: #fff; padding: 1rem; margin-bottom: 1rem; }
.step-heading { display: flex; flex-wrap: wrap; gap: 0.5rem 1rem; align-items: baseline; margin-bottom: 0.75rem; }
.step-heading p { margin: 0; color: #4d5a63; }
.evidence-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 1rem; margin-bottom: 1rem; }
.evidence-pane { margin: 0; min-width: 0; }
.evidence-pane h3 { font-size: 1rem; margin: 0 0 0.35rem; }
.screenshot-frame { min-height: 6rem; border: 1px solid #c8d0d6; background: #f8fafb; display: grid; place-items: center; overflow: hidden; color: #5e6b73; position: relative; }
.screenshot-frame img { display: block; width: 100%; height: auto; }
.overlay-svg { position: absolute; top: 0; left: 0; width: 100%; height: 100%; pointer-events: none; }
.overlay-svg .overlay-box { fill: none; stroke-width: 2; vector-effect: non-scaling-stroke; }
.overlay-svg .overlay-box.source-ui { stroke: #1e88e5; }
.overlay-svg .overlay-box.source-ocr { stroke: #fb8c00; }
.overlay-svg .overlay-boxes { display: none; }
.overlay-svg.show-boxes .overlay-boxes { display: inline; }
.overlay-svg .action-tap { fill: #e53935; fill-opacity: 0.25; stroke: #e53935; stroke-width: 2; vector-effect: non-scaling-stroke; }
.overlay-svg .action-swipe-line { stroke: #e53935; stroke-width: 3; vector-effect: non-scaling-stroke; fill: none; }
.overlay-svg .action-swipe-dot { fill: #e53935; }
.overlay-toggle { margin-bottom: 1rem; }
.step-details { margin-top: 0.75rem; }
.step-details summary { cursor: pointer; font-weight: 600; }
.step-details pre { white-space: pre-wrap; overflow-wrap: anywhere; margin: 0.65rem 0 0; padding: 0.65rem; border: 1px solid #d5dce0; background: #f8fafb; }
.operation-grid { display: grid; grid-template-columns: minmax(7rem, 0.4fr) minmax(0, 1fr); gap: 0.35rem 0.75rem; margin: 0.65rem 0 0; }
.operation-grid dt { font-weight: 600; }
.operation-grid dd { margin: 0; overflow-wrap: anywhere; }
.observation-list, .ui-tree-nodes { margin: 0.65rem 0 0; padding-left: 1.25rem; }
.observation-list li, .ui-tree-nodes li { margin-bottom: 0.45rem; overflow-wrap: anywhere; }
.observation-list span, .ui-tree-nodes span { color: #4d5a63; margin-left: 0.4rem; }
.ui-tree-nodes code { overflow-wrap: anywhere; }
@media (max-width: 640px) {
.evidence-grid { grid-template-columns: minmax(0, 1fr); }
.timeline-step { padding: 0.75rem; }
}
{% endblock %}
{% block body %}
<h1>Task {{ task.get("id") or "" }}</h1>
<p class="task-back"><a href="/tasks">&larr; Back to executions</a></p>
<h1>Execution</h1>
<table>
<thead><tr><th>Field</th><th>Value</th></tr></thead>
<tbody>{% for row in task_rows %}<tr><td>{{ row[0] }}</td><td>{{ row[1] }}</td></tr>{% endfor %}</tbody>
</table>
{% if cancel_notice %}<p class="notice" id="cancel-notice">{{ cancel_notice }}</p>{% endif %}
{% if cancel_error %}<p class="error" id="cancel-error">{{ cancel_error }}</p>{% endif %}
{% if can_cancel %}
<form method="post" action="/tasks/{{ task['id'] }}/cancel">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<p><button type="submit">Cancel task</button></p>
</form>
{% endif %}
<h2>Timeline</h2>
{% if not timeline_steps %}
<p>No timeline records.</p>
{% else %}
{% for step in timeline_steps %}
<div style="border:1px solid #ccc;background:#fff;padding:0.75rem;margin-bottom:0.75rem;">
<p><strong>Step {{ step.index }}</strong> &mdash; {{ step.timestamp }}</p>
<p>Prompt: {{ step.prompt }}</p>
<p>Tool call: <code>{{ step.tool_call_text }}</code></p>
<p>Result: <code>{{ step.result_text }}</code></p>
{% if step.screenshot_src %}<img src="{{ step.screenshot_src }}" alt="screenshot" style="max-width:100%;border:1px solid #ccc;margin-top:0.5rem;">{% endif %}
</div>
{% endfor %}
<p class="overlay-toggle">
<label><input type="checkbox" id="overlay-boxes-toggle"> Show OCR/UI-tree bounding boxes on before-action screenshots</label>
</p>
{% endif %}
{% for step in timeline_steps %}
<section class="timeline-step">
<div class="step-heading">
<strong>Step {{ step.index }}</strong>
<p>{{ step.timestamp }}</p>
</div>
<div class="evidence-grid">
<figure class="evidence-pane">
<h3>Before action</h3>
<div class="screenshot-frame">
{% if step.before_screenshot_src %}
<img src="{{ step.before_screenshot_src }}" alt="Screenshot before action">
{% if step.overlay.width and step.overlay.height %}
<svg class="overlay-svg" data-step-overlay
viewBox="0 0 {{ step.overlay.width }} {{ step.overlay.height }}"
preserveAspectRatio="none"></svg>
<script type="application/json" class="step-overlay-data">{{ {"overlay": step.overlay, "tool_call": step.tool_call} | tojson }}</script>
{% endif %}
{% else %}
<span>No screenshot</span>
{% endif %}
</div>
</figure>
<figure class="evidence-pane">
<h3>After action</h3>
<div class="screenshot-frame">
{% if step.after_screenshot_src %}
<img src="{{ step.after_screenshot_src }}" alt="Screenshot after action">
{% else %}
<span>No screenshot</span>
{% endif %}
</div>
</figure>
</div>
<details class="step-details" open>
<summary>Operation</summary>
<dl class="operation-grid">
<dt>Action</dt><dd>{{ step.tool_call.get("action") or "-" }}</dd>
<dt>Description</dt><dd>{{ step.tool_call.get("description") or "-" }}</dd>
</dl>
<pre>{{ step.tool_call | tojson(indent=2) }}</pre>
</details>
<details class="step-details">
<summary>Result</summary>
<pre>{{ step.result | tojson(indent=2) }}</pre>
</details>
{% if step.prompt %}
<details class="step-details">
<summary>Planner prompt</summary>
<pre>{{ step.prompt }}</pre>
</details>
{% endif %}
{% if step.ocr_results %}
<details class="step-details">
<summary>OCR results ({{ step.ocr_results|length }})</summary>
<ul class="observation-list">
{% for ocr in step.ocr_results %}
<li>
<strong>{{ ocr.get("text") or "-" }}</strong>
<span>{{ ocr.get("bounds") | tojson }}</span>
{% if ocr.get("confidence") is not none %}<span>confidence {{ "%.3f" | format(ocr.get("confidence")) }}</span>{% endif %}
</li>
{% endfor %}
</ul>
</details>
{% endif %}
{% if step.ui_tree_nodes %}
<details class="step-details">
<summary>UI tree ({{ step.ui_tree_nodes|length }} normalized nodes)</summary>
<ul class="ui-tree-nodes">
{% for node in step.ui_tree_nodes %}
<li>
<strong>{{ node.get("type") or "unknown" }}</strong>
<span>{{ node.get("text") or node.get("id") or "-" }}</span>
<code>{{ node.get("bounds") | tojson }}</code>
{% if node.get("confidence") is not none %}<span>confidence {{ "%.3f" | format(node.get("confidence")) }}</span>{% endif %}
</li>
{% endfor %}
</ul>
</details>
{% endif %}
</section>
{% endfor %}
<script>
(function () {
const SVG_NS = "http://www.w3.org/2000/svg";
function rect(bounds, className) {
const el = document.createElementNS(SVG_NS, "rect");
el.setAttribute("x", bounds.x);
el.setAttribute("y", bounds.y);
el.setAttribute("width", bounds.width);
el.setAttribute("height", bounds.height);
el.setAttribute("class", className);
return el;
}
function buildBoxesGroup(elements) {
const group = document.createElementNS(SVG_NS, "g");
group.setAttribute("class", "overlay-boxes");
(elements || []).forEach(function (element) {
const bounds = element.bounds;
if (!bounds) return;
const source = element.source === "ui" ? "source-ui" : "source-ocr";
const box = rect(bounds, "overlay-box " + source);
const label = element.text || element.id || "";
if (label) {
const title = document.createElementNS(SVG_NS, "title");
title.textContent = label;
box.appendChild(title);
}
group.appendChild(box);
});
return group;
}
function buildActionGroup(toolCall) {
const group = document.createElementNS(SVG_NS, "g");
group.setAttribute("class", "overlay-action");
if (!toolCall) return group;
const action = toolCall.action;
const args = toolCall.args || {};
if (action === "tap" && isFinite(args.x) && isFinite(args.y)) {
const circle = document.createElementNS(SVG_NS, "circle");
circle.setAttribute("cx", args.x);
circle.setAttribute("cy", args.y);
circle.setAttribute("r", 14);
circle.setAttribute("class", "action-tap");
group.appendChild(circle);
} else if (
action === "swipe" &&
isFinite(args.start_x) &&
isFinite(args.start_y) &&
isFinite(args.end_x) &&
isFinite(args.end_y)
) {
const line = document.createElementNS(SVG_NS, "line");
line.setAttribute("x1", args.start_x);
line.setAttribute("y1", args.start_y);
line.setAttribute("x2", args.end_x);
line.setAttribute("y2", args.end_y);
line.setAttribute("class", "action-swipe-line");
group.appendChild(line);
const dot = document.createElementNS(SVG_NS, "circle");
dot.setAttribute("r", 8);
dot.setAttribute("class", "action-swipe-dot");
const motion = document.createElementNS(SVG_NS, "animateMotion");
motion.setAttribute("dur", "1.2s");
motion.setAttribute("repeatCount", "indefinite");
motion.setAttribute(
"path",
"M" + args.start_x + "," + args.start_y + " L" + args.end_x + "," + args.end_y
);
dot.appendChild(motion);
group.appendChild(dot);
}
return group;
}
document.querySelectorAll("svg.overlay-svg[data-step-overlay]").forEach(function (svg) {
const dataScript = svg.nextElementSibling;
if (!dataScript || !dataScript.classList.contains("step-overlay-data")) return;
let payload;
try {
payload = JSON.parse(dataScript.textContent);
} catch (err) {
return;
}
svg.appendChild(buildBoxesGroup(payload.overlay && payload.overlay.elements));
svg.appendChild(buildActionGroup(payload.tool_call));
});
const toggle = document.getElementById("overlay-boxes-toggle");
if (toggle) {
const STORAGE_KEY = "task-detail-overlay-boxes-visible";
// 页面加载时恢复之前的选择
const savedState = localStorage.getItem(STORAGE_KEY);
if (savedState !== null) {
const shouldShow = savedState === "true";
toggle.checked = shouldShow;
document.querySelectorAll("svg.overlay-svg").forEach(function (svg) {
svg.classList.toggle("show-boxes", shouldShow);
});
}
toggle.addEventListener("change", function () {
const isChecked = toggle.checked;
// 保存状态到 localStorage
localStorage.setItem(STORAGE_KEY, String(isChecked));
document.querySelectorAll("svg.overlay-svg").forEach(function (svg) {
svg.classList.toggle("show-boxes", isChecked);
});
});
}
})();
</script>
{% endblock %}
@@ -29,17 +29,19 @@
</section>
<section id="local-tasks">
<h2>Local Runtime tasks</h2>
<h2>Executed tasks on this Host</h2>
{% if metadata_store_missing %}
<p class="error">Task metadata store is not configured.</p>
{% elif not tasks %}
<p>No tasks recorded.</p>
<p>No executions recorded yet.</p>
{% else %}
<table>
<thead><tr><th>Task ID</th><th>Status</th><th>Device</th><th>Created</th><th>Updated</th></tr></thead>
<thead><tr><th>Execution ID</th><th>Cloud task</th><th>Attempt</th><th>Status</th><th>Device</th><th>Created</th><th>Updated</th></tr></thead>
<tbody>{% for task in tasks %}
<tr>
<td><a href="/tasks/{{ task["id"] }}">{{ task["id"] }}</a></td>
<td>{{ task.get("source_task_id") or "" }}</td>
<td>{{ task.get("source_attempt") if task.get("source_attempt") is not none else "" }}</td>
<td>{{ task.get("status") or "" }}</td>
<td>{{ task.get("device_id") or "" }}</td>
<td>{{ task.get("created_at") or "" }}</td>
@@ -49,4 +51,4 @@
</table>
{% endif %}
</section>
{% endblock %}
{% endblock %}
+2 -1
View File
@@ -2,7 +2,7 @@
name = "device-host-agent"
version = "0.1.0"
description = "Outbound device-host worker for the Device Cloud Platform."
requires-python = ">=3.14"
requires-python = ">=3.13,<3.14"
dependencies = [
"device-agent-runtime==0.1.0",
"device-cloud-platform==0.1.0",
@@ -10,6 +10,7 @@ dependencies = [
"filelock>=3.0",
"httpx>=0.27.0",
"jinja2>=3.1",
"mcp>=1.28,<2",
"uvicorn[standard]>=0.30.0",
]
+11
View File
@@ -0,0 +1,11 @@
import pytest
@pytest.fixture(autouse=True)
def _humanize_disabled_by_default_in_tests(monkeypatch):
"""Humanize defaults ON in production; tests default it OFF so existing
exact-coordinate assertions stay deterministic. Tests that want to
exercise humanize call ``monkeypatch.setenv("APEX_HUMANIZE_ENABLED", "true")``
in their own body, which overrides this fixture (test body runs after
fixture setup)."""
monkeypatch.setenv("APEX_HUMANIZE_ENABLED", "false")
@@ -193,6 +193,9 @@ def make_task_detail_context(
task: dict[str, Any] | None = None,
task_rows: list[tuple[str, Any]] | None = None,
timeline_steps: list[dict[str, Any]] | None = None,
can_cancel: bool = False,
cancel_notice: str | None = None,
cancel_error: str | None = None,
) -> dict[str, Any]:
if task is None:
task = {
@@ -216,15 +219,22 @@ def make_task_detail_context(
"index": 0,
"timestamp": "2026-01-01T00:01:00Z",
"prompt": "Tap the Settings icon",
"tool_call_text": '{"action": "tap", "x": 100, "y": 200}',
"result_text": '{"ok": true}',
"screenshot_src": None,
"tool_call": {"action": "tap", "x": 100, "y": 200},
"result": {"ok": True},
"before_screenshot_src": None,
"after_screenshot_src": None,
"ocr_results": [],
"ui_tree_nodes": [],
},
]
return {
"title": "Task task-001",
"session": session,
"csrf_token": session.csrf_token,
"task": task,
"task_rows": task_rows,
"timeline_steps": timeline_steps,
"can_cancel": can_cancel,
"cancel_notice": cancel_notice,
"cancel_error": cancel_error,
}
@@ -47,6 +47,7 @@ def test_devices_renders(env, sample_session) -> None:
**make_devices_context(sample_session)
)
assert '<form method="post" action="/devices/save">' in html
assert 'class="screenshot-button"' in html
def test_account_renders(env, sample_session) -> None:
@@ -77,6 +78,43 @@ def test_task_detail_renders(env, sample_session) -> None:
assert "<h2>Timeline</h2>" in html
def test_task_detail_shows_cancel_button_for_non_terminal_task(
env, sample_session
) -> None:
html = env.get_template("task_detail.html").render(
**make_task_detail_context(sample_session, can_cancel=True)
)
assert 'action="/tasks/task-001/cancel"' in html
assert "Cancel task" in html
def test_task_detail_hides_cancel_button_for_terminal_task(
env, sample_session
) -> None:
html = env.get_template("task_detail.html").render(
**make_task_detail_context(sample_session, can_cancel=False)
)
assert 'action="/tasks/task-001/cancel"' not in html
def test_task_detail_renders_cancel_notice_and_error(env, sample_session) -> None:
html = env.get_template("task_detail.html").render(
**make_task_detail_context(
sample_session,
cancel_notice="Cancellation requested. It may take a moment to take effect.",
)
)
assert 'id="cancel-notice"' in html
html = env.get_template("task_detail.html").render(
**make_task_detail_context(
sample_session,
cancel_error="Failed to request cancellation. Try again.",
)
)
assert 'id="cancel-error"' in html
# ---------------------------------------------------------------------------
# 6.4 XSS-probe tests (parametrised over templates with operator-influenced
# string fields set to <script>alert(1)</script>)
@@ -170,9 +208,12 @@ def _xss_context(template: str, session: SessionState) -> dict[str, Any]:
"index": 0,
"timestamp": XSS_PROBE,
"prompt": XSS_PROBE,
"tool_call_text": XSS_PROBE,
"result_text": XSS_PROBE,
"screenshot_src": None,
"tool_call": {"action": XSS_PROBE, "description": XSS_PROBE},
"result": {"ok": XSS_PROBE},
"before_screenshot_src": None,
"after_screenshot_src": None,
"ocr_results": [],
"ui_tree_nodes": [],
}
],
)
+85
View File
@@ -7,6 +7,7 @@ from datetime import UTC, datetime, timedelta
import httpx
import pytest
from starlette.testclient import TestClient
from cloud.internal_api.models import (
AssignmentModel,
@@ -15,10 +16,23 @@ from cloud.internal_api.models import (
)
from device.manager import DeviceManager
from host_agent.app import HostAgentApplication, create_application
from host_agent.assignment import AssignmentExecutor
from host_agent.config import HostAgentConfig
from host_agent.execution import create_execution_factories
from host_agent.history import ConsoleHistoryStore
from host_agent.identity import HostIdentityStore
from host_agent.instance_lock import InstanceAlreadyRunningError
from host_agent.local_account import LocalAccountStore
from host_agent.mcp_lock import McpBusyTracker
from host_agent.mcp_token import McpTokenStore
from host_agent.status import AgentStatusTracker
from host_agent.web.app import create_console_app
from host_agent.web.auth import SessionManager
from host_agent.web.mcp import build_mcp_server
from storage.artifact_store import ArtifactStore
from storage.device_config import DeviceConfigStore
from storage.task_metadata import TaskMetadataStore
from storage.timeline import Timeline
def _free_loopback_port() -> int:
@@ -653,6 +667,77 @@ def test_create_application_with_independent_identity_paths_coexist(
asyncio.run(app_a.client.aclose())
def test_create_application_wires_mcp_components(tmp_path, monkeypatch) -> None:
"""create_application produces a console app with /mcp mounted (auth-protected)
and persists the host_mcp_token.json file alongside the identity."""
monkeypatch.chdir(tmp_path)
config = _config()
config_store = DeviceConfigStore(tmp_path / "devices.sqlite3")
identity_store = HostIdentityStore(config.identity_path)
history_store = ConsoleHistoryStore(
tmp_path / "host_console_history.sqlite3",
limit=config.console_history_limit,
)
metadata_store = TaskMetadataStore(db_path=config.task_progress_db_path)
timeline = Timeline(ArtifactStore(root=config.task_artifact_dir))
status_tracker = AgentStatusTracker()
application = create_application(
config=config,
device_config_store=config_store,
identity_store=identity_store,
manager=DeviceManager(),
)
# Token file must exist after create_application.
assert (config.identity_path.parent / "host_mcp_token.json").exists()
# Heartbeat must hold the in-process McpBusyTracker.
assert application.heartbeat.mcp_busy_tracker is not None
# Build the same console app the production path builds and verify /mcp
# is mounted (responds 401, not 404) without a bearer token.
mcp_token_store = McpTokenStore(config.identity_path.parent / "host_mcp_token.json")
mcp_token_store.load_or_create()
mcp_busy_tracker = McpBusyTracker(ttl_seconds=20.0)
mcp_server = build_mcp_server(
manager=application.heartbeat.manager,
mcp_busy_tracker=mcp_busy_tracker,
status_tracker=status_tracker,
)
console_app = create_console_app(
config=config,
manager=application.heartbeat.manager,
config_store=config_store,
local_account_store=LocalAccountStore(config.local_account_path),
identity_store=identity_store,
history_store=history_store,
status_tracker=status_tracker,
session_manager=SessionManager(ttl_seconds=config.console_session_ttl_seconds),
enrollment_client=None,
host_client=application.client,
metadata_store=metadata_store,
timeline=timeline,
executor=AssignmentExecutor(
create_execution_factories(
application.heartbeat.manager,
metadata_store=metadata_store,
timeline=timeline,
host_agent_config=config,
),
mcp_busy_tracker=mcp_busy_tracker,
),
mcp_server=mcp_server,
mcp_token_store=mcp_token_store,
mcp_busy_tracker=mcp_busy_tracker,
)
with TestClient(console_app) as client:
resp = client.post("/mcp/")
assert resp.status_code == 401 # auth required, not 404
asyncio.run(application.client.aclose())
def test_lock_released_after_run_async_allows_restart(tmp_path, monkeypatch) -> None:
monkeypatch.chdir(tmp_path)
identity_path = tmp_path / "host_identity.json"
@@ -25,6 +25,7 @@ def _assignment(**overrides) -> AssignmentModel:
def test_goal_assignment_executes_through_task_runner() -> None:
received: list[Task] = []
created: list[tuple[str, str | None, int | None]] = []
class FakeTaskRunner:
def run(self, task: Task) -> Task:
@@ -32,10 +33,21 @@ def test_goal_assignment_executes_through_task_runner() -> None:
task.status = "completed"
return task
class FakeMetadataStore:
def create_task(
self,
task: Task,
*,
source_task_id: str | None = None,
source_attempt: int | None = None,
) -> None:
created.append((task.id, source_task_id, source_attempt))
factories = ExecutionFactories(
task_runner_factory=lambda: FakeTaskRunner(), # type: ignore[arg-type,return-value]
workflow_runner_factory=lambda: object(), # type: ignore[arg-type,return-value]
workflow_store=object(), # type: ignore[arg-type]
metadata_store=FakeMetadataStore(), # type: ignore[arg-type]
)
result = AssignmentExecutor(factories).execute(_assignment())
@@ -44,6 +56,7 @@ def test_goal_assignment_executes_through_task_runner() -> None:
assert received[0].goal == "open settings"
assert received[0].device_id == "device-a"
assert result.metadata["runtime_task_id"] == received[0].id
assert created == [(received[0].id, "cloud-task", 1)]
def test_goal_assignment_preserves_runtime_failure_reason() -> None:
@@ -65,6 +78,39 @@ def test_goal_assignment_preserves_runtime_failure_reason() -> None:
assert result.failure_reason == "planner unavailable"
def test_goal_assignment_maps_cancellation_stop_to_cancelled_status() -> None:
# First should_stop() call is Executor.execute()'s pre-flight check (must pass
# through so the runner is actually invoked); the runner's own loop then stops.
calls = {"count": 0}
def should_stop() -> bool:
calls["count"] += 1
return calls["count"] > 1
class FakeTaskRunner:
def run(self, task: Task, *, should_stop=None, stop_reason=None) -> Task:
assert should_stop is not None and should_stop()
assert stop_reason is not None
task.status = "cancelled"
task.failure_reason = stop_reason()
return task
factories = ExecutionFactories(
task_runner_factory=lambda: FakeTaskRunner(), # type: ignore[arg-type,return-value]
workflow_runner_factory=lambda: object(), # type: ignore[arg-type,return-value]
workflow_store=object(), # type: ignore[arg-type]
)
result = AssignmentExecutor(factories).execute(
_assignment(),
should_stop=should_stop,
stop_reason=lambda: "cancellation requested by control plane",
)
assert result.status == "cancelled"
assert result.failure_reason == "cancellation requested by control plane"
def test_workflow_assignment_loads_and_executes_definition() -> None:
definition = object()
calls: list[tuple[object, str]] = []
@@ -96,6 +142,108 @@ def test_workflow_assignment_loads_and_executes_definition() -> None:
}
def test_workflow_assignment_maps_cancellation_stop_to_cancelled_status() -> None:
calls = {"count": 0}
def should_stop() -> bool:
calls["count"] += 1
return calls["count"] > 1
class FakeWorkflowStore:
def get_definition(self, definition_id: str):
return object() if definition_id == "workflow-a" else None
class FakeWorkflowRunner:
def run(
self,
loaded_definition,
device_id: str,
*,
should_stop=None,
stop_reason=None,
):
assert should_stop is not None and should_stop()
assert stop_reason is not None
return SimpleNamespace(
id="run-a", status="cancelled", failure_reason=stop_reason()
)
factories = ExecutionFactories(
task_runner_factory=lambda: object(), # type: ignore[arg-type,return-value]
workflow_runner_factory=lambda: FakeWorkflowRunner(), # type: ignore[arg-type,return-value]
workflow_store=FakeWorkflowStore(), # type: ignore[arg-type]
)
result = AssignmentExecutor(factories).execute(
_assignment(goal=None, workflow_definition_id="workflow-a"),
should_stop=should_stop,
stop_reason=lambda: "cancellation requested by control plane",
)
assert result.status == "cancelled"
assert result.metadata == {
"workflow_run_id": "run-a",
"workflow_status": "cancelled",
}
def test_execute_fails_fast_when_mcp_session_holds_device() -> None:
"""Cloud assignment arriving for a device currently held by an MCP
session must fail immediately rather than fight for the device."""
from host_agent.mcp_lock import McpBusyTracker
tracker = McpBusyTracker()
tracker.acquire("phone-1", "sess-mcp")
executor = AssignmentExecutor(
_build_factories(),
mcp_busy_tracker=tracker,
)
assignment = _assignment(device_id="phone-1")
result = executor.execute(assignment)
assert result.status == "failed"
assert "MCP" in (result.failure_reason or "")
def test_execute_skips_check_when_tracker_is_none() -> None:
"""Default backward-compat: no tracker → no fail-fast."""
executor = AssignmentExecutor(_build_factories())
# Without a real workflow store / task runner this test verifies the
# entry-point path doesn't raise on the mcp_busy check.
# We use a goal + a mock runner factory so execute() runs through.
assignment = _assignment()
result = executor.execute(assignment)
# Should run through normally (not fail on MCP check)
assert result.status == "done"
def _build_factories() -> ExecutionFactories:
"""Shared factory fixture used by MCP-hold tests."""
received: list[Task] = []
class FakeTaskRunner:
def run(self, task: Task) -> Task:
received.append(task)
task.status = "completed"
return task
class FakeMetadataStore:
def create_task(
self,
task: Task,
*,
source_task_id: str | None = None,
source_attempt: int | None = None,
) -> None:
pass
return ExecutionFactories(
task_runner_factory=lambda: FakeTaskRunner(), # type: ignore[arg-type,return-value]
workflow_runner_factory=lambda: object(), # type: ignore[arg-type,return-value]
workflow_store=object(), # type: ignore[arg-type]
metadata_store=FakeMetadataStore(), # type: ignore[arg-type]
)
def test_unknown_workflow_fails_without_running() -> None:
class FakeWorkflowStore:
def get_definition(self, definition_id: str):
+18
View File
@@ -146,3 +146,21 @@ def test_duplicate_instance_exits_with_clear_error(
err = capsys.readouterr().err
assert "another Host Agent instance" in err
assert str(lock_path) in err
def test_mcp_token_subcommand_prints_token(tmp_path, capsys, monkeypatch) -> None:
monkeypatch.setenv("HOST_AGENT_IDENTITY_PATH", str(tmp_path / "host_identity.json"))
monkeypatch.setenv(
"HOST_AGENT_LOCAL_ACCOUNT_PATH", str(tmp_path / "host_local_account.json")
)
# Also set control plane URL to satisfy config loading
monkeypatch.setenv("HOST_AGENT_CONTROL_PLANE_URL", "https://cloud.example")
from host_agent.cli import main
main(["mcp-token"])
out = capsys.readouterr().out.strip()
assert len(out) >= 40 # token is ~43 chars
# Subsequent invocation prints the same token (idempotent).
main(["mcp-token"])
out2 = capsys.readouterr().out.strip()
assert out == out2
@@ -140,6 +140,30 @@ def test_stale_lease_response_raises_typed_error_without_retry() -> None:
assert attempts == 1
def test_renew_deserializes_cancel_requested_flag() -> None:
def handler(request: httpx.Request) -> httpx.Response:
return httpx.Response(
200,
json={
"status": "renewed",
"lease_expires_at": "2026-07-12T00:05:00Z",
"cancel_requested": True,
},
)
async def scenario() -> None:
async with httpx.AsyncClient(
transport=httpx.MockTransport(handler),
base_url="https://control.example",
) as http_client:
client = HostAgentClient(_config(), http_client=http_client)
response = await client.renew(_assignment())
assert response.status == "renewed"
assert response.cancel_requested is True
asyncio.run(scenario())
def test_result_report_retries_identical_payload_after_response_loss() -> None:
payloads: list[dict[str, object]] = []
@@ -329,6 +353,60 @@ def test_submit_self_task_does_not_duplicate_when_response_is_lost() -> None:
assert attempts == 1
def test_heartbeat_includes_mcp_busy_device_ids_in_payload() -> None:
"""When mcp_busy_device_ids is passed, the client sends it in the request."""
captured: list[dict[str, object]] = []
def handler(request: httpx.Request) -> httpx.Response:
captured.append(json.loads(request.content))
return httpx.Response(
200,
json={
"host_id": "host-a",
"accepted_devices": 0,
"received_at": "2026-07-12T00:00:00Z",
},
)
async def scenario() -> None:
async with httpx.AsyncClient(
transport=httpx.MockTransport(handler),
base_url="https://control.example",
) as http_client:
client = HostAgentClient(_config(), http_client=http_client)
await client.heartbeat([], mcp_busy_device_ids=["phone-1"])
asyncio.run(scenario())
assert captured[0]["mcp_busy_device_ids"] == ["phone-1"]
def test_heartbeat_omits_mcp_busy_device_ids_when_empty() -> None:
"""Empty list is omitted from the payload (backward compatible)."""
captured: list[dict[str, object]] = []
def handler(request: httpx.Request) -> httpx.Response:
captured.append(json.loads(request.content))
return httpx.Response(
200,
json={
"host_id": "host-a",
"accepted_devices": 0,
"received_at": "2026-07-12T00:00:00Z",
},
)
async def scenario() -> None:
async with httpx.AsyncClient(
transport=httpx.MockTransport(handler),
base_url="https://control.example",
) as http_client:
client = HostAgentClient(_config(), http_client=http_client)
await client.heartbeat([], mcp_busy_device_ids=[])
asyncio.run(scenario())
assert "mcp_busy_device_ids" not in captured[0]
def test_bootstrap_client_directly_enrolls_and_enrolls_device() -> None:
requests: list[httpx.Request] = []
host_attempts = 0
@@ -5,7 +5,10 @@ import json
import httpx
import pytest
from host_agent.cloud_planner_client import CloudProxyToolCallingClient
from host_agent.cloud_planner_client import (
_CLOUD_PROXY_HTTP_TIMEOUT_SECONDS,
CloudProxyToolCallingClient,
)
from host_agent.config import HostAgentConfig
from host_agent.planner_context import PlannerExecutionContext, _context
from runtime.tool_calling_client import ToolCallDecision, ToolCallUnavailable
@@ -33,7 +36,14 @@ def test_decide_returns_tool_call_decision_on_success() -> None:
seen_requests.append(request)
return httpx.Response(
200,
json={"tool_name": "tap", "arguments": {"x": 1, "y": 2}},
json={
"tool_name": "tap",
"arguments": {"x": 1, "y": 2},
"rationale": "The button is visible. Opening it.",
"thinking": "A tap should navigate to the next page.",
"purpose": "Open the next page.",
"expected_outcome": "The next page is visible.",
},
)
client = _client(handler)
@@ -46,7 +56,14 @@ def test_decide_returns_tool_call_decision_on_success() -> None:
timeout=30.0,
)
assert decision == ToolCallDecision(tool_name="tap", arguments={"x": 1, "y": 2})
assert decision == ToolCallDecision(
tool_name="tap",
arguments={"x": 1, "y": 2},
text_output="The button is visible. Opening it.",
thinking="A tap should navigate to the next page.",
purpose="Open the next page.",
expected_outcome="The next page is visible.",
)
assert len(seen_requests) == 1
request = seen_requests[0]
assert request.url.path == "/internal/v1/hosts/host-a/planner/decide"
@@ -79,6 +96,59 @@ def test_decide_base64_encodes_screenshot() -> None:
assert body["screenshot_base64"] == "aGVsbG8="
def test_decide_forwards_planner_history() -> None:
seen_requests: list[httpx.Request] = []
def handler(request: httpx.Request) -> httpx.Response:
seen_requests.append(request)
return httpx.Response(200, json={"tool_name": "tap", "arguments": {}})
client = _client(handler)
history = [
{
"user_prompt": "first screen",
"tool_name": "tap",
"arguments": {"x": 1, "y": 2},
"rationale": "Open it.",
"tool_result": {"success": True},
}
]
client.decide(
system_prompt="sp",
user_prompt="next screen",
screenshot=None,
tools=_TOOLS,
timeout=10.0,
history=history,
)
assert json.loads(seen_requests[0].content)["history"] == history
def test_decide_clamps_legacy_timeout_and_waits_for_cloud_profile_timeout() -> None:
seen_requests: list[httpx.Request] = []
def handler(request: httpx.Request) -> httpx.Response:
seen_requests.append(request)
return httpx.Response(200, json={"tool_name": "tap", "arguments": {}})
client = _client(handler)
client.decide(
system_prompt="sp",
user_prompt="up",
screenshot=None,
tools=_TOOLS,
timeout=180.0,
)
request = seen_requests[0]
body = json.loads(request.content)
assert body["timeout_seconds"] == 120.0
assert request.extensions["timeout"]["read"] == _CLOUD_PROXY_HTTP_TIMEOUT_SECONDS
def test_decide_includes_bound_assignment_context() -> None:
seen_requests: list[httpx.Request] = []
+38 -12
View File
@@ -11,11 +11,31 @@ from host_agent.config import (
)
def test_load_host_agent_config_uses_managed_cloud_default() -> None:
assert load_host_agent_config({}) == HostAgentConfig(
def test_load_host_agent_config_uses_managed_cloud_defaults() -> None:
config = load_host_agent_config({})
assert config == HostAgentConfig(
control_plane_url="https://amcp.home.jerryyan.top",
enrollment_managed=True,
)
assert config.ai_planner_transport == "cloud"
def test_load_host_agent_config_allows_explicit_direct_planner_transport() -> None:
config = load_host_agent_config({"AI_PLANNER_TRANSPORT": "direct"})
assert config.ai_planner_transport == "direct"
def test_load_host_agent_config_supports_local_mode() -> None:
config = load_host_agent_config({"HOST_AGENT_MODE": "local"})
assert config.mode == "local"
assert config.control_plane_url == ""
assert config.enrollment_managed is False
assert config.ai_planner_transport == "direct"
assert config.dependency_supervisor_enabled is True
assert config.appium_supervised is True
def test_load_host_agent_config_parses_poll_and_retry_values() -> None:
@@ -182,9 +202,6 @@ def test_dependency_supervisor_defaults_to_disabled() -> None:
assert config.appium_supervised is False
assert config.appium_host == "127.0.0.1"
assert config.appium_port == 4723
assert config.runtime_supervised is False
assert config.runtime_host == "127.0.0.1"
assert config.runtime_port == 8000
assert config.dependency_restart_max_attempts == 5
@@ -195,9 +212,6 @@ def test_dependency_supervisor_env_vars_parse_bool_and_numeric_fields() -> None:
"HOST_AGENT_APPIUM_SUPERVISED": "1",
"HOST_AGENT_APPIUM_HOST": "0.0.0.0",
"HOST_AGENT_APPIUM_PORT": "4724",
"HOST_AGENT_RUNTIME_SUPERVISED": "true",
"HOST_AGENT_RUNTIME_HOST": "localhost",
"HOST_AGENT_RUNTIME_PORT": "8001",
"HOST_AGENT_DEPENDENCY_RESTART_MAX_ATTEMPTS": "8",
}
)
@@ -206,9 +220,6 @@ def test_dependency_supervisor_env_vars_parse_bool_and_numeric_fields() -> None:
assert config.appium_supervised is True
assert config.appium_host == "0.0.0.0"
assert config.appium_port == 4724
assert config.runtime_supervised is True
assert config.runtime_host == "localhost"
assert config.runtime_port == 8001
assert config.dependency_restart_max_attempts == 8
@@ -216,7 +227,6 @@ def test_dependency_supervisor_env_vars_parse_bool_and_numeric_fields() -> None:
"overrides",
[
{"HOST_AGENT_APPIUM_PORT": "0"},
{"HOST_AGENT_RUNTIME_PORT": "not-a-number"},
{"HOST_AGENT_DEPENDENCY_RESTART_MAX_ATTEMPTS": "-1"},
],
)
@@ -225,3 +235,19 @@ def test_dependency_supervisor_numeric_fields_reject_invalid_values(
) -> None:
with pytest.raises(HostAgentConfigurationError):
load_host_agent_config(overrides)
@pytest.mark.parametrize(
"setting,value",
[
("HOST_AGENT_RUNTIME_SUPERVISED", "true"),
("HOST_AGENT_RUNTIME_HOST", "127.0.0.1"),
("HOST_AGENT_RUNTIME_PORT", "8000"),
],
)
def test_removed_runtime_supervision_settings_are_rejected(
setting: str,
value: str,
) -> None:
with pytest.raises(HostAgentConfigurationError, match="standalone Runtime service"):
load_host_agent_config({setting: value})
@@ -16,8 +16,6 @@ from host_agent.dependency_supervisor import (
_SupervisorKnobs,
appium_argv_factory,
probe_appium,
probe_runtime,
runtime_argv_factory,
)
from host_agent.config import HostAgentConfig
@@ -175,7 +173,6 @@ def test_probe_returns_no_listener_when_port_is_closed(monkeypatch) -> None:
_raise_connection_refused,
)
assert probe_appium("127.0.0.1", 4723) is ProbeResult.NO_LISTENER
assert probe_runtime("127.0.0.1", 8000) is ProbeResult.NO_LISTENER
def test_probe_returns_healthy_on_appium_status_endpoint(monkeypatch) -> None:
@@ -190,18 +187,6 @@ def test_probe_returns_healthy_on_appium_status_endpoint(monkeypatch) -> None:
assert probe_appium("127.0.0.1", 4723) is ProbeResult.HEALTHY
def test_probe_returns_healthy_on_runtime_devices_endpoint(monkeypatch) -> None:
monkeypatch.setattr(
"host_agent.dependency_supervisor.socket.create_connection",
_ok_connection,
)
monkeypatch.setattr(
"host_agent.dependency_supervisor.httpx.get",
lambda url, timeout=2.0: httpx.Response(200, json=[]),
)
assert probe_runtime("127.0.0.1", 8000) is ProbeResult.HEALTHY
def test_probe_returns_unhealthy_when_listener_returns_non_200(monkeypatch) -> None:
monkeypatch.setattr(
"host_agent.dependency_supervisor.socket.create_connection",
@@ -223,7 +208,7 @@ def test_probe_returns_unhealthy_when_listener_returns_non_json(monkeypatch) ->
"host_agent.dependency_supervisor.httpx.get",
lambda url, timeout=2.0: httpx.Response(200, text="not json"),
)
assert probe_runtime("127.0.0.1", 8000) is ProbeResult.UNHEALTHY_LISTENER
assert probe_appium("127.0.0.1", 4723) is ProbeResult.UNHEALTHY_LISTENER
def test_probe_returns_unhealthy_on_http_transport_error(monkeypatch) -> None:
@@ -335,38 +320,6 @@ def test_spawn_uses_appium_argv_factory() -> None:
asyncio.run(scenario())
def test_spawn_uses_runtime_argv_factory() -> None:
async def scenario() -> None:
captured: list[list[str]] = []
def recording_popen(argv, **kwargs):
captured.append(list(argv))
return _FakePopen(argv)
dep, _ = _dep(
name="runtime",
port=8000,
probe_responses=(ProbeResult.NO_LISTENER, ProbeResult.HEALTHY),
argv_factory=runtime_argv_factory,
)
sup, _ = _build_supervisor([dep], popen_factory=recording_popen)
await sup.start()
assert captured == [
[
"uvicorn",
"api.rest:create_app",
"--factory",
"--host",
"127.0.0.1",
"--port",
"8000",
]
]
asyncio.run(scenario())
def test_readiness_timeout_leaves_process_running_without_restart_loop() -> None:
async def scenario() -> None:
# NO_LISTENER for initial probe; probe never becomes HEALTHY → startup
@@ -529,7 +482,7 @@ def test_from_host_agent_config_builds_empty_supervisor_when_no_dep_selected() -
assert sup.dependencies == []
def test_from_host_agent_config_includes_appium_and_runtime_when_selected() -> None:
def test_from_host_agent_config_includes_appium_when_selected() -> None:
config = HostAgentConfig(
control_plane_url="https://control.example",
host_id="host-a",
@@ -538,17 +491,12 @@ def test_from_host_agent_config_includes_appium_and_runtime_when_selected() -> N
appium_supervised=True,
appium_host="10.0.0.5",
appium_port=4724,
runtime_supervised=True,
runtime_host="10.0.0.5",
runtime_port=8001,
dependency_restart_max_attempts=7,
)
sup = DependencySupervisor.from_host_agent_config(config)
names = [dep.name for dep in sup.dependencies]
assert names == ["appium", "runtime"]
assert names == ["appium"]
appium = sup.dependencies[0]
assert appium.host == "10.0.0.5"
assert appium.port == 4724
runtime = sup.dependencies[1]
assert runtime.port == 8001
assert sup._max_attempts == 7
+11
View File
@@ -47,6 +47,9 @@ class FakeDriver(Driver):
def tap(self, x: float, y: float) -> None:
self.calls.append(("tap", (x, y)))
def long_press(self, x: float, y: float, duration_ms: int = 1200) -> None:
return None
def swipe(
self,
start_x: float,
@@ -57,6 +60,14 @@ class FakeDriver(Driver):
) -> None:
return None
def swipe_path(
self, waypoints: list[tuple[float, float]], duration_ms: int
) -> None:
return None
def double_tap(self, x: float, y: float, interval_ms: int = 80) -> None:
return None
def input(self, text: str) -> None:
return None
+36 -7
View File
@@ -33,6 +33,9 @@ class FakeDriver(Driver):
def tap(self, x: float, y: float) -> None:
return None
def long_press(self, x: float, y: float, duration_ms: int = 1200) -> None:
return None
def swipe(
self,
start_x: float,
@@ -43,6 +46,14 @@ class FakeDriver(Driver):
) -> None:
return None
def swipe_path(
self, waypoints: list[tuple[float, float]], duration_ms: int
) -> None:
return None
def double_tap(self, x: float, y: float, interval_ms: int = 80) -> None:
return None
def input(self, text: str) -> None:
return None
@@ -130,6 +141,24 @@ def test_created_task_runner_observer_uses_configured_manager(
assert seen == {"device_id": "phone-1", "manager": manager}
@pytest.mark.parametrize(
("driver_type", "expected_platform"),
[("wda", "ios"), ("uiautomator2", "android")],
)
def test_created_task_runner_resolves_platform_from_configured_driver(
tmp_path, driver_type: str, expected_platform: str
) -> None:
manager = DeviceManager()
manager.register_device("phone-1", lambda: FakeDriver(), driver_type=driver_type)
factories = create_execution_factories(
manager, workflow_store=WorkflowStore(tmp_path / "workflows.sqlite3")
)
task_runner = factories.task_runner_factory()
assert task_runner.device_platform_provider is not None
assert task_runner.device_platform_provider("phone-1") == expected_platform
def test_created_task_runner_defaults_to_ai_planner(
tmp_path, monkeypatch: pytest.MonkeyPatch
) -> None:
@@ -158,8 +187,9 @@ def test_created_task_runner_honors_explicit_ai_planner_opt_out(
assert type(task_runner.planner) is Planner
def test_cloud_transport_builds_ai_planner_with_cloud_proxy_client(
tmp_path, monkeypatch: pytest.MonkeyPatch
@pytest.mark.parametrize("transport", [None, "cloud"])
def test_default_and_explicit_cloud_transport_build_ai_planner_with_cloud_proxy_client(
tmp_path, monkeypatch: pytest.MonkeyPatch, transport: str | None
) -> None:
monkeypatch.delenv("AI_PLANNER_ENABLED", raising=False)
manager = DeviceManager()
@@ -167,7 +197,7 @@ def test_cloud_transport_builds_ai_planner_with_cloud_proxy_client(
control_plane_url="https://control-plane.example",
host_id="host-a",
token="token-a",
ai_planner_transport="cloud",
**({} if transport is None else {"ai_planner_transport": transport}),
)
factories = create_execution_factories(
manager,
@@ -182,9 +212,8 @@ def test_cloud_transport_builds_ai_planner_with_cloud_proxy_client(
assert task_runner.planner.client.config is host_agent_config
@pytest.mark.parametrize("transport", [None, "direct"])
def test_direct_transport_preserves_existing_local_provider_construction(
tmp_path, monkeypatch: pytest.MonkeyPatch, transport: str | None
def test_explicit_direct_transport_preserves_local_provider_construction(
tmp_path, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.delenv("AI_PLANNER_ENABLED", raising=False)
manager = DeviceManager()
@@ -192,7 +221,7 @@ def test_direct_transport_preserves_existing_local_provider_construction(
control_plane_url="https://control-plane.example",
host_id="host-a",
token="token-a",
**({} if transport is None else {"ai_planner_transport": transport}),
ai_planner_transport="direct",
)
factories = create_execution_factories(
manager,
+100 -6
View File
@@ -8,6 +8,7 @@ from cloud.internal_api.models import HostGovernancePolicyModel
from device.manager import DeviceManager
from host_agent.config import HostAgentConfig
from host_agent.heartbeat import HeartbeatSynchronizer, build_device_snapshot
from host_agent.mcp_lock import McpBusyTracker
from host_agent.policy_cache import HostPolicyCacheStore
from host_agent.status import AgentStatusTracker
@@ -16,6 +17,9 @@ class ConnectableDriver:
def connect(self) -> None:
return None
def screenshot(self) -> bytes:
return b"ok"
def _config() -> HostAgentConfig:
return HostAgentConfig(
@@ -60,7 +64,9 @@ def test_heartbeat_synchronizer_runs_at_configured_interval_until_stopped() -> N
calls: list[list[str]] = []
class FakeClient:
async def heartbeat(self, devices, *, address=None, policy_revision=0):
async def heartbeat(
self, devices, *, address=None, policy_revision=0, **kwargs
):
calls.append([device.device_id for device in devices])
return HeartbeatResponse(
host_id="host-a",
@@ -86,6 +92,33 @@ def test_heartbeat_synchronizer_runs_at_configured_interval_until_stopped() -> N
assert manager.status("device-a") == "busy"
def test_offline_device_is_retried_on_next_heartbeat_cycle() -> None:
class FlakyDriver(ConnectableDriver):
def __init__(self, fail: bool) -> None:
self.fail = fail
def screenshot(self) -> bytes:
if self.fail:
raise RuntimeError("WDA disconnected")
return b"ok"
instances: list[FlakyDriver] = []
def factory() -> FlakyDriver:
driver = FlakyDriver(not instances)
instances.append(driver)
return driver
manager = DeviceManager()
manager.register_device("device-a", factory) # type: ignore[arg-type]
manager.connect("device-a")
sync = HeartbeatSynchronizer(manager, object(), _config()) # type: ignore[arg-type]
sync.probe_connected_devices()
assert manager.status("device-a") == "offline"
sync.connect_devices()
assert manager.status("device-a") == "busy"
def test_sync_once_notifies_status_tracker_and_on_sync_with_device_count() -> None:
manager = DeviceManager()
manager.register_device(
@@ -98,7 +131,9 @@ def test_sync_once_notifies_status_tracker_and_on_sync_with_device_count() -> No
)
class FakeClient:
async def heartbeat(self, devices, *, address=None, policy_revision=0):
async def heartbeat(
self, devices, *, address=None, policy_revision=0, **kwargs
):
return HeartbeatResponse(
host_id="host-a",
accepted_devices=len(devices),
@@ -133,7 +168,9 @@ def test_heartbeat_caches_safe_host_policy_and_reuses_its_revision(tmp_path) ->
revisions: list[int] = []
class UpdatingClient:
async def heartbeat(self, devices, *, address=None, policy_revision=0):
async def heartbeat(
self, devices, *, address=None, policy_revision=0, **kwargs
):
revisions.append(policy_revision)
return HeartbeatResponse(
host_id="host-a",
@@ -175,6 +212,63 @@ def test_heartbeat_caches_safe_host_policy_and_reuses_its_revision(tmp_path) ->
asyncio.run(scenario())
assert revisions == [0]
assert '"token":' not in (
tmp_path / "host_policy.json"
).read_text(encoding="utf-8")
assert '"token":' not in (tmp_path / "host_policy.json").read_text(encoding="utf-8")
def test_sync_once_passes_mcp_busy_device_ids_to_client() -> None:
"""When mcp_busy_tracker has a lease, sync_once relays the device_ids."""
manager = DeviceManager()
tracker = McpBusyTracker()
assert tracker.acquire("phone-1", "sess-a")
last_kwargs: dict[str, object] = {}
class FakeClient:
async def heartbeat(
self, devices, *, address=None, policy_revision=0, **kwargs
):
last_kwargs.update(kwargs)
return HeartbeatResponse(
host_id="host-a",
accepted_devices=len(devices),
received_at=datetime.now(UTC),
)
async def scenario() -> None:
sync = HeartbeatSynchronizer(
manager,
FakeClient(), # type: ignore[arg-type]
_config(),
mcp_busy_tracker=tracker,
)
await sync.sync_once()
asyncio.run(scenario())
assert last_kwargs.get("mcp_busy_device_ids") == ["phone-1"]
def test_sync_once_passes_empty_when_tracker_is_none() -> None:
"""Default: no tracker → no busy device ids forwarded."""
manager = DeviceManager()
last_kwargs: dict[str, object] = {}
class FakeClient:
async def heartbeat(
self, devices, *, address=None, policy_revision=0, **kwargs
):
last_kwargs.update(kwargs)
return HeartbeatResponse(
host_id="host-a",
accepted_devices=len(devices),
received_at=datetime.now(UTC),
)
async def scenario() -> None:
sync = HeartbeatSynchronizer(
manager,
FakeClient(), # type: ignore[arg-type]
_config(),
)
await sync.sync_once()
asyncio.run(scenario())
assert not last_kwargs.get("mcp_busy_device_ids")
+4 -8
View File
@@ -93,17 +93,14 @@ def test_history_store_records_task_submission_without_device(tmp_path) -> None:
entries = store.list_recent()
assert entries[0]["summary"] == (
"task submitted: task-cloud-2 (automatic device)"
)
assert entries[0]["summary"] == ("task submitted: task-cloud-2 (automatic device)")
assert entries[0]["detail"] == {"task_id": "task-cloud-2"}
def test_history_store_task_submission_redacts_goal_and_secrets(tmp_path) -> None:
store = ConsoleHistoryStore(tmp_path / "history.sqlite3")
secret_goal = (
"rotate secret-XYZ-abcdef-very-secret "
"cookie=session-abc; lease=lease-stale"
"rotate secret-XYZ-abcdef-very-secret cookie=session-abc; lease=lease-stale"
)
store.record_task_submission(task_id="task-cloud-3", device_id="device-cloud-a")
@@ -111,8 +108,7 @@ def test_history_store_task_submission_redacts_goal_and_secrets(tmp_path) -> Non
entries = store.list_recent()
rendered = "\n".join(
repr(entry["summary"]) + " " + json.dumps(entry["detail"])
for entry in entries
repr(entry["summary"]) + " " + json.dumps(entry["detail"]) for entry in entries
)
assert secret_goal not in rendered
@@ -135,4 +131,4 @@ def test_history_store_task_submissions_prune_beyond_limit(tmp_path) -> None:
"task-4",
"task-3",
"task-2",
]
]
+57 -7
View File
@@ -29,7 +29,7 @@ def test_lease_renews_while_execution_is_active() -> None:
renewed = asyncio.Event()
class BlockingExecutor:
def execute(self, assignment, *, should_stop=None):
def execute(self, assignment, *, should_stop=None, stop_reason=None):
execution_started.set()
release_execution.wait(timeout=2)
return AssignmentExecutionResult(status="done")
@@ -61,13 +61,13 @@ def test_lease_renews_while_execution_is_active() -> None:
asyncio.run(scenario())
def test_stale_lease_stops_later_interruptible_actions() -> None:
def test_cancel_requested_renewal_stops_execution_with_cancelled_status() -> None:
async def scenario() -> None:
first_action_started = Event()
actions: list[str] = []
class CooperativeExecutor:
def execute(self, assignment, *, should_stop=None):
def execute(self, assignment, *, should_stop=None, stop_reason=None):
assert should_stop is not None
actions.append("first")
first_action_started.set()
@@ -76,9 +76,59 @@ def test_stale_lease_stops_later_interruptible_actions() -> None:
Event().wait(0.001)
if not should_stop():
actions.append("second")
reason = stop_reason() if stop_reason is not None else None
return AssignmentExecutionResult(
status="failed",
failure_reason="execution interrupted",
status="cancelled" if reason and "cancel" in reason else "failed",
failure_reason=reason,
)
def latest_progress(self):
return None
class CancellingClient:
async def renew(self, assignment, *, progress=None):
assert await asyncio.to_thread(first_action_started.wait, 1)
return LeaseRenewalResponse(
status="renewed",
lease_expires_at=datetime.now(UTC) + timedelta(seconds=30),
cancel_requested=True,
)
result = await asyncio.wait_for(
ActiveAssignmentRunner(
CancellingClient(), # type: ignore[arg-type]
CooperativeExecutor(),
).run(_assignment()),
timeout=1,
)
assert result.status == "cancelled"
assert result.failure_reason == "cancellation requested by control plane"
assert actions == ["first"]
asyncio.run(scenario())
def test_stale_lease_stops_later_interruptible_actions() -> None:
async def scenario() -> None:
first_action_started = Event()
actions: list[str] = []
class CooperativeExecutor:
def execute(self, assignment, *, should_stop=None, stop_reason=None):
assert should_stop is not None
actions.append("first")
first_action_started.set()
assert first_action_started.wait(timeout=1)
while not should_stop():
Event().wait(0.001)
if not should_stop():
actions.append("second")
reason = stop_reason() if stop_reason is not None else None
assert reason == "lease rejected by control plane"
return AssignmentExecutionResult(
status="cancelled" if reason and "cancel" in reason else "failed",
failure_reason=reason,
)
def latest_progress(self):
@@ -108,7 +158,7 @@ def test_renewal_loop_exits_when_execution_finishes() -> None:
renew_calls = 0
class ImmediateExecutor:
def execute(self, assignment, *, should_stop=None):
def execute(self, assignment, *, should_stop=None, stop_reason=None):
return AssignmentExecutionResult(status="done")
def latest_progress(self):
@@ -142,7 +192,7 @@ def test_shutdown_request_stops_active_execution_cooperatively() -> None:
execution_started = Event()
class CooperativeExecutor:
def execute(self, assignment, *, should_stop=None):
def execute(self, assignment, *, should_stop=None, stop_reason=None):
assert should_stop is not None
execution_started.set()
while not should_stop():
@@ -0,0 +1,58 @@
from __future__ import annotations
from pathlib import Path
from starlette.applications import Starlette
from starlette.responses import JSONResponse
from starlette.testclient import TestClient
from host_agent.mcp_token import McpTokenStore
from host_agent.web.mcp_auth import BearerAuthMiddleware
def _make_client(tmp_path: Path) -> tuple[TestClient, str]:
store = McpTokenStore(tmp_path / "host_mcp_token.json")
token = store.load_or_create().token
async def hello(request): # type: ignore[no-untyped-def]
return JSONResponse({"ok": True})
inner = Starlette(routes=[])
inner.router.add_route("/", hello, methods=["GET"])
wrapped = Starlette()
wrapped.add_middleware(BearerAuthMiddleware, token_store=store)
wrapped.mount("/", inner)
return TestClient(wrapped), token
def test_no_header_returns_401(tmp_path: Path) -> None:
client, _ = _make_client(tmp_path)
resp = client.get("/")
assert resp.status_code == 401
assert resp.headers["WWW-Authenticate"] == "Bearer"
assert resp.json() == {"error": "invalid token"}
def test_wrong_token_returns_401(tmp_path: Path) -> None:
client, _ = _make_client(tmp_path)
resp = client.get("/", headers={"Authorization": "Bearer wrong"})
assert resp.status_code == 401
def test_correct_token_passes_through(tmp_path: Path) -> None:
client, token = _make_client(tmp_path)
resp = client.get("/", headers={"Authorization": f"Bearer {token}"})
assert resp.status_code == 200
assert resp.json() == {"ok": True}
def test_non_bearer_scheme_returns_401(tmp_path: Path) -> None:
client, token = _make_client(tmp_path)
resp = client.get("/", headers={"Authorization": f"Basic {token}"})
assert resp.status_code == 401
def test_header_case_insensitive(tmp_path: Path) -> None:
client, token = _make_client(tmp_path)
resp = client.get("/", headers={"authorization": f"Bearer {token}"})
assert resp.status_code == 200
@@ -0,0 +1,175 @@
from __future__ import annotations
import threading
from datetime import UTC, datetime
from host_agent.mcp_lock import McpBusyTracker
def _tracker_with_now() -> tuple[McpBusyTracker, list[datetime]]:
times: list[datetime] = []
def now() -> datetime:
return times[-1] if times else datetime(2026, 1, 1, tzinfo=UTC)
tracker = McpBusyTracker(ttl_seconds=60.0, now=now)
return tracker, times
def test_acquire_succeeds_on_empty() -> None:
tracker, _ = _tracker_with_now()
assert tracker.acquire("phone-1", "sess-a") is True
assert "phone-1" in tracker.busy_device_ids()
def test_acquire_fails_when_held_by_other_session() -> None:
tracker, _ = _tracker_with_now()
assert tracker.acquire("phone-1", "sess-a") is True
assert tracker.acquire("phone-1", "sess-b") is False
def test_acquire_is_idempotent_for_same_session() -> None:
tracker, _ = _tracker_with_now()
assert tracker.acquire("phone-1", "sess-a") is True
# Same session re-acquiring is allowed (acts as renew).
assert tracker.acquire("phone-1", "sess-a") is True
def test_renew_refreshes_last_seen() -> None:
tracker, times = _tracker_with_now()
times.append(datetime(2026, 1, 1, 12, 0, tzinfo=UTC))
tracker.acquire("phone-1", "sess-a")
initial = tracker.snapshot()[0]
times.append(datetime(2026, 1, 1, 12, 0, 30, tzinfo=UTC))
assert tracker.renew("phone-1", "sess-a") is True
refreshed = tracker.snapshot()[0]
assert refreshed.last_seen_at > initial.last_seen_at
def test_renew_fails_when_held_by_other() -> None:
tracker, _ = _tracker_with_now()
tracker.acquire("phone-1", "sess-a")
assert tracker.renew("phone-1", "sess-b") is False
def test_release_returns_freed_device_ids() -> None:
tracker, _ = _tracker_with_now()
tracker.acquire("phone-1", "sess-a")
tracker.acquire("phone-2", "sess-a")
freed = tracker.release("sess-a")
assert sorted(freed) == ["phone-1", "phone-2"]
assert tracker.busy_device_ids() == []
def test_release_only_frees_caller_session() -> None:
tracker, _ = _tracker_with_now()
tracker.acquire("phone-1", "sess-a")
tracker.acquire("phone-1", "sess-b") # fails
freed = tracker.release("sess-b")
assert freed == []
assert "phone-1" in tracker.busy_device_ids()
def test_ttl_sweeps_expired_leases() -> None:
tracker, times = _tracker_with_now()
times.append(datetime(2026, 1, 1, 12, 0, tzinfo=UTC))
tracker.acquire("phone-1", "sess-a")
# Advance past TTL without renew.
times.append(datetime(2026, 1, 1, 12, 1, 1, tzinfo=UTC)) # 61s later
assert tracker.busy_device_ids() == []
def test_renew_after_ttl_tolerates_same_session() -> None:
"""Scene 10: lease expired but session_id matches -> re-acquire."""
tracker, times = _tracker_with_now()
times.append(datetime(2026, 1, 1, 12, 0, tzinfo=UTC))
tracker.acquire("phone-1", "sess-a")
times.append(datetime(2026, 1, 1, 12, 1, 1, tzinfo=UTC)) # expired
# renew from the same session should succeed (re-acquire).
assert tracker.renew("phone-1", "sess-a") is True
assert "phone-1" in tracker.busy_device_ids()
def test_snapshot_matches_busy_device_ids() -> None:
tracker, _ = _tracker_with_now()
tracker.acquire("phone-1", "sess-a")
tracker.acquire("phone-2", "sess-a")
snap = tracker.snapshot()
assert {lease.device_id for lease in snap} == set(tracker.busy_device_ids())
def test_wait_until_usable_succeeds_when_free() -> None:
tracker, _ = _tracker_with_now()
ok = tracker.wait_until_usable("phone-1", "sess-a", timeout=1.0, poll_interval=0.01)
assert ok is True
assert "phone-1" in tracker.busy_device_ids()
def test_wait_until_usable_returns_false_on_timeout() -> None:
tracker, _ = _tracker_with_now()
tracker.acquire("phone-1", "sess-a")
ok = tracker.wait_until_usable("phone-1", "sess-b", timeout=0.1, poll_interval=0.02)
assert ok is False
def test_wait_until_usable_blocks_then_succeeds_when_released() -> None:
tracker, _ = _tracker_with_now()
tracker.acquire("phone-1", "sess-a")
def releaser() -> None:
import time
time.sleep(0.05)
tracker.release("sess-a")
t = threading.Thread(target=releaser)
t.start()
try:
ok = tracker.wait_until_usable(
"phone-1", "sess-b", timeout=2.0, poll_interval=0.02
)
assert ok is True
finally:
t.join()
def test_wait_until_usable_blocks_then_fails_when_cloud_remains_busy() -> None:
tracker, _ = _tracker_with_now()
ok = tracker.wait_until_usable(
"phone-1",
"sess-a",
timeout=0.1,
poll_interval=0.02,
cloud_busy_check=lambda: True,
)
assert ok is False
assert tracker.busy_device_ids() == []
def test_default_ttl_is_20_seconds() -> None:
"""The McpBusyTracker default TTL is 20s: short enough to recover from a
dead MCP session within a heartbeat interval without an explicit release
callback (mcp SDK 1.28.1 has no per-session shutdown hook), but long
enough that an actively-busy session does not lose its lease during
normal operator pauses."""
tracker = McpBusyTracker()
assert tracker._ttl == 20.0
def test_default_ttl_recovers_dead_session_within_one_window() -> None:
"""With the 20s default, a session that never renews its lease is
reaped within one TTL window on the next read. This is the
concrete fallback behavior for I1 (no FastMCP session-end hook)."""
times: list[datetime] = []
def now() -> datetime:
return times[-1] if times else datetime(2026, 1, 1, tzinfo=UTC)
tracker = McpBusyTracker(now=now) # default 20s TTL
times.append(datetime(2026, 1, 1, 12, 0, tzinfo=UTC))
assert tracker.acquire("phone-1", "dead-session") is True
# No renew: advance 21s. Lease should be swept on next read.
times.append(datetime(2026, 1, 1, 12, 0, 21, tzinfo=UTC))
assert tracker.busy_device_ids() == []
# New session can now acquire cleanly (no stale-busy contamination).
assert tracker.acquire("phone-1", "new-session") is True
@@ -0,0 +1,107 @@
from __future__ import annotations
import json
import os
import stat
import sys
from datetime import datetime
from pathlib import Path
import pytest
from host_agent.mcp_token import McpToken, McpTokenStore, McpTokenStoreError
def test_load_or_create_generates_when_missing(tmp_path: Path) -> None:
store = McpTokenStore(tmp_path / "host_mcp_token.json")
token = store.load_or_create()
assert token.version == 1
assert len(token.token) >= 40 # secrets.token_urlsafe(32) -> ~43 chars
assert isinstance(token.created_at, datetime)
# File now exists.
assert (tmp_path / "host_mcp_token.json").exists()
def test_load_or_create_is_idempotent(tmp_path: Path) -> None:
store = McpTokenStore(tmp_path / "host_mcp_token.json")
first = store.load_or_create()
second = McpTokenStore(tmp_path / "host_mcp_token.json").load_or_create()
assert first.token == second.token
def test_load_or_create_writes_json_schema(tmp_path: Path) -> None:
path = tmp_path / "host_mcp_token.json"
McpTokenStore(path).load_or_create()
data = json.loads(path.read_text())
assert set(data) == {"version", "token", "created_at"}
assert data["version"] == 1
assert isinstance(data["token"], str)
# created_at is ISO 8601.
datetime.fromisoformat(data["created_at"])
@pytest.mark.skipif(sys.platform == "win32", reason="POSIX perms only")
def test_load_or_create_sets_posix_permissions(tmp_path: Path) -> None:
path = tmp_path / "host_mcp_token.json"
McpTokenStore(path).load_or_create()
mode = stat.S_IMODE(os.fstat(os.open(path, os.O_RDONLY)).st_mode)
assert mode == 0o600
def test_verify_accepts_correct_token(tmp_path: Path) -> None:
store = McpTokenStore(tmp_path / "host_mcp_token.json")
token = store.load_or_create()
assert store.verify(token.token) is True
def test_verify_rejects_wrong_token(tmp_path: Path) -> None:
store = McpTokenStore(tmp_path / "host_mcp_token.json")
store.load_or_create()
assert store.verify("wrong") is False
def test_load_or_create_raises_on_corrupt_json(tmp_path: Path) -> None:
path = tmp_path / "host_mcp_token.json"
path.write_text("{not valid json")
with pytest.raises(McpTokenStoreError):
McpTokenStore(path).load_or_create()
@pytest.mark.skipif(sys.platform == "win32", reason="POSIX chmod enforcement only")
def test_load_or_create_raises_on_unwritable_dir(tmp_path: Path) -> None:
unwritable = tmp_path / "ro"
unwritable.mkdir()
os.chmod(unwritable, 0o500) # r-x for owner
try:
with pytest.raises(McpTokenStoreError):
McpTokenStore(unwritable / "host_mcp_token.json").load_or_create()
finally:
os.chmod(unwritable, 0o700) # restore so cleanup works
@pytest.mark.skipif(
sys.platform == "win32",
reason="POSIX atomic-rename semantics only",
)
def test_load_or_create_concurrent_calls_do_not_corrupt(
tmp_path: Path,
) -> None:
"""Two store instances racing to create: both end up reading the same token."""
import threading
path = tmp_path / "host_mcp_token.json"
results: list[McpToken] = []
barrier = threading.Barrier(2)
def worker() -> None:
barrier.wait()
store = McpTokenStore(path)
results.append(store.load_or_create())
threads = [threading.Thread(target=worker) for _ in range(2)]
for t in threads:
t.start()
for t in threads:
t.join()
assert len(results) == 2
assert results[0].token == results[1].token
@@ -88,6 +88,38 @@ def test_processor_preserves_runtime_failure_reason() -> None:
asyncio.run(scenario())
def test_processor_reports_cancelled_status_with_reason() -> None:
async def scenario() -> None:
reports: list[dict[str, object]] = []
class CancelledExecutor:
async def run(self, assignment):
return AssignmentExecutionResult(
status="cancelled",
failure_reason="cancellation requested by control plane",
metadata={"runtime_status": "cancelled"},
)
class RecordingClient:
async def report_result(self, assignment, **kwargs):
reports.append(kwargs)
return TerminalResultResponse(status="recorded")
result = await AssignmentProcessor(
RecordingClient(), # type: ignore[arg-type]
CancelledExecutor(),
).process(_assignment())
assert result.report_status == "recorded"
assert reports[0] == {
"status": "cancelled",
"failure_reason": "cancellation requested by control plane",
"result": {"runtime_status": "cancelled"},
}
asyncio.run(scenario())
def test_status_tracker_sees_started_then_finished_even_on_raise() -> None:
async def scenario() -> None:
tracker = AgentStatusTracker()
@@ -0,0 +1,93 @@
"""Tests for the host-agent skill sync wiring (§7.2/7.4)."""
from __future__ import annotations
import httpx
import pytest
from host_agent.config import HostAgentConfig
from host_agent.skill_sync import HostAgentSkillSync
def _config(tmp_path) -> HostAgentConfig:
return HostAgentConfig(
control_plane_url="https://cloud.example",
host_id="host-1",
token="host-token",
identity_path=tmp_path / "identity.json",
skill_sync_interval_seconds=0.01,
)
def test_skill_sync_pulls_delta_and_reports_inventory(tmp_path):
seen_inventory = {"host": None, "body": None}
def handler(request: httpx.Request) -> httpx.Response:
path = request.url.path
if path.endswith("/skills/sync"):
return httpx.Response(
200,
json={
"skills": [
{
"id": "c1",
"name": "Cloud Skill",
"kind": "knowledge",
"description": "",
"tags": [],
"revision": 1,
"created_at": "2026-01-01T00:00:00+00:00",
"updated_at": "2026-01-01T00:00:00+00:00",
"content": "body",
"steps": [],
"parameters": {},
}
],
"removed_ids": [],
"latest_version": 1,
"is_full_replace": True,
},
)
if path.endswith("/skills/inventory"):
seen_inventory["host"] = request.url.path.split("/")[4]
seen_inventory["body"] = request.read()
return httpx.Response(204)
return httpx.Response(404)
sync = HostAgentSkillSync(
_config(tmp_path),
http_client=httpx.Client(transport=httpx.MockTransport(handler)),
)
# One manual tick applies the cloud skill to the synced store.
outcomes = sync.runner.tick()
assert outcomes["host-1"].success is True
visible = sync.synced_store.list_skills({"host-1"})
assert [m.name for m in visible] == ["Cloud Skill"]
# Inventory report of an authored local skill is best-effort and payload-shaped.
from skills_learning.models import KnowledgeSkill, SkillMetadata
sync.local_store.create_local(
KnowledgeSkill(metadata=SkillMetadata(name="Local Note", kind="knowledge"), content="x")
)
sync.report_inventory_once()
assert seen_inventory["host"] == "host-1"
assert b"Local Note" in seen_inventory["body"]
# start/stop lifecycle does not raise.
sync.start()
sync.stop()
def test_skill_sync_inventory_failure_is_isolated(tmp_path):
def handler(request: httpx.Request) -> httpx.Response:
if request.url.path.endswith("/skills/inventory"):
return httpx.Response(500)
return httpx.Response(200, json={"skills": [], "removed_ids": [], "latest_version": 0, "is_full_replace": True})
sync = HostAgentSkillSync(
_config(tmp_path),
http_client=httpx.Client(transport=httpx.MockTransport(handler)),
)
# A failed inventory report must not raise.
sync.report_inventory_once()
sync.client.close()
+449 -10
View File
@@ -3,25 +3,32 @@ from __future__ import annotations
import re
from collections.abc import Awaitable, Callable
from datetime import UTC, datetime
from typing import Any
from fastapi.testclient import TestClient
from mcp.server.fastmcp import FastMCP
from cloud.internal_api.models import AssignmentModel
from core.models import Task
from device.manager import DeviceManager
from host_agent.client import HostAgentAPIError, HostTaskSubmissionUnknownError
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.mcp_lock import McpBusyTracker
from host_agent.mcp_token import McpTokenStore
from host_agent.status import AgentStatusTracker
from host_agent.web.app import SESSION_COOKIE_NAME, create_console_app
from host_agent.web.auth import SessionManager
from host_agent.web.mcp import build_mcp_server
from storage.device_config import DeviceConfigStore
from storage.task_metadata import TaskMetadataStore
CSRF_PATTERN = re.compile(r'name="csrf_token" value="([^"]+)"')
TaskSubmissionCallable = Callable[[str, str | None], Awaitable[str]]
TaskCancellationCallable = Callable[[str], Awaitable[Any]]
def _build_client(
@@ -29,7 +36,11 @@ def _build_client(
*,
create_account: bool = True,
submit_self_task: TaskSubmissionCallable | None = None,
cancel_task: TaskCancellationCallable | None = None,
include_metadata_store: bool = True,
mcp_server: FastMCP | None = None,
mcp_token_store: McpTokenStore | None = None,
mcp_busy_tracker: McpBusyTracker | None = None,
) -> tuple[TestClient, dict]:
config = HostAgentConfig(
control_plane_url="https://control.example",
@@ -49,9 +60,7 @@ def _build_client(
session_manager = SessionManager(ttl_seconds=3600.0)
metadata_store: TaskMetadataStore | None = None
if include_metadata_store:
metadata_store = TaskMetadataStore(
db_path=tmp_path / "task_metadata.sqlite3"
)
metadata_store = TaskMetadataStore(db_path=tmp_path / "task_metadata.sqlite3")
app = create_console_app(
config=config,
@@ -64,7 +73,11 @@ def _build_client(
session_manager=session_manager,
enrollment_client=None,
submit_self_task=submit_self_task,
cancel_task=cancel_task,
metadata_store=metadata_store,
mcp_server=mcp_server,
mcp_token_store=mcp_token_store,
mcp_busy_tracker=mcp_busy_tracker,
)
client = TestClient(app)
context = {
@@ -229,6 +242,147 @@ def test_add_device_appears_in_devices_page_and_manager(tmp_path) -> None:
assert [device.id for device in context["manager"].list_devices()] == ["device-a"]
def test_devices_page_captures_screenshot_only_when_button_endpoint_is_called(
tmp_path,
) -> None:
class ScreenshotDriver:
def __init__(self) -> None:
self.capture_count = 0
def connect(self) -> None:
pass
def disconnect(self) -> None:
pass
def screenshot(self) -> bytes:
self.capture_count += 1
return b"fake-png"
driver = ScreenshotDriver()
client, context = _build_client(tmp_path)
context["config_store"].add(
device_id="device-a",
name="Lab iPhone",
driver_type="wda",
connection_info={},
)
context["manager"].register_device("device-a", lambda: driver)
context["manager"].connect("device-a")
csrf_token = _login(client)
page = client.get("/devices")
assert page.status_code == 200
assert 'class="screenshot-button"' in page.text
assert 'data-device-id="device-a"' in page.text
assert driver.capture_count == 0
response = client.post(
"/api/devices/device-a/screenshot",
headers={"X-CSRF-Token": csrf_token},
)
assert response.status_code == 200
assert response.content == b"fake-png"
assert response.headers["content-type"] == "image/png"
assert response.headers["cache-control"] == "no-store"
assert driver.capture_count == 1
def test_device_screenshot_requires_csrf_and_connected_device(tmp_path) -> None:
class ScreenshotDriver:
def connect(self) -> None:
pass
def disconnect(self) -> None:
pass
def screenshot(self) -> bytes:
return b"fake-png"
client, context = _build_client(tmp_path)
context["manager"].register_device("device-a", ScreenshotDriver)
csrf_token = _login(client)
missing_csrf = client.post("/api/devices/device-a/screenshot")
assert missing_csrf.status_code == 403
offline = client.post(
"/api/devices/device-a/screenshot",
headers={"X-CSRF-Token": csrf_token},
)
assert offline.status_code == 503
def test_device_connection_test_connects_checks_health_and_disconnects(
tmp_path, monkeypatch
) -> None:
events: list[str] = []
class ProbeDriver:
def connect(self) -> None:
events.append("connect")
def health_check(self) -> None:
events.append("health")
def disconnect(self) -> None:
events.append("disconnect")
def factory(driver_type, connection_info):
assert driver_type == "wda"
assert connection_info["udid"] == "ios-udid"
return ProbeDriver
monkeypatch.setattr("host_agent.web.app.build_driver_factory", factory)
client, context = _build_client(tmp_path)
csrf_token = _login(client)
response = client.post(
"/api/devices/test-connection",
json={"driver_type": "wda", "connection_info": {"udid": "ios-udid"}},
headers={"X-CSRF-Token": csrf_token},
)
assert response.status_code == 200
assert response.json() == {"ok": True, "driver_type": "wda"}
assert events == ["connect", "health", "disconnect"]
assert context["config_store"].list() == []
def test_ios_discovery_returns_connected_devices_and_unique_ports(
tmp_path, monkeypatch
) -> None:
monkeypatch.setattr(
"host_agent.web.app.discover_connected_ios_devices",
lambda: [
{
"udid": "ios-new",
"name": "New iPhone",
"model": "iPhone 15",
"os_version": "18.0",
"transport": "wired",
}
],
)
client, context = _build_client(tmp_path)
context["config_store"].add(
device_id="existing",
driver_type="wda",
connection_info={"udid": "ios-old", "wda_local_port": 8100, "mjpegServerPort": 9100},
)
_login(client)
response = client.get("/api/devices/discover-ios")
assert response.status_code == 200
device = response.json()["devices"][0]
assert device["udid"] == "ios-new"
assert device["configured"] is False
assert device["suggested_wda_port"] == 8101
assert device["suggested_mjpeg_port"] == 9101
def test_remove_device_unregisters_from_manager(tmp_path) -> None:
client, context = _build_client(tmp_path)
csrf_token = _login(client)
@@ -373,7 +527,11 @@ def _make_submission_recorder(
def asyncio_run(coro):
import asyncio
return asyncio.get_event_loop().run_until_complete(coro) if asyncio.get_event_loop().is_running() else asyncio.run(coro)
return (
asyncio.get_event_loop().run_until_complete(coro)
if asyncio.get_event_loop().is_running()
else asyncio.run(coro)
)
def test_tasks_page_renders_submission_form_with_device_options(tmp_path) -> None:
@@ -444,8 +602,7 @@ def test_authenticated_explicit_device_submission_uses_runtime_id(tmp_path) -> N
assert response.status_code == 303
assert (
response.headers["location"]
== "/tasks?submitted=1&task_id=task-cloud-explicit"
response.headers["location"] == "/tasks?submitted=1&task_id=task-cloud-explicit"
)
assert captured == {"goal": "open mail", "device_id": "device-cloud-a"}
@@ -577,9 +734,7 @@ def test_cloud_definitive_rejection_renders_safe_error_without_calling_history(
tmp_path,
) -> None:
submit, captured = _make_submission_recorder(
raise_api_error=HostAgentAPIError(
403, "Host self-submission is disabled"
),
raise_api_error=HostAgentAPIError(403, "Host self-submission is disabled"),
)
client, context = _build_client(tmp_path, submit_self_task=submit)
context["manager"].register_device(
@@ -796,4 +951,288 @@ def test_submitted_redirect_does_not_include_goal_text(tmp_path) -> None:
assert "bearer-token-deadbeef" not in response.headers["location"]
follow = client.get(response.headers["location"])
assert secret_goal not in follow.text
assert "bearer-token-deadbeef" not in follow.text
def _make_cancellation_recorder(
*,
raise_api_error: HostAgentAPIError | None = None,
) -> tuple[TaskCancellationCallable, dict]:
captured: dict = {}
async def cancel(task_id: str) -> None:
captured["task_id"] = task_id
if raise_api_error is not None:
raise raise_api_error
return cancel, captured
def _seed_local_task(
metadata_store: TaskMetadataStore,
*,
status: str = "running",
source_task_id: str | None = "cloud-task-1",
) -> str:
task = Task(goal="open settings", device_id="dev-1", status=status)
metadata_store.create_task(task, source_task_id=source_task_id, source_attempt=1)
return task.id
def test_task_detail_page_shows_cancel_button_for_non_terminal_task(
tmp_path,
) -> None:
cancel, _ = _make_cancellation_recorder()
client, context = _build_client(tmp_path, cancel_task=cancel)
execution_id = _seed_local_task(context["metadata_store"], status="running")
_login(client)
response = client.get(f"/tasks/{execution_id}")
assert response.status_code == 200
assert f'action="/tasks/{execution_id}/cancel"' in response.text
assert "Cancel task" in response.text
def test_task_detail_page_hides_cancel_button_for_terminal_task(tmp_path) -> None:
cancel, _ = _make_cancellation_recorder()
client, context = _build_client(tmp_path, cancel_task=cancel)
execution_id = _seed_local_task(context["metadata_store"], status="completed")
_login(client)
response = client.get(f"/tasks/{execution_id}")
assert response.status_code == 200
assert f'action="/tasks/{execution_id}/cancel"' not in response.text
def test_task_detail_page_hides_cancel_button_when_client_unavailable(
tmp_path,
) -> None:
client, context = _build_client(tmp_path, cancel_task=None)
execution_id = _seed_local_task(context["metadata_store"], status="running")
_login(client)
response = client.get(f"/tasks/{execution_id}")
assert response.status_code == 200
assert f'action="/tasks/{execution_id}/cancel"' not in response.text
def test_cancel_task_success_calls_client_with_cloud_task_id_and_redirects(
tmp_path,
) -> None:
cancel, captured = _make_cancellation_recorder()
client, context = _build_client(tmp_path, cancel_task=cancel)
execution_id = _seed_local_task(
context["metadata_store"], status="running", source_task_id="cloud-task-99"
)
csrf_token = _login(client)
response = client.post(
f"/tasks/{execution_id}/cancel",
data={"csrf_token": csrf_token},
follow_redirects=False,
)
assert response.status_code == 303
assert response.headers["location"] == f"/tasks/{execution_id}?cancelled=1"
assert captured == {"task_id": "cloud-task-99"}
follow = client.get(response.headers["location"])
assert "cancel-notice" in follow.text
def test_cancel_task_client_error_redirects_with_cancel_error(tmp_path) -> None:
cancel, _ = _make_cancellation_recorder(
raise_api_error=HostAgentAPIError(502, "control plane unavailable")
)
client, context = _build_client(tmp_path, cancel_task=cancel)
execution_id = _seed_local_task(context["metadata_store"], status="running")
csrf_token = _login(client)
response = client.post(
f"/tasks/{execution_id}/cancel",
data={"csrf_token": csrf_token},
follow_redirects=False,
)
assert response.status_code == 303
assert response.headers["location"] == f"/tasks/{execution_id}?cancel_error=1"
follow = client.get(response.headers["location"])
assert "cancel-error" in follow.text
def test_cancel_task_unknown_execution_id_returns_404(tmp_path) -> None:
cancel, captured = _make_cancellation_recorder()
client, _ = _build_client(tmp_path, cancel_task=cancel)
csrf_token = _login(client)
response = client.post(
"/tasks/does-not-exist/cancel",
data={"csrf_token": csrf_token},
)
assert response.status_code == 404
assert captured == {}
def test_unauthenticated_cancel_redirects_to_login_without_calling_client(
tmp_path,
) -> None:
cancel, captured = _make_cancellation_recorder()
client, context = _build_client(tmp_path, cancel_task=cancel)
execution_id = _seed_local_task(context["metadata_store"], status="running")
response = client.post(
f"/tasks/{execution_id}/cancel",
data={},
follow_redirects=False,
)
assert response.status_code == 303
assert response.headers["location"] == "/login"
assert captured == {}
def test_cancel_task_without_csrf_token_is_rejected(tmp_path) -> None:
cancel, captured = _make_cancellation_recorder()
client, context = _build_client(tmp_path, cancel_task=cancel)
execution_id = _seed_local_task(context["metadata_store"], status="running")
_login(client)
response = client.post(
f"/tasks/{execution_id}/cancel",
data={"csrf_token": "wrong-token"},
)
assert response.status_code == 403
assert captured == {}
# ---------------------------------------------------------------------------
# MCP mount + /api/status fields + dashboard row
# ---------------------------------------------------------------------------
def _build_mcp_components(tmp_path) -> tuple[FastMCP, McpTokenStore, McpBusyTracker]:
manager = DeviceManager()
status_tracker = AgentStatusTracker()
tracker = McpBusyTracker()
token_store = McpTokenStore(tmp_path / "host_mcp_token.json")
server = build_mcp_server(
manager=manager,
mcp_busy_tracker=tracker,
status_tracker=status_tracker,
)
return server, token_store, tracker
def test_console_app_mounts_mcp_when_all_components_provided(tmp_path) -> None:
server, token_store, tracker = _build_mcp_components(tmp_path)
client, _ = _build_client(
tmp_path,
mcp_server=server,
mcp_token_store=token_store,
mcp_busy_tracker=tracker,
)
# Without auth, the bearer middleware should respond 401 — not 404.
resp = client.post("/mcp/", json={"jsonrpc": "2.0", "method": "ping", "id": 1})
assert resp.status_code != 404
def test_console_app_does_not_mount_mcp_when_components_missing(tmp_path) -> None:
client, _ = _build_client(tmp_path)
resp = client.post("/mcp/", json={"jsonrpc": "2.0", "method": "ping", "id": 1})
assert resp.status_code == 404
def test_api_status_includes_mcp_busy_devices(tmp_path) -> None:
server, token_store, tracker = _build_mcp_components(tmp_path)
# Acquire a lease without going through HTTP — tracker exposes a direct API.
tracker.acquire("phone-1", "test-session")
client, _ = _build_client(
tmp_path,
mcp_server=server,
mcp_token_store=token_store,
mcp_busy_tracker=tracker,
)
_login(client)
response = client.get("/api/status")
assert response.status_code == 200
body = response.json()
assert "mcp_busy_devices" in body
assert "phone-1" in body["mcp_busy_devices"]
assert body["mcp_endpoint"] == "/mcp"
def test_api_status_omits_mcp_fields_when_components_missing(tmp_path) -> None:
client, _ = _build_client(tmp_path)
_login(client)
response = client.get("/api/status")
assert response.status_code == 200
body = response.json()
assert body["mcp_busy_devices"] == []
assert body["mcp_endpoint"] is None
def test_dashboard_renders_mcp_status_row(tmp_path) -> None:
server, token_store, tracker = _build_mcp_components(tmp_path)
tracker.acquire("phone-1", "test-session")
client, _ = _build_client(
tmp_path,
mcp_server=server,
mcp_token_store=token_store,
mcp_busy_tracker=tracker,
)
_login(client)
response = client.get("/")
assert response.status_code == 200
text = response.text
assert "<td>MCP</td>" in text
assert "/mcp" in text
assert "phone-1" in text
def test_dashboard_renders_mcp_not_configured_when_components_missing(
tmp_path,
) -> None:
client, _ = _build_client(tmp_path)
_login(client)
response = client.get("/")
assert response.status_code == 200
text = response.text
assert "<td>MCP</td>" in text
assert "not configured" in text
def test_mcp_endpoint_unauthorized_without_bearer_token(tmp_path) -> None:
server, token_store, tracker = _build_mcp_components(tmp_path)
client, _ = _build_client(
tmp_path,
mcp_server=server,
mcp_token_store=token_store,
mcp_busy_tracker=tracker,
)
resp = client.post("/mcp/", json={"jsonrpc": "2.0", "method": "ping", "id": 1})
assert resp.status_code == 401
def test_mcp_endpoint_rejects_invalid_bearer_token(tmp_path) -> None:
server, token_store, tracker = _build_mcp_components(tmp_path)
client, _ = _build_client(
tmp_path,
mcp_server=server,
mcp_token_store=token_store,
mcp_busy_tracker=tracker,
)
resp = client.post(
"/mcp/",
headers={"Authorization": "Bearer not-the-real-token"},
json={"jsonrpc": "2.0", "method": "ping", "id": 1},
)
assert resp.status_code == 401
@@ -0,0 +1,415 @@
from __future__ import annotations
from datetime import UTC, datetime
from typing import Any
import pytest
from cloud.internal_api.models import AssignmentModel
from device.manager import DeviceManager
from driver.base import Driver
from host_agent.mcp_lock import McpBusyTracker
from host_agent.status import AgentStatusTracker
from host_agent.web.mcp import (
McpDeviceBusyError,
_call_tool_sync,
_current_session_id,
build_mcp_server,
)
from mcp.server.fastmcp import Context
from mcp.shared.context import RequestContext
class _FakeDriver(Driver):
"""Minimal driver. connect/screenshot/tap are exercised; remaining abstract
methods are stubbed to satisfy Driver's ABC contract."""
def __init__(self) -> None:
self.taps: list[tuple[float, float]] = []
def connect(self) -> None:
return None
def disconnect(self) -> None:
return None
def screenshot(self) -> bytes:
return b"fake"
def tap(self, x: float, y: float) -> None:
self.taps.append((x, y))
def long_press(self, x: float, y: float, duration_ms: int = 1200) -> None:
return None
def swipe(
self,
start_x: float,
start_y: float,
end_x: float,
end_y: float,
duration_ms: int = 500,
) -> None:
return None
def swipe_path(
self, waypoints: list[tuple[float, float]], duration_ms: int
) -> None:
return None
def double_tap(self, x: float, y: float, interval_ms: int = 80) -> None:
return None
def input(self, text: str) -> None:
return None
def launch(self, app_id: str) -> None:
return None
def terminate(self, app_id: str) -> None:
return None
def tree(self) -> Any:
return None
def home(self) -> None:
return None
def lock(self) -> None:
return None
def unlock(self) -> None:
return None
def _make_manager_with_device(device_id: str = "phone-1") -> DeviceManager:
manager = DeviceManager()
manager.register_device(
device_id=device_id,
driver_factory=lambda: _FakeDriver(),
name=device_id,
)
manager.connect(device_id)
return manager
def test_build_mcp_server_returns_fastmcp_instance() -> None:
from mcp.server.fastmcp import FastMCP
manager = _make_manager_with_device()
tracker = McpBusyTracker()
status = AgentStatusTracker()
server = build_mcp_server(
manager=manager, mcp_busy_tracker=tracker, status_tracker=status
)
assert isinstance(server, FastMCP)
def test_call_tool_succeeds_when_device_is_free() -> None:
manager = _make_manager_with_device()
tracker = McpBusyTracker()
status = AgentStatusTracker()
server = build_mcp_server(
manager=manager, mcp_busy_tracker=tracker, status_tracker=status
)
result = _call_tool_sync(
server, "take_screenshot", {"device_id": "phone-1"}, session_id="sess-a"
)
assert result["ok"] is True
assert "phone-1" in tracker.busy_device_ids()
def test_call_tool_fails_when_cloud_uses_device() -> None:
"""AgentStatusTracker.current_assignment.device_id matches -> busy."""
manager = _make_manager_with_device()
tracker = McpBusyTracker()
status = AgentStatusTracker()
status.mark_assignment_started(
AssignmentModel(
task_id="t1",
attempt=1,
lease_id="l1",
lease_expires_at=datetime.now(UTC),
host_id="h1",
device_id="phone-1",
goal="cloud task",
)
)
server = build_mcp_server(
manager=manager, mcp_busy_tracker=tracker, status_tracker=status
)
with pytest.raises(McpDeviceBusyError) as exc:
_call_tool_sync(
server,
"take_screenshot",
{"device_id": "phone-1"},
session_id="sess-a",
)
assert exc.value.device_id == "phone-1"
assert exc.value.busy_owner == "cloud_assignment"
def test_call_tool_fails_when_another_mcp_session_holds_device() -> None:
manager = _make_manager_with_device()
tracker = McpBusyTracker()
status = AgentStatusTracker()
# Pre-acquire as a different session.
tracker.acquire("phone-1", "sess-other")
server = build_mcp_server(
manager=manager, mcp_busy_tracker=tracker, status_tracker=status
)
with pytest.raises(McpDeviceBusyError) as exc:
_call_tool_sync(
server,
"take_screenshot",
{"device_id": "phone-1"},
session_id="sess-a",
)
assert exc.value.busy_owner.startswith("mcp_session:")
def test_call_tool_renews_when_same_session_already_holds() -> None:
manager = _make_manager_with_device()
tracker = McpBusyTracker()
status = AgentStatusTracker()
server = build_mcp_server(
manager=manager, mcp_busy_tracker=tracker, status_tracker=status
)
_call_tool_sync(
server, "take_screenshot", {"device_id": "phone-1"}, session_id="sess-a"
)
# Second call from the same session should succeed.
result = _call_tool_sync(
server, "take_screenshot", {"device_id": "phone-1"}, session_id="sess-a"
)
assert result["ok"] is True
def test_list_devices_uses_display_status() -> None:
"""Connected-but-idle devices report as 'connected', not 'busy'."""
manager = _make_manager_with_device()
tracker = McpBusyTracker()
status = AgentStatusTracker()
server = build_mcp_server(
manager=manager, mcp_busy_tracker=tracker, status_tracker=status
)
result = _call_tool_sync(server, "list_devices", {}, session_id="sess-a")
assert isinstance(result, list)
assert result[0]["status"] == "connected"
def test_unknown_device_returns_semantic_error_dict() -> None:
"""take_screenshot against an unknown device returns the api-errors semantic
error dict (``ok=False, error="device not found"``) rather than raising.
Note: this test adapts the brief's exception-assertion semantics to the
actual behavior of ``call_with_semantic_errors`` in ``api/errors.py``
the brief's expectation that an exception is raised here is incorrect for
the current handler implementation."""
manager = _make_manager_with_device()
tracker = McpBusyTracker()
status = AgentStatusTracker()
server = build_mcp_server(
manager=manager, mcp_busy_tracker=tracker, status_tracker=status
)
result = _call_tool_sync(
server,
"take_screenshot",
{"device_id": "does-not-exist"},
session_id="sess-a",
)
assert isinstance(result, dict)
assert result["ok"] is False
assert "device" in result["error"].lower()
def test_manager_required_for_build_mcp_server() -> None:
"""Reinforces D12 — build_mcp_server requires manager as keyword-only."""
import inspect
sig = inspect.signature(build_mcp_server)
assert sig.parameters["manager"].kind == inspect.Parameter.KEYWORD_ONLY
def _fake_ctx(session_obj: object) -> Context:
"""Build a Context whose ``session`` attribute returns ``session_obj``.
Context's ``session`` is a property backed by ``request_context.session``;
we construct a minimal ``RequestContext`` and set it as the private
``_request_context`` field. The pydantic public API doesn't expose a
setter for ``session``, so we use ``object.__setattr__`` on the private
backing field.
"""
ctx = Context.model_construct()
request_ctx = RequestContext(
request_id="req-test",
meta=None,
session=session_obj,
lifespan_context=None,
)
object.__setattr__(ctx, "_request_context", request_ctx)
return ctx
def test_current_session_id_is_stable_across_calls_same_session() -> None:
"""Production-path identity: two tool calls from the same MCP session
must yield the same session_id so the busy tracker can renew the lease.
This exercises the ``Context.session`` code path (NOT the
``_TEST_SESSION_ID`` fallback used by ``_call_tool_sync``)."""
sentinel_session = object()
ctx = _fake_ctx(sentinel_session)
first = _current_session_id(ctx)
second = _current_session_id(ctx)
assert first == second
assert first.startswith("mcp_session:")
# Object identity of the underlying ServerSession is the key — verifies
# we use id(ctx.session) rather than e.g. ctx.request_id.
assert first == f"mcp_session:{id(sentinel_session)}"
def test_current_session_id_differs_across_sessions() -> None:
"""Two different MCP sessions (distinct ServerSession objects) must
produce distinct session_ids so the busy tracker can isolate them."""
sess_a = object()
sess_b = object()
assert _current_session_id(_fake_ctx(sess_a)) != _current_session_id(
_fake_ctx(sess_b)
)
def test_current_session_id_falls_back_when_no_context() -> None:
"""When no Context is available (e.g. outside a FastMCP request lifecycle,
or via ``_call_tool_sync`` which omits the ctx kwarg), the test
contextvars override provides the session_id."""
token = None
try:
from host_agent.web import mcp as mcp_mod
token = mcp_mod._TEST_SESSION_ID.set("test-session-xyz")
assert _current_session_id(None) == "test-session-xyz"
finally:
if token is not None:
from host_agent.web import mcp as mcp_mod
mcp_mod._TEST_SESSION_ID.reset(token)
def test_wrapped_tool_accepts_context_kwarg() -> None:
"""The wrapper registered on FastMCP must declare a ``ctx`` parameter so
FastMCP injects the live Context (and ``tool.context_kwarg`` is set to
``"ctx"``). Without this, FastMCP never injects context and we fall
back to the empty test default the production bug this PR fixes."""
manager = _make_manager_with_device()
tracker = McpBusyTracker()
status = AgentStatusTracker()
server = build_mcp_server(
manager=manager, mcp_busy_tracker=tracker, status_tracker=status
)
tool_manager = server._tool_manager # type: ignore[attr-defined]
tool = tool_manager.get_tool("take_screenshot")
assert tool is not None
assert tool.context_kwarg == "ctx"
def test_busy_error_wire_shape_is_calltoolresult_iserror() -> None:
"""Regression test for spec §7 — busy errors must be visible on the wire.
mcp SDK 1.28.1's ``Tool.run`` wraps every non-``UrlElicitationRequiredError``
exception (including ``McpError`` and our ``McpDeviceBusyError``) into
``ToolError`` (see ``mcp/server/fastmcp/tools/base.py``). The lowlevel
``call_tool`` handler then builds a ``CallToolResult(isError=True,
content=[TextContent(...)])`` (see
``mcp/server/lowlevel/server.py::_make_error_result``). There is no public
path that surfaces JSON-RPC ``-32000`` + structured ``data.busy_owner`` from
a tool call site the SDK's wire contract for tool errors is the
``isError=true`` flag plus text content. This test pins the wire shape so
any future SDK upgrade that exposes a true JSON-RPC error path is caught."""
import asyncio
import mcp.types as types
from mcp.server.fastmcp import FastMCP
from mcp.server.fastmcp.exceptions import ToolError
manager = _make_manager_with_device()
tracker = McpBusyTracker()
status = AgentStatusTracker()
tracker.acquire("phone-1", "sess-other") # different session holds the device
server: FastMCP = build_mcp_server(
manager=manager, mcp_busy_tracker=tracker, status_tracker=status
)
tool = server._tool_manager.get_tool("take_screenshot") # type: ignore[attr-defined]
assert tool is not None
sentinel_session = object()
ctx = _fake_ctx(sentinel_session)
with pytest.raises(ToolError) as tool_exc:
asyncio.run(tool.run({"device_id": "phone-1"}, context=ctx))
# ToolError text carries the original exception message verbatim,
# which is what the lowlevel handler copies into TextContent.
message = str(tool_exc.value)
assert "phone-1" in message
assert "busy" in message
assert "mcp_session:sess-oth" in message # truncated busy_owner
# The lowlevel handler converts any exception into a CallToolResult
# with isError=True (mcp SDK 1.28.1 — not a JSON-RPC error envelope).
# We invoke the SDK helper directly to lock the wire contract.
from mcp.server.lowlevel.server import Server as LowlevelServer
lowlevel = LowlevelServer("test-lowlevel")
error_result = lowlevel._make_error_result(message) # type: ignore[attr-defined]
inner = error_result.root
assert isinstance(inner, types.CallToolResult)
assert inner.isError is True
assert len(inner.content) == 1
text_block = inner.content[0]
assert isinstance(text_block, types.TextContent)
assert text_block.text == message
# And confirm the wire shape is NOT a JSON-RPC error envelope — that
# would require code=-32000 + data.busy_owner, which is not exposed
# in mcp SDK 1.28.1 for tool-call errors.
assert not hasattr(inner, "code")
assert inner.structuredContent is None
def test_busy_error_text_includes_cloud_assignment_owner() -> None:
"""Same wire-shape test for the cloud_assignment branch — verifies the
human-readable busy_owner value (the only place to surface it given the
SDK forces tool errors into CallToolResult.isError=true) is correct."""
import asyncio
from mcp.server.fastmcp.exceptions import ToolError
manager = _make_manager_with_device()
tracker = McpBusyTracker()
status = AgentStatusTracker()
status.mark_assignment_started(
AssignmentModel(
task_id="t1",
attempt=1,
lease_id="l1",
lease_expires_at=datetime.now(UTC),
host_id="h1",
device_id="phone-1",
goal="cloud task",
)
)
server = build_mcp_server(
manager=manager, mcp_busy_tracker=tracker, status_tracker=status
)
tool = server._tool_manager.get_tool("take_screenshot") # type: ignore[attr-defined]
assert tool is not None
sentinel_session = object()
ctx = _fake_ctx(sentinel_session)
with pytest.raises(ToolError) as tool_exc:
asyncio.run(tool.run({"device_id": "phone-1"}, context=ctx))
assert "cloud_assignment" in str(tool_exc.value)
assert "phone-1" in str(tool_exc.value)
assert "busy" in str(tool_exc.value)
+6
View File
@@ -27,6 +27,12 @@ budgets are enforced only for Hosts reporting `AI_PLANNER_TRANSPORT=cloud`;
direct-provider Hosts are labelled **unmetered** rather than budget compliant.
The configured proxy reservation ceiling must fit within any daily budget.
Task readers can inspect full per-step LLM interaction history for
cloud-transport tasks in the task detail view. This history includes prompts
and resolved tool calls, excludes screenshot bytes, and is unavailable by
design for direct-provider Hosts. Retention is configured on the Cloud API via
`CLOUD_PLANNER_DECISION_LOG_RETENTION_DAYS` and its prune interval.
## Local development
```bash
+10 -1
View File
@@ -3,6 +3,7 @@ import { computed, onMounted, onUnmounted, ref } from "vue";
import type { Component } from "vue";
import {
Boxes,
BookOpen,
ListChecks,
LogOut,
MonitorSmartphone,
@@ -24,8 +25,9 @@ import DevicesView from "./views/DevicesView.vue";
import PluginsView from "./views/PluginsView.vue";
import UsersView from "./views/UsersView.vue";
import LlmProvidersView from "./views/LlmProvidersView.vue";
import SkillsView from "./views/SkillsView.vue";
type ViewId = "tasks" | "devices" | "plugins" | "users" | "providers";
type ViewId = "tasks" | "devices" | "plugins" | "users" | "providers" | "skills";
const activeView = ref<ViewId>("tasks");
const currentUser = ref<CloudUser | null>(null);
@@ -53,6 +55,7 @@ const canAdminGovernance = computed(
currentUser.value?.scopes.includes("governance:admin")),
);
const canAdminProviders = computed(() => hasScope(currentUser.value, "llm-providers:admin"));
const canAdminSkills = computed(() => hasScope(currentUser.value, "skills:admin"));
const isAuthenticated = computed(() => currentUser.value !== null);
const currentUserLabel = computed(() =>
currentUser.value ? `${currentUser.value.display_name} (${currentUser.value.role})` : "",
@@ -70,6 +73,9 @@ const navItems = computed<{ id: ViewId; label: string; icon: Component }[]>(() =
if (canAdminProviders.value) {
items.push({ id: "providers", label: "LLM providers", icon: SlidersHorizontal });
}
if (canAdminSkills.value) {
items.push({ id: "skills", label: "Skills", icon: BookOpen });
}
return items;
});
@@ -128,6 +134,8 @@ const activeComponent = computed(() => {
return UsersView;
case "providers":
return LlmProvidersView;
case "skills":
return SkillsView;
default:
return TasksView;
}
@@ -165,6 +173,7 @@ const activeComponent = computed(() => {
:can-admin-governance="canAdminGovernance"
/>
<LlmProvidersView v-else-if="activeView === 'providers'" :can-admin="canAdminProviders" />
<SkillsView v-else-if="activeView === 'skills'" :can-admin="canAdminSkills" />
<component v-else :is="activeComponent" :can-submit="canSubmitTasks" />
</main>
</div>
+86
View File
@@ -13,12 +13,18 @@ import type {
PluginRecord,
PluginRegistrationPayload,
TaskAttempt,
TaskCancellationResponse,
TaskListResponse,
TaskSubmissionPayload,
TaskStatus,
TokenUsageEvent,
UserListResponse,
UserSubmissionPolicy,
CloudSkill,
CloudSkillListResponse,
CloudSkillEntitlementsResponse,
HostSkillInventoryResponse,
CloudSkillKind,
} from "./types";
const configuredBaseUrl = import.meta.env.VITE_CLOUD_API_BASE_URL as
@@ -234,6 +240,13 @@ export function getTaskAttempts(taskId: string): Promise<TaskAttempt[]> {
return request<TaskAttempt[]>(`/v1/tasks/${encodeURIComponent(taskId)}/attempts`);
}
export function cancelTask(taskId: string): Promise<TaskCancellationResponse> {
return request<TaskCancellationResponse>(
`/v1/tasks/${encodeURIComponent(taskId)}/cancel`,
{ method: "POST" },
);
}
export function getTaskPlannerDecisions(
taskId: string,
attempt: number,
@@ -328,3 +341,76 @@ export function deleteLlmProviderProfile(
{ method: "DELETE" },
);
}
export interface CloudSkillPayload {
name: string;
kind: CloudSkillKind;
description: string;
tags: string[];
content: string;
steps: Record<string, unknown>[];
parameters: Record<string, Record<string, unknown>>;
}
export function listCloudSkills(): Promise<CloudSkillListResponse> {
return request<CloudSkillListResponse>("/v1/skills");
}
export function createCloudSkill(payload: CloudSkillPayload): Promise<CloudSkill> {
return request<CloudSkill>("/v1/skills", {
method: "POST",
body: JSON.stringify(payload),
});
}
export function updateCloudSkill(
skillId: string,
payload: CloudSkillPayload,
): Promise<CloudSkill> {
return request<CloudSkill>(`/v1/skills/${encodeURIComponent(skillId)}`, {
method: "PATCH",
body: JSON.stringify(payload),
});
}
export function deleteCloudSkill(skillId: string): Promise<void> {
return request<void>(`/v1/skills/${encodeURIComponent(skillId)}`, {
method: "DELETE",
});
}
export function listCloudSkillEntitlements(
skillId: string,
): Promise<CloudSkillEntitlementsResponse> {
return request<CloudSkillEntitlementsResponse>(
`/v1/skills/${encodeURIComponent(skillId)}/entitlements`,
);
}
export function grantCloudSkillEntitlement(
skillId: string,
hostId: string,
): Promise<void> {
return request<void>(
`/v1/skills/${encodeURIComponent(skillId)}/entitlements/${encodeURIComponent(hostId)}`,
{ method: "POST" },
);
}
export function revokeCloudSkillEntitlement(
skillId: string,
hostId: string,
): Promise<void> {
return request<void>(
`/v1/skills/${encodeURIComponent(skillId)}/entitlements/${encodeURIComponent(hostId)}`,
{ method: "DELETE" },
);
}
export function getHostSkillInventory(
hostId: string,
): Promise<HostSkillInventoryResponse> {
return request<HostSkillInventoryResponse>(
`/v1/hosts/${encodeURIComponent(hostId)}/skill-inventory`,
);
}
@@ -0,0 +1,24 @@
import { describe, expect, it } from "vitest";
import { canCancelTask } from "./taskCancellation";
import type { TaskStatus } from "./types";
describe("canCancelTask", () => {
it.each<TaskStatus>(["queued", "assigned", "dispatched"])(
"allows cancelling a %s task when the caller can submit",
(status) => {
expect(canCancelTask(status, true)).toBe(true);
},
);
it.each<TaskStatus>(["done", "failed", "cancelled"])(
"refuses to cancel a terminal %s task even when the caller can submit",
(status) => {
expect(canCancelTask(status, true)).toBe(false);
},
);
it("refuses to cancel a cancellable task when the caller lacks submit permission", () => {
expect(canCancelTask("assigned", false)).toBe(false);
});
});
+20
View File
@@ -0,0 +1,20 @@
import type { TaskStatus } from "./types";
/**
* Statuses for which cancellation is still meaningful: the task has not yet
* reached a terminal state. `cancelled` itself is excluded so a task can't be
* cancelled twice through the UI.
*/
const CANCELLABLE_STATUSES: ReadonlySet<TaskStatus> = new Set<TaskStatus>([
"queued",
"assigned",
"dispatched",
]);
/**
* Whether the Cancel action should be shown/enabled for a task, given the
* caller's submit permission and the task's current status.
*/
export function canCancelTask(status: TaskStatus, canSubmit: boolean): boolean {
return canSubmit && CANCELLABLE_STATUSES.has(status);
}
+42 -1
View File
@@ -3,7 +3,8 @@ export type TaskStatus =
| "assigned"
| "dispatched"
| "done"
| "failed";
| "failed"
| "cancelled";
export interface TaskListItem {
id: string;
@@ -41,6 +42,11 @@ export interface TaskListResponse {
offset: number;
}
export interface TaskCancellationResponse {
task_id: string;
status: TaskStatus;
}
export interface TaskAttempt {
task_id: string;
attempt: number;
@@ -190,9 +196,44 @@ export interface PlannerDecisionItem {
user_prompt: string;
tool_name: string;
arguments: Record<string, unknown>;
rationale?: string | null;
thinking?: string | null;
purpose?: string | null;
expected_outcome?: string | null;
created_at: string;
}
export interface PlannerDecisionListResponse {
items: PlannerDecisionItem[];
}
export type CloudSkillKind = "knowledge" | "flow_template";
export interface CloudSkill {
id: string;
name: string;
kind: CloudSkillKind;
description: string;
tags: string[];
revision: number;
created_at: string;
updated_at: string;
content: string;
steps: Record<string, unknown>[];
parameters: Record<string, Record<string, unknown>>;
}
export interface CloudSkillListResponse {
items: CloudSkill[];
}
export interface CloudSkillEntitlementsResponse {
skill_id: string;
host_ids: string[];
}
export interface HostSkillInventoryResponse {
host_id: string;
payload: Record<string, unknown>[];
reported_at: string | null;
}
+309
View File
@@ -0,0 +1,309 @@
<script setup lang="ts">
import { onMounted, reactive, ref } from "vue";
import { LoaderCircle, Pencil, Plus, RefreshCw, Trash2, X } from "@lucide/vue";
import {
createCloudSkill,
deleteCloudSkill,
grantCloudSkillEntitlement,
listCloudSkillEntitlements,
listCloudSkills,
revokeCloudSkillEntitlement,
updateCloudSkill,
getHostSkillInventory,
type CloudSkillPayload,
} from "../api";
import type { CloudSkill, CloudSkillKind, HostSkillInventoryResponse } from "../types";
defineProps<{ canAdmin: boolean }>();
const skills = ref<CloudSkill[]>([]);
const loading = ref(false);
const errorMessage = ref("");
const successMessage = ref("");
const editingId = ref<string | null>(null);
const entitlementsFor = ref<Record<string, string[]>>({});
const entitlementHostInput = ref<Record<string, string>>({});
const inventoryHostId = ref("");
const inventory = ref<HostSkillInventoryResponse | null>(null);
const inventoryLoading = ref(false);
const form = reactive<CloudSkillPayload>({
name: "",
kind: "knowledge",
description: "",
tags: [],
content: "",
steps: [],
parameters: {},
});
const tagsInput = ref("");
const stepsJson = ref("[]");
const parametersJson = ref("{}");
const formError = ref("");
function resetForm() {
editingId.value = null;
form.name = "";
form.kind = "knowledge";
form.description = "";
form.tags = [];
form.content = "";
form.steps = [];
form.parameters = {};
tagsInput.value = "";
stepsJson.value = "[]";
parametersJson.value = "{}";
formError.value = "";
}
function showError(error: unknown, fallback: string) {
successMessage.value = "";
errorMessage.value = error instanceof Error ? error.message : fallback;
}
async function refresh() {
loading.value = true;
errorMessage.value = "";
try {
const response = await listCloudSkills();
skills.value = response.items;
await Promise.all(skills.value.map(loadEntitlements));
} catch (error) {
showError(error, "failed to load skills");
} finally {
loading.value = false;
}
}
async function loadEntitlements(skill: CloudSkill) {
try {
const resp = await listCloudSkillEntitlements(skill.id);
entitlementsFor.value[skill.id] = resp.host_ids;
} catch {
entitlementsFor.value[skill.id] = [];
}
}
function editSkill(skill: CloudSkill) {
editingId.value = skill.id;
form.name = skill.name;
form.kind = skill.kind;
form.description = skill.description;
form.tags = [...skill.tags];
tagsInput.value = skill.tags.join(", ");
form.content = skill.content;
form.steps = skill.steps;
form.parameters = skill.parameters;
stepsJson.value = JSON.stringify(skill.steps, null, 2);
parametersJson.value = JSON.stringify(skill.parameters, null, 2);
formError.value = "";
successMessage.value = "";
}
function buildPayload(): CloudSkillPayload | null {
if (!form.name.trim()) {
formError.value = "name is required";
return null;
}
let steps: Record<string, unknown>[] = [];
let parameters: Record<string, Record<string, unknown>> = {};
if (form.kind === "flow_template") {
try {
steps = JSON.parse(stepsJson.value || "[]");
parameters = JSON.parse(parametersJson.value || "{}");
} catch {
formError.value = "steps/parameters must be valid JSON";
return null;
}
} else if (!form.content.trim()) {
formError.value = "knowledge skill content is required";
return null;
}
return {
name: form.name.trim(),
kind: form.kind as CloudSkillKind,
description: form.description,
tags: tagsInput.value.split(",").map((t) => t.trim()).filter(Boolean),
content: form.content,
steps,
parameters,
};
}
async function saveSkill() {
formError.value = "";
const payload = buildPayload();
if (payload === null) return;
try {
if (editingId.value) {
await updateCloudSkill(editingId.value, payload);
successMessage.value = "skill updated";
} else {
await createCloudSkill(payload);
successMessage.value = "skill created";
}
resetForm();
await refresh();
} catch (error) {
showError(error, "failed to save skill");
}
}
async function removeSkill(skill: CloudSkill) {
try {
await deleteCloudSkill(skill.id);
successMessage.value = "skill deleted";
await refresh();
} catch (error) {
showError(error, "failed to delete skill");
}
}
async function addHost(skillId: string) {
const hostId = (entitlementHostInput.value[skillId] || "").trim();
if (!hostId) return;
try {
await grantCloudSkillEntitlement(skillId, hostId);
entitlementHostInput.value[skillId] = "";
await loadEntitlements(skills.value.find((s) => s.id === skillId)!);
} catch (error) {
showError(error, "failed to grant entitlement");
}
}
async function removeHost(skillId: string, hostId: string) {
try {
await revokeCloudSkillEntitlement(skillId, hostId);
await loadEntitlements(skills.value.find((s) => s.id === skillId)!);
} catch (error) {
showError(error, "failed to revoke entitlement");
}
}
async function loadInventory() {
const hostId = inventoryHostId.value.trim();
if (!hostId) return;
inventoryLoading.value = true;
try {
inventory.value = await getHostSkillInventory(hostId);
} catch (error) {
showError(error, "failed to load host inventory");
} finally {
inventoryLoading.value = false;
}
}
onMounted(refresh);
</script>
<template>
<section class="skills-view">
<header class="row">
<h2>Skills</h2>
<button :disabled="loading" @click="refresh">
<RefreshCw :size="14" /> Refresh
</button>
</header>
<p v-if="errorMessage" class="error">{{ errorMessage }}</p>
<p v-if="successMessage" class="success">{{ successMessage }}</p>
<form v-if="canAdmin" class="skill-form" @submit.prevent="saveSkill">
<h3>{{ editingId ? "Edit skill" : "New skill" }}</h3>
<label>name <input v-model="form.name" /></label>
<label>kind
<select v-model="form.kind">
<option value="knowledge">knowledge</option>
<option value="flow_template">flow_template</option>
</select>
</label>
<label>description <input v-model="form.description" /></label>
<label>tags (comma-separated) <input v-model="tagsInput" /></label>
<label v-if="form.kind === 'knowledge'">content
<textarea v-model="form.content" rows="4"></textarea>
</label>
<template v-else>
<label>steps (JSON)
<textarea v-model="stepsJson" rows="4"></textarea>
</label>
<label>parameters (JSON)
<textarea v-model="parametersJson" rows="4"></textarea>
</label>
</template>
<p v-if="formError" class="error">{{ formError }}</p>
<div class="row">
<button type="submit"><Plus :size="14" /> {{ editingId ? "Save" : "Create" }}</button>
<button type="button" @click="resetForm"><X :size="14" /> Cancel</button>
</div>
</form>
<LoaderCircle v-if="loading" class="spin" :size="20" />
<ul v-else class="skill-list">
<li v-for="skill in skills" :key="skill.id">
<div class="skill-head">
<strong>{{ skill.name }}</strong>
<span class="badge">{{ skill.kind }}</span>
<span class="muted">rev {{ skill.revision }}</span>
<div class="row">
<button v-if="canAdmin" @click="editSkill(skill)"><Pencil :size="12" /> edit</button>
<button v-if="canAdmin" @click="removeSkill(skill)"><Trash2 :size="12" /> delete</button>
</div>
</div>
<p class="muted">{{ skill.description }}</p>
<div class="entitlements">
<span>entitled hosts:</span>
<span v-for="host in entitlementsFor[skill.id] || []" :key="host" class="chip">
{{ host }}
<button v-if="canAdmin" @click="removeHost(skill.id, host)"><X :size="10" /></button>
</span>
<template v-if="canAdmin">
<input
v-model="entitlementHostInput[skill.id]"
placeholder="host id"
@keyup.enter="addHost(skill.id)"
/>
<button @click="addHost(skill.id)">grant</button>
</template>
</div>
</li>
</ul>
<section class="inventory">
<h3>Host local-skill inventory</h3>
<div class="row">
<input v-model="inventoryHostId" placeholder="host id" @keyup.enter="loadInventory" />
<button :disabled="inventoryLoading" @click="loadInventory">view</button>
</div>
<p v-if="inventory && inventory.payload.length === 0" class="muted">no local skills reported</p>
<ul v-if="inventory && inventory.payload.length">
<li v-for="(item, idx) in inventory.payload" :key="idx">
{{ item.name }} ({{ item.kind }}) origin {{ item.origin }}
</li>
</ul>
<p v-if="inventory?.reported_at" class="muted">reported {{ inventory.reported_at }}</p>
</section>
</section>
</template>
<style scoped>
.skills-view { display: flex; flex-direction: column; gap: 1rem; }
.row { display: flex; gap: 0.5rem; align-items: center; }
.skill-form { display: flex; flex-direction: column; gap: 0.5rem; border: 1px solid var(--border, #ccc); padding: 1rem; border-radius: 6px; }
.skill-form label { display: flex; flex-direction: column; gap: 0.25rem; font-size: 0.85rem; }
.skill-list { list-style: none; padding: 0; display: flex; flex-direction: column; gap: 0.75rem; }
.skill-list li { border: 1px solid var(--border, #ccc); padding: 0.75rem; border-radius: 6px; }
.skill-head { display: flex; align-items: center; gap: 0.5rem; }
.skill-head .row { margin-left: auto; }
.badge { font-size: 0.7rem; background: var(--muted-bg, #eee); padding: 0.1rem 0.4rem; border-radius: 4px; }
.muted { color: var(--muted, #888); font-size: 0.8rem; }
.entitlements { display: flex; flex-wrap: wrap; gap: 0.25rem; align-items: center; margin-top: 0.5rem; }
.chip { display: inline-flex; align-items: center; gap: 0.25rem; background: var(--muted-bg, #eee); padding: 0.1rem 0.4rem; border-radius: 10px; font-size: 0.75rem; }
.chip button { border: none; background: none; cursor: pointer; padding: 0; display: flex; }
input, select, textarea { padding: 0.3rem; border: 1px solid var(--border, #ccc); border-radius: 4px; }
button { display: inline-flex; align-items: center; gap: 0.3rem; cursor: pointer; }
.spin { animation: spin 1s linear infinite; }
@keyframes spin { to { transform: rotate(360deg); } }
.error { color: #c00; } .success { color: #070; }
</style>
+49
View File
@@ -3,6 +3,7 @@ import { computed, onMounted, ref, watch } from "vue";
import { LoaderCircle, RefreshCw } from "@lucide/vue";
import {
CloudApiError,
cancelTask,
getTaskAttempts,
getTaskPlannerDecisions,
listTasks,
@@ -20,6 +21,7 @@ import type {
PlannerDecisionItem,
} from "../types";
import { formatTaskProgress } from "../taskProgress";
import { canCancelTask } from "../taskCancellation";
import { computePlannerHistoryState } from "../plannerHistory";
const props = defineProps<{ canSubmit: boolean }>();
@@ -30,6 +32,7 @@ const STATUSES: TaskStatus[] = [
"dispatched",
"done",
"failed",
"cancelled",
];
const statusFilter = ref<TaskStatus | "">("");
@@ -55,6 +58,7 @@ const devices = ref<DeviceRecord[]>([]);
const plannerDecisions = ref<PlannerDecisionItem[]>([]);
const plannerLoading = ref(false);
const plannerError = ref("");
const cancelling = ref(false);
const availableDevices = computed(() =>
devices.value.filter((device) => device.host_id === submitHostId.value),
);
@@ -83,6 +87,8 @@ async function refresh() {
selectedTask.value = null;
attempts.value = [];
plannerDecisions.value = [];
} else {
await selectTask(stillPresent);
}
}
} catch (err) {
@@ -185,6 +191,21 @@ async function selectTask(task: TaskListItem) {
}
}
async function cancelSelectedTask() {
if (!selectedTask.value) return;
cancelling.value = true;
errorMessage.value = "";
try {
const response = await cancelTask(selectedTask.value.id);
selectedTask.value = { ...selectedTask.value, status: response.status };
await refresh();
} catch (err) {
handleError(err, "failed to cancel task");
} finally {
cancelling.value = false;
}
}
function handleError(err: unknown, fallback: string) {
if (err instanceof CloudApiError) {
errorMessage.value = err.message;
@@ -257,6 +278,11 @@ const selectedTaskProgress = computed(() =>
selectedTask.value ? formatTaskProgress(selectedTask.value) : null,
);
const canCancelSelectedTask = computed(
() =>
!!selectedTask.value && canCancelTask(selectedTask.value.status, props.canSubmit),
);
const selectedHostTransport = computed<"direct" | "cloud" | null>(() => {
if (!selectedTask.value?.assigned_host_id) return null;
const host = hosts.value.find(
@@ -417,6 +443,13 @@ function formatArguments(args: Record<string, unknown>): string {
<h2>
Task <code>{{ selectedTask.id.slice(0, 8) }}</code>
</h2>
<button
v-if="canCancelSelectedTask"
:disabled="cancelling"
@click="cancelSelectedTask"
>
{{ cancelling ? "Cancelling…" : "Cancel" }}
</button>
<button @click="clearSelection">Back to list</button>
</div>
<p class="muted">
@@ -504,6 +537,22 @@ function formatArguments(args: Record<string, unknown>): string {
<summary>Arguments</summary>
<pre class="planner-prompt">{{ formatArguments(decision.arguments) }}</pre>
</details>
<details v-if="decision.purpose">
<summary>Action purpose</summary>
<pre class="planner-prompt">{{ decision.purpose }}</pre>
</details>
<details v-if="decision.expected_outcome">
<summary>Expected outcome</summary>
<pre class="planner-prompt">{{ decision.expected_outcome }}</pre>
</details>
<details v-if="decision.rationale">
<summary>Rationale</summary>
<pre class="planner-prompt">{{ decision.rationale }}</pre>
</details>
<details v-if="decision.thinking">
<summary>Thinking</summary>
<pre class="planner-prompt">{{ decision.thinking }}</pre>
</details>
</div>
</div>
<div v-else-if="plannerHistoryState.kind === 'empty_cloud_transport'" class="muted">
+4
View File
@@ -27,6 +27,10 @@ services:
CLOUD_DATABASE_URL: postgresql+psycopg://${POSTGRES_USER}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB}
CLOUD_TRUST_PROXY_HEADERS: ${CLOUD_TRUST_PROXY_HEADERS:-false}
CLOUD_LLM_PROVIDER_ENCRYPTION_KEY: ${CLOUD_LLM_PROVIDER_ENCRYPTION_KEY}
CLOUD_PLANNER_TOKEN_RESERVATION_CEILING: ${CLOUD_PLANNER_TOKEN_RESERVATION_CEILING:-4096}
CLOUD_PLANNER_TOKEN_RESERVATION_TTL_SECONDS: ${CLOUD_PLANNER_TOKEN_RESERVATION_TTL_SECONDS:-300}
CLOUD_PLANNER_DECISION_LOG_PRUNE_INTERVAL_SECONDS: ${CLOUD_PLANNER_DECISION_LOG_PRUNE_INTERVAL_SECONDS:-3600}
CLOUD_PLANNER_DECISION_LOG_RETENTION_DAYS: ${CLOUD_PLANNER_DECISION_LOG_RETENTION_DAYS:-7}
ports:
- "${CLOUD_API_PORT:-8001}:8001"
depends_on:
+4
View File
@@ -28,6 +28,10 @@ services:
CLOUD_DATABASE_URL: postgresql+psycopg://${POSTGRES_USER}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB}
CLOUD_TRUST_PROXY_HEADERS: ${CLOUD_TRUST_PROXY_HEADERS:-false}
CLOUD_LLM_PROVIDER_ENCRYPTION_KEY: ${CLOUD_LLM_PROVIDER_ENCRYPTION_KEY}
CLOUD_PLANNER_TOKEN_RESERVATION_CEILING: ${CLOUD_PLANNER_TOKEN_RESERVATION_CEILING:-4096}
CLOUD_PLANNER_TOKEN_RESERVATION_TTL_SECONDS: ${CLOUD_PLANNER_TOKEN_RESERVATION_TTL_SECONDS:-300}
CLOUD_PLANNER_DECISION_LOG_PRUNE_INTERVAL_SECONDS: ${CLOUD_PLANNER_DECISION_LOG_PRUNE_INTERVAL_SECONDS:-3600}
CLOUD_PLANNER_DECISION_LOG_RETENTION_DAYS: ${CLOUD_PLANNER_DECISION_LOG_RETENTION_DAYS:-7}
ports:
- "${CLOUD_API_PORT:-8001}:8001"
depends_on:
-1
View File
@@ -1 +0,0 @@
VITE_API_BASE_URL=http://127.0.0.1:8000
-4
View File
@@ -1,4 +0,0 @@
node_modules
dist
.DS_Store
*.local
-46
View File
@@ -1,46 +0,0 @@
# Apex Agent Console
Independent Vue 3 + Vite SPA for the operator console.
## Run Locally
Start the backend from the repository root:
```bash
uvicorn api.rest:create_app --factory --host 127.0.0.1 --port 8000
```
Start the frontend from this directory:
```bash
npm install
npm run dev
```
The frontend reads `VITE_API_BASE_URL` and defaults to `http://127.0.0.1:8000`.
Copy `.env.example` to `.env.local` if the backend runs on another host or port.
The backend mounts `/console/*` routes and enables permissive CORS in `create_app()`
for local frontend development.
## Build
```bash
npm run build
```
## Same-Origin, Single-Process Mode
For an edge/dev setup where running a separate `npm run dev` process is too heavy,
the backend can serve the built console directly from the same process:
```bash
VITE_API_BASE_URL= npm run build
RUNTIME_CONSOLE_STATIC_DIR=$(pwd)/dist uvicorn api.rest:create_app --factory --host 127.0.0.1 --port 8000
```
`VITE_API_BASE_URL=` (empty) makes the build use relative API paths so it works
same-origin without CORS. The console is then served at `/ui/` (with `/`
redirecting there); `/console/*` remains the JSON API used by both this mode
and local `npm run dev`. Rebuild (`npm run build`) after frontend changes —
this mode does not hot-reload.
-12
View File
@@ -1,12 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Apex Agent Console</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
-1211
View File
File diff suppressed because it is too large Load Diff
-22
View File
@@ -1,22 +0,0 @@
{
"name": "apex-agent-console",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite --host 127.0.0.1",
"build": "vue-tsc --noEmit && vite build",
"preview": "vite preview --host 127.0.0.1",
"typecheck": "vue-tsc --noEmit"
},
"dependencies": {
"@lucide/vue": "^1.23.0",
"vue": "^3.5.39"
},
"devDependencies": {
"@vitejs/plugin-vue": "^6.0.7",
"typescript": "^6.0.3",
"vite": "^8.1.3",
"vue-tsc": "^3.3.6"
}
}
-521
View File
@@ -1,521 +0,0 @@
<script setup lang="ts">
import { computed, onMounted, onUnmounted, reactive, ref } from "vue";
import type { Component } from "vue";
import {
ChevronLeft,
ChevronRight,
ListChecks,
LoaderCircle,
MonitorSmartphone,
Plus,
RefreshCw,
Save,
Settings2,
Trash2,
} from "@lucide/vue";
import {
API_BASE_URL,
getConfig,
getTask,
getTimeline,
listDevices,
listTasks,
registerDevice,
unregisterDevice,
updateConfig,
} from "./api";
import type { Device, TaskRecord, TimelineRecord } from "./types";
type ViewId = "dashboard" | "tasks" | "config";
const navItems: { id: ViewId; label: string; icon: Component }[] = [
{ id: "dashboard", label: "Devices", icon: MonitorSmartphone },
{ id: "tasks", label: "Tasks", icon: ListChecks },
{ id: "config", label: "Config", icon: Settings2 },
];
const taskStatuses = ["created", "running", "completed", "failed", "cancelled"];
const activeView = ref<ViewId>("dashboard");
const loading = ref(false);
const refreshError = ref("");
const devices = ref<Device[]>([]);
const tasks = ref<TaskRecord[]>([]);
const selectedTask = ref<TaskRecord | null>(null);
const timeline = ref<TimelineRecord[]>([]);
const selectedStepIndex = ref(0);
const deviceFilter = ref("");
const statusFilter = ref("");
const deviceError = ref("");
const configError = ref("");
const configSaved = ref("");
const maxSteps = ref(20);
const deviceForm = reactive({
name: "",
driver_type: "wda",
server_url: "http://127.0.0.1:4723",
udid: "",
wda_local_port: "",
});
let refreshTimer: number | undefined;
const currentStep = computed<TimelineRecord | null>(() => {
if (!timeline.value.length) {
return null;
}
return timeline.value[selectedStepIndex.value] ?? timeline.value[0];
});
const runningTasks = computed(
() => tasks.value.filter((task) => task.status === "running").length,
);
const failedTasks = computed(
() => tasks.value.filter((task) => task.status === "failed").length,
);
onMounted(async () => {
await refreshAll();
refreshTimer = window.setInterval(() => {
void refreshStatus();
}, 10000);
});
onUnmounted(() => {
if (refreshTimer !== undefined) {
window.clearInterval(refreshTimer);
}
});
async function refreshAll(): Promise<void> {
loading.value = true;
refreshError.value = "";
try {
await Promise.all([refreshDevices(), refreshTasks(), refreshConfig()]);
} catch (error) {
refreshError.value = errorMessage(error);
} finally {
loading.value = false;
}
}
async function refreshStatus(): Promise<void> {
try {
await Promise.all([refreshDevices(), refreshTasks()]);
} catch (error) {
refreshError.value = errorMessage(error);
}
}
async function refreshDevices(): Promise<void> {
devices.value = await listDevices();
}
async function refreshTasks(): Promise<void> {
tasks.value = await listTasks({
deviceId: deviceFilter.value,
status: statusFilter.value,
});
if (selectedTask.value) {
await openTask(selectedTask.value.id, false);
}
}
async function refreshConfig(): Promise<void> {
const config = await getConfig();
maxSteps.value = config.max_steps;
}
async function applyTaskFilters(): Promise<void> {
await refreshTasks();
}
async function openTask(taskId: string, switchView = true): Promise<void> {
const [task, records] = await Promise.all([getTask(taskId), getTimeline(taskId)]);
selectedTask.value = task;
timeline.value = records;
selectedStepIndex.value = records.length ? Math.min(selectedStepIndex.value, records.length - 1) : 0;
if (switchView) {
activeView.value = "tasks";
}
}
async function submitDevice(): Promise<void> {
deviceError.value = "";
const connectionInfo: Record<string, unknown> = {};
if (deviceForm.server_url.trim()) {
connectionInfo.server_url = deviceForm.server_url.trim();
}
if (deviceForm.udid.trim()) {
connectionInfo.udid = deviceForm.udid.trim();
}
if (deviceForm.wda_local_port.trim()) {
const port = Number(deviceForm.wda_local_port);
if (!Number.isFinite(port)) {
deviceError.value = "wda_local_port must be a number";
return;
}
connectionInfo.wda_local_port = port;
}
try {
await registerDevice({
driver_type: deviceForm.driver_type,
name: deviceForm.name.trim() || null,
connection_info: connectionInfo,
});
deviceForm.name = "";
deviceForm.udid = "";
deviceForm.wda_local_port = "";
await refreshDevices();
} catch (error) {
deviceError.value = errorMessage(error);
}
}
async function removeDevice(device: Device): Promise<void> {
if (!window.confirm(`Remove ${displayDeviceName(device)}?`)) {
return;
}
deviceError.value = "";
try {
await unregisterDevice(device.id);
await refreshDevices();
} catch (error) {
deviceError.value = errorMessage(error);
}
}
async function saveConfig(): Promise<void> {
configError.value = "";
configSaved.value = "";
try {
const updated = await updateConfig({ max_steps: Number(maxSteps.value) });
maxSteps.value = updated.max_steps;
configSaved.value = "Saved";
} catch (error) {
configError.value = errorMessage(error);
}
}
function previousStep(): void {
selectedStepIndex.value = Math.max(0, selectedStepIndex.value - 1);
}
function nextStep(): void {
selectedStepIndex.value = Math.min(timeline.value.length - 1, selectedStepIndex.value + 1);
}
function displayDeviceName(device: Device): string {
return device.name || device.id;
}
function findDeviceName(deviceId: string): string {
return devices.value.find((device) => device.id === deviceId)?.name || deviceId;
}
function formatDate(value: string | null): string {
if (!value) {
return "-";
}
return new Intl.DateTimeFormat(undefined, {
month: "short",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
}).format(new Date(value));
}
function prettyJson(value: unknown): string {
return JSON.stringify(value ?? {}, null, 2);
}
function errorMessage(error: unknown): string {
return error instanceof Error ? error.message : "Request failed";
}
</script>
<template>
<div class="app-shell">
<aside class="sidebar" aria-label="Console navigation">
<div class="brand">
<MonitorSmartphone :size="22" aria-hidden="true" />
<div>
<strong>Apex Console</strong>
<span>{{ API_BASE_URL }}</span>
</div>
</div>
<nav class="nav-list">
<button
v-for="item in navItems"
:key="item.id"
class="nav-button"
:class="{ active: activeView === item.id }"
type="button"
@click="activeView = item.id"
>
<component :is="item.icon" :size="18" aria-hidden="true" />
<span>{{ item.label }}</span>
</button>
</nav>
</aside>
<main class="workspace">
<header class="topbar">
<div>
<h1>{{ navItems.find((item) => item.id === activeView)?.label }}</h1>
<p>{{ devices.length }} devices / {{ tasks.length }} tasks</p>
</div>
<button class="icon-text-button" type="button" :disabled="loading" @click="refreshAll">
<LoaderCircle v-if="loading" class="spin" :size="17" aria-hidden="true" />
<RefreshCw v-else :size="17" aria-hidden="true" />
<span>Refresh</span>
</button>
</header>
<p v-if="refreshError" class="alert error">{{ refreshError }}</p>
<section v-if="activeView === 'dashboard'" class="view-grid">
<div class="metrics">
<div class="metric">
<span class="metric-label">Devices</span>
<strong>{{ devices.length }}</strong>
</div>
<div class="metric">
<span class="metric-label">Running</span>
<strong>{{ runningTasks }}</strong>
</div>
<div class="metric">
<span class="metric-label">Failed</span>
<strong>{{ failedTasks }}</strong>
</div>
</div>
<section class="panel">
<div class="section-title">
<h2>Device Status</h2>
</div>
<div v-if="!devices.length" class="empty-state">
<MonitorSmartphone :size="34" aria-hidden="true" />
<span>No devices registered. Add one from Config.</span>
</div>
<ul v-else class="device-list">
<li v-for="device in devices" :key="device.id" class="device-row">
<div>
<strong>{{ displayDeviceName(device) }}</strong>
<span>{{ device.id }}</span>
</div>
<div class="row-meta">
<span class="driver-label">{{ device.driver_type }}</span>
<span class="status-pill" :class="device.status">{{ device.status }}</span>
</div>
</li>
</ul>
</section>
</section>
<section v-if="activeView === 'tasks'" class="tasks-layout">
<section class="panel task-browser">
<div class="section-title">
<h2>Task List</h2>
</div>
<div class="filters">
<label>
Device
<select v-model="deviceFilter" @change="applyTaskFilters">
<option value="">All devices</option>
<option v-for="device in devices" :key="device.id" :value="device.id">
{{ displayDeviceName(device) }}
</option>
</select>
</label>
<label>
Status
<select v-model="statusFilter" @change="applyTaskFilters">
<option value="">All statuses</option>
<option v-for="statusName in taskStatuses" :key="statusName" :value="statusName">
{{ statusName }}
</option>
</select>
</label>
</div>
<div v-if="!tasks.length" class="empty-state compact">
<ListChecks :size="30" aria-hidden="true" />
<span>No tasks match the current filters.</span>
</div>
<button
v-for="task in tasks"
v-else
:key="task.id"
class="task-row"
:class="{ selected: selectedTask?.id === task.id }"
type="button"
@click="openTask(task.id)"
>
<span class="task-goal">{{ task.goal }}</span>
<span class="task-meta">
{{ findDeviceName(task.device_id) }} / {{ formatDate(task.created_at) }}
</span>
<span class="status-pill" :class="task.status">{{ task.status }}</span>
</button>
</section>
<section class="panel timeline-panel">
<div class="section-title">
<h2>Task Detail</h2>
<span v-if="selectedTask" class="status-pill" :class="selectedTask.status">
{{ selectedTask.status }}
</span>
</div>
<div v-if="!selectedTask" class="empty-state">
<ListChecks :size="34" aria-hidden="true" />
<span>Select a task to inspect its timeline.</span>
</div>
<div v-else class="task-detail">
<dl class="detail-grid">
<div>
<dt>Goal</dt>
<dd>{{ selectedTask.goal }}</dd>
</div>
<div>
<dt>Device</dt>
<dd>{{ findDeviceName(selectedTask.device_id) }}</dd>
</div>
<div>
<dt>Updated</dt>
<dd>{{ formatDate(selectedTask.updated_at) }}</dd>
</div>
<div v-if="selectedTask.failure_reason">
<dt>Failure</dt>
<dd>{{ selectedTask.failure_reason }}</dd>
</div>
</dl>
<div class="timeline-controls">
<button
class="icon-button"
type="button"
title="Previous step"
:disabled="selectedStepIndex === 0"
@click="previousStep"
>
<ChevronLeft :size="18" aria-hidden="true" />
</button>
<span>{{ timeline.length ? selectedStepIndex + 1 : 0 }} / {{ timeline.length }}</span>
<button
class="icon-button"
type="button"
title="Next step"
:disabled="selectedStepIndex >= timeline.length - 1"
@click="nextStep"
>
<ChevronRight :size="18" aria-hidden="true" />
</button>
</div>
<div v-if="!currentStep" class="empty-state compact">
<span>No timeline records captured.</span>
</div>
<div v-else class="timeline-stage">
<div class="screenshot-frame">
<img
v-if="currentStep.image_base64"
:src="`data:image/png;base64,${currentStep.image_base64}`"
alt="Task step screenshot"
/>
<span v-else>No screenshot</span>
</div>
<div class="step-data">
<div>
<h3>Tool Call</h3>
<pre>{{ prettyJson(currentStep.tool_call) }}</pre>
</div>
<div>
<h3>Result</h3>
<pre>{{ prettyJson(currentStep.result) }}</pre>
</div>
</div>
</div>
</div>
</section>
</section>
<section v-if="activeView === 'config'" class="config-layout">
<section class="panel">
<div class="section-title">
<h2>Device Configuration</h2>
</div>
<form class="form-grid" @submit.prevent="submitDevice">
<label>
Name
<input v-model="deviceForm.name" type="text" placeholder="Desk iPhone" />
</label>
<label>
Driver
<select v-model="deviceForm.driver_type">
<option value="wda">wda</option>
</select>
</label>
<label>
Server URL
<input v-model="deviceForm.server_url" type="url" />
</label>
<label>
UDID
<input v-model="deviceForm.udid" type="text" />
</label>
<label>
WDA local port
<input v-model="deviceForm.wda_local_port" type="number" min="1" />
</label>
<button class="icon-text-button submit-button" type="submit">
<Plus :size="17" aria-hidden="true" />
<span>Add Device</span>
</button>
</form>
<p v-if="deviceError" class="alert error">{{ deviceError }}</p>
<ul class="device-list managed">
<li v-for="device in devices" :key="device.id" class="device-row">
<div>
<strong>{{ displayDeviceName(device) }}</strong>
<span>{{ device.id }}</span>
</div>
<button
class="icon-button danger"
type="button"
title="Remove device"
@click="removeDevice(device)"
>
<Trash2 :size="17" aria-hidden="true" />
</button>
</li>
</ul>
</section>
<section class="panel">
<div class="section-title">
<h2>Runtime Parameters</h2>
</div>
<form class="settings-form" @submit.prevent="saveConfig">
<label>
Max steps
<input v-model.number="maxSteps" type="number" min="1" />
</label>
<button class="icon-text-button" type="submit">
<Save :size="17" aria-hidden="true" />
<span>Save</span>
</button>
</form>
<p v-if="configError" class="alert error">{{ configError }}</p>
<p v-if="configSaved" class="alert success">{{ configSaved }}</p>
</section>
</section>
</main>
</div>
</template>
-96
View File
@@ -1,96 +0,0 @@
import type {
Device,
RegisterDevicePayload,
RuntimeConfig,
TaskRecord,
TimelineRecord,
} from "./types";
const configuredBaseUrl = import.meta.env.VITE_API_BASE_URL as string | undefined;
export const API_BASE_URL = (
configuredBaseUrl !== undefined ? configuredBaseUrl : "http://127.0.0.1:8000"
).replace(/\/$/, "");
async function request<T>(path: string, init: RequestInit = {}): Promise<T> {
const response = await fetch(`${API_BASE_URL}${path}`, {
...init,
headers: {
Accept: "application/json",
...(init.body ? { "Content-Type": "application/json" } : {}),
...init.headers,
},
});
if (!response.ok) {
let message = `${response.status} ${response.statusText}`;
try {
const payload = (await response.json()) as { detail?: unknown };
if (typeof payload.detail === "string") {
message = payload.detail;
} else if (payload.detail) {
message = JSON.stringify(payload.detail);
}
} catch {
message = await response.text();
}
throw new Error(message);
}
if (response.status === 204) {
return undefined as T;
}
return (await response.json()) as T;
}
export function listDevices(): Promise<Device[]> {
return request<Device[]>("/console/devices");
}
export function registerDevice(payload: RegisterDevicePayload): Promise<Device> {
return request<Device>("/console/devices", {
method: "POST",
body: JSON.stringify(payload),
});
}
export function unregisterDevice(deviceId: string): Promise<void> {
return request<void>(`/console/devices/${encodeURIComponent(deviceId)}`, {
method: "DELETE",
});
}
export function listTasks(filters: {
deviceId?: string;
status?: string;
}): Promise<TaskRecord[]> {
const params = new URLSearchParams();
if (filters.deviceId) {
params.set("device_id", filters.deviceId);
}
if (filters.status) {
params.set("status", filters.status);
}
const query = params.toString();
return request<TaskRecord[]>(`/console/tasks${query ? `?${query}` : ""}`);
}
export function getTask(taskId: string): Promise<TaskRecord> {
return request<TaskRecord>(`/console/tasks/${encodeURIComponent(taskId)}`);
}
export function getTimeline(taskId: string): Promise<TimelineRecord[]> {
return request<TimelineRecord[]>(
`/console/tasks/${encodeURIComponent(taskId)}/timeline`,
);
}
export function getConfig(): Promise<RuntimeConfig> {
return request<RuntimeConfig>("/console/config");
}
export function updateConfig(payload: RuntimeConfig): Promise<RuntimeConfig> {
return request<RuntimeConfig>("/console/config", {
method: "PUT",
body: JSON.stringify(payload),
});
}
-5
View File
@@ -1,5 +0,0 @@
import { createApp } from "vue";
import App from "./App.vue";
import "./style.css";
createApp(App).mount("#app");
-552
View File
@@ -1,552 +0,0 @@
:root {
color: #202124;
background: #f6f7f9;
font-family:
Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI",
sans-serif;
font-synthesis: none;
text-rendering: optimizeLegibility;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
min-width: 320px;
min-height: 100vh;
letter-spacing: 0;
}
button,
input,
select {
font: inherit;
letter-spacing: 0;
}
button {
cursor: pointer;
}
button:disabled {
cursor: not-allowed;
opacity: 0.55;
}
.app-shell {
display: grid;
grid-template-columns: 248px minmax(0, 1fr);
min-height: 100vh;
}
.sidebar {
display: flex;
flex-direction: column;
gap: 24px;
border-right: 1px solid #d9dde5;
background: #ffffff;
padding: 20px 16px;
}
.brand {
display: grid;
grid-template-columns: 32px minmax(0, 1fr);
align-items: center;
gap: 10px;
}
.brand strong,
.brand span {
display: block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.brand strong {
font-size: 16px;
}
.brand span {
color: #667085;
font-size: 12px;
}
.nav-list {
display: grid;
gap: 8px;
}
.nav-button,
.icon-text-button,
.icon-button,
.task-row {
border: 1px solid #d4d9e2;
border-radius: 8px;
background: #ffffff;
color: #202124;
}
.nav-button,
.icon-text-button {
display: inline-flex;
align-items: center;
gap: 8px;
min-height: 38px;
padding: 8px 11px;
}
.nav-button {
width: 100%;
justify-content: flex-start;
}
.nav-button.active {
border-color: #2f7c67;
background: #e7f4ef;
color: #1f5f4e;
}
.workspace {
min-width: 0;
padding: 22px;
}
.topbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
margin-bottom: 18px;
}
.topbar h1 {
margin: 0;
font-size: 24px;
line-height: 1.2;
}
.topbar p {
margin: 4px 0 0;
color: #667085;
font-size: 13px;
}
.view-grid,
.config-layout {
display: grid;
gap: 16px;
}
.tasks-layout {
display: grid;
grid-template-columns: minmax(300px, 420px) minmax(0, 1fr);
gap: 16px;
align-items: start;
}
.metrics {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 12px;
}
.metric,
.panel {
border: 1px solid #d9dde5;
border-radius: 8px;
background: #ffffff;
}
.metric {
padding: 14px;
}
.metric-label {
display: block;
margin-bottom: 8px;
color: #667085;
font-size: 12px;
}
.metric strong {
font-size: 26px;
}
.panel {
padding: 16px;
}
.section-title {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 14px;
}
.section-title h2 {
margin: 0;
font-size: 16px;
line-height: 1.3;
}
.empty-state {
display: grid;
place-items: center;
gap: 10px;
min-height: 170px;
border: 1px dashed #c8ced8;
border-radius: 8px;
color: #667085;
text-align: center;
padding: 22px;
}
.empty-state.compact {
min-height: 88px;
}
.device-list {
display: grid;
gap: 8px;
margin: 0;
padding: 0;
list-style: none;
}
.device-row {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
align-items: center;
gap: 12px;
min-height: 58px;
border: 1px solid #e2e6ec;
border-radius: 8px;
padding: 10px 12px;
}
.device-row strong,
.device-row span,
.task-goal,
.task-meta {
overflow-wrap: anywhere;
}
.device-row span {
display: block;
color: #667085;
font-size: 12px;
}
.row-meta {
display: flex;
align-items: center;
gap: 8px;
}
.driver-label,
.status-pill {
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 24px;
border-radius: 999px;
padding: 3px 9px;
font-size: 12px;
font-weight: 650;
}
.driver-label {
background: #eef0f4;
color: #444b56;
}
.status-pill.idle,
.status-pill.completed {
background: #e5f4ec;
color: #1f6b4a;
}
.status-pill.busy,
.status-pill.running {
background: #e8f1fb;
color: #275b8d;
}
.status-pill.created,
.status-pill.cancelled {
background: #f0edf8;
color: #67508f;
}
.status-pill.offline,
.status-pill.failed,
.status-pill.error {
background: #fdebea;
color: #a43c37;
}
.filters,
.form-grid,
.settings-form,
.detail-grid {
display: grid;
gap: 12px;
}
.filters {
grid-template-columns: repeat(2, minmax(0, 1fr));
margin-bottom: 12px;
}
label {
display: grid;
gap: 6px;
color: #475467;
font-size: 12px;
font-weight: 650;
}
input,
select {
width: 100%;
min-height: 38px;
border: 1px solid #cbd2dc;
border-radius: 8px;
background: #ffffff;
color: #202124;
padding: 8px 10px;
}
.task-browser {
max-height: calc(100vh - 96px);
overflow: auto;
}
.task-row {
display: grid;
width: 100%;
grid-template-columns: minmax(0, 1fr) auto;
gap: 4px 10px;
margin-bottom: 8px;
padding: 11px;
text-align: left;
}
.task-row.selected {
border-color: #2f7c67;
box-shadow: 0 0 0 2px #d9efe8;
}
.task-goal {
font-weight: 650;
}
.task-meta {
color: #667085;
font-size: 12px;
}
.task-row .status-pill {
grid-row: 1 / span 2;
grid-column: 2;
align-self: center;
}
.detail-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
margin: 0 0 14px;
}
.detail-grid div {
border: 1px solid #e2e6ec;
border-radius: 8px;
padding: 10px;
}
.detail-grid dt {
margin-bottom: 4px;
color: #667085;
font-size: 12px;
}
.detail-grid dd {
margin: 0;
overflow-wrap: anywhere;
}
.timeline-controls {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 12px;
}
.icon-button {
display: inline-grid;
place-items: center;
width: 36px;
height: 36px;
padding: 0;
}
.icon-button.danger {
color: #a43c37;
}
.timeline-stage {
display: grid;
grid-template-columns: minmax(220px, 360px) minmax(0, 1fr);
gap: 14px;
}
.screenshot-frame {
display: grid;
place-items: center;
min-height: 360px;
border: 1px solid #d9dde5;
border-radius: 8px;
background: #111827;
color: #e5e7eb;
overflow: hidden;
}
.screenshot-frame img {
display: block;
width: 100%;
height: 100%;
max-height: 520px;
object-fit: contain;
}
.step-data {
display: grid;
gap: 12px;
}
.step-data h3 {
margin: 0 0 6px;
font-size: 14px;
}
pre {
max-height: 248px;
overflow: auto;
margin: 0;
border: 1px solid #e2e6ec;
border-radius: 8px;
background: #f9fafb;
padding: 10px;
white-space: pre-wrap;
overflow-wrap: anywhere;
}
.form-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
align-items: end;
}
.submit-button {
align-self: end;
}
.managed {
margin-top: 14px;
}
.settings-form {
grid-template-columns: minmax(140px, 240px) auto;
align-items: end;
justify-content: start;
}
.alert {
margin: 12px 0 0;
border-radius: 8px;
padding: 10px 12px;
font-size: 13px;
}
.alert.error {
background: #fdebea;
color: #a43c37;
}
.alert.success {
background: #e5f4ec;
color: #1f6b4a;
}
.spin {
animation: spin 0.9s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
@media (max-width: 980px) {
.app-shell {
grid-template-columns: 1fr;
}
.sidebar {
position: sticky;
top: 0;
z-index: 2;
border-right: 0;
border-bottom: 1px solid #d9dde5;
}
.nav-list {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.tasks-layout,
.timeline-stage {
grid-template-columns: 1fr;
}
.task-browser {
max-height: none;
}
}
@media (max-width: 680px) {
.workspace {
padding: 14px;
}
.topbar,
.section-title,
.device-row {
align-items: stretch;
}
.topbar,
.device-row,
.settings-form {
flex-direction: column;
grid-template-columns: 1fr;
}
.metrics,
.filters,
.form-grid,
.detail-grid {
grid-template-columns: 1fr;
}
.nav-button {
justify-content: center;
}
.brand {
grid-template-columns: 32px minmax(0, 1fr);
}
}
-48
View File
@@ -1,48 +0,0 @@
export type DeviceStatus = "idle" | "busy" | "offline" | "error";
export interface Device {
id: string;
name: string | null;
status: DeviceStatus;
driver_type: string;
connection_info: Record<string, unknown>;
}
export type TaskStatus =
| "created"
| "running"
| "completed"
| "failed"
| "cancelled";
export interface TaskRecord {
id: string;
goal: string;
device_id: string;
status: TaskStatus;
created_at: string;
updated_at: string;
completed_at: string | null;
failure_reason: string | null;
}
export interface TimelineRecord {
index: number;
scene: Record<string, unknown>;
prompt: string;
tool_call: Record<string, unknown>;
result: Record<string, unknown>;
timestamp: string;
screenshot_path?: string | null;
image_base64?: string;
}
export interface RuntimeConfig {
max_steps: number;
}
export interface RegisterDevicePayload {
driver_type: string;
name?: string | null;
connection_info: Record<string, unknown>;
}
-1
View File
@@ -1 +0,0 @@
/// <reference types="vite/client" />
-20
View File
@@ -1,20 +0,0 @@
{
"compilerOptions": {
"target": "ES2022",
"useDefineForClassFields": true,
"module": "ESNext",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"skipLibCheck": true,
"moduleResolution": "Bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "preserve",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true
},
"include": ["src/**/*.ts", "src/**/*.vue"],
"references": [{ "path": "./tsconfig.node.json" }]
}
-12
View File
@@ -1,12 +0,0 @@
{
"compilerOptions": {
"composite": true,
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "Bundler",
"allowSyntheticDefaultImports": true,
"strict": true
},
"include": ["vite.config.ts"]
}
-7
View File
@@ -1,7 +0,0 @@
import { defineConfig } from "vite";
import vue from "@vitejs/plugin-vue";
export default defineConfig({
plugins: [vue()],
base: "/ui/",
});
+80 -3
View File
@@ -71,6 +71,41 @@ class Device:
}
_STATE_FIELDS = ("enabled", "clickable", "selected", "checked", "focused")
_COLOR_FIELDS = ("foreground_color", "background_color")
@dataclass(frozen=True)
class ActiveApp:
"""Native identifier for the application currently in the foreground."""
platform: str
bundle_id: str | None = None
package: str | None = None
activity: str | None = None
def to_dict(self) -> dict[str, str]:
data = {"platform": self.platform}
for field_name in ("bundle_id", "package", "activity"):
value = getattr(self, field_name)
if value is not None:
data[field_name] = value
return data
@classmethod
def from_dict(cls, data: dict[str, Any]) -> "ActiveApp":
def text(key: str) -> str | None:
value = data.get(key)
return value if isinstance(value, str) and value else None
return cls(
platform=text("platform") or "unknown",
bundle_id=text("bundle_id"),
package=text("package"),
activity=text("activity"),
)
@dataclass
class SceneElement:
id: str
@@ -79,6 +114,18 @@ class SceneElement:
text: str | None = None
confidence: float | None = None
source: str | None = None
# Accessibility-tree interaction state, when the platform reports it.
# None means "not reported by this platform/element", not "false".
enabled: bool | None = None
clickable: bool | None = None
selected: bool | None = None
checked: bool | None = None
focused: bool | None = None
# Text/background color sampled from the screenshot pixels under the OCR
# box ("#rrggbb"). None means unavailable (not OCR-sourced, or sampling
# failed), not "no color".
foreground_color: str | None = None
background_color: str | None = None
@property
def center(self) -> tuple[float, float]:
@@ -94,6 +141,10 @@ class SceneElement:
}
if self.source:
data["source"] = self.source
for field_name in _STATE_FIELDS + _COLOR_FIELDS:
value = getattr(self, field_name)
if value is not None:
data[field_name] = value
return data
@classmethod
@@ -105,6 +156,13 @@ class SceneElement:
bounds=Bounds.from_dict(data["bounds"]),
confidence=data.get("confidence"),
source=data.get("source"),
enabled=data.get("enabled"),
clickable=data.get("clickable"),
selected=data.get("selected"),
checked=data.get("checked"),
focused=data.get("focused"),
foreground_color=data.get("foreground_color"),
background_color=data.get("background_color"),
)
@@ -113,22 +171,42 @@ class Scene:
width: int
height: int
elements: list[SceneElement] = field(default_factory=list)
# Keep raw OCR observations for local execution evidence without duplicating
# them in the normalized, LLM-facing scene payload.
ocr_elements: list[SceneElement] = field(default_factory=list)
# The foreground app is supplied by the Driver, separately from the
# accessibility tree, and is absent for drivers that cannot query it.
# Kept last to preserve Scene's existing positional constructor arguments.
active_app: ActiveApp | None = None
def to_dict(self) -> dict[str, Any]:
return {
data: dict[str, Any] = {
"screen": {"width": self.width, "height": self.height},
"elements": [element.to_dict() for element in self.elements],
}
if self.active_app is not None:
data["app"] = self.active_app.to_dict()
return data
def ocr_results_to_dict(self) -> list[dict[str, Any]]:
return [element.to_dict() for element in self.ocr_elements]
@classmethod
def from_dict(cls, data: dict[str, Any]) -> "Scene":
screen = data.get("screen") or {}
raw_app = data.get("app")
return cls(
width=int(screen.get("width") or data.get("width") or 0),
height=int(screen.get("height") or data.get("height") or 0),
elements=[
SceneElement.from_dict(element) for element in data.get("elements", [])
],
active_app=ActiveApp.from_dict(raw_app)
if isinstance(raw_app, dict)
else None,
ocr_elements=[
SceneElement.from_dict(element)
for element in data.get("elements", [])
for element in data.get("ocr_elements", [])
],
)
@@ -179,4 +257,3 @@ class Step:
"result": self.result,
"error": self.error,
}
+20
View File
@@ -122,6 +122,26 @@ class DeviceManager:
self._drivers.pop(device_id, None)
self._set_status(device_id, "offline" if offline else "error")
def probe(self, device_id: str) -> bool:
"""Check whether an established driver is still reachable."""
with self._lock:
self._device(device_id)
driver = self._drivers.get(device_id)
if driver is None:
return False
try:
health_check = getattr(driver, "health_check", None)
if callable(health_check):
health_check()
else:
# Compatibility for drivers implemented before health_check
# existed. Built-in drivers use the non-screen health check.
driver.screenshot()
except Exception:
self.mark_error(device_id, offline=True)
return False
return True
def active_driver(self, device_id: str | None = None) -> Driver:
with self._lock:
if device_id is None:
+102 -51
View File
@@ -37,14 +37,14 @@ uv run --package device-host-agent device-host-agent
```
At startup, the Host Agent loads device registrations from
`tasks/device_config.sqlite3`, the same `DeviceConfigStore` used by the local
Runtime console API. Register or update devices before starting the Host Agent,
then restart it to reload changes. In Compose,
`tasks/device_config.sqlite3`. Register or update devices before starting the
Host Agent, then restart it to reload changes. In Compose,
`HOST_AGENT_TASKS_PATH` selects the host directory mounted at `/app/tasks`; it
defaults to `./tasks`.
The Host Agent only initiates outbound HTTP requests. It does not expose an
inbound port.
The Host Agent only initiates outbound requests to the Cloud Control Plane. It
does expose an authenticated local console on loopback by default; this is not
a Cloud-facing inbound API.
## Direct Edge Enrollment
@@ -96,9 +96,9 @@ image, repository, log, or general backup.
The Host Agent always serves a small local-only web console on the
edge machine: heartbeat/enrollment status, registered local devices, current
assignment progress, local device add/edit/remove, a local account password
change, and recent assignment/heartbeat history. It authenticates with the
same local account created by `device-host-agent setup` above — there is no
separate console credential.
change, assignment/heartbeat history, and complete execution evidence. It
authenticates with the same local account created by `device-host-agent setup`
above; there is no separate console credential.
```text
HOST_AGENT_CONSOLE_BIND_HOST=127.0.0.1
@@ -123,10 +123,10 @@ HOST_AGENT_CONSOLE_HISTORY_LIMIT=200
### 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.
The Host Agent persists step-by-step task execution state (metadata, Timeline
artifacts, screenshots, OCR, and UI-tree results) to local SQLite/files on the
edge machine. Runtime is an in-process library, so there is no second Runtime
database or service to inspect.
```text
HOST_AGENT_TASK_PROGRESS_DB_PATH=host_agent_data/task_progress.sqlite3
@@ -147,12 +147,15 @@ HOST_AGENT_TASK_RETENTION_MAX_AGE_DAYS=7
**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.
- **Host Agent console**: Open `http://127.0.0.1:8765/tasks` for the
authoritative list of tasks actually executing on that Host (Cloud task ID,
attempt, status, device, timestamps). Click an execution ID for the full
step-by-step timeline with before/after screenshots, operation details, OCR,
and UI-tree results.
- **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.
Agent piggybacks on each lease renewal. For Cloud-proxy hosts it also shows
retained LLM prompt/decision history, but it does not store screenshots.
Treat `HOST_AGENT_CONSOLE_ALLOW_NON_LOOPBACK` as an explicit,
operator-accepted risk: the console has no built-in TLS and no rate
@@ -380,33 +383,21 @@ back to the upstream.
## Runtime AI Planner
The Host Agent reuses the local Runtime planner. Unlike the shared Runtime
The Host Agent reuses the shared Runtime planner. Unlike the shared Runtime
library (whose own default is the deterministic stub planner), the **Host
Agent defaults `AI_PLANNER_ENABLED` to on** -- it is the actual device-control
path, so goal assignments use a model unless an operator explicitly opts out.
Provide provider credentials before deploying:
With `AI_PLANNER_TRANSPORT` unset, the Host Agent uses the **`cloud`**
transport. Configure an active Cloud Provider profile before deploying; without
one, every planning step raises immediately and the task fails on its first
step (no silent fallback to the stub planner). Set `AI_PLANNER_ENABLED=false`
to opt back out to the deterministic stub planner (e.g. for offline/dev hosts
with no provider credentials).
```text
AI_PLANNER_PROVIDER=anthropic
AI_PLANNER_MODEL=claude-sonnet-5
AI_PLANNER_TIMEOUT_SECONDS=30
ANTHROPIC_API_KEY=<secret manager reference>
```
### Cloud-proxy transport (default)
Without a valid API key, every planning step raises immediately and the task
fails on its first step (no silent fallback to the stub planner). Set
`AI_PLANNER_ENABLED=false` to opt back out to the deterministic stub planner
(e.g. for offline/dev hosts with no provider credentials).
For OpenAI, set `AI_PLANNER_PROVIDER=openai`, choose the deployed model through
`AI_PLANNER_MODEL`, and provide `OPENAI_API_KEY`. This is the **`direct`
transport** (the default): the Host Agent holds provider credentials and
calls Anthropic/OpenAI itself.
### Cloud-proxy transport (`AI_PLANNER_TRANSPORT=cloud`)
Set `AI_PLANNER_TRANSPORT=cloud` on the Host Agent to instead route every
planning decision through the Cloud API's
With `AI_PLANNER_TRANSPORT` unset or set to `cloud`, every planning decision
routes through the Cloud API's
`POST /internal/v1/hosts/{host_id}/planner/decide` endpoint (the same
host-scoped bearer credential used for heartbeat/claim/renew/result). In this
mode:
@@ -416,8 +407,11 @@ mode:
sign in to `/console/` as an administrator and create an active entry under
**LLM providers**. Provider API keys are encrypted in the database and are
never returned by the API or Console. Edge Hosts do not hold Provider keys.
The Cloud API does not read `AI_PLANNER_PROVIDER`, `AI_PLANNER_MODEL`,
`AI_PLANNER_TIMEOUT_SECONDS`, `ANTHROPIC_API_KEY`, or `OPENAI_API_KEY`.
The profile's provider, model, base URL, and timeout (1 to 120 seconds) are
the authority for every Cloud-proxy call. The Cloud API does not read
`AI_PLANNER_PROVIDER`, `AI_PLANNER_MODEL`, `AI_PLANNER_TIMEOUT_SECONDS`,
`ANTHROPIC_API_KEY`, or `OPENAI_API_KEY`; those variables apply only to the
explicit `direct` transport.
- **Profile types:** choose **Anthropic** for native Anthropic tool use, or
**OpenAI-compatible** for the OpenAI Chat Completions tool-calling protocol.
Both accept an optional absolute HTTP(S) Base URL; leave it blank for the
@@ -425,9 +419,9 @@ mode:
provider's existing request schema and authentication; custom headers or
incompatible parameter dialects are not supported by this path.
- **Activation is immediate:** a newly activated enabled profile becomes the
Provider/model for the next Cloud-proxy planner decision. A Cloud-planner
request fails closed until one enabled profile is active; it never falls back
to a Cloud API environment credential.
Provider/model/timeout for the next Cloud-proxy planner decision. A
Cloud-planner request fails closed until one enabled profile is active; it
never falls back to a Cloud API environment credential.
- **Trade-offs to accept before enabling:**
- *Latency*: every planning step now makes a round trip to the Cloud API in
addition to the LLM provider call.
@@ -435,13 +429,37 @@ mode:
Cloud API outages via retry/backoff), a planning step fails immediately if
the Cloud API or its configured provider is unreachable -- there is no
fallback to the stub planner or to a local direct call.
- *Expanded data path*: goal/scene prompts and screenshots now transit the
Cloud API. The endpoint logs only metadata (host id, resolved tool name,
latency, error class) and never prompt text or screenshot bytes, but the
request bodies themselves do cross the network to the control plane.
- *Expanded data path and retained history*: goal/scene prompts and
screenshots transit the Cloud API. Application logs retain only metadata
(host id, resolved tool name, latency, error class), but every successful
Cloud-proxy decision with task context is also stored as system prompt,
user prompt, resolved tool name, arguments, and step index. The Cloud
Console task detail exposes that history to authorized task readers. The
decision log never stores screenshot bytes; direct-transport Hosts produce
no Cloud-side LLM history.
- *Retention*: `CLOUD_PLANNER_DECISION_LOG_RETENTION_DAYS` defaults to `7`.
The Cloud prunes a terminal task's decision rows after that window;
`CLOUD_PLANNER_DECISION_LOG_PRUNE_INTERVAL_SECONDS` defaults to `3600`.
Prompt retention is therefore a deliberate operational and data-handling
choice, not merely transient request processing.
`AI_PLANNER_TRANSPORT` unset or `direct` preserves the existing
direct-to-provider behavior with no change.
### Direct transport (explicit opt-out)
Set `AI_PLANNER_TRANSPORT=direct` only for Hosts that must call a provider
without the Cloud proxy. Those Hosts hold their own provider credentials:
```text
AI_PLANNER_TRANSPORT=direct
AI_PLANNER_PROVIDER=anthropic
AI_PLANNER_MODEL=claude-sonnet-5
AI_PLANNER_TIMEOUT_SECONDS=30
ANTHROPIC_API_KEY=<secret manager reference>
```
For OpenAI, set `AI_PLANNER_PROVIDER=openai` and provide `OPENAI_API_KEY`.
Direct Hosts are not covered by Cloud token budgets or Cloud-side Provider key
rotation. `AI_PLANNER_TIMEOUT_SECONDS` controls the provider call only in this
direct mode.
### Host governance and Cloud-proxy token budgets
@@ -458,8 +476,10 @@ current UTC day. Set `CLOUD_PLANNER_TOKEN_RESERVATION_TTL_SECONDS` (default
`300`) to bound an unknown-usage reservation after provider/transport failure.
The daily Host budget must accommodate the reservation ceiling; otherwise the
proxy rejects before calling the provider. On a provider response, the
reservation is settled to reported usage and the Console retains only timestamp,
provider/model, token counts, and optional task/attempt identifiers.
reservation is settled to reported usage and the separate usage ledger retains
only timestamp, provider/model, token counts, and optional task/attempt
identifiers. This ledger is distinct from the bounded planner-decision history
described above.
Hosts reporting `AI_PLANNER_TRANSPORT=direct` are explicitly shown as
**unmetered**. Cloud cannot enforce or verify their provider token use. Do not
@@ -476,6 +496,29 @@ legacy Provider environment configuration, while a rollback to `direct`
transport requires valid provider credentials on that Host; preserve usage and
policy rows rather than deleting accounting history.
## Skill Management
Cloud-origin Skills are administrator-managed through the Cloud Console's
**Skills** view (requires the `skills:admin` scope, which administrators
hold via the `*` scope). Create/edit/delete skills (knowledge or flow-template
kinds), and grant or revoke per-host entitlement — an agent only ever sees the
cloud skills entitled to its own host.
Agents pull their entitled cloud skills incrementally from the Cloud API
(`GET /internal/v1/hosts/{host_id}/skills/sync`) on a configurable cadence
(`HOST_AGENT_SKILL_SYNC_INTERVAL_SECONDS`, default 300s) and cache them in a
local SQLite file (`tasks/skills.sqlite3`). Agents may also author their own
**local skills** (persisted in a separate `tasks/local_skills.sqlite3`) and
**override** a cloud skill locally via the MCP authoring tools; an override
shadows the cloud skill until removed, and forks into a standalone local skill
if the cloud entitlement is revoked. Agents report a best-effort read-only
inventory of their local skills to the Cloud so the Console can display them
per host.
The cloud skill store, entitlement mapping, per-host sync versioning, and
inventory readback live in the Cloud platform database (migration
`0010_skill_management`).
## Operational Limitations
Run exactly one scheduler-enabled Cloud API process. SQLite supports only the
@@ -497,6 +540,14 @@ device workflows to tolerate repeated actions when the target operation allows
it. Do not use this release for operations that require a transactional
exactly-once guarantee across the cloud database and an external device.
Task cancellation is collaborative, not instantaneous, for tasks that have
already left the queue. Cancelling a `queued` task takes effect immediately.
Cancelling an `assigned`/`dispatched` task only records the request; the
owning Host Agent learns about it at its next lease renewal (at most roughly
one third of `lease_duration_seconds`, the same interval used for lease-loss
detection) and then stops at the next cooperative checkpoint. As with lease
loss, an action already sent to a device cannot be rolled back mid-flight.
## Shutdown And Rollback
For a normal shutdown, stop Host Agents first so they stop polling, interrupt
+5 -2
View File
@@ -48,8 +48,11 @@ owned by `packages/cloud-platform` and may depend on the Runtime through an
explicit workspace source; the Runtime distribution must never depend on or
package `cloud`.
All Python members share the committed root `uv.lock`. The Vue/Vite `console/`
remains outside the Python workspace and keeps its independent npm lifecycle.
All Python members share the committed root `uv.lock`. Runtime is an
in-process execution library; its operator evidence view is the authenticated,
server-rendered Host Agent console rather than a separately packaged Runtime
service. The unrelated `cloud-console/` Vue/Vite application keeps its own
independent npm lifecycle.
## Change Discipline
+64 -129
View File
@@ -102,7 +102,7 @@ export WDA_BUNDLE_ID="com.<your-name>.WebDriverAgentRunner"
### 4.1 Homebrew、Python 和 Node.js
本项目当前在 `pyproject.toml` 中要求 Python `>=3.14`。Appium 3.1 要求 Node.js
本项目当前在 `pyproject.toml` 中要求 Python `>=3.13,<3.14`。Appium 3.1 要求 Node.js
`^20.19.0 || ^22.12.0 || >=24.0.0`,并要求 npm `>=10`
已安装 Homebrew 时执行:
@@ -123,7 +123,7 @@ npm --version
在仓库根目录执行:
```bash
uv python install 3.14
uv python install 3.13
uv sync --locked --all-packages
uv run --package device-agent-runtime python --version
uv run --package device-cloud-platform python -c "import cloud"
@@ -153,9 +153,13 @@ uv pip install \
```
这种降级安装不包含 OCR。`find_text` 和基于 OCR 的 screen description 可能返回空
结果,但 WDA 基础控制不受影响。不要为了兼容 PaddleOCR 擅自降到 Python 3.13,
因为当前项目元数据明确要求 Python 3.14;如需降级,应先作为独立兼容性变更修改
和验证 `pyproject.toml`
结果,但 WDA 基础控制不受影响。
项目已固定使用 Python 3.13(见 `pyproject.toml``requires-python`),原因是
`paddlepaddle` 在 PyPI 上尚未发布 Python 3.14 (cp314) 的 wheel,3.14 环境下无法
安装 `paddlepaddle`,会导致 OCR 相关功能在运行时报 `RuntimeError`。注意
`paddlepaddle` 本身并未作为 `paddleocr` 的声明依赖被 `uv sync` 自动安装,需要
在 3.13 环境下手动执行 `uv pip install paddlepaddle` 才能让 OCR 引擎真正可用。
### 4.3 安装 Appium 和 XCUITest Driver
@@ -269,81 +273,15 @@ uv run --package device-agent-runtime pytest -m integration tests/test_wda_integ
该测试只从环境变量读取 server URL、UDID 和 device name,不传签名 capabilities,
因此应在 WDA 已成功签名/安装后运行。
## 8. 启动可控制真机的 Runtime API
## 8. 独立 Runtime API 已撤销
当前不能只运行 README 中的普通 `uvicorn ... --factory` 命令,因为它只会加载已登记
设备,不会调用 `DeviceManager.connect()`。使用下面的启动方式,在同一进程中完成
设备注册、WDA 连接和 REST API 启动:
Runtime 现在是由 Host Agent 在进程内调用的执行库,不再提供 `api.rest`
端口 `8000``/ui/``/console/*`。不要再启动单独的 Runtime 服务,
也不要使用无鉴权的 REST 调用控制设备。
```bash
python - <<'PY'
import os
import uvicorn
from api.rest import create_app
from device.manager import DeviceManager
from driver.registry import build_driver_factory
device_id = "iphone-1"
connection_info = {
"server_url": "http://127.0.0.1:4723",
"device_name": "iPhone",
"udid": os.environ["DEVICE_UDID"],
"xcodeOrgId": os.environ["APPLE_TEAM_ID"],
"xcodeSigningId": "Apple Development",
"updatedWDABundleId": os.environ["WDA_BUNDLE_ID"],
}
manager = DeviceManager()
app = create_app(manager=manager)
manager.register_device(
device_id,
build_driver_factory("wda", connection_info),
name="Local iPhone",
driver_type="wda",
connection_info=connection_info,
)
manager.connect(device_id, max_retries=1)
uvicorn.run(app, host="127.0.0.1", port=8000)
PY
```
保持进程运行,在另一个 Terminal 验证:
```bash
curl -s http://127.0.0.1:8000/devices
curl -s -X POST http://127.0.0.1:8000/devices/iphone-1/tap \
-H 'Content-Type: application/json' \
-d '{"x": 100, "y": 200}'
curl -s -X POST http://127.0.0.1:8000/devices/iphone-1/launch \
-H 'Content-Type: application/json' \
-d '{"app_id": "com.apple.Preferences"}'
```
点击坐标必须按当前设备屏幕坐标选择。先截图或使用 Appium Inspector 确认坐标,避免
误操作。
如需启动 Web Console,保持 Runtime API 运行,再在第三个 Terminal 执行:
```bash
cd console
npm install
npm run dev
```
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`,不支持热更新。
真机连接和任务执行都由下一节的受管 Host Agent 完成。保持 §6 的 Appium 服务
可用,然后启动 Host Agent;本机执行记录、截图、OCR 和 UI 树均从其已鉴权的
Console 查看。
## 9. 启动云端受管 Host Agent
@@ -385,17 +323,29 @@ uv run --package device-host-agent device-host-agent setup
缓存身份不存在时,Host Agent 会直接向云端注册,由云端返回 `host_id`;后续运行
使用本地持久化的随机 Host secret。无需配置静态 Host 或 enrollment token:
Host Agent 的 Planner transport 默认是 `cloud`。启动前在 Cloud Console 创建并激活
LLM Provider profile;Provider API key 只由 Cloud API 加密保存,边缘 Host 不需要也
不应配置厂商 API key。`cloud` transport 下的 provider、model、base URL 与 timeout
均以激活 Profile 为准,Host 上的 `AI_PLANNER_*` Provider/model/timeout 配置不会覆盖
云端 Profile。
```bash
export HOST_AGENT_IDENTITY_PATH="tasks/host_identity.json"
export HOST_AGENT_DISPLAY_NAME="Edge Mac 01"
export AI_PLANNER_ENABLED="true"
export AI_PLANNER_PROVIDER="anthropic"
export ANTHROPIC_API_KEY="<secret>"
uv run --package device-host-agent device-host-agent
```
只有需要绕过 Cloud API 时,才显式设置
`AI_PLANNER_TRANSPORT=direct`,并在该 Host 上配置
`AI_PLANNER_PROVIDER``AI_PLANNER_MODEL` 与对应的厂商 API key。
Cloud transport 的成功规划决策会在云端按任务保存 system/user prompt、工具调用和步骤序号,
供有任务读取权限的 Cloud Console 用户排障;截图不会写入该云端决策记录。默认在任务终态
7 天后清理,具体配置和数据处理边界见 `docs/CLOUD_DEPLOYMENT.md` 的 Runtime AI Planner 一节。
首次启动顺序为:持久化候选 Host secret、向云端换取 `host_id`、为每个本地设备
换取 `device_id`、保存映射、连接 WDA、发送 heartbeat、开始 long-poll 领取任务。
Host Agent 会在本机回环地址提供 Console。必须保留并保护 `tasks/host_identity.json`
@@ -447,15 +397,16 @@ http://127.0.0.1:8765
`DeviceManager` 上生效,无需重启 Host Agent。
- 修改密码:更新本地操作账号密码,需要先输入当前密码。
- 最近历史:近期 assignment 与 heartbeat 的执行记录。
- **任务进度页面**`http://127.0.0.1:8765/tasks` 展示本机 Host Agent 上已执行/正在执行
的任务列表(状态、设备、时间戳),点击任务 ID 可查看逐步 timeline 含截图。
- **任务进度页面**`http://127.0.0.1:8765/tasks` 是本机实际执行任务的权威查看入口。
它展示已执行/正在执行的任务、Cloud task ID 与 attempt;点击执行 ID 可查看每步
timeline 的操作、前后截图、OCR 和 UI 树结果。
完整的 `HOST_AGENT_CONSOLE_*` 环境变量列表(端口、非回环 bind 的显式 opt-in、
session TTL、历史记录条数上限等)参见 `docs/CLOUD_DEPLOYMENT.md`;生产/远程场景下
应优先使用 SSH 端口转发访问该 Console,而不是直接把它暴露到非回环地址。
Host Agent 会把每步执行状态与截图持久化到本地 SQLite/文件系统,路径与 Runtime 自身的
`tasks/tasks.sqlite3` 不冲突
Host Agent 会把每步执行状态、前后截图、OCR 和 UI 树结果持久化到本地
SQLite/文件系统。该 Host Console 是这些实际执行证据的唯一 Web 查看入口
```bash
# 任务进度持久化路径(默认值,可通过环境变量覆盖)
@@ -465,64 +416,34 @@ Host Agent 会把每步执行状态与截图持久化到本地 SQLite/文件系
# HOST_AGENT_TASK_RETENTION_MAX_AGE_DAYS=7 # 超过 7 天的任务自动清理
```
Retention 策略取"数量上限与天数上限中更严格的"——即先按 `max_count` 取最近 N 个、
Retention 策略取"数量上限与天数上限中更严格的" - 即先按 `max_count` 取最近 N 个、
再按 `max_age_days` 过滤掉过老的,最终保留两者中较小的集合。任务完成后,这些记录
在 Console 的 `/tasks` 页面可查。
### 可选:由 Host Agent 托管 Appium 和 Runtime API
### 可选:由 Host Agent 托管 Appium
默认情况下 Host Agent **不会**自动启动 Appium 或本地 Runtime API:必须按
§6 在独立 Terminal 中保持 `appium --address 127.0.0.1 --port 4723` 运行,按
§8 在另一个 Terminal 中启动 Runtime API。忘记其中任意一个,Host Agent 不会报错,
heartbeat 仍会成功,但设备会静默保持 `offline`、所有任务卡在 `queued`
`host-agent-dependency-supervisor` 是一个可选模式,让 Host Agent 自己把这两个
外部进程作为子进程托管,覆盖单机真机工作流。它默认关闭,需要显式 opt-in:
默认情况下 Host Agent 不会自动启动 Appium;可继续按 §6 在独立 Terminal 中保持
`appium --address 127.0.0.1 --port 4723` 运行。也可以显式让 Host Agent 托管
Appium,避免忘记启动导致设备保持 `offline`
```bash
export HOST_AGENT_DEPENDENCY_SUPERVISOR_ENABLED="true"
# 任选其一或两者都开。两者默认 false。
export HOST_AGENT_APPIUM_SUPERVISED="true"
export HOST_AGENT_RUNTIME_SUPERVISED="true"
# 可覆盖默认地址/端口(默认值与 §6/§8 手动流程一致):
# export HOST_AGENT_APPIUM_HOST="127.0.0.1"
# export HOST_AGENT_APPIUM_PORT="4723"
# export HOST_AGENT_RUNTIME_HOST="127.0.0.1"
# export HOST_AGENT_RUNTIME_PORT="8000"
# 单次 Host Agent 进程生命周期内允许的最大重启次数,默认 5。
# export HOST_AGENT_DEPENDENCY_RESTART_MAX_ATTEMPTS="5"
uv run --package device-host-agent device-host-agent
```
启用后的行为(详见 `openspec/changes/host-agent-dependency-supervisor/`):
- **启动顺序**:Host Agent 在第一次 `connect_devices()` 之前,先按上面选中的
依赖项依次做"先探测后启动"。这样一旦开启,Appium 不再需要单独的 Terminal
- **Adopt-don't-fight**:探测 `(host, port)` 时若已经有进程在监听并通过健康检查
(Appium `GET /status` 返回 200 JSON,Runtime `GET /devices` 返回 200 JSON),
Host Agent 会以 *adopted* 方式记录日志,**不会**再 spawn 一个重复进程,也不会
在退出/崩溃时杀掉或重启它。如果端口被占但健康检查失败,记一条 port-conflict
错误并跳过该依赖,不抢端口、不静默继续。
- **崩溃重启**:只有 Host Agent 自己 spawn 出来的子进程才会被监控。子进程意外
退出时,按指数退避(1s、2s、4s、8s,封顶 30s)重启;当某个依赖在本进程生命
周期内累计达到 `HOST_AGENT_DEPENDENCY_RESTART_MAX_ATTEMPTS` 次重启后,停止
再次尝试直到 Host Agent 重启。被 adopt 的进程永远不会被 Host Agent 重启或杀死。
- **spawn 失败 ≠ crash**:如果 `appium` 可执行文件不在 `PATH` 上,启动会以
"dependency-supervisor: appium spawn failed — executable not found" 形式记一条
依赖主管特有的错误,与正常 crash 区分开。请确认按 §4.3 安装好 `appium`
XCUITest/UiAutomator2 driver。
- **退出时**:Host Agent 在自身 graceful shutdown 阶段只会 `terminate` 它自己
spawn 的子进程;adopted 进程保留不动。
- **不影响 Docker/Compose**:本模式只针对 macOS 单机真机工作流;
`compose.yaml` / `compose.deploy.yaml` 完全不受影响。
Host Agent 会先探测 Appium;健康实例会被 adopt 而不会重复启动或停止。仅由 Host
Agent 自己启动的 Appium 子进程会在异常退出后按有界指数退避重启,并在 Host Agent
正常关闭时 terminate。已移除的 `HOST_AGENT_RUNTIME_*` 变量会使启动明确失败;
不要再配置或启动独立 Runtime 服务
如果偏好保持对 Appium 终端日志的完全控制、或者已经在用其他进程管理工具
(launchd、systemd、tmux 等)托管 Appium,可以继续使用 §6/§8 的手动流程,
不开启本模式即可。
(launchd、systemd、tmux 等)托管 Appium,可以继续使用 §6 的手动流程,不开启
本模式即可。
## 10. 多设备与端口
@@ -606,6 +527,20 @@ Appium server 默认使用 4723;WDA 通常使用 8100。多设备必须为每
input、launch 和 UI tree 验证基础控制,再单独处理 PaddleOCR/PaddlePaddle 的 macOS
wheel 与 Apple Silicon 兼容性。
## MCP server (Hermes Agent integration)
Host-agent now exposes an MCP server on the same port as the local
console (`127.0.0.1:8765/mcp`). To drive your iPhone from Hermes Agent
or any MCP-compatible client:
1. Start host-agent normally.
2. Get the bearer token: `device-host-agent mcp-token`.
3. Configure Hermes per `docs/MCP_INTEGRATION.md`.
The MCP path reuses the same WDA session that the cloud worker uses.
Per-device locking prevents both sides from driving the same device at
once; see `docs/MCP_INTEGRATION.md` for the full concurrency model.
## 12. 完成检查表
- [ ] Xcode 能看到已解锁的 iPhone。
@@ -617,16 +552,16 @@ wheel 与 Apple Silicon 兼容性。
- [ ] `curl http://127.0.0.1:4723/status` 返回正常。
- [ ] 直接 Python 验证能生成 `/tmp/device-agent-runtime.png`
- [ ] 实机 integration test 通过。
- [ ] Runtime API 返回 `iphone-1`,并能执行 screenshot/tap/launch
- [ ] Host Agent Console 显示已连接设备,并能在 `/tasks` 查看一次完成任务的完整证据
- [ ] 如需 OCR,另行确认 PaddleOCR 在当前 Mac/Python 架构下可运行。
- [ ] 云端受管部署已保存 Host identity,并能在 `/v1/hosts``/v1/devices` 中看到。
## 13. 后续代码改进建议
为了让后续执行不再依赖内联 Python 启动脚本,建议另开变更实现:
为了让后续设备管理和验收更易操作,建议另开变更实现:
- 为 Console/REST 增加显式 connect/disconnect endpoint
- 增加正式 CLI,例如 `device-runtime serve --device-config ...`
- 为 Host Agent Console 增加显式 connect/disconnect 状态诊断
- 增加正式 CLI,用于校验 Host 的设备配置和 Appium 连通性
- 统一将遗留的 `APEX_WDA_*` 环境变量改名为 `DEVICE_RUNTIME_WDA_*`,并保留兼容期。
- 将 PaddleOCR 改成 optional dependency,拆分基础控制与 OCR 安装路径。
- 增加 macOS CI 的无真机 smoke test,以及受控环境中的真机验收脚本。
+125
View File
@@ -0,0 +1,125 @@
# Host-Agent MCP Server Integration
The host-agent process exposes a Streamable HTTP MCP server on the same
port as the local console (default `127.0.0.1:8765`), at path `/mcp`. This
lets any MCP-compatible client — Hermes Agent, Claude Desktop, custom
scripts using the `mcp` Python SDK — drive devices directly through the
same `DeviceManager` the cloud worker uses.
## Prerequisites
- Host-agent built from this repo (see `docs/MACOS_IPHONE_SETUP.md`).
- An MCP client that supports the Streamable HTTP transport (mcp SDK
1.20+ on the client side).
## Get the bearer token
The first time host-agent starts after this feature ships, it generates
a random bearer token and writes it to:
<identity_path.parent>/host_mcp_token.json
(Default: `tasks/host_mcp_token.json` next to `host_identity.json`.)
To print it for copy/paste:
device-host-agent mcp-token
To rotate: delete the file and restart host-agent. Old tokens stop
working immediately.
## Hermes Agent configuration
Add to `~/.hermes/config.yaml`:
```yaml
mcp_servers:
apex_device:
url: "http://127.0.0.1:8765/mcp"
headers:
Authorization: "Bearer <paste-token-here>"
```
Start (or restart) Hermes. Verify by asking Hermes to list devices:
> Use the apex_device MCP to list connected devices.
## Tools exposed
All 11 device tools from `api/mcp.py`:
- `take_screenshot(device_id?)`
- `tap(x, y, device_id?)`
- `swipe(start_x, start_y, end_x, end_y, duration_ms?, device_id?)`
- `input_text(text, device_id?)`
- `launch_app(app_id, device_id?)`
- `find_text(query, device_id?)`
- `find_icon(name, device_id?)`
- `get_ui_tree(device_id?, include_app_info?)`
- `describe_screen(device_id?)`
- `list_devices()`
- `device_status(device_id)`
## Concurrency model
- The cloud worker and MCP clients share the same `DeviceManager`.
- Per-device, session-level locking: the first caller (cloud or MCP) to
touch a device holds it; the other side sees a busy error.
- MCP sessions hold their lock until **20 seconds of inactivity**
(the `McpBusyTracker` default TTL). The mcp SDK 1.28.1 does not expose
a per-session shutdown callback, so a clean Hermes disconnect is also
recovered via the 20s TTL sweep — see the implementation note in
spec §6.5. Cloud assignments hold theirs until the assignment
terminates.
- The cloud scheduler is told about MCP-held devices via the heartbeat
`mcp_busy_device_ids` field, so it normally won't even try to dispatch
to them. A 30-second window exists between an MCP acquire and the next
heartbeat; during that window cloud may dispatch, and the host-agent
will fail-fast the assignment with `failure_reason="device held by an
active MCP session"`.
## Network binding
The MCP endpoint is bound to the same address as the local console. By
default this is `127.0.0.1` (loopback only). To expose on a different
interface, set `HOST_AGENT_CONSOLE_BIND_HOST` AND
`HOST_AGENT_CONSOLE_ALLOW_NON_LOOPBACK=true` — both are required. This
is the same escape hatch the local console uses; there is no MCP-only
override.
## Error responses
The mcp SDK 1.28.1 forces tool errors into `CallToolResult(isError=true,
content=[TextContent(message)])` — there is no public path that surfaces
JSON-RPC `-32000` with a structured `data.busy_owner` field from a tool
call site. The busy-owner value lives inside the text content (full
string for `cloud_assignment`, truncated session_id prefix for
`mcp_session:` collisions).
| Condition | JSON-RPC envelope | `result.content[0].text` |
|---|---|---|
| Missing/wrong bearer token | HTTP 401 (transport-level) | `{"error": "invalid token"}` + `WWW-Authenticate: Bearer` |
| Device busy (cloud) | `result.isError = true` | `"device <X> is busy (held by cloud assignment)"` |
| Device busy (other MCP) | `result.isError = true` | `"device <X> is busy (held by mcp_session:<8-char-prefix>)"` |
| Unknown device | `result.isError = false` | JSON `{"ok": false, "error": "device not found: <X>"}` |
| Tool error | `result.isError = true` | `"Error executing tool <name>: <original-message>"` |
## Troubleshooting
- **`list_devices` returns `[]`**: no devices registered. Use the local
console at `http://127.0.0.1:8765/` to add one (Login → Devices).
- **`device X is busy` even when cloud console says device is idle**:
check whether another MCP session is holding it. The local console
dashboard shows active MCP sessions and held device_ids.
- **Token verification fails after restart**: confirm you copied the
token from the current `host_mcp_token.json`, not an older one.
Rotation = delete file + restart.
## Out of scope (current version)
- `wait_until_usable` MCP tool: implemented internally but not exposed.
MVP callers must handle busy errors themselves.
- MCP call history in the local console: only current state is surfaced,
not a call log.
- Token rotation CLI: use delete-and-restart for now.
- Non-loopback binding without explicit opt-in.
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff

Some files were not shown because too many files have changed in this diff Show More