Owner correction (2026-09-26, later): a real lesson is ONE continuous teach->quiz->teach run - there are no run-would-stop moments mid-lesson, and the run ends that DO exist are quiz stopper exits where delivery must not fire. Subagent results must land BETWEEN messages INSIDE the run, never splitting a toolCall from its toolResult. Vehicle 1 now hands markers to agent.steer() - pi's STEERING queue: the loop drains it at TURN BOUNDARIES (after a completed assistant message AND its tool results - the bonded pair survives injection) and again at RUN START (a continuation drains before its first LLM call), then CONTINUES the run with the marker in context. steeringMode "all" replaces followUpMode. registerLiveAgent moved BEFORE runPiTurn and now hands pending markers itself: the start poll drains the queue SYNCHRONOUSLY inside the runPiTurn call, so a later registration would miss it. A marker stranded by a quiz-terminated run therefore rides the ANSWER run's first LLM call - the native replacement for the old continuation coalescing. Settlement reconciliation, the idle wake, probe-handoff exclusion, and the registry-as-truth design are UNCHANGED; stranding is now exactly the stopper-exit case. Tests: jobs 45/45 (fakeAgent.steer; new pin - registration steers pending markers into the new run, parked wake never fires); chat-flow 24/24 (new e2e through the real composition - a gated mid-run turn, a marker delivered while the turn streams, record shape [call, result, MARKER, response] with the marker adjacent to the completed unit, the model SEES the marker in its next prompt, zero wakes); full suite 999/999, tsc/lint/build green. Live smoke (PORT=3004): research dispatched in the probe turn parked behind the dangling quiz; on the quiz answer the marker injected at the answer run's START poll - record [answer result, marker, teacher response], job flipped done/delivered at settlement, brief card rendered with its sources toggle. Plan doc: docs/PI-NATIVE-MIGRATION-PLAN.md gains the pivot section. Deferred (owner list): thinking blocks rendering after messages; vanishing tool-call output tokens.
625 lines
42 KiB
Markdown
625 lines
42 KiB
Markdown
# Pi-native architecture migration plan
|
||
|
||
Recon wave 2026-09-26 · READ-ONLY deliverable · `feat/platform` · untracked until owner review
|
||
Evidence: wiretap `/tmp/lh-wire/`, 11 controlled OpenRouter replays (`/tmp/rp-*.json/.out`), pi-ai + pi-agent-core dist sources, pi's own session files under `~/.pi/agent/sessions/`.
|
||
|
||
---
|
||
|
||
## 0. Executive summary
|
||
|
||
- **Two banked facts are overturned by the wiretap evidence** (§1). The tool-stripping mystery was model confabulation — the registry was complete on the wire. The truncation is an upstream (OpenInference) bug triggered by multi-tool-result history volume, not by our serialized shape.
|
||
- Five custom seams mapped against pi-native equivalents (§2). Each has a native replacement; the getApiKey fix (76051d0) is the template: delete the custom seam, use the library path.
|
||
- What genuinely has no native equivalent is small and enumerable (§3): question-tool UX, tool toggles/phases, jobs, prompt overrides, our reattach/persistence infrastructure.
|
||
- Proposed risk order (§4) and test strategy (§5). Seams touching `src/agent` are flagged for Teacher's review per band rules.
|
||
|
||
---
|
||
|
||
## 1. Corrections to banked facts (evidence first)
|
||
|
||
### 1.1 TOOL-SET STRIPPING — SOLVED: the model confabulated; no seam varies the registry
|
||
|
||
- Wiretap captures **7 and 9 are chat_muhb8l00's two exact turns** (timestamps 18:44:47 / 18:48:10 match the record created 18:44:41Z). **Both carried the full 11-tool registry** — `quiz, ask_user_question, read, ls, find, grep, research, pdf, list_sessions, list_propositions, probe_done` — including the graph tools the model claimed not to have.
|
||
- The response-side capture shows the same request streaming the "no graph tools in this session… the harness is stripped to the two question tools" answer with `finish_reason: stop`. The claim is false; z-ai/glm-5.3-flash (relayed via OpenRouter upstreams Relace/OpenInference) confabulated a toolset, likely trusting prose descriptions over the actual `tools` array.
|
||
- Census of every "missing tool" claim across all master chat records (`data/chats/*.json`):
|
||
- `chat_mtxsy1lq` (09-12), `chat_mucnl5j3`/`chat_mucnyjvu` (09-22): honest — probe-phase (no graph tools yet) and/or research correctly absent (no search key → `searchAvailable` false).
|
||
- `chat_mu73galx` (09-18): the closed "no sub-agents" scare (probe-phase prober, teacher tools are teacher-only).
|
||
- `chat_mufpa030` (09-24): model accurately enumerated its full toolkit; quiz worked.
|
||
- **"Another session reportedly lacked quiz entirely": not found in any record** (regex census over all chats).
|
||
- Verdict: `tools.ts` gating and `chat-handler`'s ctx passing work as designed. No `src/agent` change required for this mystery (flagged for Teacher anyway since tools.ts is in that band). Mitigation is model-facing (stronger system-prompt tool enumeration, or dropping flash for tool-heavy phases) — an owner decision, not a seam fix.
|
||
|
||
### 1.2 HISTORY-SERIALIZATION TRUNCATION — REFINED: upstream bug on multi-tool-result histories; our shape is not the trigger
|
||
|
||
11 controlled replays today against `z-ai/glm-5.3-flash` via OpenRouter (all served by the **OpenInference** upstream; the original truncating request `/tmp/replay.json` = wiretap capture 2, the ML session):
|
||
|
||
| variant | change vs. truncating request | prompt_tokens | outcome |
|
||
|---|---|---|---|
|
||
| base | verbatim | 7006 | **TRUNC** (4 chunks, ~22 chars reasoning, billed 258 completion, false `finish: stop`) |
|
||
| A | assistant content cleaned (no glued thinking), parallel calls kept | 6803 | **TRUNC** |
|
||
| B | parallel calls split into two sequential assistant turns | 7017 | **TRUNC** |
|
||
| G | `reasoning` param removed | 7006 | **TRUNC** |
|
||
| H | `max_completion_tokens` 131072→8192 | 7006 | **TRUNC** |
|
||
| D | both tool results shortened to ~30 chars | 5982 | works (11 tool calls) |
|
||
| P | two results at 100+100 chars | 6037 | works |
|
||
| L | two results at 300+300 chars | 6201 | **TRUNC** |
|
||
| K | two results at 700+700 chars | 6564 | **TRUNC** |
|
||
| J | big result (1564) + tiny (30) | 6658 | **TRUNC** |
|
||
| E | single result (1564) | 6626 | works |
|
||
| F | single result (802) | 6296 | works |
|
||
| C9 | capture 9 verbatim (the request that streamed the confabulation live) | 6146 | works (332 chars) |
|
||
| I | single result padded to 2764 chars | 7347 | works (4 tool calls) |
|
||
|
||
Readings:
|
||
- **Not the glued-thinking content** (A truncates clean), **not parallelism** (B truncates sequential), **not prompt size** (I streams 7347 tokens; L truncates at 6201), **not the reasoning param or completion cap** (G, H).
|
||
- **Trigger: ≥2 tool-result messages with combined content ≥ ~200–600 chars** (P works at 200, L truncates at 600, J truncates with 1564+30). A single tool result of any tested size (≤2764 chars) streams fine. Direct Anthropic/OpenAI and non-flash GLM tolerate everything.
|
||
- The failure mode is consistent: a sliver of reasoning streams, 150–260 completion tokens are billed, `finish_reason: stop` arrives with ~zero content — a silent upstream truncation, stable and deterministic 4+ hours after the live capture.
|
||
- **Consequence for the plan:** pi-native serialization would NOT have prevented this — tool results are byte-identical on the wire either way. The live bug needs provider-side action (report to OpenRouter; or pin/avoid the flaky upstream for flash; short-term: route flash traffic elsewhere). The migration below is still correct, for the reasons in §1.3 — but it is not the truncation fix, and saying otherwise would be false comfort.
|
||
|
||
### 1.3 What IS wrong with our serialization (the honest indictment of record-adapter)
|
||
|
||
- `src/server/pi/record-adapter.ts:161-163` stamps every replayed assistant message `provider: "openrouter", model: "replayed"` → `transformMessages` (`pi-ai/dist/api/transform-messages.js:66`) sees `isSameModel === false` for **every** replayed turn, forever.
|
||
- Cross-model path converts thinking blocks to plain text (`transform-messages.js:78-81`), which `openai-completions.js` then glues into the assistant content string (no separator — capture 2's content is literally `"<thinking>…question.<text>There's already…"`).
|
||
- `thinkingSignature` cannot survive the UIMessage round-trip (records persist text only), so replayed turns never restore `reasoning_details`. pi's own sessions do: every assistant line in `~/.pi/agent/sessions/**/*.jsonl` carries full `provider/model/api/usage/stopReason` + `thinkingSignature`, which is why pi's replay stays same-model and signature-faithful.
|
||
- Net: every history turn we send diverges from what pi itself would send — discarded reasoning state, cache-hostile, and any provider quirk about reasoning-in-content becomes ours to keep debugging.
|
||
|
||
---
|
||
|
||
## 2. Seam-by-seam map: custom remnant → pi-native replacement
|
||
|
||
### Seam 1 — Message history persistence & replay
|
||
- **Now:** `LHChatRecord` stores Vercel-SDK-shaped UIMessages (part states `input-available`/`output-available`, `data-*` UI parts); `src/server/pi/record-adapter.ts` (`recordToPiMessages`, 189 lines) hand-rebuilds pi-ai `Message[]` per turn; marker-only user messages flatten to canonical text; dangling tool calls get synthesized `INTERRUPTED` results; chat-handler rebuilds history on every POST.
|
||
- **Native:** pi persists append-only JSONL records of full pi-ai messages (verified in pi's own session files); `Agent` accepts `initialState.messages` (`pi-agent-core/dist/agent.js:21-45`); custom message types ride `CustomAgentMessages` declaration-merging (`types.d.ts:269-283`) and are filtered from the LLM view by the default `convertToLlm` (`agent.js:8-10`) or mapped in a `transformContext` hook (`agent-loop.js:179-183`).
|
||
- **Replace with:** store pi `AgentMessage[]` natively in the chat record (custom messages for plan blocks/artifacts/verdict markers instead of `data-*` parts); replay = `initialState.messages` from the store; dangling-call synthesis and marker flattening move into one `convertToLlm`/`transformContext` hook — one place, tested once.
|
||
- **Delete:** `record-adapter.ts` wholesale; the UIMessage part-state model as the history format (UI gets a transcript view model instead).
|
||
- **Kills:** the `model: "replayed"` stamp, thinking-glue, lost signatures (§1.3) — replayed turns become same-model, signature-faithful, cache-friendly.
|
||
|
||
### Seam 2 — Tool definition wrapper
|
||
- **Now:** `src/agent/lh-tool.ts` (244 lines): `LhTool {name, description, io: zod, execute(args, ctx), clientQuestion}`; `toPiTool` bridges via `z.toJSONSchema`, re-runs zod validation inside `execute` so `superRefine` failures throw `ToolInputError`; `TOOL_TIMEOUT_MS` 30s + 250ms abort grace; `clientQuestion` → `executionMode: "sequential"`.
|
||
- **Native:** `AgentTool` (`pi-agent-core/dist/types.d.ts:340`): required `label`, TypeBox `parameters`, optional `prepareArguments` shim (raw-arg rescue — replaces our re-validation hack), `execute(toolCallId, params, signal, onUpdate)`, per-tool `executionMode`, `replay` policy. pi's own tools are `ToolDefinition`s (`pi-coding-agent/dist/core/extensions/types.d.ts:344`: `promptSnippet`, `promptGuidelines`, `renderCall`, `renderResult`) wrapped by `wrapToolDefinition` (`core/tools/tool-definition-wrapper.js`; reference impl `core/tools/read.js`: TypeBox `Type.Object` schema + snippet/guidelines).
|
||
- **Replace with:** TypeBox-schema ToolDefinitions; `prepareArguments` for arg rescue; timeout stays inside `execute` (it's ours to keep, but as execute internals, not a wrapper layer).
|
||
- **Delete:** `LhTool`, `toPiTool`, the zod→JSON bridge, the `clientQuestion` flag (`executionMode: "sequential"` already expresses it).
|
||
- **Teacher flag:** `src/agent` band.
|
||
|
||
### Seam 3 — Tool registration gates
|
||
- **Now:** `src/agent/tools.ts` (244 lines): prober/teacher registries, `filterEnabledTools` (drops `enabled[name] === false`), `probe_done` re-added always-on, `research` gated on `searchAvailable`, graph tools gated on `ctx.graphStore`, quiz schema differs per phase.
|
||
- **Native:** registration is plain array assembly (`wrapToolDefinitions`); pi has no gating concept because gating is app policy.
|
||
- **Verdict:** the gating logic itself is app policy, works (§1.1 proved no stripping), and stays — but it assembles ToolDefinitions from seam 2 instead of LhTools. `filterEnabledTools` + toggles map unchanged onto the new shape.
|
||
- **Teacher flag:** `src/agent` band.
|
||
|
||
### Seam 4 — Question tools & turn stopping
|
||
- **Now:** `src/agent/teacher-agent.ts` `makeStopper` ends the run on a cleanly-resolved question tool call (maxTurns 20 default); question tools `execute` returns a synthetic `'Question posed'` placeholder never persisted; the client renders the question and the learner's answer arrives as the next user message.
|
||
- **Native:** `AgentToolResult.terminate: true` — `agent-loop.js:385` `shouldTerminateToolBatch` stops the loop after a batch whose results all terminate; `types.d.ts:237/330` document the flag; `shouldStopAfterTurn` (`agent-loop.js:154`) covers cross-batch policy.
|
||
- **Replace with:** quiz/ask execute → `{ content, terminate: true }`. Delete `makeStopper`. The pending-question UX (client rendering, answer flow) is app-level and stays as-is.
|
||
- **This is the cleanest example of the whole wave:** our stopper re-implements a library flag.
|
||
|
||
### Seam 5 — Agent construction & steering
|
||
- **Now:** stateless rebuild per POST (`teacher-agent.ts` — fresh Agent per turn, `composeInstructions` + `effectivePromptSegments` per turn, maxTurns); steering via `claimSteers` coalescing + the wake pipeline's deferred turn.
|
||
- **Native:** stateful `Agent` (`agent.js`): `initialState`, `subscribe` listeners awaited in settlement, `steer()`/`followUp()` queues (`one-at-a-time`/`all` — `steer()` is literally our wake-message semantics), `sessionId` forwarded for cache-aware providers, `thinkingBudgets`, per-agent `toolExecution` default.
|
||
- **Replace with (staged):** keep stateless rebuild first, but feed `initialState.messages` from the native store (seam 1) — same wire, smaller step. A stateful per-chat Agent registry (mirroring `turn-runs.ts`) with `steer()` for the wake path is a later, optional consolidation.
|
||
- **Keep:** `composeInstructions`/prompt overrides (app feature), write-ahead persistence, turn gates.
|
||
- **Teacher flag:** `src/agent` band.
|
||
|
||
### Seam 6 — SSE / streaming bridge
|
||
- **Now:** `src/server/pi/ui-stream.ts` (423 lines): `runPiTurn` runs the loop, keeps a memory-only chunk log + subscriber set (background-turn wave 00a92e4), and hand-maps pi `AgentEvent`s into the Vercel `UIMessageStream` chunk dialect (text/thinking/toolcall mapping, question-tool output suppression, input gating); `[DONE]` terminal.
|
||
- **Native:** pi events are already a typed stream (`AgentEvent`: `agent_start`/`message_update`/`turn_start`/`turn_end`/`agent_end`, carrying partial+final messages; `pi-ai` `AssistantMessageEventStream` underneath); pi's own client binds via transcript snapshots + event subscriptions (`pi-coding-agent/dist/client/remote-session.js`).
|
||
- **Replace with:** thin passthrough — serialize pi `AgentEvent`s (or message_update partials) directly instead of remapping into UIMessage chunks; keep our subscriber-log/reattach/abort machinery (that's the 00a92e4 background-turn layer and it's good); the question-tool suppression hacks die naturally because native events carry tool-call state honestly.
|
||
- **Cost:** wire dialect changes → **UI stream coordination required** (client half dispatch after merge, same as previous waves).
|
||
- **Delete:** the event-mapping switch in `ui-stream.ts` (the log/subscriber/abort halves stay).
|
||
|
||
### Already native (the template)
|
||
- `getApiKey` (fixed 76051d0): the deleted hook is exactly this wave's pattern — custom seam removed, pi-ai's native key resolution used. Key-presence gating stays app-side at the turn gate.
|
||
|
||
---
|
||
|
||
## 3. What genuinely needs custom (no native equivalent — keep, but express natively)
|
||
|
||
1. **Question tools awaiting human answers** — closest native is `terminate: true` + app-level answer flow (the "pause the world for a human" semantics is ours).
|
||
2. **Tool toggles / phases / graphTools gating** — app policy at registration time (seam 3).
|
||
3. **Jobs + extraction pipeline** — outside the agent loop entirely; untouched by this wave.
|
||
4. **Prompt override switches** (`composeInstructions`/`effectivePromptSegments`) — app feature; stays.
|
||
5. **Write-ahead persistence, background turns, reattach subscribers, remote abort** — our infrastructure from waves 00a92e4/4d694b3; stays.
|
||
|
||
---
|
||
|
||
## 4. Risk order
|
||
|
||
1. **Wave A — tools + stopping (seams 2+3+4):** smallest blast radius; deletes lh-tool + makeStopper; gates unchanged; UI unaffected (tool names/wire identical).
|
||
2. **Wave B — native history store + replay (seam 1):** biggest win (wire fidelity, signatures, cache, kills §1.3), biggest risk (record migration for existing chats). Needs a one-time record migration + golden-master diff (§5) before cutover.
|
||
3. **Wave C — stateful agents (seam 5, optional):** only after B settles.
|
||
4. **Wave D — SSE dialect (seam 6):** wire contract change; dispatch UI half after merge.
|
||
5. **Independent, immediate:** the §1.2 truncation is an upstream bug — report to OpenRouter / route-pin away from the flaky upstream for `glm-5.3-flash`. Not gated on any wave.
|
||
|
||
---
|
||
|
||
## 5. Test strategy
|
||
|
||
- **Wire-shape pin (kills §1.3 forever):** replay a stored same-model session through `convertMessages` and assert the assistant history carries `reasoning_details`/`thinkingSignature` preserved (fails today on `model: "replayed"`).
|
||
- **Terminate pins:** question tool result `terminate: true` ends the loop natively (replaces `makeStopper` pins; mirror the existing chat-flow scripted-teacher tests).
|
||
- **Registry census pin:** faux-provider wiretap-style test asserting the full registry (11 tools incl. graph tools) is present on the wire for both phases regardless of toggles-off state — pins §1.1 shut.
|
||
- **Golden-master for Wave B:** for every stored chat, diff old adapter output vs native replay output message-by-message before cutover; migrate one throwaway copy of master `data/chats` first (established one-way copy rule).
|
||
- **Per-wave gates unchanged:** typecheck / 950+ tests / lint / build; live smoke on PORT=3004 (faux + real provider), reattach and abort paths re-verified after Wave D.
|
||
|
||
---
|
||
|
||
## 6. Open owner decisions
|
||
|
||
1. Flash-model policy for tool-heavy phases (confabulation §1.1 + truncation §1.2 both bite `z-ai/glm-5.3-flash` via OpenRouter): route-pin, provider-pin, or accept + prompt-mitigate.
|
||
2. Whether to report the OpenInference multi-tool-result truncation to OpenRouter (I can package the 15 replay files as a repro bundle).
|
||
3. Wave B record migration: in-place upgrade vs new-record-format-version with lazy dual-read (settings-delta-wave precedent).
|
||
4. Wave D timing (UI stream coordination).
|
||
|
||
---
|
||
|
||
# Wave B planning — native history store + replay (2026-09-26 recon, post-Wave-A)
|
||
|
||
Wave A landed (93b59cd): tools/stopping are pi-native. Wave B = seam 1 — the
|
||
record becomes pi-native history; `record-adapter.ts` dies. The owner's
|
||
standing rulings already settle the plan's §6.3 (record migration): **zero
|
||
backwards compat, all session data wiped, no migration** — so the golden-master
|
||
cutover is reduced to a fresh-format smoke, not a data project.
|
||
|
||
## What Wave B replaces today (the seam, precisely)
|
||
|
||
- `src/server/pi/record-adapter.ts` (189 lines): record→pi-ai rebuild per turn;
|
||
the `model: "replayed"` stamp (breaks `isSameModel` forever — §1.3), thinking
|
||
glued into content, `thinkingSignature` lost (records persist text only),
|
||
verdict/handoff marker flattening (`systemTextOf`), dangling-call
|
||
INTERRUPTED synthesis.
|
||
- `chat-handler`: write-ahead persist of the CLIENT's full UIMessage list (the
|
||
client owns the transcript between turns — useChat resend + addToolOutput),
|
||
`recordToPiMessages(outgoing)` at agent build, probe_done detection over
|
||
UIMessage parts.
|
||
- `ui-stream.runPiTurn`: assembles UIMessage parts from pi events; that
|
||
assembly is BOTH the wire dialect (Wave D's concern) AND the persist target.
|
||
- `turn-service`: `withMessageIds` (SDK client-side-id patch), plan-block
|
||
attachment onto UIMessage parts.
|
||
|
||
## Consumer census (who reads record.messages as UIMessage part shapes)
|
||
|
||
Server/core: phases (`phaseAt3` — tool-probe_done part state), pending
|
||
(`findPendingToolCall`), plan-blocks (`withPlanBlocks`), coverage
|
||
(`coverageOf`), graph extraction sources (`toolParts`), extraction
|
||
(`messageViewLines`), store summaries (phase + pendingQuestion),
|
||
`/api/chats/[id]` GET, jobs.ts (steers as data-part messages; `claimSteers`
|
||
over the POST body). Client: `useChat` POSTs the full list, renders every
|
||
part type, `mergeAdopted`, addToolOutput answers, artifact/plan/abort cards.
|
||
**None of these need the STORAGE to be UIMessage — they need a UIMessage-SHAPED
|
||
VIEW.** That distinction is Wave B's core move.
|
||
|
||
## The design: server-owned native record + one-way projection
|
||
|
||
1. **`LHChatRecord.messages: AgentMessage[]`** (pi-ai `UserMessage |
|
||
AssistantMessage | ToolResultMessage | CustomMessage`). The agent's own
|
||
final messages persist at `message_end` — provider/model/usage/stopReason/
|
||
thinkingSignature stamped by the loop itself (what pi persists in its own
|
||
sessions). §1.3 dies by construction: replayed turns are byte-faithful,
|
||
same-model, signature-preserving; replay = `state.messages` assignment,
|
||
zero conversion.
|
||
2. **Custom messages for our non-LLM content** (pi's `CustomMessage`: role
|
||
"custom", customType, content, display, details): plan-block,
|
||
subagent-artifact, subagent-aborted, question-answered markers. The
|
||
verdict/probe-handoff steers persist AS custom messages whose CONTENT is
|
||
the canonical model-facing text (verdictText / HANDOFF_TEXT written once
|
||
at append) — `systemTextOf` flattening disappears; `convertToLlm` default
|
||
passes content through, UI-only types filter via details/customType.
|
||
3. **One-way projection `AgentMessage[] → UIMessage[]`** (new
|
||
`src/server/pi/project.ts`): serves `/api/chats/[id]` GET, the summary
|
||
scans (phase/pending/coverage/plan-blocks/graph/extraction keep their
|
||
existing UIMessage code — they read the projection), and the client until
|
||
Wave D. The lossy DIRECTION (UIMessage→pi) is deleted; the projection is
|
||
Wave D's deletion target.
|
||
4. **Question answers are native tool results (owner ruling, 2026-09-26).**
|
||
The history stays a NORMAL agent history: quiz toolCall followed by its
|
||
toolResult — the process just stops in between. The placeholder result
|
||
is an in-memory loop device only (terminate:true ends the turn); it is
|
||
never persisted, exactly as today's wire suppression. On answer, the
|
||
server appends a native ToolResultMessage whose content is the same
|
||
encoding the adapter produces today (the model-facing wire is
|
||
byte-shape-identical to current behavior). The record carries the
|
||
dangling call only in the pose→answer window, where no turn can run
|
||
(the question gate holds steers), and the INTERRUPTED synthesis survives
|
||
solely for the abort case — same as today. pi's own resume path is the
|
||
mechanism: assign state.messages (record ending at the toolResult),
|
||
continue().
|
||
5. **Turn input contract**: the POST body's new-content DELTA (new user text,
|
||
answers, markers) is appended natively; the full-list body stays for now
|
||
(client unchanged — see fork 3).
|
||
|
||
## Forks — ALL RESOLVED (owner rulings, 2026-09-26)
|
||
|
||
1. **Answer semantics — normal agent history.** The answer is the tool
|
||
call's toolResult, appended natively at answer time; the placeholder is
|
||
an in-memory loop device only (terminate), never persisted. No custom
|
||
answer markers. Verified against pi's source: the loop always pairs
|
||
calls with results in a RUN, but the record may end at the assistant
|
||
message (no turn can run in the pose→answer window — the question gate
|
||
holds steers); pi's own resume path (assign state.messages, continue()
|
||
from the toolResult) is the mechanism. The model-facing wire is
|
||
byte-shape-identical to today.
|
||
2. **Storage shape — `AgentMessage[]` inside LHChatRecord JSON.** No new
|
||
dependencies; metadata stays in the one atomic tmp+rename file. (pi
|
||
SessionManager JSONL rejected: pi-coding-agent is a 139MB harness dep
|
||
and its tree/branching/compaction machinery is unused here.)
|
||
3. **POST contract — input events NOW, in this pass** (owner overrode the
|
||
server-side-delta recommendation): POST /api/chat carries
|
||
`{chatId, input}` where input is one of:
|
||
`{type:"text", text, note?}` (composer send; note rides the pending
|
||
verdict interplay),
|
||
`{type:"answer", toolCallId, answer:{values,other,text,dontKnow,note},
|
||
dismissed?}` (quiz/ask answer or dismissal — graded client-side as
|
||
today, the structured verdict becomes the native toolResult content),
|
||
`{type:"verdict", accepted, note?}` (plan verdict).
|
||
Consequences: the client's 409 merge-retry DIES (nothing to merge — a
|
||
409 means wait-and-retry the same idempotent event); claimSteers/
|
||
write-ahead of full lists dies; the wake pipeline's internal turns
|
||
(setTurnRunner) and jobs steers append natively server-side; useChat
|
||
stays as the RENDERER (message list = projected record + stream
|
||
assembly — receive side unchanged until Wave D); sends go through a
|
||
thin client session-client: POST the event, read the status, cancel
|
||
the 200 body (a view may detach freely — the turn runs on), then
|
||
attach via the standard resume path. Client touches: chat-transport
|
||
send half, chat.tsx call sites (composer, onSubmitAnswer, verdict,
|
||
dismiss). Receive half (SSE dialect, part rendering) stays Wave D.
|
||
4. **Raw vs validated tool args — resolved by design**: persist pi's own
|
||
AssistantMessage verbatim (what message_end carries). That means RAW
|
||
streamed args — byte-faithful replay, identical to what pi persists in
|
||
its own sessions, cache-parity with the original emission. The dock's
|
||
parse seam already tolerates raw (Wave A boundaryParse fix); no other
|
||
record consumer reads tool-call args.
|
||
5. **Abort semantics — folded into 1**: persist partial (current
|
||
behavior); the record ends at the dangling call for the abort case and
|
||
the INTERRUPTED synthesis at replay covers it (same as today).
|
||
|
||
## Test strategy
|
||
|
||
- Wire-shape pin (§5's oldest promise, now trivial): persist a scripted
|
||
session's agent messages, rebuild the agent, `convertMessages` → assert
|
||
`provider`/`model` real (not "replayed"), `thinkingSignature` present.
|
||
- Projection parity: every existing UIMessage consumer test (988 suite) runs
|
||
against the projection unchanged — the suite IS the golden master.
|
||
- Question flow: pose → answer-as-marker → next turn's model view carries the
|
||
verdict; abort mid-question → resume works (kills the synthesis tests).
|
||
- Live smoke: real turn + reattach + remote abort on PORT=3004.
|
||
|
||
## Blast radius (files)
|
||
|
||
Delete: record-adapter.ts; chat-transport's retryingFetch/merge logic.
|
||
New: project.ts (AgentMessage[]→UIMessage projection), input-events
|
||
contract (core/contracts), client session-client send module.
|
||
Rewrite: chat-handler (build = state.messages from record; input-event
|
||
append; probe_done detection over native messages), ui-stream (part
|
||
assembly becomes WIRE-ONLY; the record write comes from the agent's own
|
||
messages at message_end), turn-service (native appends replace
|
||
withMessageIds/withPlanBlocks part mutation; plan-blocks/artifact steers
|
||
become CustomMessages), store/chats + core scanners (phaseAt3,
|
||
findPendingToolCall, coverage, graph sources — read the projection,
|
||
mechanical), jobs.ts (steers append native CustomMessages; setTurnRunner
|
||
internal path), client send call sites (composer, onSubmitAnswer, verdict,
|
||
dismissal — chat-transport send half). useChat's RECEIVE half and all part
|
||
renderers untouched (Wave D). Agent band untouched (Wave A's). Wave C
|
||
(steer() consolidation) stays deferred.
|
||
|
||
---
|
||
|
||
# Wave D planning — the native wire + client rewrite (2026-09-26 recon, post-Wave-B)
|
||
|
||
Wave B landed the native record (LhMessage[], input events, projection,
|
||
record-adapter death). Wave D = seam 6 + the client half — the wire becomes a
|
||
verbatim passthrough of pi's own AgentEvents, the Vercel AI SDK leaves the
|
||
codebase entirely, and every projection consumer reads native. Wave C (stateful
|
||
agents) stays optional and deferred (owner, 2026-09-26).
|
||
|
||
## What Wave D replaces (the seam, precisely)
|
||
|
||
- `ui-stream.ts`'s translation switch (~250 lines): the AgentEvent →
|
||
UIMessageChunk remap (text/reasoning part assembly + id minting,
|
||
toolInputs/newToolParts bookkeeping, input-available validation gating,
|
||
question-output suppression, finishReason mapping, the start/messageId
|
||
continuation rule). The log/subscriber/abort/settle machinery STAYS.
|
||
- The Vercel SDK client: `ai` + `@ai-sdk/react` packages — `useChat`
|
||
(messages/status/sendMessage/addToolOutput/setMessages/error/stop/
|
||
clearError/resumeStream), the `DefaultChatTransport` subclass
|
||
(chat-transport.ts: send-half prepareSendMessagesRequest, receive-half
|
||
reconnectToStream adapters — settled terminal, continuation-seam id
|
||
rewrite, SSE head re-injection), `lastAssistantMessageIsCompleteWithToolCalls`.
|
||
- `core/projection.ts` + `core/ui-message.ts` (TeacherUIMessage,
|
||
mergeAdopted): deleted. The grouping canon moves CLIENT-SIDE (fork 1).
|
||
- The projection's readers go native-read: phases, pending, coverage,
|
||
graph toolParts, plan-blocks snapshot, extraction view, store summaries,
|
||
GET /api/chats/[id], app/chat/[id]/page.tsx.
|
||
|
||
## Forks — ALL RESOLVED (owner rulings, 2026-09-26)
|
||
|
||
1. **The client's view state — today's exact look is the golden master.**
|
||
Everything the agent produces renders as ONE continuous document per run:
|
||
step separators exactly as now, artifact/answered cards inline, the
|
||
teacher's reaction continuing below an answered quiz. Implemented by a
|
||
pure client-side grouping function (the projection's canon, app-owned,
|
||
zero `ai` types) over (record + live turn events). Raw per-native-message
|
||
block rendering was rejected (visible layout change).
|
||
2. **Question tools on the wire — forward everything.** No server-side
|
||
suppression: the quiz's closing `tool_execution_end` (the in-memory
|
||
"Question posed" bookkeeping result) flows to the client, which ignores
|
||
it (question tools are client-owned — the rule lives where the dock
|
||
lives). The server is a pure passthrough with zero special cases. The
|
||
placeholder still never persists, never reaches the model, never renders.
|
||
3. **Extraction unit — the message, with one inseparability rule.** The
|
||
coverage/fingerprint unit is the native message; the batcher's one rule:
|
||
a toolCall message and its toolResult message(s) are ALWAYS grouped into
|
||
the same chunk (covered together, read together). A fresh answer's
|
||
result pulls its asking assistant message into the chunk as context.
|
||
Fingerprints reset to the new unit once (scratch data, accepted).
|
||
|
||
## The design
|
||
|
||
### Server: verbatim AgentEvent passthrough (ui-stream.ts rewrite)
|
||
|
||
- The per-turn log stores AgentEvents verbatim; `data: {…}\n\n` lines, one
|
||
JSON event each, `data: [DONE]\n\n` terminal. Every pi event type forwards
|
||
as-is: agent_start, message_start/update/end, tool_execution_start/update/
|
||
end, turn_start, turn_end, agent_end. `message_update` carries the FULL
|
||
partial message (`partial: AssistantMessage` on every AssistantMessageEvent)
|
||
— the client REPLACES the streaming assistant message; no delta assembly.
|
||
- App-level events kept (our protocol, not pi's): the reattach terminal
|
||
`{type:"turn-settled"}` and a loop-level error event (agent.continue()
|
||
throws outside the event stream; mapped through the handler's onError
|
||
unmasking policy). Question suppression: NONE (fork 2).
|
||
- The start/messageId continuation seam DIES — the client's grouping canon
|
||
handles continuation (an answered-quiz continuation turn appends into the
|
||
same group by construction).
|
||
- piTurnResponse headers, POST /api/chat response, GET stream route: all
|
||
unchanged (same subscriber shape, same terminals).
|
||
|
||
### Client: the session hook + the view model (the SDK dies)
|
||
|
||
- **View model** (new client-safe pure module): the grouping canon verbatim
|
||
from projection.ts — consecutive assistant+toolResult messages bounded by
|
||
user/marker messages form one assistant view unit (step separators at
|
||
turn boundaries); toolResults attach to their call's block (backward
|
||
toolCallId search — answered cards, error cards, artifact outputs);
|
||
marker customs (verdict, probe-handoff, steer cards) project to their
|
||
current render shapes; plan-block markers splice after the last successful
|
||
plan_init result. Input: (record base + live turn messages). Golden
|
||
master: today's UI, byte-for-byte.
|
||
- **Session hook** (new, replaces useChat): owns view messages, status
|
||
(ready/submitted/streaming vocabulary KEPT — chat.tsx's busy/poll gates
|
||
transplant mechanically), error, and the channels:
|
||
- `send(input: ChatInput)` — POST {chatId, input} with the 409 identity
|
||
retry (retryingFetch survives chat-transport's death); the pendingInput
|
||
stash + loud-throw contract stays, enforced in the hook.
|
||
- `answer(toolCallId, answer)` — optimistic local result append + send
|
||
(the addToolOutput replacement; same UX: instant graded card).
|
||
- `attach()` — GET /api/chats/[id]/stream → the same SSE event parser;
|
||
turn-settled → syncFromServer (the settled terminal's contract).
|
||
- abort channel unchanged (POST /api/activity/abort); cancel detaches
|
||
the view only (the background-turn ruling is untouched).
|
||
- **SSE parser**: data lines → view updates (message_update replaces the
|
||
streaming assistant; message_end finalizes; turn_end appends results;
|
||
agent_end → settle → syncFromServer; error event → the banner).
|
||
- **Mid-turn record adoption** (the mergeAdopted lesson, native): steers/
|
||
artifacts persist into the record while a turn runs; on the jobs-poll
|
||
nudge the hook re-reads the record and rebases the view when it is
|
||
append-only below the live tail (else defers to full sync at idle).
|
||
- **Renderers**: chat.tsx's switch moves from UIMessage part types to view
|
||
blocks (text/reasoning/toolCall-with-result) — visually identical.
|
||
ai-elements' `import type { UIMessage}` swaps to view types; streamdown
|
||
stays (independent library).
|
||
|
||
### Scanners go native (projection deleted)
|
||
|
||
- phases: probe→plan on a concluded probe_done (native: probe_done toolCall
|
||
WITH a clean result); plan→teach on the verdict marker (accepted:true).
|
||
- pending: `danglingQuestionOf` (+ input) is THE dock seam;
|
||
streamingToolActivity reads the live streaming message's open toolCalls.
|
||
- coverage: unit = native message (fork 3) with the call+result chunk rule;
|
||
messageViewLines goes native (same one-line vocabulary); the ledger
|
||
resets to the new units once.
|
||
- graph toolParts: native pairing (assistant toolCall + result lookup).
|
||
- extraction transcriptView: native lines.
|
||
- plan-blocks: derivePlanStatuses over the native transcript.
|
||
- GET /api/chats/[id] serves record.messages verbatim; page.tsx seeds the
|
||
client view model; store summaries read native scans.
|
||
|
||
## Files
|
||
|
||
New: client session module (hook + SSE parser + adoption), client view
|
||
model module (pure). Rewrite: ui-stream.ts (passthrough), coverage.ts,
|
||
phases.ts, pending.ts, graph.ts toolParts, extraction view, store/chats,
|
||
chat.tsx (mechanics transplant), ai-elements type swaps. Delete:
|
||
projection.ts, ui-message.ts, chat-transport.ts (retryingFetch moves into
|
||
the session module), `ai` + `@ai-sdk/react` from package.json. Routes:
|
||
unchanged. Agent band (src/agent): untouched.
|
||
|
||
## Test strategy
|
||
|
||
- Wire pin: scripted turn → SSE data lines are verbatim AgentEvents;
|
||
subscribe() replay = the same log; [DONE] terminal; turn-settled
|
||
unchanged; no suppression of question results (fork 2 pinned).
|
||
- View-model golden tests: the projection suite's expectations (grouping,
|
||
card attachment, splice, separators) become the view model's tests.
|
||
- Scanner natives: phases/pending/coverage (incl. the chunk rule)/graph/
|
||
plan-blocks.
|
||
- Hook tests: attach/replay/settle, mid-turn adoption, optimistic answer,
|
||
error banner, 409 identity retry.
|
||
- chat-flow rewrite: SSE assertions switch to AgentEvents; the answer path
|
||
drives the hook (closing the Wave B coverage gap that let the missing
|
||
stash ship).
|
||
- Live smoke: real turn + answer + reattach + remote abort on PORT=3004.
|
||
|
||
## Implementation order
|
||
|
||
1. Server passthrough (ui-stream + log type) — the wire changes here.
|
||
2. View model (pure) + scanner native rewrites + coverage chunk rule.
|
||
3. GET/page serve native; store summaries.
|
||
4. Session hook + chat.tsx transplant + renderer switch + ai-elements.
|
||
5. Deletes (projection, ui-message, chat-transport, deps) + gates.
|
||
6. Test migration + live smoke.
|
||
|
||
---
|
||
|
||
# Wave C planning — pi-native steer/follow-up delivery (2026-09-26 recon)
|
||
|
||
## Trigger
|
||
|
||
Live crash on first post-Wave-B steer delivery: a pump-fired wake turn
|
||
double-appended its own steer (the Wave B regression of the 2026-09-18
|
||
duplicate-key bug). Hotfixed as `c364013` (the carried filter restored;
|
||
record `chat_muhlcltz` deduped by hand). The hotfix makes steers WORK
|
||
again — Wave C is the structural migration onto pi's own delivery seams.
|
||
|
||
## What pi offers natively (verified in pi-agent-core source)
|
||
|
||
- `getSteeringMessages()` — injected at turn boundaries, mid-run.
|
||
- `getFollowUpMessages()` — polled when the agent would otherwise STOP;
|
||
returned messages join the context and the SAME run continues.
|
||
Emitted as full `message_start`/`message_end` events (our verbatim
|
||
wire carries them to clients for free) and pushed into the run's
|
||
`newMessages` (persistTurn persists them).
|
||
- `shouldStopAfterTurn` runs BEFORE both polls and EXITS the loop — the
|
||
existing `makeQuestionStopper` guarantees an open quiz is never
|
||
overridden by a queued steer. No new guard needed.
|
||
- `CustomAgentMessages` declaration merging — our `LhMarkerMessage`
|
||
(role "custom") can become a REGISTERED pi custom message type:
|
||
first-class in agent state, events, and newMessages.
|
||
`toLlmMessages` already is the sanctioned `convertToLlm` seam.
|
||
- `runAgentLoopContinue` takes no new messages: the wake's context must
|
||
already carry them. And pi polls follow-ups only AFTER the first LLM
|
||
call — an empty-input wake would produce a stray teacher turn before
|
||
the artifact arrives.
|
||
|
||
## Owner rulings (2026-09-26)
|
||
|
||
1. Hotfix first, Wave C after (done: `c364013`).
|
||
2. **Follow-ups ONLY** (owner corrected an initial steering choice):
|
||
turns are typically single-message; follow-up semantics reproduces
|
||
today's pacing (never interrupt a running turn) with least risk.
|
||
`getSteeringMessages` is NOT wired.
|
||
3. **One queue, no claim machinery** (the unified intent): the per-chat
|
||
steer queue is the single delivery registry; `claimSteers` and
|
||
continuation coalescing are DELETED. The idle wake carries its
|
||
markers as the run's input (write-ahead appends — today's semantics,
|
||
now the ONLY appender), because follow-up polls don't exist at run
|
||
start.
|
||
|
||
## The design
|
||
|
||
### Delivery paths (exactly one queue, three arrival contexts)
|
||
|
||
1. **Steer arrives mid-run** (teacher working): the agent's
|
||
`getFollowUpMessages` returns queued markers at the run's natural
|
||
stop; the same run continues (no second turn claim, no 409 dance, no
|
||
client re-attach — the marker streams over the live view).
|
||
2. **Steer queued during a quiz chain**: the answer's run stops on the
|
||
question stopper BEFORE the follow-up poll → the steer waits (exactly
|
||
today's waitForIdle semantics); it rides the first run that ends
|
||
without an open question — that run's follow-up poll delivers it.
|
||
(This replaces continuation coalescing: `claimSteers` dies.)
|
||
3. **No run at all** (client closed, idle): the pump keeps
|
||
`waitForIdle` + fires the wake turn carrying the queued markers as
|
||
its input appends (write-ahead durable, then the run responds).
|
||
The pump becomes: gates → fire ONE wake for everything queued → done.
|
||
The 409 backoff stays (a client turn may race the wake).
|
||
|
||
### Marker representation
|
||
|
||
- `declare module` augmentation registers `LhMarkerMessage` under
|
||
`CustomAgentMessages` — type-level only; the runtime shape is
|
||
unchanged (role "custom", customType, content, details, id,
|
||
timestamp). Markers stay the record shape (SubagentSteerCard,
|
||
probe-handoff, verdict rendering all keep working).
|
||
- `getFollowUpMessages` returns the marker as-is; pi pushes it into
|
||
context; `toLlmMessages` flattens it for the provider (unchanged).
|
||
|
||
### Idempotency + crash story
|
||
|
||
- Dequeue-at-poll: a follow-up marker leaves the queue when handed to
|
||
the loop; it reaches the record at that run's persistTurn. A crash
|
||
between poll and persist loses it — the SAME exposure today's
|
||
in-memory queue has (server restart re-derives steers only for
|
||
running jobs via job respawn). Not worse, not better.
|
||
- The wake path keeps write-ahead persistence (durable before the run).
|
||
- `steerDelivered` (id-in-transcript) remains the dedupe oracle at the
|
||
wake's append and for redelivery races.
|
||
|
||
### Client
|
||
|
||
- `applyWireEvent` extends to append NON-assistant messages arriving via
|
||
`message_start`/`message_end` (custom/user roles) — today it drops
|
||
them, and in-run follow-up markers arrive exactly that way on the
|
||
live stream. Assistant streaming keeps the slot logic (Wave D fix).
|
||
- Nothing else: `projectView` already renders steer markers; the wake is
|
||
still a turn (reattach paths unchanged).
|
||
|
||
### Scope boundaries
|
||
|
||
- **probe-handoff stays a wake input** (not a follow-up): the
|
||
prober→teacher switch is a record-level branch (`hasProbeDone` → the
|
||
next run builds the teacher); `prepareNextTurn` cannot swap a
|
||
toolset, so the handoff must start a new run regardless.
|
||
- Abort steers and pdf/research/demo/visual result steers ride the same
|
||
queue (kind is inert to the delivery mechanism).
|
||
- Extraction/coverage treatment of markers is unchanged (custom role,
|
||
view-line-less → invisible to coverage).
|
||
|
||
## Deletions
|
||
|
||
- `claimSteers` + its handler call site + the carried-filter complexity
|
||
(nothing left to double-append: the wake is the only appender).
|
||
- The pump's per-entry retry loop simplifies (one fire for the whole
|
||
queue snapshot; the 409 backoff survives).
|
||
- jobs.test coalescing suite → rewritten as follow-up delivery tests.
|
||
|
||
## Test plan
|
||
|
||
- jobs.test: queue → getFollowUpMessages handoff (dequeue-once);
|
||
quiz-open deferral (stopper exits before the poll); pump idle wake
|
||
fires ONE turn for N queued steers; 409 backoff unchanged.
|
||
- chat-flow: mid-run follow-up e2e — a scripted teacher run whose
|
||
follow-up poll returns a marker → the SAME stream continues with the
|
||
marker's message events + the teacher's response; record holds one
|
||
copy; `steerDelivered` redelivery no-op.
|
||
- session-core: custom-role message events append to the tail (wire
|
||
events from in-run injections).
|
||
- Live smoke on PORT=3004: dispatch a researcher job, deliver mid-turn
|
||
and idle, reload, abort steer.
|
||
|
||
## Implementation order
|
||
|
||
1. Declaration merging (type-level) + agentOpts wiring of
|
||
`getFollowUpMessages` reading the queue.
|
||
2. Client `applyWireEvent` non-assistant appends.
|
||
3. Pump simplification + `claimSteers` deletion + handler cleanup.
|
||
4. Test migration (jobs, chat-flow, session-core) + live smoke.
|
||
|
||
## Wave C pivot (owner correction, later 2026-09-26): follow-ups → STEERING
|
||
|
||
The owner re-examined the follow-ups-only ruling and overturned it: a real
|
||
learning session is ONE continuous run — teach a thing, verify it with a
|
||
quiz tool call, begin teaching the next thing. There are no "run would
|
||
otherwise stop" moments mid-lesson, and the few run ends that do exist are
|
||
exactly the ones where delivery must NOT happen (the question stopper). So
|
||
subagent results must be delivered MID-RUN, BETWEEN messages.
|
||
|
||
The pivot (all in the same two-vehicle skeleton):
|
||
|
||
- Vehicle 1 hands markers to `agent.steer()` — pi's STEERING queue. The
|
||
loop drains it at TURN BOUNDARIES: after a completed assistant message
|
||
AND its tool results (never between a toolCall and its toolResult — the
|
||
bonded pair survives injection), and again at RUN START (a continuation
|
||
drains the queue BEFORE its first LLM call). The run CONTINUES with the
|
||
marker in context.
|
||
- `steeringMode: "all"` (was `followUpMode`) — every handed marker joins
|
||
one boundary injection.
|
||
- `registerLiveAgent` moved BEFORE `runPiTurn` in the handler and now
|
||
hands pending markers itself: the loop's start poll drains the queue
|
||
SYNCHRONOUSLY inside the `runPiTurn` call, so a later registration would
|
||
miss it. A marker stranded by a quiz-terminated run therefore rides the
|
||
ANSWER run's first LLM call — the native replacement for the old
|
||
continuation coalescing.
|
||
- Settlement reconciliation, the idle wake, probe-handoff exclusion, and
|
||
the registry-as-truth design are UNCHANGED (stranding is now exactly the
|
||
stopper-exit case; the wake still covers a cold-and-idle queue).
|
||
|
||
Deferred after the pivot (owner's list): thinking blocks sometimes
|
||
rendering after messages; output tokens of certain tool calls vanishing
|
||
with no tool call actually happening.
|