Skip to content

feat: pi-parity for chat + agent LLM loops (shared stream layer, reasoning round-trip, developer role) - #116

Merged
ddutchie merged 9 commits into
mainfrom
ddutchie/piagent
Aug 6, 2026
Merged

ddutchie merged 9 commits into
mainfrom
ddutchie/piagent

Conversation

@ddutchie

@ddutchie ddutchie commented Aug 6, 2026 •

Copy link
Copy Markdown
Owner

What does this PR do?

Brings the chat and agent tool-call loops to parity with pi and fixes several bugs the two loops had silently drifted on.

  • Extracts the OpenAI-compatible streaming / message-prep layer (SSE parse, request body, truncation guard, reasoning round-trip, message prep) into a shared electron/lib/llm-stream.ts consumed by both chat-loop and pi-agent-loop, so they can't diverge again.
  • The system prompt is sent as a role: "system" message (never a top-level system: field) — fixes unknown parameter system failures on strict providers, incl. the agent's context compaction.
  • finish_reason is now captured; tool calls from a length-truncated turn or an interrupted stream (no finish_reason) are refused instead of executed with possibly-truncated arguments.
  • Reasoning round-trips to the same model under its native field (reasoning_content / reasoning / reasoning_text), is converted to plain text cross-model, and is never baked into reply content (pi behaviour). Empty reasoning-only turns are filtered out so they can't 400 the next request.
  • Reasoning models get the developer system role on providers that support it (models.dev reasoning flag + pi's detectCompat denylist).
  • Ask-questions forms now keep the question visible while you type (was textarea placeholder) and auto-scroll into view.

Type of change

  • Bug fix
  • New feature
  • Refactor / code quality
  • Docs / changelog
  • Tests

Screenshots / recording

UI change (ask-questions form) — no screenshot; the form now shows the question as persistent text above the answer field instead of a placeholder that disappears on typing.

Checklist

  • npm run type-check:all passes
  • npm run lint passes
  • npm test passes (runs npm run compile first so electron/bundle-guard.test.ts actually executes — npm run test:bundle to run just that)
  • npm run test:e2e passes (run before merging UI changes or cutting a release)
  • If this ships a major, user-facing feature: added an entry to scripts/features.config.js so it appears in the "What's New" modal (generated JSON — run node scripts/generate-features.js) — N/A: not a headline feature; covered by the v2.6.5 changelog.
  • No hardcoded colours — CSS variables only (var(--accent), var(--text-primary), etc.)
  • No text-[Npx] pixel font classes — rem equivalents only (text-[0.714rem], text-xs, etc.)
  • New IPC handlers wrapped in handle() and return IpcResult<T> — N/A: no new IPC handlers.
  • New DB migrations appended (not edited) in schema.ts — N/A: no migrations.
  • New SQL goes in electron/db/queries.ts — single source of truth — N/A: no new SQL.
  • New dependencies or devDependencies added to ROLE_MAP in scripts/generate-licenses.js and licenses.json regenerated — N/A: no new deps.
  • New MCP tools registered in electron/mcp/tools/index.ts dispatch + electron/lib/tool-schemas.ts Zod schema — N/A: no new MCP tools (schema edit only, existing ask_questions description).
  • If you added/removed a --external:<pkg> flag in the compile script: updated the appropriate allowlist group — N/A: no flag changes.

Notes for reviewer

  • Shared llm-stream.ts is now the single source of truth for SSE parsing (reasoning-field capture, finish_reason, tool-call buffering), request-body construction, the truncation guard, and message prep. Both loops keep their own orchestration (tool execution is parallel in both; the agent additionally has approval gates / plan mode / subagents).
  • Reasoning round-trip is pi-parity but one deliberate divergence: pi converts cross-model thinking to text; we do the same (was previously "drop"), and bare reasoning with no round-trip metadata is still stripped.
  • developer role is gated on pi's supportsDeveloperRole denylist (DeepSeek, NVIDIA, Cerebras, xAI, Together, chutes.ai, ZAI, Moonshot, Cloudflare, Ant Ling, on-device Llama; OpenRouter only for anthropic/*/openai/*) so it can't 400 an unsupported provider. No manual Auto/On/Off override yet — flagged as follow-up on the task card if mis-cataloged models need it.
  • Ask-questions scroll: depends on pendingQuestions?.length (scalar) rather than the array to avoid a React deps-size dev warning.

Centralize the OpenAI-compatible streaming/message-prep layer (sse.ts
consumer, request body, truncation guard, reasoning round-trip) into a
shared llm-stream module so chat-loop and pi-agent-loop can't drift again.

- system prompt sent as role:"system" message (never top-level system:), incl. compaction
- finish_reason capture + refuse truncated/interrupted tool calls in both loops
- reasoning round-trips to the same model under its native field; converts
  to text cross-model; never baked into content (pi behaviour)
- empty assistant turns filtered from outgoing requests
- reasoning models get role:"developer" on supported providers (models.dev
  reasoning flag + pi detectCompat denylist); model catalog now parses it
@coderabbitai

coderabbitai Bot commented Aug 6, 2026 •

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@ddutchie, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 32 minutes

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 3258cd30-3374-4f57-8a11-54aa2b6afcfc

📥 Commits

Reviewing files that changed from the base of the PR and between 5b24d4b and c759357.

📒 Files selected for processing (12)
  • changelogs/v2.6.5.md
  • electron/ipc/pi-agent.ts
  • electron/lib/chat-loop-truncation.test.ts
  • electron/lib/chat-loop.ts
  • electron/lib/llm-stream.test.ts
  • electron/lib/llm-stream.ts
  • electron/lib/llm.ts
  • electron/lib/pi-agent-loop.test.ts
  • electron/lib/pi-agent-loop.ts
  • src/components/agent/AgentChatPane.tsx
  • src/components/agent/AgentMessageBubble.tsx
  • src/components/chat/chat-panel/QuestionForm.tsx
📝 Walkthrough

Walkthrough

Changes

Reasoning-aware chat and agent flows

Layer / File(s) Summary
Model metadata and request configuration
shared/models/model-catalog.*, electron/lib/llm.ts, electron/lib/tools.ts, src/hooks/useChatStream.ts, src/components/agent/..., src/components/chat/..., electron/ipc/chat.ts, electron/ipc/pi-agent.ts, electron/lib/tool-schemas.ts
Model metadata now identifies reasoning models. Chat and agent requests propagate this flag. System roles resolve to developer or system based on provider and model. Question prompts remain visible and pending forms trigger auto-scroll.
Shared streaming and message preparation
electron/lib/llm-stream.ts, electron/lib/llm-stream.test.ts
Shared helpers parse streaming content, reasoning, tool calls, usage, finish reasons, and thought signatures. They build request bodies, prepare context messages, preserve model-scoped reasoning, filter empty turns, and reject incomplete tool calls.
Chat and agent loop integration
electron/lib/chat-loop.ts, electron/lib/pi-agent-loop.ts, electron/lib/compaction.ts, electron/lib/pi-agent-loop.test.ts
Chat and agent loops use the shared helpers. Truncated or interrupted tool calls produce failed tool results instead of executing. Compaction sends explicit system and user messages. Tests cover streaming, reasoning replay, cross-model conversion, and retry behavior.
Release record
changelogs/v2.6.5.md
The changelog records the prompt, streaming, reasoning, tool-call, and question-form changes.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant ChatOrAgent
  participant ContextPreparation
  participant LLMProvider
  participant StreamConsumer
  participant ToolExecutor
  ChatOrAgent->>ContextPreparation: prepare model-aware messages
  ContextPreparation->>LLMProvider: send chat-completion request
  LLMProvider->>StreamConsumer: stream content, reasoning, and tool calls
  StreamConsumer->>ChatOrAgent: return parsed turn and finish reason
  ChatOrAgent->>ToolExecutor: execute complete tool calls
  ToolExecutor->>ChatOrAgent: return tool results or truncation errors
Loading

Possibly related PRs

  • ddutchie/cairn#60: Earlier reasoning-stream implementation related to reasoning preservation and replay.
  • ddutchie/cairn#90: Introduced the shared chat-loop streaming implementation extended here.
  • ddutchie/cairn#113: Related reasoning-model handling and empty assistant-turn filtering.
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 71.43% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main changes: shared LLM streaming, reasoning round-tripping, and developer-role support for chat and agent loops.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch ddutchie/piagent

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 8

🧹 Nitpick comments (4)
electron/lib/pi-agent-loop.ts (1)

754-766: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Align the execution path with this index mapping.

The guard resolves chip call IDs through turn.toolCallIndexes[i], which converts an array position into the stream tool_calls[].index. The execution path at Line 808 uses streamCallIds.get(tcIdx) and therefore assumes the two are identical. If a provider emits non-contiguous indexes, that lookup returns the wrong ID or undefined, and the pending chip created by onToolPending never updates. Use the same mapping in both places.

♻️ Proposed change at Line 808
-      const pendingCallId = streamCallIds.get(tcIdx);
+      const pendingCallId = streamCallIds.get(turn.toolCallIndexes[tcIdx] ?? tcIdx);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@electron/lib/pi-agent-loop.ts` around lines 754 - 766, Update the execution
path near onToolPending to resolve call IDs through turn.toolCallIndexes using
the tool-call array position, matching callIdFor in
failToolCallsFromTruncatedMessage. Replace the direct streamCallIds.get(tcIdx)
lookup while preserving the existing behavior for contiguous and non-contiguous
provider indexes.
src/components/chat/chat-panel/QuestionForm.tsx (1)

44-56: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Associate the prompt text with the textarea for assistive technology.

The prompt moved out of the placeholder, so screen-reader users no longer receive it through the field itself. Link the <p> to the textarea with aria-describedby, and give the textarea an accessible name.

♻️ Proposed accessibility wiring
-            <p className="text-[0.714rem] text-[var(--text-tertiary)] leading-relaxed">{q.prompt}</p>
+            <p id={`q-${q.id}-prompt`} className="text-[0.714rem] text-[var(--text-tertiary)] leading-relaxed">{q.prompt}</p>
             <textarea
+              aria-label={q.label}
+              aria-describedby={`q-${q.id}-prompt`}
               value={answers[q.id] ?? ""}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/components/chat/chat-panel/QuestionForm.tsx` around lines 44 - 56, Update
the question field rendering around the prompt <p> and textarea: assign each
prompt a unique id derived from q.id, reference it from the textarea via
aria-describedby, and add an accessible name to the textarea so assistive
technologies identify the field and its associated prompt.
electron/lib/llm-stream.ts (2)

268-289: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Consider a data-driven denylist.

The condition mixes provider ids and URL fragments in one long boolean chain. A table makes additions safer and testable.

const NON_STANDARD_PROVIDERS = new Set(["nvidia", "cerebras", "xai", "together", "zai", "moonshotai", "moonshotai-cn", "ant-ling", "localllm"]);
const NON_STANDARD_URL_FRAGMENTS = ["integrate.api.nvidia.com", "cerebras.ai", "api.x.ai", /* … */];
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@electron/lib/llm-stream.ts` around lines 268 - 289, Refactor
supportsDeveloperRole to replace the long isNonStandard boolean chain with
data-driven NON_STANDARD_PROVIDERS and NON_STANDARD_URL_FRAGMENTS collections,
then derive the denylist match from the provider and baseUrl. Preserve all
existing provider IDs, URL fragments, OpenRouter model handling, and return
behavior.

96-163: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

Narrow the try to JSON.parse only.

The catch at line 162 is documented as a malformed-JSON guard. It currently also covers the callback invocations (onUsage, onToken, onThought, onToolPending) and the tool-call buffering. An error thrown by a consumer callback is silently discarded, and the remainder of that chunk is skipped. Parse first, then process outside the try.

♻️ Proposed scoping
-    try {
-      // eslint-disable-next-line `@typescript-eslint/no-explicit-any`
-      const chunk = JSON.parse(jsonStr) as any;
-
+    // eslint-disable-next-line `@typescript-eslint/no-explicit-any`
+    let chunk: any;
+    try {
+      chunk = JSON.parse(jsonStr);
+    } catch { continue; /* skip malformed SSE JSON line */ }

The closing } catch { ... } at line 162 is then removed and the block body de-indented.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@electron/lib/llm-stream.ts` around lines 96 - 163, Restrict the try/catch in
the SSE chunk processing flow to JSON.parse(jsonStr) only, retaining the
malformed-JSON skip behavior. Move usage handling, finish-reason extraction,
delta processing, reasoning accumulation, tool buffering, and callback
invocations outside the catch so consumer errors propagate instead of being
silently discarded.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@changelogs/v2.6.5.md`:
- Line 5: Update the changelog entry to describe both agent-loop and
context-compaction paths as using chat’s provider-aware system-role resolution,
with system as the fallback, rather than claiming they always send role
"system".

In `@electron/lib/chat-loop.ts`:
- Around line 293-294: Restrict the streamInterrupted calculation and check in
the turn-finish handling to executions using the streaming branch, not the
provider === "localllm" non-streaming path. Add a local flag set only in the
streaming else branch and require it when evaluating streamInterrupted, while
preserving the existing "length" handling and tool-call flow.

In `@electron/lib/llm-stream.ts`:
- Around line 250-256: The tool-call result mapping must handle entries where
tc.id is empty or missing, as produced by consumeAssistantStream truncation. In
the map around opts.emitStart and opts.emitEnd, skip id-less tool calls or
synthesize a valid identifier, and ensure the same identifier is used
consistently for the emitted tool result and corresponding assistant message.
- Around line 351-398: Update prepareContextMessages so reasoningByMsg does not
depend on object identity: associate reasoning with a stable message index or
identifier and restore it correctly after pruner transformations. Alternatively,
explicitly enforce and document that the pruner must preserve references if no
stable key exists. Also update the documentation near prepareContextMessages to
state that the system prompt role is controlled by systemRole and may be
developer, not always system.

In `@electron/lib/llm.ts`:
- Line 80: Update calculatePromptBreakdown so messages with role "developer"
follow the same system-token accounting branch as role "system", preventing the
system prompt from also contributing to conversationTokens. Preserve existing
handling for tool, assistant, and user messages.

In `@electron/lib/pi-agent-loop.test.ts`:
- Line 498: Replace the vacuous error assertions in
electron/lib/pi-agent-loop.test.ts at lines 498-498 and 526-526 with a predicate
that checks whether any log entry starts with "error:" and asserts the result is
false; update both sites identically.

In `@electron/lib/pi-agent-loop.ts`:
- Around line 577-581: Pass provider through the agent configuration flow from
electron/ipc/pi-agent.ts into AgentLLMConfig, then include it in the
resolveSystemRole call in the agent loop alongside isReasoningModel, baseUrl,
and modelId. Preserve the existing shared role-resolution behavior used by the
chat path.

In `@src/components/agent/AgentChatPane.tsx`:
- Around line 529-533: Ensure the model catalog is prewarmed or subscribed to
through the established catalog-loading flow before the `getModelInfo` call that
sets `isReasoningModel` in `AgentChatPane`. Update `handleApprovePlan` to
propagate the same `isReasoningModel` value in its config so plan-approval turns
use the same system-role behavior as normal prompt turns.

---

Nitpick comments:
In `@electron/lib/llm-stream.ts`:
- Around line 268-289: Refactor supportsDeveloperRole to replace the long
isNonStandard boolean chain with data-driven NON_STANDARD_PROVIDERS and
NON_STANDARD_URL_FRAGMENTS collections, then derive the denylist match from the
provider and baseUrl. Preserve all existing provider IDs, URL fragments,
OpenRouter model handling, and return behavior.
- Around line 96-163: Restrict the try/catch in the SSE chunk processing flow to
JSON.parse(jsonStr) only, retaining the malformed-JSON skip behavior. Move usage
handling, finish-reason extraction, delta processing, reasoning accumulation,
tool buffering, and callback invocations outside the catch so consumer errors
propagate instead of being silently discarded.

In `@electron/lib/pi-agent-loop.ts`:
- Around line 754-766: Update the execution path near onToolPending to resolve
call IDs through turn.toolCallIndexes using the tool-call array position,
matching callIdFor in failToolCallsFromTruncatedMessage. Replace the direct
streamCallIds.get(tcIdx) lookup while preserving the existing behavior for
contiguous and non-contiguous provider indexes.

In `@src/components/chat/chat-panel/QuestionForm.tsx`:
- Around line 44-56: Update the question field rendering around the prompt <p>
and textarea: assign each prompt a unique id derived from q.id, reference it
from the textarea via aria-describedby, and add an accessible name to the
textarea so assistive technologies identify the field and its associated prompt.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 489e754c-d8a6-4957-8f59-987e6f7f3f0b

📥 Commits

Reviewing files that changed from the base of the PR and between 271f7c9 and 5b24d4b.

📒 Files selected for processing (18)
  • changelogs/v2.6.5.md
  • electron/ipc/chat.ts
  • electron/ipc/pi-agent.ts
  • electron/lib/chat-loop.ts
  • electron/lib/compaction.ts
  • electron/lib/llm-stream.test.ts
  • electron/lib/llm-stream.ts
  • electron/lib/llm.ts
  • electron/lib/pi-agent-loop.test.ts
  • electron/lib/pi-agent-loop.ts
  • electron/lib/tool-schemas.ts
  • electron/lib/tools.ts
  • shared/models/model-catalog.test.ts
  • shared/models/model-catalog.ts
  • src/components/agent/AgentChatPane.tsx
  • src/components/chat/chat-panel/QuestionForm.tsx
  • src/components/chat/chat-panel/index.tsx
  • src/hooks/useChatStream.ts

Comment thread changelogs/v2.6.5.md Outdated
Comment thread electron/lib/chat-loop.ts Outdated
Comment thread electron/lib/llm-stream.ts
Comment thread electron/lib/llm-stream.ts
Comment thread electron/lib/llm.ts
Comment thread electron/lib/pi-agent-loop.test.ts Outdated
Comment thread electron/lib/pi-agent-loop.ts
Comment thread src/components/agent/AgentChatPane.tsx
- chat-loop: interrupted-stream guard restricted to the SSE streaming branch
  (localllm null finish_reason is a normal response)
- llm.ts: calculatePromptBreakdown counts role:'developer' under the system
  bucket so it isn't double-counted as conversation
- llm-stream: consumeAssistantStream synthesizes ids for tool calls cut before
  the id chunk; failToolCallsFromTruncatedMessage same-id defense; SSE try/catch
  narrowed to JSON.parse so consumer errors propagate; supportsDeveloperRole is
  data-driven; prepareContextMessages docs cover systemRole + pruner identity
- pi-agent: provider plumbed into AgentLLMConfig (localllm derived from local
  baseUrl) and used in resolveSystemRole; onToolPending callId resolves through
  turn.toolCallIndexes for non-contiguous provider indexes
- AgentChatPane: prewarms the model catalog; plan-approval config propagates
  isReasoningModel
- QuestionForm: accessible textarea naming + aria-describedby for the prompt
- tests: vacuous not.toContain error assertions replaced with real predicates;
  new tests for id synthesis and truncation id consistency
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant