Poller/Stager Pattern — Standalone Interview Fork
Status: ACTIVE (concept) Agent: opencode/ext-agent (sandshrew) Timestamp UTC: 2026-05-12T00:30:00Z Session: MjF design — categorical nesting + polling interview + stager fork, separate from active unit instance
Prior Context
- [[correction-interview-protocol]] — Branching interview trees for corrections
- [[gamepad-input-patterns]] — Gamepad-friendly input patterns
- [[langgraph-live-prompt-injection]] — Player corrections at nodes
- [[langgraph-context-curation-pattern]] — Cross-node context routing
The Separation
Two actors, one node:
UNIT INSTANCE POLLER/STAGER (forked)
──────────────────── ────────────────────────
Owns the node's work Spawned on demand
Calls the agent harness Runs interview loop
Produces output Polls player with MC
Reads staged config from state Refines iteratively
Writes output to state Stages config to state
Never talks to the player Only talks to the player
The unit does the work. The poller helps the player configure what the unit should do. They communicate through state — the poller writes, the unit reads. The poller is a fork, not a sub-routine. It spawns, runs, resolves, and the unit picks up the results.
Top-Line Categories (The Entry Points)
When a node produces output and the player reviews it, these are the top-line options:
| Category | What It Triggers | Poller Behavior |
|---|---|---|
| Retry | Re-run the node with corrections | Interview: what was wrong? → MC drill-down → stage corrections |
| Deepen | Add depth to a specific section | Interview: which section? → what's missing? → MC options |
| Redirect | Change the unit's approach | Interview: what direction instead? → MC presets |
| Config | Change model/harness/tools | Direct selection (no poller needed — just pick) |
| Route | Send output to another node | Existing routing submenu (no poller needed) |
| Accept | Output is good, mark complete | No poller. Done. |
"Retry" and "Deepen" and "Redirect" trigger the poller. "Config" and "Route" and "Accept" are direct actions.
The Poller Interview Loop
The poller is a LangGraph subgraph or forked node with its own interrupt cycle:
┌─────────────────────────────────────────────────────────┐
│ POLLER INTERVIEW LOOP │
│ │
│ START → present_category_question │
│ │ │
│ ▼ │
│ interrupt() ← "What's wrong?" │
│ │ │
│ ▼ │
│ narrow_question │
│ │ │
│ ▼ │
│ interrupt() ← "Which section?" │
│ │ │
│ ▼ │
│ suggest_fix │
│ │ │
│ ▼ │
│ interrupt() ← "Would this fix it?" │
│ │ │
│ ┌────┼────┐ │
│ ▼ ▼ ▼ │
│ Close Correct Not at all │
│ │ │ │ │
│ ▼ ▼ ▼ │
│ branch stage restart │
│ deeper config from top │
│ │ │
│ ▼ │
│ "Is it A? B? C? Combination?" │
│ │ │
│ ▼ │
│ interrupt() ← player selects │
│ │ │
│ ▼ │
│ refine → loop back to "Would this fix it?" │
│ │
│ ▼ (Correct) │
│ stage_config → write to state → END │
└─────────────────────────────────────────────────────────┘
The Interpretation Echo
After every player selection, the poller says back what it thinks:
┌──────────────────────────────────────────────────────────┐
│ POLLER: "You said: output too shallow. Missing: │
│ performance benchmarks for the sprite sheet. │
│ Suggested fix: add a 'Performance' section with │
│ frame-time estimates for 36 hex renders." │
│──────────────────────────────────────────────────────────│
│ Is this what you meant? │
│ [A] Correct [X] Close [Y] Not at all [B] Back │
└──────────────────────────────────────────────────────────┘
Correct → poller writes the staged config and resolves.
Close → poller branches deeper: "Is it A. wrong metric, B. wrong section, C. too specific, D. combination?" Player selects. Poller refines. Loops back.
Not at all → poller restarts from the top category question.
The Close → Branch Deeper Tree
When the player says "Close but not exactly":
┌──────────────────────────────────────────────────────────┐
│ "Close but not exactly..." │
│──────────────────────────────────────────────────────────│
│ What's off? │
│ ▶ A. The suggested section is wrong │
│ ▶ B. The metric is wrong (should be different data) │
│ ▶ C. Too specific — need higher-level guidance │
│ ▶ D. Combination of above │
│──────────────────────────────────────────────────────────│
│ [A-D] Select [B] Back │
└──────────────────────────────────────────────────────────┘
If D (combination), the poller presents a multi-select:
┌──────────────────────────────────────────────────────────┐
│ Select all that apply: │
│ ☑ A. Wrong section │
│ ☐ B. Wrong metric │
│ ☑ C. Too specific │
│──────────────────────────────────────────────────────────│
│ [A] Toggle [X] Confirm [B] Back │
└──────────────────────────────────────────────────────────┘
The poller then refines based on the combination and echoes back again. The loop continues until the player says "Correct" or aborts.
State: How Poller and Unit Communicate
The poller and unit share state keys. The poller writes. The unit reads.
# After poller resolves (player says "Correct"):
state["staged_configs"]["node_14"] = {
"action": "retry", # what the unit should do
"corrections": [ # accumulated corrections
"Add performance benchmarks section",
"Focus on 36-hex render time specifically"
],
"context_additions": [ # new context to include
"wiki/research/sprite-formats.md#performance"
],
"resolved_at": "2026-05-12T00:30:00Z",
"poller_id": "poller-14-001" # traceability
}
The unit, on its next invocation, reads staged_configs[node_id] and applies it:
def hex_14(state):
staged = state.get("staged_configs", {}).get("node_14")
if staged and staged["action"] == "retry":
# Build system prompt with staged corrections
system_prompt = f"""
Previous output was corrected by player.
Player said: {staged['corrections']}
Additional context: {staged['context_additions']}
Restructure accordingly.
"""
# ... run agent with corrected prompt
The Fork Mechanism
The poller is a separate LangGraph node or subgraph. It's triggered when the player selects "Retry" or "Deepen" or "Redirect" from the interrupt menu.
# In the unit's interrupt handler:
action = interrupt({
"options": ["accept", "retry", "deepen", "redirect", "config", "route"]
})
if action == "retry":
# Fork to poller — this runs the interview loop
return Command(goto="poller_node", update={"poller_context": {
"trigger": "retry",
"source_node": "node_14",
"unit": "rif"
}})
# The poller runs its interview, stages config, and returns:
# Command(goto="node_14", graph=parent) — back to the unit
The poller has its own interrupt() loops. The player interacts with the poller, not the unit. The poller resolves, writes to state, and routes back to the unit. The unit reads the staged config and continues.
Why This Separation Matters
| Without Poller (unit does everything) | With Poller (forked) |
|---|---|
| Unit's node function handles both work AND interview logic | Unit only handles work |
| Interrupt options mixed with agent logic | Poller's interrupts are clean interview steps |
| Hard to retry mid-interview | Poller loops independently until resolved |
| Player correction directly modifies unit state | Poller stages corrections; unit applies them atomically |
| One node function = one responsibility violation | Two functions = clear separation of concerns |
The poller is a "configuration session." The unit is a "work session." They're different activities with different interaction patterns. Separating them keeps both clean.
The "Graceful Resolve"
The poller always resolves. It never hangs. Exit paths:
- Correct → poller stages config, returns to unit. Unit applies and re-runs.
- Abort → player presses Start during poller. Poller saves partial state ("interrupted by player"), returns to unit. Unit keeps existing output, marks node as
in_progresswith note. - Timeout → if poller runs too many interview rounds (recursion limit), it auto-resolves with best-effort config and a note: "Interview truncated after N rounds. Best interpretation staged."
The unit never waits for the poller indefinitely. The poller is a fire-and-resolve pattern.