AgentCanvas / Pages / Developer Guide / Nodesets / Method / AO-Planner
2026-07-06

AO-Planner (24.07; Chen et al., AAAI 2025) is a zero-shot continuous-VLN (R2R-CE) agent built on Visual Affordances Prompting (VAP): a Grounded-SAM floor mask is grid-sampled into candidate pixels, a proposer VLM selects reachable waypoints, the pixels are unprojected to polar moves via depth + intrinsics, and a second PathAgent VLM picks the next location from four annotated direction views. This page documents the aoplanner port — a faithful two-VLM VAP pipeline driving env_habitat continuous navigation in aoplanner_ce.json, deliberately built to the paper's pure-foundation-model framing: the released code's supervised ETPNav/TRM waypoint predictor is dropped (the fidelity audit confirms it is dead-loaded at eval anyway). Both VLMs are gpt-5-mini.

At a glance
UpstreamAO-Planner (24.07), AAAI 2025 — repo chen-judge/AO-Planner pinned @ 719f42a1. Zero-shot R2R-CE via Visual Affordances Prompting + PathAgent.
Env consumedenv_habitat — in-loop action step_path (multi-point Path walk, hightolow polar convention) + a post-loop step_hightolow(0,0) STOP stamp, obs observe_panorama (n_views=4, RGB-D) + observe_egocentric (intrinsics) + observe_camera_pose (position+rotation), reset (env-override knobs: RGB 512 / DEPTH 512 / MAX_EPISODE_STEPS 5000 = upstream LLM_base_task.yaml) / evaluate (gym-like contract)
FM consumedbuilt-in llmCall (×5/step) — reference graph pins gpt-5-mini · temp 1.0 · max_tokens 2000 · response_format=json_object for both the proposer and the PathAgent; server-mode model_grounding_dino__detect (Swin-T OGC, ac-detany3d env) + model_sam__segment_box (sam1, ViT-H ckpt, ac-fm env) — the graph-level Grounded-SAM composition that replaced the embedded ground_mask node (2026-07-06; upstream's exact detector/SAM pair, unioned in sample_waypoints)
Method nodesetaoplanner — 16 local reasoning/geometry tools (in-process; VAP geometry, view glue, proposer glue, PathAgent decider, + a post-loop emit_stop). Prompts verbatim in aoplanner/_prompts.py.
State3 lastWrite graph_state entries — history (TEXT) + planning (ANY/list) + ghost_map (ANY), all episode lifetime; single-writer-per-key (update_history / parse_response / aggregate)
Graphworkspace/graphs/vln/verified/aoplanner_ce.json (63 nodes, 129 edges; promoted 2026-07-04, Grounded-SAM composition inlined 2026-07-06) — four replicated perception lanes (front/left/backward/right) fan into one PathAgent decider
Fidelityfaithful with justified deviations (grill-implement Iteration 3, 2026-07-04; §3E emptied). VLM prompts byte-identical; step_path/turn/try-out tables verbatim; run-config chain now matched (512×512 RGB-D render, horizon 5, MAX_EPISODE_STEPS 5000). Iteration 3 walked the release run-config chain + fork-diffed against parent ETPNav and found 8 new gaps — all fixed same-day and re-filed to §3A (changelog). §3B: two substrate rows (static-graph call shape). B/C/D re-examined — all genuinely irreducible with cited rationale.
StatusVerified at 100-ep scale on the faithful config (2026-07-04): SR 0.35 (35/100, 95% CI ±0.09) · oracle 0.50 · SPL 0.21 · nDTW 0.41 — rand100 split, gpt-5-mini both VLMs, run 20260704_011258 (100/100 completed, 59 min, 10 workers). Paper zero-shot line: SR 25.5 / SPL 16.6 (arXiv 2407.05890 Table 1) — the port sits above it, but not same-model / not same-split (§4). Pre-alignment history (224 px · budget 15: SR 0.10 @ 20 ep) superseded — see §4.

1. Upstream method analysis (Chen et al. 2025)

AO-Planner is a VLN-CE rollout (vlnce_baselines/zero_shot_agent.py) whose action space is produced by two foundation-model calls per step, not a trained policy. The release run (run_r2r/zero_shot.bashzero_shot_eval.yamlLLM_base_task.yaml) renders 512×512 RGB and DEPTH (LLM_base_task.yaml:13–21) and caps the loop at 5 high-level steps (IL.max_traj_len 5, zero_shot.bash:17 — the bash override wins over the yaml's 15). Per step (zero_shot_agent.py:691, for stepk in range(max_len=5)):

  1. Per-view affordance proposal (zero_shot_agent.py:708–740, four views front/left/backward/right at 0°/90°/180°/270°). For each view, grounded_sam_Gemini.py: GroundingDINO detects "ground" boxes (SwinT @ box/text 0.4), SAM masks them and unions all masks (:307–346); sample_points lays a 50 px grid over the union (up to 9×9 on 512) and prepends a foot point (W//2, H−5) as candidate 0 (:159–180, :348); >40 candidates (face-wall) or ==1 (no floor) abort the view (:354–362); otherwise the grid points are drawn as numbered dots (labels 1..N — the foot is never drawn) and a proposer VLM (Gemini-1.5-pro, temp 0) returns {Waypoints, Paths} (:267–304).
  2. Unproject (utils.py:87–130): each chosen pixel → camera ray via depth + intrinsics → angle = atan2(x, d), planar distance = hypot(x, d). Depth is de-normalized ×10 (utils.py:26, habitat NORMALIZE_DEPTH=True, MAX_DEPTH=10); RGB and depth render at the same 512×512, so depth indexes 1:1 at RGB pixel coords (utils.py:115).
  3. Ghost-graph bookkeeping (ZeroShotGraphMap, graph_utils.py:402–440): every candidate mints a fresh episode-monotonic ghost ID (ghost_cnt += 1, no merge); the current step's IDs form the PathAgent's option set — make_graph_baseline_prompts takes a ghost_pos argument but never reads it, so past ghosts are not offered as options. At eval this map is pure geometry — learned node/ghost embeddings are commented out, ghost_aug=0.
  4. PathAgent decision (prompt_manager.py::make_graph_baseline_prompts:34–89): a static 10-piece task_description system prompt + a per-step user prompt (Instruction / History / Previous Planning / Options); the contributing (ghost-yielding) direction views are packed with "({dir}) Locations {ids} in Image {i}" captions (:58) at detail:"low" (GPT_api.py:69); GPT-4o (gpt-4o-2024-05-13, temp 0, max_tokens 600, json_object) returns {Thought, New Planning, Action}.
  5. Parse + act: parse_num (:119–125) takes the first integer of Action; action.lower()=='stop' stops (:851); an ID beyond the episode's accumulated path list raises IndexError → stop (:838–844). The chosen ghost ID's Path (a multi-point pixel route) is executed by the env controller via multi_step_control — optionally with a try-out sliding obstacle-avoidance pass (environments_llm.py:447–526; the release sets ALLOW_SLIDING True, so use_tryout=False and habitat's native sliding does the job). History interleaves the actual past-step images at step>0 (prompt_manager.py:68–76). On the last step the selected path is walked and a STOP is stamped (zero_shot_agent.py:889–901). Eval metrics are hand-computed from the POSITION measure at episode end (:917–941) — see the §4 metric-ruler note.
habitat — AO-Planner zero-shot rollout · ×4 views (front/left/backward/right) panorama ×4RGB-D 0/90/180/270° Grounded-SAMunion floor mask :307–346 sample + annotategrid 50px + foot :159–180 proposer VLMGemini → Waypoints/Paths GT depth ×10 + Kutils.py:26 · intrinsics unprojectpixel→polar utils:87–130 Waypoints/Paths ZeroShotGraphMapghost IDs :767–800 TRM + ETPNavloaded, never called @eval prompt + image pack"Image N:" GPT_api GPT-4o PathAgent{Thought,Plan,Action} system+user+imgs parse_numfirst int · 'stop' :119 manager attrs history (images) planning · nodes in-process multi-point pathtry-out slide :447–526 ghost ID → Path habitat steprotate + 0.25m fwd R2R-CE evalSR · SPL · nDTW for stepk in range(5) — next step re-observes the panorama habitat / env VAP / manager fn VLM call loaded but unused @eval object-attr state data flow attr read/write rollout loop (next step)

1.1 Upstream invariants worth marking

2. The AgentCanvas port

The port realises VAP as four replicated perception lanes (one per panorama view) that fan into a single SmartWay-style PathAgent decider. Verbatim prompts and pure parsers live in aoplanner/_prompts.py; geometry and overlay live in aoplanner/__init__.py. The supervised TRM/ETPNav stack is dropped (pure-FM framing); the ghost-graph is folded into a per-step aggregate manifest.

2.1 Per-step flow

env_habitat → ×4 lanes (view0/1/2/3 = front/left/backward/right) observe_panoraman_views=4 RGB-D extract_view → detect → segboxGDINO boxes → SAM masks (servers) sample + annotategrid 50px + foot vlm1 proposergpt-5-mini → Waypoints observe_egocentricintrinsics fx,cx parse_proposal → projectdepth×10 · 512 native response (Waypoints) make_bundle → aggregateper-step gid manifest ×4 lanes fan in assemble + build_imagessystem + 4 annot views vlm2 PathAgentgpt-5-mini · history TEXT system+user+imgs parse_response−1 = STOP graph_state history (text) planning (list) lastWrite · episode iterOut · stopends loop (two-pivot) is_stop resolve_actionfallback=forward action_id + manifest step_pathmulti-hop route · []=STOP evaluate← iterOut.final_stop final_stop (post-loop) iterOut → iterIn — step pulse only (agent memory lives in graph_state) · step_budget 5 env_habitat node aoplanner node (server-mode noted) llmCall loop control graph_state container data wire state access loop-back (iterOut → iterIn)

Three simplifications in the drawing. ① The four perception lanes are identical — view0..3 → detect → ground_boxes → segment_box → sample → annotate → vlm1 → parse_proposal → project → make_bundle — and are collapsed into one row labelled "×4"; only their make_bundle outputs differ, fanning into aggregate.view_bundles (a LIST[ANY]) in lane order. ② propose_prep (the proposer system prompt, instruction-derived), observe_egocentric (intrinsics), and observe_camera_pose (agent pose for world-coord projection) are shared across all four lanes, not replicated. ③ 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 an empty hop list which step_path treats as the canonical habitat STOP (step(0)). Post-loop, iter_out.final_stop → emit_stop → final_step (step_hightolow) → evaluate: emit_stop feeds a constant (0,0) so a final step(0) registers an explicit STOP at the final pose (matching upstream's last-step move-and-stop; a no-op if the episode already stopped). evaluate harvests after that STOP.

2.2 Node inventory

16 tools; the four perception-lane tools are instantiated 4× in the graph (one per view). The proposer/PathAgent VLM calls are built-in llmCall nodes (5×), not aoplanner tools. pick_ground_box ships in the nodeset but is not instantiated by the reference graph (the lanes wire ground_boxes, which passes all boxes so SAM masks union over every ground box — the faithful path; pick_ground_box remains the single-box simplification variant, 0 graph refs).

NodeInOutRole
aoplanner__extract_view
(×4)
views:ANYrgb_b64, depth_b64, heading_degPull one panorama view by index (0/1/2/3 → heading 0/90/180/270°).
model_grounding_dino__detect
(×4, server)
image_b64:TEXTresult:TEXTGroundingDINO Swin-T OGC, caption "ground" @ box/text 0.4 (upstream run_grounded_sam.sh); ac-detany3d conda env. Replaced the embedded Grounded-SAM ground_mask node 2026-07-06.
aoplanner__ground_boxes
(×4)
detections:TEXTboxes:TEXT, countExtract ALL detect boxes as [[x1,y1,x2,y2],…] — SAM masks union over every ground box (the faithful path).
model_sam__segment_box
(×4, server)
image:TEXT, boxes:TEXTmasks:TEXTSAM ViT-H (sam1 variant, ckpt=data/detany3d/weights/sam_vit_h_4b8939.pth), one mask per box, multimask_output=False; ac-fm env. Masks carry the accepted 1–3 px cross-env conv drift vs the old embedded SAM (candidate_pixels verified 12/12 exact).
aoplanner__sample_waypoints
(×4)
sam_result:TEXTcandidate_pixels:TEXT, countUnions the SAM masks (_union_sam_masks), then 50 px grid inside the mask (up to 9×9 at the 512 render); prepends foot point (W//2,H−5) as candidate 0; >40 or ≤1 candidates → early-abort (view skipped, upstream face-wall/no-floor heuristic).
aoplanner__annotate_markers
(×4)
image_b64, points, labels?annotated_b64, annotated_image:IMAGENumbered dots at candidate pixels → the proposer VLM's image. Graph config skip_first+label_start=1: grid points labelled 1..N, foot anchor never drawn (upstream vis_candidates).
llmCall (vlm1)
(×4)
system_prompt:TEXT, rgb:LIST[IMAGE]response:TEXTProposer VLM (gpt-5-mini): annotated grid → {Waypoints, Paths} JSON.
aoplanner__parse_proposal
(×4)
response, candidate_pixelsselected_pixels:TEXT, selected_paths:TEXT, countMap Waypoint IDs → destination pixels + per-waypoint pixel routes (D-2); on a list-form Waypoints/Paths count mismatch, uses each path[-1] as the destination (upstream llm/utils.py:43–56); exclude_foot_point drops id 0 from destinations (§3D).
aoplanner__project_waypoints
(×4)
candidate_pixels, paths_pixels?, depth_b64, intrinsics, view_heading_deg, position?, rotation?candidates:TEXT {idx,u,v,angle,distance,path}World-coord projection when position+rotation present (from observe_camera_pose): compose view rotation = agent_rot × Q_y(view_heading), unproject pixel→camera frame [x,y,−d], rotate to world, apply _calculate_vp_rel_pos. Fallback: heading_sign=+1/bearing_sign=−1 polar approximation. depth_scale=10; RGB and depth both render 512 (reset knobs), so depth indexes 1:1 (the 256→RGB-res resize remains as a guard for other configs).
aoplanner__make_bundle
(×4)
rgb_b64, candidatesbundle:ANYPack {view_dir, rgb_b64, candidates} for the aggregate fan-in.
aoplanner__propose_prep
(shared)
instruction:TEXTsystem_prompt:TEXTBuild the verbatim proposer system prompt from the instruction; feeds all 4 vlm1.
aoplanner__aggregateview_bundles:LIST[ANY]candidates_dict, action_space_text, view_images_b64, view_labels, countEpisode-monotonic ghost IDs via ghost_map graph_state counter (next_gid): always mints a fresh ID per candidate per step, never merges — matching upstream ZeroShotGraphMap.update_graph (ghost_cnt += 1, no merge). Skips candidate-less views entirely (no image, no label; Image {i} counts contributing views — upstream packing). Draws per-route random-colour polylines + a bottom-anchor segment (upstream vis_ghost_nodes), then ghost ID markers. Manifest carries _meta.total (ghosts ever minted) for resolve_action's out-of-range STOP. Sole writer of ghost_map.
aoplanner__assemble_promptinstruction, action_space_texttask_description:TEXT, prompt:TEXTVerbatim PathAgent system prompt + per-step user prompt (history read from graph_state).
aoplanner__build_imagesview_images_b64, view_labels, action_space_text?images:LIST[IMAGE], image_labels:LIST[TEXT]History images (from history_images state) + the contributing annotated views with "({dir}) Locations {ids} in Image {i}" captions. At t>0 the "Options (step t): …" line rides the first option image's label — reproducing upstream's text→history-images→Options→option-images content order.
llmCall (vlm2)system_prompt, plan:TEXT, rgb:LIST[IMAGE], image_labels:LIST[TEXT]response:TEXTPathAgent VLM (gpt-5-mini): 4 annotated views → {Thought, New Planning, Action}.
aoplanner__parse_responseresponseaction_id:TEXT, is_stop:BOOL, thought, new_planningParse the decision; sole writer of planning (appends).
aoplanner__resolve_actionaction_id, is_stop, candidates_dictpath_angles, path_distances, angle, distance, chosen_view_dirManifest lookup → the chosen ghost's polar hop route for step_path; empty route on STOP. An ID ≥ _meta.total STOPs (upstream IndexError path); a past ghost ID (< total, not current) has no stored path (§3D) → fallback=forward (most-forward current candidate).
aoplanner__update_historyaction_id, is_stop, step_done?history:TEXTSole writer of history (text, debug) and history_images (ANY, list of {caption,b64} — upstream make_graph_history format). Receives chosen_view_dir from resolve_action + view_images_b64 from aggregate to pick and store the direction image.
aoplanner__emit_stop
(post-loop)
trigger?:ANYangle:TEXT, distance:TEXTEmits constant (0,0) after the loop → a final step_hightolow stamps an explicit habitat STOP at the final pose (§3 last-step-STOP fix); no-op if the episode already stopped.

2.3 State & memory

graph_state entryReducer · type · lifetimeSole writerUpstream counterpart
historylastWrite · TEXT · episode (initial "")update_historyDebug text trail; build_images uses history_images (below) for the actual visual history.
history_imageslastWrite · ANY · episode (initial [])update_history, read by build_imagesList of {caption, b64} entries — upstream self.history equivalent; build_images prepends these before option views (D-4 alignment 2026-06-18).
planninglastWrite · ANY (list) · episode (initial ["Navigation has just started, with no planning yet."])parse_responseself.planning (template reads [-1])
ghost_maplastWrite · ANY · episode (initial {"next_gid":0})aggregateMonotonic ghost ID counter — mirrors upstream ZeroShotGraphMap.ghost_cnt; stores only next_gid (no node positions, no spatial dedup). IDs never reset within an episode (E-1 fix 2026-06-19).

Three episode-lifetime keys. ghost_map stores only next_gid — a monotonic counter mirroring upstream ZeroShotGraphMap.ghost_cnt; no spatial dedup (E-1 fix 2026-06-19). The backtrack/tag topology that SmartWay carries is absent. Five nodes hold access_grants to graph_state: aggregate (writes ghost_map), assemble_prompt (reads history + planning), parse_response (writes planning), update_history (writes history + history_images), build_images (reads history_images).

2.4 Boundary contract

Reasoning is split from models and from the env. aoplanner (local, in-process) is pure reasoning + geometry; the only model-bearing dependency is the server-mode model_grounding_dino nodeset (GroundingDINO Swin-T OGC + SAM ViT-H) running in the ac-detany3d conda env (GROUNDING_DINO_PYTHON override). The two VLMs are built-in llmCall nodes. Env side: env_habitat via reset (env-override knobs RGB/DEPTH 512 + MAX_EPISODE_STEPS 5000) / observe_panorama (n_views=4) / observe_egocentric (intrinsics) / observe_camera_pose (world-frame position + quaternion) / step_path (in-loop; per-hop rotate + ⌊distance/0.25⌋ forward primitives, empty path = STOP) / step_hightolow (post-loop (0,0) STOP stamp) / evaluate. Boundary-compliant per TODO #56's method/foundation-model split.

2.5 Prompt assets

All verbatim text lives in workspace/nodesets/method/aoplanner/_prompts.py: the proposer system prompt (5 pieces + instr_des, build_proposer_system), the PathAgent task_description (10 pieces, build_task_description), the per-step user prompt (assemble_pathagent_prompt), the image caption (image_label), the parsers (parse_proposal, parse_pathagent), and the seed strings (DEFAULT_PLANNING, INIT_HISTORY). All five were byte-diffed against the pinned upstream and are identical (§3A). The graph carries no duplicated prompt copy; the vlm1 template is a minimal JSON-nudge (port glue — llmCall requires non-empty user text).

3. Delta vs upstream — five buckets

Each row carries an equivalence icon (orthogonal to the bucket letter — the letter says why it differs, the mark says whether behaviour changed): 🟢 equivalent (identical or semantically identical behaviour) · 🟡 near-equivalent (same intent; a mechanism, library, or edge case differs — output usually matches) · 🟠 divergent (behaviour can differ — by design or, rarely, a defect). Never ❌/🔴 — a divergence is a design fact; a genuine defect is a 🟠 row in bucket E with a severity. Audited 2026-06-17, inventory-first, against chen-judge/AO-Planner @ 719f42a1.

A. Preserved verbatim

Behaviour is equivalent (🟢) or near-equivalent (🟡). Equivalent items are A regardless of mechanism difference — a substrate-relocated item is A, not B.

Equiv.ElementUpstream anchorHow preserved
🟢Proposer system prompt (6 pieces, both instruction branches)grounded_sam_Gemini.py:267–282Byte-identical — programmatic diff whole-prompt + each piece (build_proposer_system)
🟢PathAgent task_description (10 pieces, incl. 2 trailing-space quirks)prompt_manager.py:36–50Byte-identical (2020 bytes; both ' \n' preserved) — build_task_description
🟢PathAgent step-0 user prompt + "Options (step t): Locations {ids}"prompt_manager.py:63–66Byte-identical (assemble_pathagent_prompt at t=0)
🟡Image caption "({dir}) Locations {ids} in Image {i}"prompt_manager.py:58Near-identical — image_label appends a trailing : not present upstream. Cosmetic. History-image captions (history_image_label) same trailing colon.
🟢Pixel→polar projection: angle=atan2(x,d), distance=hypot(x,d)utils.py:87–130Numerically identical to 4 dp at 6 test pixels (_project_pixels)
🟢SAM mask union over all "ground" boxesgrounded_sam_Gemini.py:307–346model_grounding_dino__ground_mask boolean-ORs per-box SAM masks (the live graph path)
🟢Grid spacing 50 px, bottom-up enumeration; foot point (W//2, H−5) at candidate index 0; grid labels 1..N with the foot never drawngrounded_sam_Gemini.py:159–180, :183–203, :348Grid math and foot position identical (sample_waypoints); at the 512 render (W//2,H−5)=(256,507) = upstream exactly. Labeling aligned 2026-07-04: annotate_markers graph config skip_first+label_start=1 draws grid points 1..N and hides the foot, matching vis_candidates(multi_start=True).
🟢Sensor render 512×512 RGB and DEPTH; candidate grid up to 9×9/viewLLM_base_task.yaml:13–21Reset knobs rgb_resolution=512 / depth_resolution=512 (2026-07-04; was 224 px RGB / 256 px depth-resized — ≤9 grid candidates/view vs upstream's up-to-81-capped-40). In-vivo: smoke logs show 512×512 PNG option images + per-view candidate counts 6–15.
🟢Candidate-count early-abort: >40 (face-wall) or ==1 (no floor) skips the viewgrounded_sam_Gemini.py:354–362sample_waypoints returns empty on the same thresholds. Only reachable at the 512 render (224's 3×3 grid could never exceed 40); smoke logs show live aborts at count=60/73/1.
🟢Only ghost-yielding views are packed for the PathAgent; Image {i} counts contributing viewszero_shot_agent.py:727–731; prompt_manager.py:54–59aggregate skips candidate-less views (no image, no label, no Options entry) and numbers images by contributing order (fixed 2026-07-04; previously raw empty-option views leaked into the prompt).
🟢Options set = current step's ghosts only (past ghosts are not offered)prompt_manager.py:34–59 (ghost_pos parameter is never read)Port equivalent by construction — aggregate offers the current manifest. (The former §3D claim that upstream offers ALL accumulated ghosts was wrong.)
🟢Action ID beyond the accumulated ghost set → STOPzero_shot_agent.py:838–844 (waypoint_path_coord[id] IndexError → 'stop')resolve_action STOPs when action_id ≥ _meta.total (aggregate's episode-monotonic mint count) — added 2026-07-04.
🟢Waypoints/Paths count mismatch → each path[-1] becomes the destinationllm/utils.py:43–56parse_proposal replicates the branch for list-form routes (2026-07-04); object-form routes carry their own Waypoint id (§3C tolerance).
🟢t>0 content order: text → history images → Options line → option imagesprompt_manager.py:69–79The Options line rides the first option image's label (llmCall emits one text item per image label), merging upstream's two adjacent text items into one — semantically identical order. Moved out of §3B 2026-07-04.
🟡Ghost-image route drawing: per-route random colour, bottom-anchor segment from the image bottom row to the route start, then the ghost-ID markerllm/utils.py:145–223 (vis_ghost_nodes)aggregate draws random-colour PIL polylines + the bottom-anchor segment before the ID markers (2026-07-04). 🟡: solid lines vs upstream's alpha-0.8 cv2 blend; marker text white-centered vs upstream blue-left-offset — same visual-prompting content.
🟡Candidate/ghost annotation via cv2 (vis_candidates/vis_points: r=5 circle, fontScale 1.0)PIL _draw_markers, graph config r=5 / font 22 px at the 512 renderSame numbered-dot overlay; toolkit differs (moved here from §3C — cv2 is available, PIL is a port choice, and the behaviour is near-equivalent).
🟢Interleaved past-step image history (each entry: caption text + the chosen direction's ghost image)prompt_manager.py:13–31, :72–73update_history stores {caption, b64} per step; build_images prepends them before the option views (D-4 alignment 2026-06-18) — history_image_label matches make_graph_history's phrasing.
🟢GroundingDINO Swin-T OGC @ box/text 0.4, caption "ground", SAM ViT-Hllm/run_grounded_sam.shExact detector pair + thresholds + caption (C-2 alignment 2026-06-17; moved here from §3C — nothing is forced or divergent anymore). $GROUNDING_DINO_WEIGHTS/$GROUNDING_DINO_CONFIG still allow Swin-B.
🟢Multi-point Path execution: multi_step_control/single_step_control/turn + try-out sliding, re-aiming from the actual pose per hopenvironments_llm.py:364–526env_habitat__step_path is a verbatim port (turn rounding, ksteps, try-out turn_seqs tables, random.choice slide direction); tryout=false in the graph because ALLOW_SLIDING=True in both configs → upstream's own use_tryout=False. Live-verified n_hops≤3 (run 20260617_171311). Moved here from §3D (restored, equivalent).
🟢MAX_EPISODE_STEPS 5000 (low-level step cap never binds)LLM_base_task.yaml:2Reset knob max_episode_steps=5000 (2026-07-04; env default 500 could truncate a worst-case multi-hop episode → success=0 without a STOP).
🟢Action-ID parse re.findall(r'\d+', action)[0]prompt_manager.py:119–125Same digit extraction (parse_pathagent; port adds tolerant id-object handling)
🟢Per-step VLM call count: 4 proposer + 1 PathAgentzero_shot_agent.py:708–740, :806vlm1 + 1× vlm2 in the graph
🟢Episode horizon 5 high-level steps; response_format=json_objectzero_shot.bash:17 (IL.max_traj_len 5 — the release bash overrides zero_shot_eval.yaml:45's 15); zero_shot_agent.py:807step_budget=5 (fixed 2026-07-04 — the graph ran 15, citing the overridden yaml); both llmCall set json_object
🟢DEFAULT_PLANNING / INIT_HISTORY seed stringszero_shot_agent.py:683; prompt_manager.py:64Verbatim constants in _prompts.py
🟢Depth de-normalized ×10: ob_depth * 10.0utils.py:26Same formula; achieved via project_waypoints.depth_scale=10 graph config rather than inline code — output identical
🟢RGB and depth equal-resolution before projection (depth indexed at RGB pixel coords)utils.py:115Both sensors now render 512 natively (reset knobs, 2026-07-04) — depth indexes 1:1 like upstream; the NEAREST resize-to-intrinsics-res guard in _decode_depth_m stays for non-512 configs
🟢PromptManager.history / .planning cross-step stateprompt_manager.py:8–9Same two-key persistence; relocated to graph_state containers (history TEXT + planning ANY/list, lifetime=episode) — identical cross-step data availability
🟢Forward-cone assert π/4 ≤ θ ≤ 3π/4utils.py:128No-op for all in-frame pixels in a 90° HFOV view; absent from _project_pixels — behaviour-neutral
🟢View direction labels front/left/backward/right for 0°/90°/180°/270°zero_shot_agent.py:708bundle.view_dir = front/left/backward/right matching upstream's image_list order; geometry uses numeric heading_deg
🟢VLM content: text-first then images, per-image detail:'low'GPT_api.py:58–71Aligned 2026-06-18 globally via call.py

B. Forced by substrate (script → typed graph)

Mechanism must differ due to AgentCanvas's typed-graph constraints; behaviour may or may not be equivalent. (The former Options-text-position row was fixed 2026-07-04 — the line now rides the first option image's label, reproducing upstream's content order — and moved to §3A.)

Equiv.UpstreamPortWhy forced
🟢When no view yields candidates, the GPT call is skipped and the step short-circuits to Stop (zero_shot_agent.py:805–830)vlm2 still fires (a static graph cannot conditionally skip a node); parse_response then force-STOPs on n_candidates=0, discarding the replyBehaviour identical (STOP either way); one wasted VLM call per empty step is a cost-only divergence inherent to static-graph execution
🟢Two-process file-based communication: the py37 rollout writes PNGs and polls for the py310 Grounded-SAM+Gemini watcher's JSONs (llm/utils.py:9–21, grounded_sam_Gemini.py:439–448)Direct wires: ground_mask is a server-mode call, the proposer is an in-graph llmCall; no disk round-trip, no polling sleepsThe graph substrate replaces upstream's ad-hoc IPC; payloads (PNG bytes, JSON) are the same content

C. Forced by environment / cost

(The model substitutions themselves are choices, not constraints — gpt-4o is API-accessible — so they moved to §3D with their recorded rationale; what stays here is what the chosen model forces.)

Equiv.UpstreamPortReason
🟠Deterministic sampling: Gemini temp 0 (grounded_sam_Gemini.py:365), GPT-4o temp 0 / max_tokens 600 (GPT_api.py:44)Both llmCalls: temp 1.0 / max_tokens 2000Forced by the gpt-5 family — temp≠1.0 or short max_tokens silently returns empty (platform/project_gpt5_llmcall_params.md). Consequence: no deterministic mode → run-to-run variance dominates small-N evals (§4)
🟡Proposer parse: bare list-of-int IDs (Gemini); a malformed ID kills the whole view (the rollout's blanket except, zero_shot_agent.py:721–725); duplicate IDs kept_ints tolerates string/object IDs ("id_2", {"id":2}); parse_proposal normalizes object-form Paths {"Waypoint":id,"Path":[…]} aligned by waypoint id, drops just the bad/duplicate ID rather than the viewForced by gpt-5-mini’s real output shape (upstream Gemini returns clean ints) — NOT decorative: removing it suppressed D-2 multi-hop (run 20260617_165734). Re-filed here from a mistaken D-6 ‘strict’ trim

D. Intentional divergences

Equiv.UpstreamPortRationale (recorded)
🟢Supervised TRM + ETPNav constructed & checkpoint-loadedAbsent (dropped)Pure-FM framing — and the audit confirms they are dead-loaded at eval (forwards never called, zero_shot_agent.py:636–986), so the drop is behaviour-neutral. (project_aoplanner_port.md L12)
🟠Proposer = Gemini-1.5-pro (Gemini.py:14); PathAgent = gpt-4o-2024-05-13 (zero_shot.bash:19)Both VLMs = gpt-5-mini (llmCall profile)Deliberate substitution — both VLMs unified to one provider/tier for the AAS benchmark; choice, not constraint (gpt-4o is accessible). User decision 2026-06-16 (project_aoplanner_port.md L11). Consequence for §4: paper-gap attribution stays a hypothesis until a paper-tier control run.
🟡A PathAgent ID naming a past ghost (in [0, total) but not this step) replays that ghost's stored world-coord path from the current pose — waypoint_path_coord accumulates per episode (zero_shot_agent.py:686, :732, :838–844; the :861–866 fallback re-picks only the history image, not the path)No cross-step path store: a past-ghost ID falls back to the most-forward current candidate (resolve_action.fallback=forward). IDs ≥ total STOP exactly like upstream (§3A).Cross-step ghost memory (D-3) deferred by user 2026-06-17 (project_aoplanner_port.md L23) — replicating the replay needs per-gid world-path storage + a world-target step verb. Ghost-ID minting itself is aligned (episode-monotonic, §2.2); upstream's option set is current-step-only too (§3A), so the divergence is confined to this replay path.
🟡Waypoint id 0 (the foot anchor) is a legal destination if the proposer names itparse_proposal.exclude_foot_point=True drops id 0 from destinations (still a valid route start)D-5 decision 2026-06-17 — a destination at the agent's own feet is a no-op hop; the foot's ~0-depth start contributes no movement either way (project_aoplanner_port.md L23)
🟡Proposer user content = "Image 0:" label + the annotated image (Gemini, Gemini.py:33–38)vlm1 template = "Provide your answer as the specified JSON object." + the imagePort glue — llmCall requires non-empty user text, and gpt-5-mini in json_object mode needs the JSON nudge; replaces Gemini's bare image label (§2.5; project_aoplanner_port.md M4a)

E. Unexplained / defects — none open

Probed (2026-07-04, iteration 3, source-first): the release run-config chain (run_r2r/zero_shot.bashzero_shot_eval.yamlLLM_base_task.yamlbase_il_trainer.eval()/_set_config effective values) · zero_shot_agent.py full rollout including the hand-computed metric block (:917–941) · grounded_sam_Gemini.py full · llm/utils.py full · prompt_manager.py full · GPT_api.py/Gemini.py (packing, detail, sampling) · environments_llm.py full (step dispatch, only_stop/act-0 semantics) · graph_utils.py ZeroShotGraphMap · measures.py POSITION/TopDownMap agent_angle · collect_val_traj episode-set construction · fork-diff vs parent ETPNav (README lineage; deltas = the new llm/+zero_shot_agent+environments_llm+run_r2r/ files and comment-level edits to inherited files — nothing live hides in the inherited tree). Iteration 3 surfaced 8 findings (512×512 render, horizon 5, empty-view leak, out-of-range STOP, count-mismatch branch, proposer label scheme, route-drawing style, MAX_EPISODE_STEPS) — all fixed 2026-07-04 and re-filed to §3A rows above; earlier passes' findings (2026-06-17: 7; 2026-06-19: 3) are likewise closed into §3A/§3D. Zero open rows; history in the changelog.

4. Evaluation

WhatNumberEvidence
Paper-reported, zero-shot (R2R-CE val_unseen, full split)SR 25.5 · OSR 38.3 · SPL 16.6 · NE 6.95arXiv 2407.05890 Table 1 (fetched 2026-07-04), Gemini-1.5-Pro proposer + GPT-4o PathAgent. (The paper's distilled/supervised variant reaches SR 47 / SPL 33 — that's the TRM-trained pipeline this port deliberately excludes, §3D.) The port's SR 0.35 sits above the paper's zero-shot line, but the comparison is not same-model (gpt-5-mini both roles, §3D), not same-split (rand100 n=100 vs the full 1839-ep val_unseen), and rides the v1-2→v1-3 dataset revision — attributing the +9.5-pt delta to the newer model tier is a hypothesis, not a measurement.
Faithful-config headline (2026-07-04) — 512×512 render · step_budget 5 · MAX_EPISODE_STEPS 5000 · rand100 split (100-ep random sample, avoids val_unseen's easy-front-loading), gpt-5-mini both VLMs, 10 workersSR 0.35 (35/100, 95% CI ±0.09) · oracle 0.50 · SPL 0.205 · nDTW 0.405 · d2g 6.53 m · path 14.86 m · 112 low-level steps/eprun 20260704_011258 — 100/100 episodes completed, zero errors/timeouts, 59 min wall. Single seed at forced temp 1.0 (§3C) — treat the CI as sampling-only; run-to-run VLM variance rides on top. vs the pre-alignment SR 0.10: the config alignment (candidate density ×9, horizon 5, native 512 depth) did real work, though the old number was a different (easier) slice.
Faithful-config smoke (2026-07-04) — same config, 2 ep val_unseenpipeline GREEN (SR 0/2 is noise at n=2)run 20260704_003651: 2/2 completed with full metric harvest; path 13.4/25.6 m in 5 high-level steps (108/198 low-level actions); in-vivo asserts from log.jsonl: 512×512 PNG option images, gpt-5-mini in inner_log, per-view candidates 6–15 with live >40/==1 aborts (count=60/73/1), episode-monotonic ghost IDs (step 0 → 0..4, step 1 → 5..9), empty views excluded, history image prepended at t>0, post-loop emit_stop→final_step→evaluate exactly 1× each.
Historical (pre-faithful-config: 224 px · step_budget 15) — gpt-5-mini, val_unseen, 20 epSR 0.10 (2/20) · oracle 0.20 · SPL 0.076 · nDTW 0.31run 20260617_122148 (10 workers; closest d2g 1.53 m). All 2026-06-17 §3E fixes in; superseded by the 2026-07-04 config alignment — kept as history, not a config reference.
Historical — same lineage, 10 epSR 0.10 · oracle 0.30 · SPL 0.036 · nDTW 0.26 · path 20.95 mrun 20260617_010112. Easy-front-loaded slice (R2R val_unseen stratification) — optimistic, not headline.
Historical — same slice, pre-depth-fixSR 0.00 · oracle 0.00 · nDTW 0.47 · path 4.07 mrun 20260616_233000 — the depth-normalization bug (below); every hop ≤1 m so no episode reached a goal

Metric rulers, pinned on both sides. Upstream hand-computes its metrics from the POSITION measure at episode end (zero_shot_agent.py:917–941): success = final geodesic ≤ 3 m after the episode's STOP (every exit path stamps one, incl. the last-step move-and-stop), SPL = success·d₀/max(d₀, path_length) with d₀ = the start geodesic (distances[0]), nDTW = exp(−dtw/(len(gt)·3)) against {split}_gt.json.gz locations, oracle = any low-level-step distance ≤ 3. The port reads habitat's official VLN-CE measures (evaluate → SUCCESS/SPL/NDTW/ORACLE_SUCCESS/PATH_LENGTH), whose formulas are the same expressions — success is stop-gated (the port's emit_stop chain stamps the STOP), SPL's shortest-path term is the same start geodesic, and measures update per low-level primitive (same trajectory granularity, since step_path drives real env.step calls). The two rulers are formula-equivalent; the one dataset-level caveat: our env serves R2R_VLNCE v1-3 (VLN-CE repo default), upstream's yaml points at v1-2 — same task, slightly different episode/gt revisions, so cross-checking against the paper's exact number additionally rides that revision.

The depth-normalization fix (between the two runs above). The first real eval scored SR 0 with every hop ≤ 1 m. Root cause: env_habitat emits normalized [0,1] depth (habitat NORMALIZE_DEPTH=True, MAX_DEPTH=10), and the port decoded it as metres — exactly the de-normalization upstream does at utils.py:26. Angles were correct (scale cancels in atan2), only distances were 10× short. Adding depth_scale=10 (+ a depth resize to the RGB/intrinsics resolution — moot since the 2026-07-04 native-512 alignment) lifted SR 0→0.10, oracle 0→0.30, path 4→21 m. Both changes are upstream-parity restorations (§3A), not new behaviour.

Sampling variance dominates at this N. The §3E fixes are in (run 20260617_122148), but their SR effect is below the noise floor: on the shared first-10 val_unseen episodes, headline SR held at 1/10 while SPL rose 0.036→0.052 and nDTW 0.26→0.40 — and the per-episode outcomes flip run-to-run (ep2 ✗→✓ at d2g 9.05→1.53; ep8 ✓→✗ at 2.21→11.67; ep1 d2g 1.72→12.30 on the same seed). Cause: gpt-5-mini is forced to temperature=1.0 (the gpt-5 family returns empty otherwise — no deterministic mode), so at 10–20 episodes the run-to-run variance exceeds any fix/tuning delta. A trustworthy headline needs the 216-ep MapGPT72 slice or multi-seed averaging; treat SR ≈ 0.10 as a noisy current estimate.

Known gaps. (1) Resolved 2026-07-04: the graph is promoted to vln/verified/ on the 100-ep random-slice headline (SR 0.35, above). (2) Paper-comparability: any claim about the gap (in either direction) vs the paper's number rides the §3D model substitution and the v1-2→v1-3 dataset revision — attribution to model tier is an unverified hypothesis until a Gemini-1.5-pro + gpt-4o control run. (3) The §3D fallback-vs-replay residue (past-ghost IDs) fires only when the PathAgent names a stale ID — rare but real. (4) Single-seed at forced temp 1.0: multi-seed averaging would tighten the headline.

5. Usage

# load — aoplanner (local, in-process) + the Grounded-SAM server nodeset
POST /api/components/nodesets/aoplanner/load
POST /api/components/nodesets/model_grounding_dino/load?mode=server   # detany3d conda env

# run (graph-only form; JobScheduler admits by profile)
# step_budget=5 = the paper horizon (IL.max_traj_len 5); per_step_budget_sec
# covers the ~75 s/step of the 512-px faithful config (4x Grounded-SAM + 5 VLM calls)
/experiment:run aoplanner-ce aoplanner_ce split=val_unseen episode_count=10 worker_count=10 step_budget=5 per_step_budget_sec=150

Graph: workspace/graphs/vln/verified/aoplanner_ce.json. Profile aoplanner-ce (vram 8000, non-exclusive) in .claude/experiment/profiles.yaml. The four perception lanes serialise on the single-GPU Grounded-SAM server, so high worker counts queue there.

6. What this nodeset is NOT

7. Source files

WhatPath
Method nodesetworkspace/nodesets/method/aoplanner/__init__.py (15 tools) + _prompts.py (verbatim prompts/parsers) + test_aoplanner_geometry.py (unit suite, all-pass gate)
Grounded-SAM nodesetworkspace/nodesets/model/model_grounding_dino.py (server-mode; ac-detany3d env)
Graphworkspace/graphs/vln/verified/aoplanner_ce.json (55 nodes, 117 edges)
Upstreamchen-judge/AO-Planner @ 719f42a1 — pinned clone via workspace/nodesets/_upstream/aoplanner/fetch_upstream.sh (tree at …/aoplanner/upstream/)
Envworkspace/nodesets/env/env_habitat.py (gym-like contract)
AgentCanvas docs