Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions changelogs/v2.6.5.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
## What's new

### Fixes

- **Cairn Agent no longer fails against providers that reject the top-level `system` parameter.** The agent loop sent the system prompt as an Anthropic-style top-level `system` field in the request body (`{ model, system: …, messages: … }`), which strict OpenAI-compatible providers reject outright with *unknown parameter system* — breaking the agent (and compaction) entirely on those providers, while regular chat worked because it sends the prompt as a `role: "system"` message. The agent loop now sends the system prompt the same way chat does — as a `role: "system"` message, or `role: "developer"` for reasoning models on providers that support it — and the context-compaction call sends its prompt as a `role: "system"` message. Neither path sends the top-level `system:` parameter anymore, so pi-agent works identically against any endpoint that chat does.
- **A cut-off agent reply can no longer execute a half-built tool call.** The agent loop never read `finish_reason` from the stream, so a model that hit its output-token limit mid-call could still have its (possibly truncated) arguments executed — the same failure chat guarded against. The agent loop now captures `finish_reason` like chat does: any tool call from a turn that ended with `finish_reason: "length"` is refused, shown as failed, and the model is asked to re-issue with complete arguments.
- **Agent reasoning now round-trips to the same model, pi-style, instead of being thrown away.** Previously the agent stripped chain-of-thought from every re-sent message, which breaks multi-turn reasoning continuity on models that expect their thinking back (Claude/OpenAI encrypted reasoning, DeepSeek/Qwen `reasoning_content`). The agent now records which reasoning field the provider used and sends it back under that same field — but only to the *same* model that produced it. If you switch provider/model mid-session, the old model's reasoning is converted to plain text (pi behaviour) so the new model keeps the context without risking an unknown-field rejection.
- **Reasoning is never baked into the reply text anymore.** When a "thinking" model exhausts its output budget having emitted only chain-of-thought, the reasoning stays in its own panel instead of being echoed back as message content — matching pi. Reasoning-only turns are still preserved and round-tripped to the same model.
- **An interrupted stream can no longer execute a half-built tool call.** A connection that drops mid-call ends the stream without any `finish_reason`. Both chat and the agent now treat that the same as an output-token cut: buffered tool calls are refused, shown as failed, and the model is asked to re-issue with complete arguments.
- **Empty assistant turns no longer poison the next agent request.** A reasoning model that stopped mid-thought can leave behind an assistant turn with neither content nor tool calls once reasoning is stripped — replaying it makes strict providers reject the whole request with *content or tool_calls must be set*. The agent now filters those turns out of outgoing requests, matching chat.
- **Chat and the agent now share one LLM streaming layer.** The SSE parsing (reasoning-field capture, `finish_reason` tracking, tool-call buffering), the request-body construction, the truncated-tool-call guard, and the message-preparation step (system prompt, reasoning round-trip, empty-turn filtering) were duplicated between `chat-loop` and the agent loop and had silently drifted. They now live in a single shared module (`llm-stream`) consumed by both loops, so a fix or improvement lands in both at once.
- **Reasoning models get the `developer` system role on supported providers (pi parity).** The system prompt is now sent as `role: "developer"` instead of `role: "system"` when the selected model is a reasoning/thinking model (from the models.dev catalog) and the provider supports it — the OpenAI convention pi follows. It falls back to `system` on providers known to reject the `developer` role (DeepSeek, NVIDIA, xAI, Together, Moonshot, ZAI, Cloudflare, Ant Ling, on-device Llama, and non-`anthropic/`/`openai/` OpenRouter models), so this can't break a provider that doesn't understand it.
- **Chat runs multiple tool calls in parallel now.** When the model returns several tool calls in one turn, chat used to execute them one at a time; it now runs them concurrently (same as the agent), so multi-tool turns finish faster. The per-tool concurrency guards (file mutex, note locks, atomic writes) are the same ones the agent already uses, and results are fed back to the model in source order. The human-approval path (heartbeat runner) stays sequential so prompts never stack.
- **The ask-questions form keeps the question visible while you answer.** The question text used to live as the *placeholder* inside the answer field, so it vanished the moment you started typing — and the form could also arrive out of view if you'd scrolled up. The question is now rendered as persistent text above each answer field (in both chat and the agent), and the thread auto-scrolls to the form so you always see what's being asked next to where you type.
- **Agent tool chips now sit above the reply text, matching chat.** The agent used to render tool-call chips below the message content (chat renders them above), so a long reply could push the tool activity out of view. The agent now shows the tool activity first, then the reasoning and the reply — the same order chat has always used.
6 changes: 5 additions & 1 deletion electron/ipc/chat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import { TOOLS, buildSystemPrompt, type ChatRequest } from "../lib/tools";
import { getExternalToolDefs } from "../lib/external-tools";
import { getCachedConfig, cacheLlmConnection } from "../lib/config-cache";
import { runToolLoop } from "../lib/chat-loop";
import { resolveSystemRole } from "../lib/llm-stream";
import { resolveLlmApiKey } from "../lib/secure-store";
import { buildAttachmentParts } from "../../shared/models/pdf-attach";
import { resolveCreditSpec, probeCredits } from "../lib/provider-credits";
Expand Down Expand Up @@ -153,7 +154,10 @@ export function registerChatHandler(db: Database.Database, workspacePath: string
: { role: "user", content: req.message };

const messages: OpenAIMessage[] = [
{ role: "system", content: buildSystemPrompt(req) },
{
role: resolveSystemRole({ isReasoningModel: req.config?.isReasoningModel, baseUrl, provider, modelId: model }),
content: buildSystemPrompt(req),
},
...(req.history ?? [])
// Drop assistant turns that carry neither content nor tool_calls — a
// thinking model that timed out or stopped mid-reasoning leaves such a
Expand Down
8 changes: 7 additions & 1 deletion electron/ipc/pi-agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ import { runAgentLoop, pendingApprovals, type PiAgentSession, type AgentLLMConfi
import { buildCompactionTransformer, compactNow } from "../lib/compaction";
import { buildPiAgentSystemPrompt } from "../lib/pi-agent-prompt";
import { discoverSkills, renderSkillsXml } from "../lib/skills";
import { normaliseBaseUrl } from "../lib/llm";
import { normaliseBaseUrl, isLocalEndpoint } from "../lib/llm";
import type { DbContext } from "./handlers";
import * as q from "../db/queries";
import { ts } from "../db/utils";
Expand Down Expand Up @@ -56,6 +56,7 @@ interface PiAgentPromptRequest {
temperature?: number;
maxTokens?: number;
autoApprove?: boolean;
isReasoningModel?: boolean;
};
}

Expand All @@ -75,6 +76,7 @@ interface PiAgentApprovePlanRequest {
temperature?: number;
maxTokens?: number;
autoApprove?: boolean;
isReasoningModel?: boolean;
};
}

Expand Down Expand Up @@ -235,6 +237,8 @@ export function registerPiAgentHandler(
temperature: reqConfig?.temperature ?? 0.3,
maxTokens: reqConfig?.maxTokens,
autoApprove: reqConfig?.autoApprove !== undefined ? reqConfig.autoApprove : true,
isReasoningModel: reqConfig?.isReasoningModel,
provider: reqConfig?.provider ?? (isLocalEndpoint(reqConfig?.baseUrl ?? "") ? "localllm" : undefined),
};

let session = sessions.get(sessionId);
Expand Down Expand Up @@ -330,6 +334,8 @@ export function registerPiAgentHandler(
temperature: reqConfig?.temperature ?? 0.3,
maxTokens: reqConfig?.maxTokens,
autoApprove: reqConfig?.autoApprove !== undefined ? reqConfig.autoApprove : true,
isReasoningModel: reqConfig?.isReasoningModel,
provider: reqConfig?.provider ?? (isLocalEndpoint(reqConfig?.baseUrl ?? "") ? "localllm" : undefined),
};

let session = sessions.get(sessionId);
Expand Down
56 changes: 56 additions & 0 deletions electron/lib/chat-loop-truncation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -185,4 +185,60 @@ describe("runToolLoop — finish_reason length truncation guard", () => {
db.close();
fs.rmSync(workspacePath, { recursive: true, force: true });
});

it("executes multiple tool calls in one turn in parallel and persists all results", async () => {
const db = makeDb();
const workspacePath = fs.mkdtempSync(path.join(os.tmpdir(), "cairn-chatloop-"));
const server = await makeServer([
[
// Two parallel ensure_note calls in a single turn (index 0 and 1).
`data: ${JSON.stringify({ choices: [{ delta: { tool_calls: [{ index: 0, id: "call_a", function: { name: "ensure_note", arguments: "" } }] } }] })}\n\n`,
`data: ${JSON.stringify({ choices: [{ delta: { tool_calls: [{ index: 0, function: { arguments: JSON.stringify({ projectId: "proj1", title: "Note A" }) } }] } }] })}\n\n`,
`data: ${JSON.stringify({ choices: [{ delta: { tool_calls: [{ index: 1, id: "call_b", function: { name: "ensure_note", arguments: "" } }] } }] })}\n\n`,
`data: ${JSON.stringify({ choices: [{ delta: { tool_calls: [{ index: 1, function: { arguments: JSON.stringify({ projectId: "proj1", title: "Note B" }) } }] } }] })}\n\n`,
`data: ${JSON.stringify({ choices: [{ delta: {}, finish_reason: "tool_calls" }] })}\n\n`,
"data: [DONE]\n\n",
].join(""),
textOnlySSE(["Both notes created."]),
]);
servers.push(server);

const emitToolCallEvents: string[] = [];
const doneEvents: { ok?: boolean; error?: string }[] = [];

const messages = [
{ role: "system" as const, content: "test" },
{ role: "user" as const, content: "create two notes" },
];

const result = await runToolLoop(
db,
chatReq,
workspacePath,
server.url,
"test-model",
"test-key",
messages,
(e) => emitToolCallEvents.push(e.tool),
undefined,
undefined,
"openai",
undefined,
(e) => doneEvents.push({ ok: e.ok, error: e.error }),
);

// Both chips fired (in source order) and both completed successfully.
expect(emitToolCallEvents).toEqual(["ensure_note", "ensure_note"]);
expect(doneEvents).toHaveLength(2);
expect(doneEvents.every((d) => d.ok)).toBe(true);

// Both parallel calls persisted their notes.
const titles = (db.prepare("SELECT title FROM notes ORDER BY title").all() as { title: string }[]).map((r) => r.title);
expect(titles).toEqual(["Note A", "Note B"]);

expect(result.content).toBe("Both notes created.");

db.close();
fs.rmSync(workspacePath, { recursive: true, force: true });
});
});
Loading