SmartWay on AgentCanvas — paper analysis + smartway port
SmartWay (25.03; Shi et al., IROS 2025) is a zero-shot VLN-CE agent: a TRM waypoint predictor proposes candidate (angle, distance) moves from the panorama, RAM+ object tags enrich each candidate's description, and a backtracking return option lets the LLM undo a mistaken move. This page documents the decomposed port — the 7-node smartway reasoning nodeset plus the smartway_waypoint server sibling and generic FM wrappers (model_dinov2, model_ram ram_plus) driving env_habitat continuous navigation in smartway_ce.json. The validated 3-node monolith (smartway_mono) remains the equivalence ground truth.
| At a glance | |
|---|---|
| Upstream | SmartWay (25.03), IROS 2025 — repo sxyxs/SmartWay-Code pinned @ daa2dd8 (MIT). Adelaide Qi Wu group paper — this is a side-experiment port, deliberately excluded from PortBench v1 (author-relationship constraint). |
| Env consumed | env_habitat — action space hightolow (step_hightolow; (0,0) = canonical STOP), obs space panorama (n_views=12), reset / evaluate (gym-like contract) |
| FM consumed | built-in llmCall — reference graph pins gpt-5-mini · temp 1.0 · max_tokens 2000 · response_format=json_object (frozen by the AAS constraints file; upstream pinned gpt-4o) |
| Method nodesets | smartway (7 local reasoning nodes) + smartway_waypoint__predict (server: vendored TRM predictor over wired DINOv2 features; ac-smartway env). FMs consumed as generic wrappers since the TODO #56 extraction (2026-07-04): model_dinov2__extract_features (ac-smartway) + model_ram__tag_views (ram_plus @ 384, ac-ram env — cross-env parity verified 12/12) |
| State | 9 lastWrite graph_state entries — 7 episode lifetime + 2 step-lifetime scratch (last_picked_*); single-writer-per-key after the decomposition |
| Graph | workspace/graphs/vln/verified/smartway_ce.json (21 nodes, 40 edges); sibling monolith verified/smartway_mono_ce.json (promoted 2026-07-05 via interface-equivalence audit; equivalence ground truth, test_equivalence.py + test_graph_interface.py) |
| Fidelity | faithful with justified deviations (audited 2026-06-11, inventory-first): task_description byte-identical; stop/parse/latch semantics source-checked; decomposition ↔ monolith equivalence tests 10/10 passed (re-run green 2026-06-12 post-reorg — §3E); bucket E empty after probes (§3E). |
| Status | Verified. Locked gpt-5-mini baseline SR 0.29 (run 20260526_163540, perf rand100, 100 ep). Post-migration re-eval 2026-06-13 (run 20260613_115915, gpt-5-mini, rand100 20 ep, step_budget 15): SR 0.20 · SPL 0.199 · nDTW 0.431 — non-zero harvest confirms the TODO #64 fix (7095f196); within 20-ep variance of the 100-ep lock. Graph promoted to graphs/vln/verified/ (§4). |
1. Upstream method analysis (Shi et al. 2025)
Upstream SmartWay is a VLN-CE rollout (vlnce_baselines/common/base_il_trainer.py) around the same OneStagePromptManager class name MapGPT uses (vlnce_baselines/GPT/one_stage_prompt_manager.py) — but rewritten for continuous environments with waypoints, tags, and backtracking. Per step:
- Predict waypoints. The panorama feeds a trained TRM waypoint predictor (
waypoint_predictor/TRM_net.py,BinaryDistPredictor_TRM+ DINOv2-small features perPolicy_ViewSelection_VLNBERT.py:111) → K candidate (angle, distance) pairs, each with its view RGB. - Tag candidates. RAM+ (
ram_plus, swin-L, image_size 384 —base_il_trainer.py:368–371) runsinference_ramon each candidate image (:440–442) → per-candidate object-tag strings. - Topology bookkeeping + synthetic return (
make_action_prompt_backtrackv2,one_stage_prompt_manager.py:44–): the current position and each candidate are appended tonodes_list; the index in that list is the episode-global Place ID.last_distanceis derived from the previous step's env action (:54–56); when the last step was not itself a backtrack andlast_distance ≠ 0, a synthetic return candidate (angle π, distance = last_distance) is appended (:81–90). - Phrase each candidate (
:108–112):"<direction> to Place N which is corresponding to Image N, and this image contains objects such as <tags>."— direction from six 60° buckets (get_action_concept_backtrack,:23–41). The trailing return candidate gets the verbatim phrase “Move back to last position in an opposite direction” (:110). - Letter options + stop gate (
make_action_options_backtrack,:148–165): fromt ≥ 2(hardcoded) a barestopis prepended; everything is letter-prefixed A/B/C…. - Prompts (
make_r2r_json_prompts,:202–245): a static 8-componenttask_descriptionsystem prompt — including a dedicated Return Policy paragraph — plus a 4-field user prompt (Instruction / History / Previous Planning / Action options).gpt_infer_back_track(api.py:48–95) packs candidate images with"Image N:"labels before each image. - Parse (
parse_json_action,:294–309): letter → index, else 0; att ≥ 2the index is decremented to absorb the prepended stop, so −1 means STOP.parse_json_planning(:284–291) extractsNew Planning(fallback"No plans currently.") and appends it to the planning list. - Act (
make_equiv_action,base_il_trainer.py:167–204): index → the candidate's (angle, distance), executed as rotate + repeated 0.25 m forward primitives. - History + backtrack latch (
make_history,:168–184): appends"step t: <phrase>"; if the executed phrase string-equals the return phrase,self.backtrack = True(:183–184) — which suppresses the synthetic return next step.
1.1 Upstream invariants worth marking
- Place IDs are episode-global, but there is no map text. Unlike MapGPT, the prompt has no
Trajectory/Map/Supplementaryfields —nodes_list/graph/trajectorybookkeeping exists to mint stable Place IDs so history phrases written at step t still mean something at step t+k. - The backtrack latch is an exact string match.
make_historycompares the executed phrase against “Move back to last position in an opposite direction” character-for-character (:183–184). Rewording the return phrase silently kills backtracking. - The return option is self-suppressing. It is offered only when the previous step was not a backtrack and
last_distance ≠ 0— the agent cannot oscillate by chaining returns. - Stop is gated at
t ≥ 2(hardcoded) and −1-encoded. Parse failure decodes as index 0, which after the shift means STOP late and first-candidate early. - The system prompt is static. All episode state rides the 4-field user prompt;
task_description(with the Return Policy paragraph) never changes across steps.
2. The AgentCanvas port
The port is a Phase-2 decomposition of the validated smartway_mono monolith (3 nodes), split along natural seams: plan_step → update_topology / build_action_options / assemble_prompt / build_images; decide_action → parse_response / resolve_action; update_history unchanged. Verbatim prompt constants and pure helpers live in smartway_mono/_prompts.py and are imported by both variants; test_equivalence.py checks the decomposition against the monolith. After the split, every graph_state key has exactly one writer.
2.1 Per-step flow
Drawn simplified in three places. ① The loop carry is minimal: iter_out collects step_hightolow.info as a pulse (persist=true) plus parse_response.is_stop on its stop port, and iter_in.step re-triggers observe_panorama — all agent memory crosses iterations through graph_state, the opposite of the MapGPT graph (which carries observations through iterOut). ② instruction enters once through iter_in's init_instruction slot (persist=true) and feeds assemble_prompt every step. ③ step_hightolow.terminated → update_history.step_done (an optional gate input) is omitted from the figure. Stop executes on both paths in the final iteration: parse_response.is_stop → iter_out.stop ends the loop (two-pivot model — no separate termination node), while resolve_action emits (0, 0) which step_hightolow treats as the canonical habitat STOP; after the loop, iter_out.final_stop triggers evaluate, harvesting the STOP step's metrics (the §4 rewire).
2.2 Node inventory
| Node | In | Out | Role |
|---|---|---|---|
smartway_waypoint__predict(server) | views:ANY, rgb_features:TEXT | candidates:ANY, num_candidates | Vendored TRM waypoint predictor (BinaryDistPredictor_TRM + VLN-CE depth encoder) over externally-wired DINOv2 features (base64-npy envelope from model_dinov2__extract_features, remapped clockwise in-engine — byte-identical to the old in-engine forward). rgb_features is required: an optional port would not gate firing and silently races to zeros. → {idx: {angle, distance, rgb_base64}}, integer-indexed 0…K−1. |
model_ram__tag_views(server, generic FM) | views:ANY | tags:ANY | RAM++ (swin-L @ 384, variant=ram_plus) keyed-dict tags — the predictor's candidates dict wires straight into views (already keyed views), same indices out, no decode mismatch. Replaced smartway_perception__tag in the TODO #56 extraction. |
smartway__update_topology | candidates:ANY | candidates_enriched:ANY, candidate_node_indices:ANY, last_backtrack:BOOL, history_snap:TEXT, planning_snap:TEXT | Sole writer of nodes_list/graph/trajectory; mints UUID place ids → episode-global Place IDs; appends the synthetic return; emits state snapshots so downstream nodes never re-read graph_state mid-iteration. |
smartway__build_action_options | candidates_enriched, candidate_node_indices, last_backtrack, tags | full_options:ANY, only_options:TEXT, only_actions:TEXT, candidates_dict:TEXT | Pure: per-candidate phrases (verbatim format incl. tags), letter prefixes, stop at t ≥ 2; emits the manifest resolve_action later consumes. |
smartway__assemble_prompt | instruction, history_snap, planning_snap, full_options | task_description:TEXT, prompt:TEXT | Pure: verbatim static system prompt + 4-field user prompt. Wired into llmCall.system_prompt — unlike the MapGPT graph, no config-borne prompt copy exists. |
smartway__build_images | candidates_enriched, candidate_node_indices | images:LIST[IMAGE], image_labels:LIST[TEXT] | Pure: base64 → RGB arrays + "Image N:" labels aligned to Place IDs (label-before-image, api.py:48–95). |
smartway__parse_response | response:TEXT, only_options:TEXT | picked_index:TEXT, is_stop:BOOL, thought:TEXT, new_planning:TEXT | Tolerant JSON parse (fence/prose stripping) → upstream index semantics (−1 = STOP); sole writer of planning (appends, list-valued). |
smartway__resolve_action | picked_index, is_stop, candidates_dict | angle:TEXT, distance:TEXT, is_return:BOOL | Manifest lookup → env action; (0, 0) on stop; writes scratch last_picked_index/last_picked_type. |
smartway__update_history | picked_index, is_stop, is_return, only_actions, distance, step_done? | history:TEXT, backtrack:BOOL | Sole writer of history/backtrack/last_distance: appends "step t: <phrase>", sets backtrack = is_return (a wire, not a string match), records last_distance for next step's synthetic return. |
2.3 State & memory
| graph_state entry | Reducer · type · lifetime | Sole writer | Upstream counterpart |
|---|---|---|---|
history | lastWrite · TEXT · episode | update_history | self.history |
planning | lastWrite · ANY (list) · episode | parse_response | self.planning (template reads [-1]) |
backtrack | lastWrite · BOOL · episode (initial false) | update_history | self.backtrack exact-string latch |
nodes_list / graph / trajectory | lastWrite · ANY · episode | update_topology | self.nodes_list / .graph / .trajectory |
last_distance | lastWrite · ANY · episode | update_history | Derived per-step from env_action (one_stage_prompt_manager.py:54–56) |
last_picked_index / last_picked_type | lastWrite · TEXT · step | resolve_action | — (port-added forensics scratch) |
The episode lifetime (vs MapGPT's run) means state clears between batch-eval episodes by container signal, with declared initial_values standing in at step 0. The two last_picked_* scratch keys are step-lifetime — per-step forensics, no cross-step reads.
2.4 Boundary contract
Reasoning is split from models and from the env. smartway (local) is pure reasoning; smartway_waypoint is a task-specific server-mode predictor in the ac-smartway conda env (Python 3.8.20, torch 2.1.1+cu121; SMARTWAY_PYTHON overrides the interpreter), and the foundation models ride generic wrappers: model_dinov2 (RGB features, ac-smartway) and model_ram (ram_plus tags, ac-ram). Env side: env_habitat via reset / observe_panorama (n_views=12) / step_hightolow / evaluate — the hightolow action space executes rotate + ⌊distance/0.25⌋ forward primitives, with (0,0) as the canonical STOP. Boundary-compliant per TODO #56's method/foundation-model split.
2.5 Prompt assets
All verbatim text lives in workspace/nodesets/method/smartway_mono/_prompts.py, shared by the monolith and this decomposition: the 8-component task_description (byte-diffed against upstream :205–232 — identical), the 4-field user-prompt template (:242–245), RETURN_PHRASE (:110), DEFAULT_PLANNING (:20), INIT_HISTORY (:234), and the pure helpers (direction buckets, options, parse). The graph wires assemble_prompt.task_description → llmCall.system_prompt — there is no duplicated prompt copy in the graph JSON.
3. Delta vs upstream — four buckets
A. Preserved verbatim
| Element | Upstream anchor | How preserved |
|---|---|---|
8-component task_description (incl. Return Policy paragraph) | one_stage_prompt_manager.py:205–232 | Byte-identical (programmatic diff, 2026-06-11) — build_task_description() |
| 4-field user prompt (Instruction/History/Previous Planning/Action options) | :242–245 | Same f-string shape incl. list-repr options and INIT_HISTORY at t=0 |
RETURN_PHRASE wording | :110 | Character-for-character (the latch semantics depend on it upstream) |
Candidate phrase format "… to Place N … contains objects such as <tags>." | :108–112 | Verbatim f-string in make_action_prompts |
| Six 60° direction buckets | get_action_concept_backtrack :23–41 | Verbatim port incl. the unreachable-angle assert |
Stop gate t ≥ 2 + letter prefixes | :148–165 | Verbatim semantics (prepend_stop_options) — hardcoded, no config knob |
| Parse index shift (−1 = STOP; fallback 0) | :294–309 | Verbatim semantics (parse_json_action) |
Planning fallback "No plans currently." | :284–291 | Verbatim (parse_json_planning) |
History payload "step t: <phrase>" | make_history :168–184 | Same string; synthetic-return offer conditions (:54–56, :81–90) preserved in update_topology |
| RAM+ config (ram_plus, swin-L, image_size 384) + per-candidate inference | base_il_trainer.py:368–371, :440–442 | Same model/size via model_ram__tag_views (variant=ram_plus; cross-env output parity verified 12/12) |
Label-before-image "Image N:" packing | api.py:48–95 | build_images emits aligned parallel arrays |
| Waypoint predictor weights/architecture | waypoint_predictor/TRM_net.py + DINOv2 per Policy_ViewSelection_VLNBERT.py:111 | TRM vendored verbatim under smartway_waypoint/_vendored/; DINOv2 forward extracted to model_dinov2 (features bitwise-equal through the envelope + clockwise remap) |
B. Forced by substrate (script → typed graph)
| Upstream | Port | Why forced |
|---|---|---|
OneStagePromptManager object attributes | 9 graph_state entries (lastWrite · episode), single-writer-per-key | Cross-iteration memory must live in visible, lifetime-managed containers |
| One class method per stage, in-process | 7-node split with explicit snapshot wires (history_snap/planning_snap) | Decomposed nodes must not re-read mutable state mid-iteration — snapshots freeze the iter's view at update_topology |
| TRM predictor + RAM+ called in-process | Two server-mode nodesets in the dedicated ac-smartway conda env | torch 2.1.1/py3.8 model stack can't live in the backend interpreter (ADR-server-001) |
Index → action inside the trainer (make_equiv_action) | candidates_dict manifest crosses a wire from build_action_options to resolve_action | Producer and consumer are separate nodes; the lookup table must be data |
| Place identity = simulator waypoint ids | UUID4 minted per observation in update_topology | The continuous env exposes no stable waypoint ids across steps; only index stability matters for the prompt |
C. Forced by environment / cost
| Upstream | Port | Reason |
|---|---|---|
| Model pinned gpt-4o | Profile-driven llmCall; reference graph frozen at gpt-5-mini · temp 1.0 · max_tokens 2000 · json_object (AAS constraints file) | The AAS experiments benchmark gpt-5-mini specifically; gpt-5-family needs temp 1.0 + max_tokens ≳ 2000 or llmCall silently returns empty |
| Sequential rollout script | JobScheduler profiles (smoke_smartway_ce / perf_smartway_ce, rand100 slice), up to 20 env workers | Batch eval throughput; the 20-worker cold start exposed a perception-server race (§4) |
D. Intentional divergences
| Upstream | Port | Rationale |
|---|---|---|
Backtrack latch = exact string match on the executed phrase (:183–184) | backtrack = is_return — a BOOL carried on a wire from resolve_action | Removes the string-match fragility and the read-clear-at-iter-top pattern; behaviour equivalence covered by test_equivalence.py |
Raw json.loads on the reply | Markdown-fence / embedded-prose stripping before parsing | Profile-driven LLMs drift to fenced or prose-wrapped JSON; recover instead of burning the step |
| Monolithic prompt manager | Phase-2 decomposition with the monolith kept as ground truth (smartway_mono_ce.json + test_equivalence.py) | Finer AAS search granularity per node; equivalence tests guard the refactor |
| — | last_picked_index/last_picked_type scratch state | Per-step forensics for eval debugging; no upstream counterpart |
E. Unexplained / defects
None found (audit 2026-06-11). What was probed beyond the port's own claims: ① upstream's fuse_close_node=True signature default (one_stage_prompt_manager.py:44) is dead code — both rollout call sites pass False (base_il_trainer.py:446, :448) and the body never reads it, so the port's omission matches actual behaviour; ② the rollout calls make_history v1 (:509) with make_history_v2 commented out (:510) — the port correctly mirrors v1; ③ decomposition ↔ monolith I/O equivalence executed, not just cited: test_equivalence.py 10 passed in 0.17 s (byte-compares every output port and state write under a UUID-determinism fixture). Re-verify 2026-06-12: the reorg commit (119f82da) had left a stale from workspace.nodesets import smartway in the UUID fixture (it migrated the other four import sites); fixed, and the suite re-ran 10 passed in 0.18 s against the post-reorg tree.
4. Evaluation
| What | Number | Evidence |
|---|---|---|
| Paper-reported | not recorded in-repo | SmartWay is a side-experiment port (author-relationship; dropped from PortBench v1) — no paper-claim closure run exists; the port is benchmarked against its own locked baseline below |
| Locked baseline (gpt-5-mini, 100 ep, R2R-CE val_unseen rand100) | SR 0.29 · SPL 0.229 · nDTW 0.433 · OS 0.40 | run 20260526_163540 (myloop v0 iter_0 no-patch baseline, perf_smartway_ce, passes=1); independent AFlow v0 baseline on the same graph: median 32%, 95% CI (23%, 41%) |
| Older reference (gpt-4o · T=0 · budget 20) | SR ≈ 0.28 | Pre-AAS config — not comparable to the gpt-5-mini line (different model/temp/step budget); noted in the gym-migration memory |
| Post-migration check (gpt-5-mini, 20 ep, step_budget 15) | reported SR 0.000 · true ≈ 0.25 | run 20260610_091657 — the TODO #64 harvest bug, see below |
Known gap (roadmap TODO #64 — confirmed on this graph): evaluate was a loop-body node triggered by step_hightolow.info; in the final iteration the loop terminated via parse_response.is_stop → termination before evaluate re-fired, so the harvested output_port__metrics holds the pre-STOP value. Habitat does compute success on the STOP step (visible in step.info.metrics): in run 20260610_091657, 5/20 episodes stopped within the 3 m radius but recorded success=0 → reported SR 0.000 vs true ≈ 0.25. Mechanism, not agent regression (nDTW 0.50 ≥ the 0.40 reference). Fix + re-verify before trusting any SR from the migrated graph. Update (2026-06-11 verify pass; committed 2026-06-11 as 7095f196): is_stop now feeds iter_out.stop and evaluate is triggered post-loop by iter_out.final_stop — the sound final-fire shape (trigger from the pivot, no upstream data-node dep). SR re-verification done 2026-06-13: run 20260613_115915 (rand100 20 ep) harvested SR 0.20 (4/20) — non-zero, every episode logging 3–15 decision steps, zero step_count=0 completions; the mechanism is fixed.
Operational gotcha (engine-level): at worker_count=20, the shared smartway_perception singleton returned HTTP 500 to 11/100 first-episode requests in run 20260516_101057 (cold-start race); GraphExecutor silently completed those episodes with step_count=0 and empty metrics (the silent-episode-completion engine hole). Check per-episode step_count before trusting aggregates from high-worker runs.
Current status: smartway_ce.json was promoted back to workspace/graphs/vln/verified/ on 2026-06-13 — the post-migration re-eval (run 20260613_115915: gpt-5-mini, rand100 20 ep, SR 0.20) confirmed the gym-rewired surface (observe_panorama / terminated / reset) and the two-pivot harvest behave correctly. A full rand100 (100-ep) run remains the headline-number target. The sibling monolith smartway_mono_ce.json was promoted to verified/ on 2026-07-05 via a no-LLM interface-equivalence audit: all 29 edges resolve against the current port declarations, its env-side wiring is handle-identical to the re-verified decomposed graph (so the gym-rewired surface and two-pivot harvest carry over), and the 10/10 mono↔decomp byte-parity suite still passes. The audit is pinned as smartway_mono/test_graph_interface.py.
5. Usage
# load — smartway (local) + the two server-mode model nodesets
POST /api/components/nodesets/smartway/load
POST /api/components/nodesets/smartway_waypoint/load?mode=server # smartway conda env
POST /api/components/nodesets/smartway_perception/load?mode=server
# graph
workspace/graphs/vln/verified/smartway_ce.json
# batch eval — graph-only form: /experiment:run <profile> <graph_name> [key=value ...]
/experiment:run perf_smartway_ce smartway_ce # rand100, 100 ep
/experiment:run smoke_smartway_ce smartway_ce episode_count=3
6. What this nodeset is NOT
- Not the monolith page.
smartway_mono(3 nodes,smartway_mono_ce.json) is the validated Phase-1 port and the equivalence ground truth for this decomposition — same upstream, same prompts module. - Not a PortBench v1 task. Deliberately excluded (Adelaide Qi Wu group authorship — author-relationship constraint); it lives as a side experiment and an AAS search target.
- Not a map-prompt method. Despite keeping
nodes_list/graph/trajectorystate, no map text ever reaches the LLM — the topology exists to mint stable Place IDs. - Not env logic. Waypoint execution (rotate + forward primitives, STOP handling) lives in
env_habitat.step_hightolow, not here.
7. Source files
| File | Purpose |
|---|---|
workspace/nodesets/method/smartway/__init__.py | The decomposed nodeset — 7 reasoning nodes (700 lines; docstring carries the decomposition map) |
workspace/nodesets/method/smartway/test_equivalence.py | Decomposition ↔ monolith equivalence tests |
workspace/nodesets/method/smartway_mono/{__init__.py, _prompts.py} | Phase-1 monolith (ground truth) + the shared verbatim constants/helpers module |
workspace/nodesets/method/smartway_waypoint/ | Server-mode TRM waypoint predictor (_vendored/waypoint_predictor/ verbatim upstream sub-tree) |
workspace/nodesets/method/smartway_perception/ | Server-mode RAM+ tagger |
workspace/graphs/vln/verified/smartway_ce.json · verified/smartway_mono_ce.json | Decomposed (this page, verified 2026-06-13) and monolith (ground truth, promoted 2026-07-05) agent graphs |
workspace/nodesets/_upstream/smartway-code/fetch_upstream.sh | Clones upstream @ daa2dd8 (MIT) into a gitignored upstream/ |
outputs/design_runs/{myloop,aflow,adas-subagent}/smartway_ce/ | AAS search runs on this graph (baseline evidence in §4) |