Skip to content
Merged
8 changes: 8 additions & 0 deletions changelogs/v2.6.2.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
## What's new

### Fixes
- **Cairn now warns when another device is on an older sync version.** Device Sync records the sync-protocol version each of your devices is using. When one of them reports a *lower* version than this device, Settings → Sync (and the mobile Sync screen) show a "device needs updating" hint — because an older version may not honour deletions, so a note you delete can reappear from it. Devices on the same version see nothing new.
- **Callouts now render inline in the editor.** In the note editor's live preview, `> [!note]`-style callouts show as the same styled callout you see in reading mode, and click into raw Markdown to edit. This was implemented earlier but switched off because the cursor landed in the wrong place when you clicked near or below a callout — text after one was effectively unselectable. The callout now mounts with its real height before the editor measures the surrounding lines, so positions below it stay accurate and you can edit around a callout normally.
- **A note deleted on another device is no longer offered for restore twice.** When you delete a note on one device and edit that same note on another before it has seen the deletion, Device Sync keeps your edit as a conflict copy — and previously it *also* listed the note as "deleted on another device" with a Restore button. Restoring then left you with two identical notes to clean up. Cairn now recognises that your edit is already safe in the conflict copy and points you to the conflicts view to resolve it, instead of offering a redundant restore. Notes deleted with no competing edit still show Restore as before.
- **Agent tool approvals read correctly on Windows and Linux.** The confirmation shown before the agent runs a tool described where the action reaches as "this Mac" regardless of platform; it now says "this device."
- **Reasoning ("thinking") models no longer break the chat.** Cairn used to cap every reply at 4,096 output tokens. A reasoning model counts its private thinking against that budget, so on a hard question it could spend the whole cap thinking and stop before writing any answer — leaving an empty reply. Worse, sending your next message then failed outright with an "invalid request" error, because the empty turn was replayed to the provider. Cairn now lets models finish naturally by default (no artificial cap), so they think as long as they need and still answer. A new **Max output tokens** control in Settings → AI lets you set a deliberate cap if you want to bound cost per reply; leave it on **Auto** (the default) for the fix. As a safety net, if a reply ever is cut off mid-thought, Cairn shows the captured reasoning instead of a blank message, and a stalled empty turn can no longer poison your next message.
24 changes: 24 additions & 0 deletions docs/plans/sync-lifecycle-hardening.md
Original file line number Diff line number Diff line change
Expand Up @@ -230,6 +230,30 @@ log stays a desktop diagnostic — on mobile the recovery path is what carries t

## 6. Risks & notes

### Wire-protocol versioning (✅ shipped v2.6.2)

The oplog now carries a `v` field (`SYNC_PROTOCOL_VERSION`, currently **1**), stamped at the
export boundary (`exportOplog`), validated by the shared guard, and read by `applyRemote` into a
per-peer record in `sync_state` (`peer_protocol:<deviceId>`, monotonic). `listPeerProtocols()`
reports any peer whose version differs; a peer that is **behind** may not honour deletes, and both
desktop (Settings → Sync) and mobile (Sync screen) surface a "device needs updating" hint.

**Skew contract:**
- A missing `v` means a pre-2.6.2 build → treated as protocol **1**. All 2.6.1 and earlier entries
read cleanly; no rewrite.
- **Bump `SYNC_PROTOCOL_VERSION` only** for a wire change a peer must understand to stay convergent
(a new causal field, a changed reconciliation rule). Additive optional fields an older peer can
safely ignore — as `observed`/`tombstone` were — do **not** need a bump.
- **Degradation when a peer predates a version:** delete-wins is only enforced on devices at that
version, so a mixed fleet is as weak as its oldest member — a behind peer can re-broadcast a note
deleted elsewhere. The new hint makes this *visible and actionable* (update the old device) rather
than silent, which was the whole point of doing this while only two shapes existed in the wild.
- **Downgrade:** an older build ignores `v` (and `sync_row_base` / `sync_oplog.observed`), reverting
to wall-clock reconciliation. Structurally safe (mobile's migration loop no-ops at a higher
`user_version`; extra columns ignored) but delete-wins is not enforced on that device.

---

- ~~Changing desktop to soft-delete means **every desktop live query must exclude
tombstones**~~ — **resolved in Phase 2**: the list/search reads already filtered
`deleted_at IS NULL`; only the by-id reads needed the guard. No ghost rows.
Expand Down
22 changes: 14 additions & 8 deletions electron/ipc/chat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import type { BrowserWindow } from "electron";
import { registerIpcHandle, registerIpcOn, broadcastEvent } from "./registry";
import { broadcastToChat } from "../chat-popout";
import type Database from "better-sqlite3";
import { isLocalEndpoint, normaliseBaseUrl, type OpenAIMessage, calculatePromptBreakdown, scaleBreakdown, type TokenBreakdown } from "../lib/llm";
import { isLocalEndpoint, normaliseBaseUrl, type OpenAIMessage, calculatePromptBreakdown, scaleBreakdown, type TokenBreakdown, isSendableMessage } from "../lib/llm";
import { TOOLS, buildSystemPrompt, type ChatRequest } from "../lib/tools";
import { getExternalToolDefs } from "../lib/external-tools";
import { getCachedConfig, cacheLlmConnection } from "../lib/config-cache";
Expand Down Expand Up @@ -154,13 +154,19 @@ export function registerChatHandler(db: Database.Database, workspacePath: string

const messages: OpenAIMessage[] = [
{ role: "system", content: buildSystemPrompt(req) },
...(req.history ?? []).map((m) => {
const out: OpenAIMessage = { role: m.role, content: m.content };
if (m.tool_calls) out.tool_calls = m.tool_calls;
if (m.tool_call_id) out.tool_call_id = m.tool_call_id;
if (m.name) out.name = m.name;
return out;
}),
...(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
// turn behind, and replaying it makes the provider reject the whole
// request with "content or tool_calls must be set" (400).
.filter(isSendableMessage)
.map((m) => {
const out: OpenAIMessage = { role: m.role, content: m.content };
if (m.tool_calls) out.tool_calls = m.tool_calls;
if (m.tool_call_id) out.tool_call_id = m.tool_call_id;
if (m.name) out.name = m.name;
return out;
}),
userMessage,
];

Expand Down
8 changes: 8 additions & 0 deletions electron/ipc/pi-agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ interface PiAgentPromptRequest {
apiKey?: string;
maxSteps?: number;
temperature?: number;
maxTokens?: number;
autoApprove?: boolean;
};
}
Expand All @@ -72,6 +73,7 @@ interface PiAgentApprovePlanRequest {
apiKey?: string;
maxSteps?: number;
temperature?: number;
maxTokens?: number;
autoApprove?: boolean;
};
}
Expand Down Expand Up @@ -201,6 +203,7 @@ export function registerPiAgentHandler(
apiKey: req.config?.apiKey,
maxSteps: req.config?.maxSteps,
temperature: req.config?.temperature,
maxTokens: req.config?.maxTokens,
autoApprove: req.config?.autoApprove,
});

Expand All @@ -214,6 +217,7 @@ export function registerPiAgentHandler(
apiKey: cached.apiKey,
maxSteps: reqConfig?.maxSteps || cached.maxSteps,
temperature: reqConfig?.temperature || cached.temperature,
maxTokens: reqConfig?.maxTokens ?? (cached as { maxTokens?: number }).maxTokens,
Comment thread
coderabbitai[bot] marked this conversation as resolved.
autoApprove: reqConfig?.autoApprove !== undefined ? reqConfig.autoApprove : cached.autoApprove,
};
} else if (cached) {
Expand All @@ -229,6 +233,7 @@ export function registerPiAgentHandler(
apiKey: resolveLlmApiKey(reqConfig?.apiKey),
maxSteps: reqConfig?.maxSteps ?? 20,
temperature: reqConfig?.temperature ?? 0.3,
maxTokens: reqConfig?.maxTokens,
autoApprove: reqConfig?.autoApprove !== undefined ? reqConfig.autoApprove : true,
};

Expand Down Expand Up @@ -293,6 +298,7 @@ export function registerPiAgentHandler(
apiKey: req.config?.apiKey,
maxSteps: req.config?.maxSteps,
temperature: req.config?.temperature,
maxTokens: req.config?.maxTokens,
autoApprove: req.config?.autoApprove,
});

Expand All @@ -306,6 +312,7 @@ export function registerPiAgentHandler(
apiKey: cached.apiKey,
maxSteps: reqConfig?.maxSteps || cached.maxSteps,
temperature: reqConfig?.temperature || cached.temperature,
maxTokens: reqConfig?.maxTokens ?? (cached as { maxTokens?: number }).maxTokens,
autoApprove: reqConfig?.autoApprove !== undefined ? reqConfig.autoApprove : cached.autoApprove,
};
} else if (cached) {
Expand All @@ -321,6 +328,7 @@ export function registerPiAgentHandler(
apiKey: resolveLlmApiKey(reqConfig?.apiKey),
maxSteps: reqConfig?.maxSteps ?? 20,
temperature: reqConfig?.temperature ?? 0.3,
maxTokens: reqConfig?.maxTokens,
autoApprove: reqConfig?.autoApprove !== undefined ? reqConfig.autoApprove : true,
};

Expand Down
36 changes: 35 additions & 1 deletion electron/lib/chat-loop.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,12 @@ export async function runToolLoop(
): Promise<RunToolLoopResult> {
const maxSteps = req.config?.maxSteps ?? 30;
const temperature = req.config?.temperature ?? 0.3;
// Max output tokens. Undefined/0 → OMIT max_tokens so the model finishes
// naturally (full reasoning + answer, bounded by the provider's own limit).
// A positive value is the user's deliberate cap. The old hardcoded 4096 was
// the bug: it guillotined "thinking" models mid-reasoning, yielding empty
// content that then tripped a 400 on the next message.
const maxTokens = req.config?.maxTokens && req.config.maxTokens > 0 ? req.config.maxTokens : undefined;
// Built-in tools (or a caller-supplied override) + any external tools in scope.
const baseTools = toolsOverride ?? TOOLS;
const combinedTools = extraTools.length > 0 ? [...baseTools, ...extraTools] : baseTools;
Expand Down Expand Up @@ -196,7 +202,9 @@ export async function runToolLoop(
messages,
tools: combinedTools,
tool_choice: "auto",
max_tokens: 4096,
// Only send max_tokens when the user set an explicit cap — omitting
// it lets the model finish naturally (see maxTokens above).
...(maxTokens ? { max_tokens: maxTokens } : {}),
temperature,
stream: true,
stream_options: { include_usage: true },
Expand All @@ -216,6 +224,7 @@ export async function runToolLoop(
if (!reader) return { exhausted: true, content: "No response stream", reasoning: "" };

let contentBuffer = "";
let streamFinishReason: string | null = null;
const toolCallBuffers: Map<number, { id: string; name: string; args: string; thought_signature?: string }> = new Map();

for await (const jsonStr of iterSseData(reader, signal ?? undefined)) {
Expand All @@ -236,6 +245,11 @@ export async function runToolLoop(
);
}
const delta = chunk.choices?.[0]?.delta;
// Capture the finish reason — "length" means the model hit max_tokens.
// A reasoning model can hit it having emitted only chain-of-thought.
if (chunk.choices?.[0]?.finish_reason) {
streamFinishReason = chunk.choices[0].finish_reason;
}
if (!delta) continue;

if (delta.content) {
Expand Down Expand Up @@ -302,6 +316,26 @@ export async function runToolLoop(
// would violate both OpenAI and Anthropic message schemas.
tool_calls: toolCalls,
};

// Reasoning-budget exhaustion safety net (cloud/streaming). By default we
// omit max_tokens so this shouldn't happen, but a user-set cap (or a
// provider's own server-side limit) can still stop a "thinking" model with
// finish_reason:"length" having emitted only reasoning and no content.
// That empty turn is useless to show AND, if persisted, poisons the next
// request (the provider rejects an assistant message with neither content
// nor tool_calls — a 400). Surface the captured reasoning so the reply is
// never empty.
if (
(!assistantMsg.content || !assistantMsg.content.trim()) &&
!assistantMsg.tool_calls?.length &&
streamFinishReason === "length" &&
accumulatedReasoning.trim()
) {
const surfaced = `*[This model hit its output-token limit before giving a final answer. Its reasoning is shown below — raise or clear "Max output tokens" in AI settings for a complete reply.]*\n\n${accumulatedReasoning.trim()}`;
assistantMsg.content = surfaced;
accumulatedContent = surfaced;
if (onToken) onToken(surfaced);
}
}

// No tool calls — model is ready to produce its final reply
Expand Down
19 changes: 13 additions & 6 deletions electron/lib/chat-subagent-loop.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@

import type { BrowserWindow } from "electron";
import type Database from "better-sqlite3";
import { buildApiUrl, type OpenAIMessage } from "./llm";
import { buildApiUrl, type OpenAIMessage, isSendableMessage } from "./llm";
import { TOOLS, type ChatRequest } from "./tools";
import { runToolLoop, type RunToolLoopResult } from "./chat-loop";
import { parseToolArgs } from "./parse-tool-args";
Expand Down Expand Up @@ -319,7 +319,7 @@ async function runSubagent(
// explicit nudge to write the findings. This is the standard "force final
// answer" pattern and rescues most empty-output cases on small models.
if (metrics.toolCalls > 0 || messages.some((m) => m.role === "tool")) {
const forced = await forceFinalAnswer(cfg, messages, metrics, addCost, signal);
const forced = await forceFinalAnswer(cfg, messages, metrics, addCost, signal, req.config?.maxTokens && req.config.maxTokens > 0 ? req.config.maxTokens : undefined);
if (forced.trim()) {
if (childId) events?.onSubagentToken?.({ childId, delta: forced.trim() });
return forced.trim();
Expand All @@ -340,6 +340,7 @@ async function forceFinalAnswer(
metrics: SubagentMetrics,
addCost?: (cost: unknown) => void,
signal?: AbortSignal,
maxTokens?: number,
): Promise<string> {
const nudged: OpenAIMessage[] = [
...messages,
Expand All @@ -364,7 +365,7 @@ async function forceFinalAnswer(
model: cfg.model,
messages: nudged,
tool_choice: "none",
max_tokens: 4096,
...(maxTokens ? { max_tokens: maxTokens } : {}),
temperature: 0.2,
stream: false,
}),
Expand Down Expand Up @@ -416,6 +417,8 @@ export async function runDispatchLoop(
const argMutator = opts?.argMutator;
const events = opts?.events;
const signal = opts?.signal;
// Undefined/0 → omit max_tokens (let the model finish naturally).
const maxTokens = req.config?.maxTokens && req.config.maxTokens > 0 ? req.config.maxTokens : undefined;
let subSeq = 0;
const date = new Date().toLocaleDateString("en-US", {
weekday: "long", year: "numeric", month: "long", day: "numeric",
Expand All @@ -430,7 +433,11 @@ export async function runDispatchLoop(

const messages: OpenAIMessage[] = [
{ role: "system", content: DISPATCH_SYSTEM_PROMPT(date) },
...(req.history ?? []).map((m) => {
...(req.history ?? [])
// Drop assistant turns with neither content nor tool_calls (a stalled
// "thinking" turn) — replaying one 400s the whole request.
.filter(isSendableMessage)
.map((m) => {
const out: OpenAIMessage = { role: m.role, content: m.content };
if (m.tool_calls) out.tool_calls = m.tool_calls;
if (m.tool_call_id) out.tool_call_id = m.tool_call_id;
Expand Down Expand Up @@ -468,7 +475,7 @@ export async function runDispatchLoop(
messages,
tools: DISPATCH_TOOLS,
tool_choice: "auto",
max_tokens: 4096,
...(maxTokens ? { max_tokens: maxTokens } : {}),
temperature,
stream: false,
stream_options: undefined,
Expand Down Expand Up @@ -534,7 +541,7 @@ export async function runDispatchLoop(
if (!finalContent && metrics.subagentRuns > 0) {
// Dispatcher-side forced answer: its cost isn't surfaced (subagent
// rings carry their own), so no cost accumulator is passed.
finalContent = (await forceFinalAnswer(cfg, messages, metrics, undefined, signal)).trim();
finalContent = (await forceFinalAnswer(cfg, messages, metrics, undefined, signal, maxTokens)).trim();
}
return { content: finalContent, reasoning: finalReasoning, metrics };
}
Expand Down
4 changes: 3 additions & 1 deletion electron/lib/config-cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ export interface CachedConfig {
apiKey?: string;
maxSteps?: number;
temperature?: number;
maxTokens?: number;
autoApprove?: boolean;
// The coding agent's active saved-provider id. The provider LIST itself is
// shared and persisted under aiConfig.savedProviders (single source of truth).
Expand Down Expand Up @@ -133,6 +134,7 @@ export function saveCachedConfig(type: "ai" | "agent" | "embeddings" | "theme" |
apiKey: resolveCachedKey(configRecord.apiKey, current.agentConfig?.apiKey),
maxSteps: typeof configRecord.maxSteps === "number" ? configRecord.maxSteps : current.agentConfig?.maxSteps,
temperature: typeof configRecord.temperature === "number" ? configRecord.temperature : current.agentConfig?.temperature,
maxTokens: typeof configRecord.maxTokens === "number" ? configRecord.maxTokens : current.agentConfig?.maxTokens,
autoApprove: typeof configRecord.autoApprove === "boolean" ? configRecord.autoApprove : current.agentConfig?.autoApprove,
activeProviderId: typeof configRecord.activeProviderId === "string" ? configRecord.activeProviderId : current.agentConfig?.activeProviderId,
};
Expand Down Expand Up @@ -179,7 +181,7 @@ export function getEmbeddingsSettingsCached(): CachedEmbeddingsConfig {
*/
export function cacheLlmConnection(
type: "ai" | "agent",
config: { provider?: string; baseUrl?: string; model?: string; apiKey?: string; maxSteps?: number; temperature?: number; autoApprove?: boolean } | undefined,
config: { provider?: string; baseUrl?: string; model?: string; apiKey?: string; maxSteps?: number; temperature?: number; maxTokens?: number; autoApprove?: boolean } | undefined,
): void {
if (!config) return;
if (config.baseUrl === undefined && config.model === undefined && config.apiKey === undefined) return;
Expand Down
33 changes: 33 additions & 0 deletions electron/lib/llm-sendable.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { describe, expect, it } from "vitest";
import { isSendableMessage } from "./llm";

describe("isSendableMessage", () => {
// Regression: a "thinking" model that times out or stops streaming
// mid-reasoning persists an assistant turn whose only payload was reasoning
// (stripped before re-send). Replaying it makes OpenAI-compatible providers
// reject the whole request: "content or tool_calls must be set" (400).
it("drops an assistant turn with neither content nor tool_calls", () => {
expect(isSendableMessage({ role: "assistant", content: null })).toBe(false);
expect(isSendableMessage({ role: "assistant", content: "" })).toBe(false);
expect(isSendableMessage({ role: "assistant", content: " " })).toBe(false);
expect(isSendableMessage({ role: "assistant", content: "", tool_calls: [] })).toBe(false);
});

it("keeps an assistant turn that has content", () => {
expect(isSendableMessage({ role: "assistant", content: "here is the draft" })).toBe(true);
});

it("keeps an assistant turn that has tool_calls even with empty content", () => {
expect(
isSendableMessage({ role: "assistant", content: null, tool_calls: [{ id: "c1" }] }),
).toBe(true);
});

it("never filters non-assistant roles, even when empty", () => {
// user/system/tool turns with empty content are the caller's concern; the
// provider only rejects assistant turns for the missing-payload reason.
expect(isSendableMessage({ role: "user", content: "" })).toBe(true);
expect(isSendableMessage({ role: "system", content: null })).toBe(true);
expect(isSendableMessage({ role: "tool", content: "" })).toBe(true);
});
});
Loading