16 KiB
Context
apex-agent-mvp is code-complete (35/38 tasks; the remaining 3 require a physical iPhone and are unaffected by this change) but was never archived — openspec/specs/ is still empty, so its capability specs (device-management, scene-perception, agent-runtime, task-memory, mcp-tool-server) exist only as pending deltas under openspec/changes/apex-agent-mvp/specs/. The working codebase already reflects a mostly driver-agnostic design: Driver is an ABC with no framework types leaking through, WDADriver is the only implementation, DeviceManager tracks devices by an opaque driver_type string, and Scene (not raw XML/OCR boxes) is the only thing the LLM-facing tools ever touch. The gaps are cosmetic-but-real: the package holding Driver+DeviceManager is called core/ (a grab-bag name), the perception package is called vision/ (names the technique, not the capability), the shared error base is literally ApexAgentError, and the one true "pick a driver by type" extension point (SUPPORTED_DRIVER_TYPES in api/console.py) lives in the API layer instead of the driver layer. Two other changes (skill-catalog-subscription, web-console) are proposed but unapplied and untouched by this change.
Two stakeholders: the LLM/agent runtime consuming the driver/device/tools/perception layers (must see zero behavior change), and future contributors/AI coding agents who will read docs/CONSTITUTION.md and docs/ROADMAP.md before adding the next driver or milestone.
Goals / Non-Goals
Goals:
- Zero behavior change: every existing test passes after the move with only import-path edits, not logic edits.
- Make the physical package layout match the mental model:
driver/(interface + implementations),device/(lifecycle/registry of devices, not drivers),core/(truly shared models/errors only). - Give driver-type registration a home in
driver/so a second driver (Android, browser, ...) never requires touchingapi/console.py. - Give perception-technique registration the same treatment via a
PerceptionProviderport, so OCR is one swappable implementation, not the pipeline's identity. - Produce durable planning artifacts (
ROADMAP.md, two ADRs,CONSTITUTION.md,research/scaffold) that later milestone changes can be scoped against, instead of re-deriving architecture context fromapex-agent-mvp/design.mdeach time. - Codify a Hexagonal (Ports-and-Adapters) + DDD layering as the project's governing architecture, with an explicit dependency direction (domain has zero framework/LLM/HTTP dependencies; adapters depend on domain, never the reverse) and a bottom-up build order that future milestone changes are expected to respect:
core(domain) →driver/device(adapters) →tools(capability/ports layer) →perception(Scene, mockable) →storage→runtime(application/orchestration) → real perception techniques → LLM-backed planning →api(outermost adapter).
Non-Goals:
- No second driver implementation (Android/browser/Windows) — only the extension point moves.
- No changes to
skill-catalog-subscriptionorweb-consoleproposals/specs. - No change to
agent-runtime,task-memory, ormcp-tool-serverbehavior/specs — only their import paths shift if they referencecore.driver/core.device_manager/vision.*. - No archiving of
apex-agent-mvpas part of this change — that remains a separate decision for the user (real-device verification tasks 7.1–7.3 are still outstanding). Noteapex-agent-mvpalready bundled Perception (real OCR), Planning (LLM), and API/MCP into one MVP change, ahead of the layering this change now codifies — that is accepted as-is (not rebuilt or reordered); the layering/build-order constraint below governs future milestone changes (Semantic, World, Skill, Workflow, Agent, Cloud Runtime), not a retroactive rewrite of already-shipped code. - No CI/lint pipeline changes beyond what's needed to keep
pytestgreen — mechanical enforcement of the layering (e.g. import-linter contracts) is explicitly deferred, not done now.
Decisions
D1: Split core/ into driver/ + device/, keep core/ as a shared-models-only package
driver/base.py gets the Driver ABC (from core/driver.py); driver/wda_driver.py gets WDADriver (from core/wda_driver.py); device/manager.py gets DeviceManager/DriverFactory/DEFAULT_MANAGER (from core/device_manager.py). core/models.py (Bounds, Device, SceneElement, Scene, Task, Step) and core/errors.py stay in core/, since they're shared across driver, device, perception, runtime, and storage — not device- or driver-specific.
- Alternative considered: Eliminate
core/entirely and push models/errors into whichever package "owns" them most (e.g.Scene/SceneElement→perception/,Task/Step→runtime/). Rejected for this change — it would forcedevice/to import fromperception/andruntime/to import fromperception/, creating cross-package coupling that doesn't exist today; splitting shared models is a legitimate follow-up but is a behavior-neutral naming change's job to avoid, not force.
D2: Rename vision/ → perception/
Straight package rename, same file names inside (ocr.py, ui_parser.py, scene_builder.py, icon_detector.py). No merging or splitting.
- Alternative considered: Keep
vision/and only rename the pipeline concept in docs. Rejected — the proposal's own roadmap explicitly calls this stage "Perception," and leaving the package named after one input signal (OCR/vision) whilescene_builder.pyalready fuses tree+OCR+vision is the exact naming drift this change exists to fix.
D3: Driver Registry moves into driver/registry.py, api/console.py imports from it
New driver/registry.py holds SUPPORTED_DRIVER_TYPES: dict[str, DriverFactoryBuilder] and build_driver_factory(driver_type, connection_info), moved verbatim from api/console.py (currently lines ~17-50). api/console.py imports build_driver_factory from driver.registry instead of defining it. Adding AndroidDriver later means adding one entry to driver/registry.py, not touching api/console.py.
- Alternative considered: Leave the registry in
api/console.pyand only document the intent to move it later. Rejected — this is precisely the kind of small, cheap, high-leverage structural fix Milestone 0 exists to make before a second driver shows up and someone has to thread a device-agnostic factory builder through an API-layer dict under time pressure.
D4: Rename ApexAgentError → DeviceRuntimeError
core/errors.py's base class and its subclasses (DriverError, DeviceNotFoundError, DeviceOfflineError, DeviceBusyError, ElementNotFoundError, TaskFailedError) keep their names; only the base changes from ApexAgentError to DeviceRuntimeError. All except ApexAgentError / raise ApexAgentError call sites across api/, tools/, runtime/, tests/ update accordingly.
- Alternative considered: Keep
ApexAgentErroras a deprecated alias (ApexAgentError = DeviceRuntimeError) for backward compatibility. Rejected — there are no external consumers of this exception type yet (nothing has shipped), so a compatibility shim adds dead code for a compatibility need that doesn't exist.
D5: Rebrand via pyproject.toml + root README.md, not a source-wide string sweep
pyproject.toml's name becomes device-agent-runtime and a description field is added; a root README.md (currently absent) states the new positioning and links to docs/ROADMAP.md. apex-agent-mvp/proposal.md and design.md are left untouched as a historical record of the original framing — they describe a change that already happened, and rewriting history in a planning artifact adds no value.
- Alternative considered: Grep-and-replace every "Apex Agent" / "IPA" string across existing
openspec/changes/apex-agent-mvp/**docs. Rejected per the chosen change scope (foundation docs + restructure only, not reconciling other pending changes) — those files are a record of a past decision, not living documentation.
D6: Roadmap, ADR, Constitution, and research/ track are pure additions, no code coupling
docs/ROADMAP.md, docs/adr/0001-device-agnostic-runtime.md, docs/CONSTITUTION.md, and research/00N-*/README.md are net-new Markdown files with no imports from or references into runtime code, so they carry zero risk to the passing test suite.
- Alternative considered: Encode
docs/CONSTITUTION.md's invariants as enforced lint rules (e.g. import-linter contracts forbiddingtools/from importingdriver/directly). Deferred, not rejected — worth doing once there's a second driver to actually violate the boundary against; adding enforcement machinery for a boundary nothing has crossed yet is premature for a foundation change.
D7: Adopt Hexagonal + DDD layering as the governing architecture, recorded in a second ADR
docs/adr/0002-layered-hexagonal-architecture.md records the dependency direction and build order as project law: core (domain: Bounds/Device/Scene/Task/Step/errors) has zero framework, LLM, or transport dependencies; driver/device are adapters translating external device SDKs (Appium/WDA today) into the domain's Driver contract; tools is the capability/port layer the Agent Runtime calls; perception produces Scene behind the PerceptionProvider port (D8); storage persists timeline/task state; runtime (Planner/Executor) is the application layer that orchestrates the above; only at runtime's Planner does an LLM enter the picture, and only api (REST/MCP) is HTTP/MCP-transport-aware. This is recorded as a standing constraint for future milestone changes (Semantic, World, Skill, Workflow, Agent, Cloud Runtime): none of them may introduce LLM or transport dependencies into core, driver, device, or tools.
- Alternative considered: Leave this as implicit convention (as it already mostly was) rather than a written ADR. Rejected — the explicit trigger for writing it down now is that
apex-agent-mvpalready shipped Perception+Planning+API bundled in one MVP change, i.e. the layering was followed loosely, not strictly by construction; upcoming AI-heavy milestones (Semantic Scene's LLM call, Skill embedding retrieval, Multi-Agent roles) are exactly where it's cheapest to keep LLM/transport concerns out of the domain/adapter layers before they're written, not after.
D8: PerceptionProvider port mirrors the Driver Registry pattern (D3)
perception/provider.py defines a PerceptionProvider ABC with one method, build_scene(screenshot, tree) -> Scene. The existing OCR+tree fusion in scene_builder.py is wrapped as the default implementation (registered, not rewritten — same behavior, same Scene output). A NullPerceptionProvider returning an empty Scene (correct width/height, no elements) is added for tests and any environment without OCR dependencies installed. tools/describe_screen.py and runtime/ depend on the port type, not on scene_builder directly.
- Alternative considered: Skip the port and let callers import
scene_builder.build_scene()directly (as today), documenting only that conceptually it's swappable. Rejected — the whole point of D7's constraint is that swapping/mocking perception must be a real, exercised code path (useful today for fast tests without OCR installed) rather than an aspiration that nothing currently proves; this mirrors why the Driver Registry (D3) was made a real registry instead of a documented convention.
Risks / Trade-offs
- [Risk] Renaming
core.driver/core.device_manager/vision.*import paths across 37 files by hand risks missing one and breaking an import at runtime rather than at test time → Mitigation: run the fullpytestsuite (all oftests/) after the move as the acceptance gate intasks.md; a missed import surfaces immediately as a collection error, not a silent behavior change. - [Risk] Moving
SUPPORTED_DRIVER_TYPES/build_driver_factoryout ofapi/console.pycould breaktests/test_console_api.pyif it patches/imports those names directly → Mitigation: checktest_console_api.py's imports as part of the move and update them alongside the production code, in the same task. - [Risk] Wrapping
scene_builder.pybehindPerceptionProvidercould subtly change its return value if the wrapper reshapes data → Mitigation: the default provider must call the existingscene_builderfunction unmodified and return its result as-is;tests/test_scene_builder.pypassing unchanged is the acceptance check, not a new test suite. - [Trade-off] Codifying D7's layering as an ADR/constitution now, without lint enforcement, means it's a convention future contributors (human or AI) must read and follow, not something that fails a build if violated → acceptable per D6's reasoning: enforcement machinery is worth adding once there's a concrete violation to enforce against (e.g. when a Semantic Scene change is tempted to call an LLM SDK from within
core), not preemptively. - [Trade-off] Keeping
core/alive (rather than eliminating it) means the package boundary story is "driver / device / core (shared) / perception / runtime / storage / api" — one more package than the roadmap's illustrative "driver/device/runtime" — acceptable because this codebase already has more layers than the roadmap's Milestone-1-only sketch assumed, and forcing models into whichever package "feels right" would create the cross-package coupling described in D1. - [Trade-off] Not archiving
apex-agent-mvpas part of this change meansopenspec/specs/stays empty and this change's own capability (driver-registry) has no prior baseline to diff against — acceptable sincedriver-registryis genuinely new (ADDED, not MODIFIED) and archival ordering is the user's call, not a blocker for this change's own correctness.
Migration Plan
- Create new packages/files (
driver/,device/,perception/) viagit mv(preserves history) rather than copy+delete. - Update every import site (37 files) from
core.driver/core.device_manager/vision.*to the new paths; renameApexAgentError→DeviceRuntimeErrorat all call sites. - Add
perception/provider.py(PerceptionProvider,NullPerceptionProvider, default provider wrappingscene_builder.py); pointtools/describe_screen.py/runtime/at the port. - Move
SUPPORTED_DRIVER_TYPES/build_driver_factoryintodriver/registry.py; updateapi/console.py's imports. - Update
pyproject.toml(name,description,packages.find.include). - Run
pytest— must be 100% green with no test-content changes (only import updates insidetests/itself where needed). - Add
README.md,docs/ROADMAP.md,docs/adr/0001-device-agnostic-runtime.md,docs/adr/0002-layered-hexagonal-architecture.md,docs/CONSTITUTION.md,research/00N-*/README.md. - Rollback: since every step is a rename/move plus additive docs with no data migration, reverting is
git revertof the commit(s); no runtime state or external system is touched.
Open Questions
- Whether
core/should eventually be renamed too (e.g.shared/ordomain/) once it's clearer which models belong to which future layer (World Model in Milestone 6 will likely want to own more ofcore/models.py) — left open, not blocking this change. - Whether
docs/CONSTITUTION.mdshould later be enforced mechanically (import-linter, custom lint rule) once a second driver exists to validate the boundary against — deferred to a future change per D6. - Whether the Semantic Scene (Milestone 5), World Model (Milestone 6), and Skill Learning (Milestone 7) changes — currently being drafted in parallel — correctly keep their LLM/embedding calls confined to a
runtime-level (or new dedicated) layer rather thanperception/core, per D7's constraint; worth a follow-up read once those changes are drafted.