18 KiB
Context
runtime/task.py's TaskRunner.run() is the Observe→Think→Act→Observe loop (agent-runtime capability, apex-agent-mvp, code-complete but unapplied): each iteration calls self.observer(device_id) to get a Scene, appends it to TaskContext.scenes, calls self.planner.plan(goal=..., scene=..., context=...), executes each PlannedStep via Executor.execute(), and appends the StepResult to TaskContext.step_results. TaskContext (runtime/context.py) is a flat dataclass — scenes: list[Scene], step_results: list[StepResult] — with no derived/summarized state; context.latest_scene is the only convenience accessor today. semantic-scene-runtime (Milestone 5, drafted alongside this change) adds enrich_scene(scene) -> SemanticScene | None, a same-step, non-persisted artifact — its own design.md explicitly leaves open "should SemanticScene be recorded in TaskContext/the timeline" as a question for this milestone to answer. Today's Planner is a two-step stub (runtime/planner.py) that emits one hardcoded describe_screen step and declares the goal reached once any step has succeeded — it does not yet reason about page identity at all, so nothing currently consumes anything like "have I already navigated here." A future LLM-driven Planner is the actual reason this capability needs to exist: without a persisted, bounded summary of "where am I / what have I already established," a real Planner has no way to skip re-navigating to a screen it visited two steps ago except by re-scanning the entire scenes/step_results history and re-deriving semantics itself, every single step.
Two stakeholders: runtime/task.py's TaskRunner (must gain a well-defined, optional integration point without changing behavior when WorldModel is not configured), and a future LLM-driven Planner (not built in this change) that will be the actual consumer reading WorldState to make cheaper plans.
Goals / Non-Goals
Goals:
- Maintain one
WorldStateper task run —current_app,current_page, avariablesdict, and a boundedhistoryof recent semantic-scene/action pairs — updated incrementally after each executed step. - Make the update purely a derivation over data the loop already produces (the just-observed
Scene, the optionalSemanticScene, thePlannedStep, and itsStepResult) — no new I/O, no new LLM call, no new external dependency introduced by this change. - Keep
historygenuinely bounded (a fixed-size ring buffer) so a long-running task (up toTaskRunnerConfig.max_steps, currently 20, but not contractually capped) cannot growWorldStatewithout limit. - Expose
WorldStateto the Planner strictly read-only and additively —Planner.plan()gains an optional keyword argument with a safe default, so every existing caller and test keeps working unmodified. - Make
WorldModeldegrade sensibly whenSemanticSceneis absent (semantic enrichment disabled, persemantic-scene-runtime's default-disabled config) — page/app tracking falls back to a raw-Scene/action heuristic rather than going stale or raising.
Non-Goals:
- No cross-task or cross-device world sharing —
WorldStateis scoped to exactly oneTask/TaskContextand is discarded when the task ends; a follow-on "world persistence" or "world sharing across a device's tasks" capability is a separate future decision, not made here. - No UI for viewing or editing world state — that belongs to
web-console'sconsole-status-api/console-config-api/web-console-uicapabilities (a separate pending change), not touched here. - No real LLM-driven Planner — this change only makes
WorldStateavailable as a Planner input; teaching a Planner to actually use it (e.g. skip a navigation step becausecurrent_pagealready matches) is future Planner-capability work, out of scope here. - No second LLM call to summarize/update world state — updates are deterministic rules over already-available per-step data, not a new enrichment call (see D2).
- No persistence of
WorldStateintostorage/timeline.pyortask-memory's timeline format —WorldStatelives only in the in-memoryTaskContextfor the duration of a run (see Open Questions).
Decisions
D1: New world/ package, sibling to semantic/, not folded into runtime/context.py
WorldState/WorldModel get their own package (world/models.py, world/model.py, world/config.py) rather than growing runtime/context.py in place. TaskContext gains only a thin world: WorldState | None field pointing at an object world/ owns and updates; the update rules (how current_page is derived from a SemanticScene, how history eviction works) live in world/model.py, not in runtime/. This mirrors semantic-scene-runtime's own D1 (a sibling package rather than folding into the layer below) and keeps runtime/ focused on orchestration (loop shape, retries, tool dispatch) rather than accumulating every future kind of derived state inline.
Alternative considered: add current_app/current_page/variables/history fields directly onto TaskContext and put the update logic in runtime/task.py's TaskRunner.run(). Rejected — TaskContext would become a grab-bag mixing raw per-step history (scenes, step_results, owned by agent-runtime) with derived cross-step summarization (owned by this new capability); a future World-related change (persistence, cross-task sharing) would then have to reach into runtime/ internals instead of a self-contained world/ package, repeating the exact "grab-bag package" problem device-agent-runtime-foundation already diagnosed and fixed for core/.
D2: Deterministic rule-based update, not a second LLM call
WorldModel.observe(scene, semantic_scene, step, result) is pure Python: if semantic_scene is present, current_page = semantic_scene.page; current_app is refreshed whenever step.action in {"launch_app", "terminate_app"} succeeds, using step.args["bundle_id"]/step.args["app_id"] (falling back to leaving current_app unchanged if the action fails); variables are updated only when step.args contains an explicit "remember" mapping ({key: value}) a Planner opted into; history appends one WorldEvent(semantic_scene_or_scene, step.action, result.success) per call and evicts the oldest entry once past the configured bound. No network call, no additional latency, no new failure mode beyond "field stays stale if inputs are absent."
Alternative considered: a second structured-output LLM call (reusing semantic-scene-runtime's llm_client.py pattern) that looks at the accumulated history and produces a compact world-state summary each step, similar to how SemanticScene itself is produced. Rejected for this milestone — doubling the per-step LLM call count (one for SemanticScene, one for WorldState) doubles latency/cost for a problem (bookkeeping already-known fields) that does not need model reasoning to solve; a rule-based derivation over the same SemanticScene/PlannedStep/StepResult data that already exists is sufficient and strictly cheaper. This can be revisited if a future milestone's Planner needs richer world summarization than simple field-tracking + bounded history provides.
D3: WorldState exposed via TaskContext.world, not a second context object threaded separately
TaskRunner sets context.world = world_model.state once (or updates it in place) rather than introducing a WorldContext parameter threaded alongside TaskContext through Planner.plan()/Executor.execute(). Planner.plan()'s new world: WorldState | None = None keyword is a convenience mirror of context.world (some future Planner implementations may prefer an explicit parameter over reaching into context), but the source of truth is always TaskContext.world.
Alternative considered: keep WorldState entirely outside TaskContext, passed as a wholly separate argument to Planner.plan()/Executor.execute()/TaskRunner.run(). Rejected — agent-runtime's existing requirement is "task context/memory available during a run" as one accessible object; splitting per-run state across two parallel objects (TaskContext for raw history, a bare WorldState for derived summary) makes every future call site that needs "everything about this run" thread two parameters rather than one, for no benefit since WorldState is inherently TaskContext-scoped (one task, one world) anyway.
D4: Bounded history via a fixed-size ring buffer, not unbounded list or time-based eviction
WorldState.history: deque[WorldEvent] is a collections.deque(maxlen=N) with N from world/config.py (default 10), so the oldest event is dropped automatically once the buffer is full — no separate cleanup pass, no unbounded memory growth even if a task runs far beyond TaskRunnerConfig.max_steps's current default of 20 (e.g. if a future change raises that ceiling).
Alternative considered: keep an unbounded list[WorldEvent] (simplest, matches TaskContext.scenes/step_results which are also unbounded lists today). Rejected — scenes/step_results are raw historical record (their unboundedness is intentional, mirroring the timeline), whereas history here exists specifically to give the Planner a recent window without asking it to reason about a growing list; an explicit bound keeps the read-only Planner-facing contract ("recent" is a stated, fixed size) rather than an implicit "whatever the task length happens to be."
D5: World Runtime tracking defaults to enabled (unlike Semantic Scene's default-disabled enrichment)
world/config.py's enable flag defaults to True, and TaskRunner's optional world_model argument, when left None, still gets a default WorldModel constructed internally (not skipped) unless the config flag is explicitly off. This differs from semantic-scene-runtime's enrichment, which defaults to disabled specifically because it makes a paid network call every step.
Alternative considered: default World Runtime to disabled as well, for consistency with the Semantic Scene precedent and to minimize behavior change on apply. Rejected — WorldModel.observe() has no network call and no meaningful cost/latency (it is a few dict/deque operations over data the loop already holds), so the reason Semantic Scene defaults off (protect cost/latency-sensitive environments from a silent new LLM bill) does not apply here; defaulting on means the capability's read-only benefit is available to a Planner immediately, and an explicit off-switch remains for anyone who wants to opt out (e.g. a test asserting exact TaskContext shape pre-this-change).
D6: Planner.plan() gains an optional world kwarg with a safe default; the existing stub Planner ignores it
runtime/planner.py's Planner.plan(self, *, goal, scene, context, world=None) accepts but does not use world — the existing stub planner's behavior (one hardcoded describe_screen step, then done) is unchanged. This is intentionally a no-op integration point in this change: making a Planner smart enough to skip steps using WorldState is real LLM-Planner work reserved for a future milestone/change, not implied by adding the parameter.
Alternative considered: leave Planner.plan()'s signature untouched and require future Planner implementations to read context.world directly instead of a dedicated parameter. Rejected — an explicit world parameter documents, at the call boundary, that world-state read access is a first-class, expected part of planning (matching how scene/context are already explicit parameters rather than everything being reached off one context blob), which matters for future callers/tests constructing a Planner subclass against a stable, self-documenting signature.
Risks / Trade-offs
- [Risk] A rule-based
current_app/current_pagederivation can drift from reality faster than an LLM-based one would (e.g. a page transition not captured by anylaunch_app/terminate_appaction, orSemanticScene.pagephrased inconsistently step to step sincesemantic-scene-runtime's Open Questions leave intents/page labels open-vocabulary) → Mitigation:WorldStateis documented and enforced as read-only, advisory context for the Planner, never a source of truth the Executor trusts blindly; a future Planner consuming it is expected to still verify against the currentScene/SemanticScenebefore skipping a step, not skip purely on staleWorldStatesays-so. This change does not build that Planner logic, only the state it would read. - [Risk] Enabling World Runtime by default (D5) means every existing
TaskRunnercaller/test that doesn't explicitly configureworld_model=Nonestarts accumulatingWorldStateit previously didn't have → Mitigation: this is additive-only (TaskContext.worldis a new field with no effect onscenes/step_results/existing assertions), the update hook cannot raise (all derivation is defensive: missingSemanticScene, missingstep.argskeys, etc. are all "no-op, leave field unchanged," never an exception), andworld/config.pyprovides an explicit off-switch for any caller/test that wants exact pre-this-changeTaskContextshape. - [Risk]
variables'sstep.args["remember"]convention has no schema/validation — a Planner could stuff arbitrary or unbounded data intovariablesover many steps → Mitigation: out of scope to solve generally in this change (no Planner exists yet that writes to it);world/config.pycan later gain a max-variables-size guard if a real Planner's usage pattern demands it, but there is no real caller to validate against yet, so adding a limit now would be speculative. - [Trade-off]
WorldStateis not persisted (Non-Goal) — if a task fails partway and is retried, or iftask-memory's timeline is later replayed, the accumulated world state from the failed run is not recoverable, only re-derivable from scratch on the retry → acceptable per this milestone's explicit scope (single task's world only); persistence is atask-memory-capability change to propose separately if needed, matching howsemantic-scene-runtimedeferred the same question for its own artifact. - [Trade-off] Choosing rule-based derivation (D2) over LLM-based world summarization (rejected alternative) means
WorldState's quality is bounded by how goodSemanticScene.page/action-name heuristics are — if a future milestone finds this insufficient (e.g. genuinely needs to reason "the user probably navigated back," not just track the last known page), upgrading to an LLM-based summarizer is aworld/model.py-internal change, not aWorldStateshape change, since the shape (current_app/current_page/variables/history) is intentionally derivation-method-agnostic.
Migration Plan
This is purely additive with optional, defensively-defaulted integration points:
- Add the
world/package (models.py:WorldState,WorldEvent;model.py:WorldModel.observe();config.py: enable flag + history size). - Add
TaskContext.world: WorldState | None = Nonetoruntime/context.py. - Add an optional
world_model: WorldModel | Noneconstructor argument toTaskRunner(runtime/task.py); whenNoneand World Runtime is enabled in config,TaskRunnerconstructs a defaultWorldModelinternally; callworld_model.observe(scene, semantic_scene, step, result)once per executed step, immediately after the existingcontext.add_step_result(result)line, and set/refreshcontext.worldfrom the model's current state. - Add the optional
world: WorldState | None = Nonekeyword argument toPlanner.plan()(runtime/planner.py);TaskRunnerpassescontext.worldthrough; the existing stubPlannerimplementation ignores it (no behavior change). - Add
world*topyproject.toml's[tool.setuptools.packages.find].includelist; no new third-party dependency (no LLM SDK, no new I/O library —collections.dequeis stdlib). - Run
pytest— the full existing suite must remain green with zero test-content changes (only additive assertions in newtests/test_world_model.py-style tests are expected, not edits to existing tests), confirming the default-enabled World Runtime tracking does not alter any existing task-completion/failure behavior. - Rollback: since this is a net-new package plus two small additive fields/kwargs with safe defaults, reverting is
git revertof the commit(s); no data migration, no persisted format touched, no external system involved.
Open Questions
- Whether
WorldStateshould eventually be persisted intostorage/timeline.py's entries (so a replayed/inspected task shows what the runtime "believed" at each step, not just the rawScene/tool-call/result) — left open per this change's Non-Goals; would be atask-memory-capability change to propose separately once there is a concrete consumer (e.g.web-console's status API wanting to show "current believed page"). - Whether
variablesneeds any validation, size bound, or namespacing convention once a real Planner starts writing to it via the"remember"args convention — deferred until a real Planner (a future milestone) exists to observe actual usage patterns against. - Whether a future Skill-synthesis milestone would want
WorldState.history's bounded window to be configurable per-task rather than one global default — left as a global-default-only setting for this milestone, matchingsemantic-scene-runtime's equivalent "leaning global-default-only" stance on its own model-selection config. - Whether
current_pagederivation should fall back to something richer than "last known value, unchanged" whenSemanticSceneis absent (e.g. a raw-Scene-shape heuristic keyed on element text/layout signature) — this change only specifies the fallback as "leave unchanged," which is safe but potentially stale; worth revisiting once semantic enrichment's real-world enable rate is known.