feat: checkpoint device agent runtime milestones

This commit is contained in:
2026-07-06 17:24:03 +08:00
parent 2d4251e98e
commit 5658735bca
153 changed files with 8060 additions and 65 deletions
@@ -0,0 +1,44 @@
## 1. Package scaffolding
- [x] 1.1 Create the `world/` package (`__init__.py`, `models.py`, `model.py`, `config.py`)
- [x] 1.2 Add `world*` to `[tool.setuptools.packages.find].include` in `pyproject.toml` (no new third-party dependency)
- [x] 1.3 Add World Runtime configuration in `world/config.py`: an enabled/disabled flag (default enabled) and a history-size bound (default 10), sourced from environment/config in one place `world/` reads from
- [x] 1.4 Extend the project's smoke test (that imports every package) to import `world`
## 2. WorldState data model (capability: world-model)
- [x] 2.1 Implement `world/models.py`: `WorldEvent` (`scene_summary: SemanticScene | Scene`, `action: str`, `success: bool`, `timestamp: datetime`) and `WorldState` (`current_app: str | None`, `current_page: str | None`, `variables: dict[str, Any]`, `history: deque[WorldEvent]`) dataclasses, with `to_dict()` mirroring the style of `core/models.py` (for future inspection/debugging use, not persistence in this change)
- [x] 2.2 Implement `WorldState.history` as a `collections.deque(maxlen=<configured bound>)` so oldest entries are evicted automatically once the bound is exceeded
- [x] 2.3 Write unit tests for `WorldState`/`WorldEvent` construction and for `history`'s bounded-eviction behavior (append past the configured `maxlen` and assert the oldest entry is gone, length stays at the bound)
## 3. WorldModel update hook (capability: world-model)
- [x] 3.1 Implement `world/model.py`: `WorldModel` owning one `WorldState` per task, with `observe(scene: Scene, semantic_scene: SemanticScene | None, step: PlannedStep, result: StepResult) -> None` as the single update entry point
- [x] 3.2 Implement the `current_page` update rule: set `current_page = semantic_scene.page` when `semantic_scene` is not `None` and its `page` is non-empty; leave unchanged otherwise
- [x] 3.3 Implement the `current_app` update rule: on a successful `StepResult` for `step.action in {"launch_app", "terminate_app"}`, set/clear `current_app` from `step.args` (e.g. `bundle_id`/`app_id`); leave unchanged for any other action or a failed result
- [x] 3.4 Implement the `variables` update rule: merge `step.args["remember"]` (a `dict`) into `WorldState.variables` when present; leave `variables` unchanged when absent
- [x] 3.5 Implement the `history` update rule: append one `WorldEvent` per call to `observe()`, using `semantic_scene` when available and falling back to `scene` otherwise
- [x] 3.6 Make every update rule defensive: missing/malformed expected fields (e.g. no `bundle_id` on a `launch_app` step, non-dict `remember` value) are logged and skipped, never raised, so `observe()` never raises for any input shape
- [x] 3.7 Write unit tests for `WorldModel.observe()` covering: page update from `SemanticScene`, page unchanged when `semantic_scene` is `None`, app update on successful `launch_app`, app unchanged on failed `launch_app` or unrelated actions, `variables` merge via `remember`, `variables` unchanged without `remember`, and history append/eviction across repeated `observe()` calls
## 4. TaskContext and TaskRunner integration (capability: world-model)
- [x] 4.1 Add `world: WorldState | None = None` field to `TaskContext` in `runtime/context.py`
- [x] 4.2 Add an optional `world_model: WorldModel | None = None` constructor argument to `TaskRunner` in `runtime/task.py`; when `None` and World Runtime is enabled in config, construct a default `WorldModel` internally; when World Runtime is disabled in config, leave `context.world` as `None` and skip the update hook entirely
- [x] 4.3 In `TaskRunner.run()`'s step loop, call `world_model.observe(scene, semantic_scene, step, result)` once per executed step, immediately after the existing `context.add_step_result(result)` line, and refresh `context.world` from the model's current `WorldState`
- [x] 4.4 Confirm `TaskRunner.run()`'s existing control flow (retry/failure/max-steps handling) is unaffected by the new hook call — the hook must never change whether a step is treated as success/failure
- [x] 4.5 Write unit tests for `TaskRunner` covering: `context.world` populated after a step when World Runtime is enabled (default), `context.world` remaining `None` when explicitly disabled via config, and a task run completing normally (unchanged pass/fail outcome) whether World Runtime is enabled or disabled
## 5. Planner integration (capability: world-model)
- [x] 5.1 Add an optional `world: WorldState | None = None` keyword argument to `Planner.plan()` in `runtime/planner.py`; the existing stub `Planner` implementation accepts but does not use it
- [x] 5.2 Update `TaskRunner.run()`'s call to `self.planner.plan(...)` to pass `world=context.world`
- [x] 5.3 Write a unit test asserting the existing stub `Planner.plan()` call sites (with and without a `world` argument) both continue to return the same steps as before this change
- [x] 5.4 Write a unit test asserting `TaskRunner` passes the current `context.world` into `Planner.plan()`'s `world` argument by the second step of a multi-step task (using a custom test `Planner` subclass that records the `world` value it was given)
## 6. End-to-end validation
- [x] 6.1 Write an end-to-end test running a multi-step task through `TaskRunner` with a mocked `Driver`/`Scene`/`SemanticScene` sequence, asserting `WorldState.current_app`/`current_page`/`history` reflect the expected values after each step
- [x] 6.2 Write an end-to-end test confirming World Runtime tracking failure modes (missing `SemanticScene`, missing expected step args) never fail or interrupt the task loop, only leave the corresponding `WorldState` field unchanged
- [x] 6.3 Confirm World Runtime tracking is enabled by default after applying this change, and that disabling it via config fully restores pre-this-change `TaskContext`/`Planner.plan()` call behavior (no `world` state populated or passed)
- [x] 6.4 Run the full test suite (`pytest`) and confirm no existing test in `tests/` needed a behavior change, only additive new tests