finishing touches on agentic loop integration w/extra logging - will remove later

This commit is contained in:
David Garson 2026-01-28 13:32:45 -07:00
parent 541650185e
commit aa4418e496
31 changed files with 3578 additions and 856 deletions

View File

@ -168,6 +168,7 @@
"@mariozechner/pi-ai": "0.49.3",
"@mariozechner/pi-coding-agent": "0.49.3",
"@mariozechner/pi-tui": "0.49.3",
"@modelcontextprotocol/sdk": "^1.25.3",
"@mozilla/readability": "^0.6.0",
"@sinclair/typebox": "0.34.47",
"@slack/bolt": "^4.6.0",

109
pnpm-lock.yaml generated
View File

@ -45,16 +45,19 @@ importers:
version: 1.2.0-beta.3
'@mariozechner/pi-agent-core':
specifier: 0.49.3
version: 0.49.3(ws@8.19.0)(zod@4.3.6)
version: 0.49.3(@modelcontextprotocol/sdk@1.25.3(hono@4.11.4)(zod@4.3.6))(ws@8.19.0)(zod@4.3.6)
'@mariozechner/pi-ai':
specifier: 0.49.3
version: 0.49.3(ws@8.19.0)(zod@4.3.6)
version: 0.49.3(@modelcontextprotocol/sdk@1.25.3(hono@4.11.4)(zod@4.3.6))(ws@8.19.0)(zod@4.3.6)
'@mariozechner/pi-coding-agent':
specifier: 0.49.3
version: 0.49.3(ws@8.19.0)(zod@4.3.6)
version: 0.49.3(@modelcontextprotocol/sdk@1.25.3(hono@4.11.4)(zod@4.3.6))(ws@8.19.0)(zod@4.3.6)
'@mariozechner/pi-tui':
specifier: 0.49.3
version: 0.49.3
'@modelcontextprotocol/sdk':
specifier: ^1.25.3
version: 1.25.3(hono@4.11.4)(zod@4.3.6)
'@mozilla/readability':
specifier: ^0.6.0
version: 0.6.0
@ -1575,6 +1578,16 @@ packages:
'@mistralai/mistralai@1.10.0':
resolution: {integrity: sha512-tdIgWs4Le8vpvPiUEWne6tK0qbVc+jMenujnvTqOjogrJUsCSQhus0tHTU1avDDh5//Rq2dFgP9mWRAdIEoBqg==}
'@modelcontextprotocol/sdk@1.25.3':
resolution: {integrity: sha512-vsAMBMERybvYgKbg/l4L1rhS7VXV1c0CtyJg72vwxONVX0l4ZfKVAnZEWTQixJGTzKnELjQ59e4NbdFDALRiAQ==}
engines: {node: '>=18'}
peerDependencies:
'@cfworker/json-schema': ^4.1.1
zod: ^3.25 || ^4.0
peerDependenciesMeta:
'@cfworker/json-schema':
optional: true
'@mozilla/readability@0.6.0':
resolution: {integrity: sha512-juG5VWh4qAivzTAeMzvY9xs9HY5rAcr2E4I7tiSSCokRFi7XIZCAu92ZkSTsIj1OPceCifL3cpfteP3pDT9/QQ==}
engines: {node: '>=14.0.0'}
@ -3411,6 +3424,10 @@ packages:
core-util-is@1.0.3:
resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==}
cors@2.8.6:
resolution: {integrity: sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==}
engines: {node: '>= 0.10'}
croner@9.1.0:
resolution: {integrity: sha512-p9nwwR4qyT5W996vBZhdvBCnMhicY5ytZkR4D1Xj0wuTDEiMnjwR57Q3RXYY/s0EpX6Ay3vgIcfaR+ewGHsi+g==}
engines: {node: '>=18.0'}
@ -3617,10 +3634,24 @@ packages:
resolution: {integrity: sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==}
engines: {node: '>=0.8.x'}
eventsource-parser@3.0.6:
resolution: {integrity: sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg==}
engines: {node: '>=18.0.0'}
eventsource@3.0.7:
resolution: {integrity: sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==}
engines: {node: '>=18.0.0'}
expect-type@1.3.0:
resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==}
engines: {node: '>=12.0.0'}
express-rate-limit@7.5.1:
resolution: {integrity: sha512-7iN8iPMDzOMHPUYllBEsQdWVB6fPDMPqwjBaFrgr4Jgr/+okjvzAy+UHlYYL/Vs0OsOrMkwS6PJDkFlJwoxUnw==}
engines: {node: '>= 16'}
peerDependencies:
express: '>= 4.11'
express@4.22.1:
resolution: {integrity: sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==}
engines: {node: '>= 0.10.0'}
@ -4062,6 +4093,9 @@ packages:
jose@4.15.9:
resolution: {integrity: sha512-1vUQX+IdDMVPj4k8kOxgUqlcK518yluMuGZwqlr44FS1ppZB/5GWh4rZG89erpOBOJjU/OBsnCVFfapsRz6nEA==}
jose@6.1.3:
resolution: {integrity: sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ==}
js-base64@3.7.8:
resolution: {integrity: sha512-hNngCeKxIUQiEUN3GPJOkz4wF/YvdUdbNL9hsBcMQTkKzboD7T/q3OYOuuPZLUE6dBxSGpwhk5mwuDud7JVAow==}
@ -4088,6 +4122,9 @@ packages:
json-schema-traverse@1.0.0:
resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==}
json-schema-typed@8.0.2:
resolution: {integrity: sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==}
json-schema@0.4.0:
resolution: {integrity: sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==}
@ -4783,6 +4820,10 @@ packages:
resolution: {integrity: sha512-1wrVzJ2STrpmONHKBy228LM1b84msXDUoAzVEl0R8Mz4Ce6EPr+IVtxm8+yvrqLYMHswREkjYFaMxnyGnaY3Ng==}
hasBin: true
pkce-challenge@5.0.1:
resolution: {integrity: sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==}
engines: {node: '>=16.20.0'}
playwright-core@1.58.0:
resolution: {integrity: sha512-aaoB1RWrdNi3//rOeKuMiS65UCcgOVljU46At6eFcOFPFHWtd2weHRRow6z/n+Lec0Lvu0k9ZPKJSjPugikirw==}
engines: {node: '>=18'}
@ -6748,10 +6789,12 @@ snapshots:
'@glideapps/ts-necessities@2.2.3': {}
'@google/genai@1.34.0':
'@google/genai@1.34.0(@modelcontextprotocol/sdk@1.25.3(hono@4.11.4)(zod@4.3.6))':
dependencies:
google-auth-library: 10.5.0
ws: 8.19.0
optionalDependencies:
'@modelcontextprotocol/sdk': 1.25.3(hono@4.11.4)(zod@4.3.6)
transitivePeerDependencies:
- bufferutil
- supports-color
@ -6799,7 +6842,6 @@ snapshots:
'@hono/node-server@1.19.9(hono@4.11.4)':
dependencies:
hono: 4.11.4
optional: true
'@huggingface/jinja@0.5.3':
optional: true
@ -7152,9 +7194,9 @@ snapshots:
transitivePeerDependencies:
- tailwindcss
'@mariozechner/pi-agent-core@0.49.3(ws@8.19.0)(zod@4.3.6)':
'@mariozechner/pi-agent-core@0.49.3(@modelcontextprotocol/sdk@1.25.3(hono@4.11.4)(zod@4.3.6))(ws@8.19.0)(zod@4.3.6)':
dependencies:
'@mariozechner/pi-ai': 0.49.3(ws@8.19.0)(zod@4.3.6)
'@mariozechner/pi-ai': 0.49.3(@modelcontextprotocol/sdk@1.25.3(hono@4.11.4)(zod@4.3.6))(ws@8.19.0)(zod@4.3.6)
'@mariozechner/pi-tui': 0.49.3
transitivePeerDependencies:
- '@modelcontextprotocol/sdk'
@ -7165,11 +7207,11 @@ snapshots:
- ws
- zod
'@mariozechner/pi-ai@0.49.3(ws@8.19.0)(zod@4.3.6)':
'@mariozechner/pi-ai@0.49.3(@modelcontextprotocol/sdk@1.25.3(hono@4.11.4)(zod@4.3.6))(ws@8.19.0)(zod@4.3.6)':
dependencies:
'@anthropic-ai/sdk': 0.71.2(zod@4.3.6)
'@aws-sdk/client-bedrock-runtime': 3.972.0
'@google/genai': 1.34.0
'@google/genai': 1.34.0(@modelcontextprotocol/sdk@1.25.3(hono@4.11.4)(zod@4.3.6))
'@mistralai/mistralai': 1.10.0
'@sinclair/typebox': 0.34.47
ajv: 8.17.1
@ -7187,12 +7229,12 @@ snapshots:
- ws
- zod
'@mariozechner/pi-coding-agent@0.49.3(ws@8.19.0)(zod@4.3.6)':
'@mariozechner/pi-coding-agent@0.49.3(@modelcontextprotocol/sdk@1.25.3(hono@4.11.4)(zod@4.3.6))(ws@8.19.0)(zod@4.3.6)':
dependencies:
'@mariozechner/clipboard': 0.3.0
'@mariozechner/jiti': 2.6.5
'@mariozechner/pi-agent-core': 0.49.3(ws@8.19.0)(zod@4.3.6)
'@mariozechner/pi-ai': 0.49.3(ws@8.19.0)(zod@4.3.6)
'@mariozechner/pi-agent-core': 0.49.3(@modelcontextprotocol/sdk@1.25.3(hono@4.11.4)(zod@4.3.6))(ws@8.19.0)(zod@4.3.6)
'@mariozechner/pi-ai': 0.49.3(@modelcontextprotocol/sdk@1.25.3(hono@4.11.4)(zod@4.3.6))(ws@8.19.0)(zod@4.3.6)
'@mariozechner/pi-tui': 0.49.3
'@silvia-odwyer/photon-node': 0.3.4
chalk: 5.6.2
@ -7269,6 +7311,28 @@ snapshots:
zod: 3.25.76
zod-to-json-schema: 3.25.1(zod@3.25.76)
'@modelcontextprotocol/sdk@1.25.3(hono@4.11.4)(zod@4.3.6)':
dependencies:
'@hono/node-server': 1.19.9(hono@4.11.4)
ajv: 8.17.1
ajv-formats: 3.0.1(ajv@8.17.1)
content-type: 1.0.5
cors: 2.8.6
cross-spawn: 7.0.6
eventsource: 3.0.7
eventsource-parser: 3.0.6
express: 5.2.1
express-rate-limit: 7.5.1(express@5.2.1)
jose: 6.1.3
json-schema-typed: 8.0.2
pkce-challenge: 5.0.1
raw-body: 3.0.2
zod: 4.3.6
zod-to-json-schema: 3.25.1(zod@4.3.6)
transitivePeerDependencies:
- hono
- supports-color
'@mozilla/readability@0.6.0': {}
'@napi-rs/canvas-android-arm64@0.1.88':
@ -9368,6 +9432,11 @@ snapshots:
core-util-is@1.0.3: {}
cors@2.8.6:
dependencies:
object-assign: 4.1.1
vary: 1.1.2
croner@9.1.0: {}
cross-fetch@4.1.0:
@ -9561,8 +9630,18 @@ snapshots:
events@3.3.0: {}
eventsource-parser@3.0.6: {}
eventsource@3.0.7:
dependencies:
eventsource-parser: 3.0.6
expect-type@1.3.0: {}
express-rate-limit@7.5.1(express@5.2.1):
dependencies:
express: 5.2.1
express@4.22.1:
dependencies:
accepts: 1.3.8
@ -10139,6 +10218,8 @@ snapshots:
jose@4.15.9: {}
jose@6.1.3: {}
js-base64@3.7.8: {}
js-tokens@9.0.1: {}
@ -10160,6 +10241,8 @@ snapshots:
json-schema-traverse@1.0.0: {}
json-schema-typed@8.0.2: {}
json-schema@0.4.0: {}
json-stringify-safe@5.0.1: {}
@ -10887,6 +10970,8 @@ snapshots:
dependencies:
pngjs: 7.0.0
pkce-challenge@5.0.1: {}
playwright-core@1.58.0: {}
playwright@1.58.0:

View File

@ -6,8 +6,12 @@
import type { ImageContent } from "@mariozechner/pi-ai";
import type { MoltbotConfig } from "../config/config.js";
import type { CcSdkModelTiers } from "../config/types.agents.js";
import type { AgentStreamParams } from "../commands/agent/types.js";
import type { ThinkLevel, VerboseLevel } from "../auto-reply/thinking.js";
import type { ReasoningLevel, ThinkLevel, VerboseLevel } from "../auto-reply/thinking.js";
import type { ExecElevatedDefaults, ExecToolDefaults } from "./bash-tools.js";
import type { BlockReplyChunking, ToolResultFormat } from "./pi-embedded-subscribe.js";
import type { ClientToolDefinition } from "./pi-embedded-runner/run/params.js";
import type { SkillSnapshot } from "./skills.js";
import type { EmbeddedPiRunResult } from "./pi-embedded-runner/types.js";
@ -42,8 +46,59 @@ export type AgentRuntimeCallbacks = {
onAgentEvent?: (evt: { stream: string; data: Record<string, unknown> }) => void;
};
/** Common parameters for agent run invocations. */
// ─────────────────────────────────────────────────────────────────────────────
// Runtime-specific option bags
// ─────────────────────────────────────────────────────────────────────────────
/**
* Pi runtime-specific options.
*
* These fields are only applicable to the Pi Agent runtime and are not
* generalizable to other runtimes.
*/
export type PiRuntimeOptions = {
/** Whether to enforce final XML tag in responses. */
enforceFinalTag?: boolean;
/** Execution tool overrides (host, security, ask, node). */
execOverrides?: Pick<ExecToolDefaults, "host" | "security" | "ask" | "node">;
/** Bash elevated execution defaults. */
bashElevated?: ExecElevatedDefaults;
/** Client-provided tools (OpenResponses hosted tools). */
clientTools?: ClientToolDefinition[];
};
/**
* CCSDK runtime-specific options.
*
* These fields are only applicable to the Claude Code SDK runtime.
*/
export type CcSdkRuntimeOptions = {
/** Enable Claude Code lifecycle hooks (pre-tool, post-tool, etc.). */
hooksEnabled?: boolean;
/** Additional SDK options passed to the runner. */
sdkOptions?: Record<string, unknown>;
/** 3-tier model configuration for Claude Code SDK. */
modelTiers?: CcSdkModelTiers;
/** Existing Claude Code session ID for resumption. */
claudeSessionId?: string;
/** Fork the session instead of continuing it. */
forkSession?: boolean;
};
// ─────────────────────────────────────────────────────────────────────────────
// Common parameters
// ─────────────────────────────────────────────────────────────────────────────
/**
* Common parameters for agent run invocations.
*
* This type contains:
* 1. Core fields required by all runtimes
* 2. Generalized fields that both runtimes can use
* 3. Runtime-specific option bags for non-generalizable features
*/
export type AgentRuntimeRunParams = {
// ─── Core fields (required by all runtimes) ─────────────────────────────────
sessionId: string;
sessionKey?: string;
sessionFile: string;
@ -65,19 +120,66 @@ export type AgentRuntimeRunParams = {
abortSignal?: AbortSignal;
extraSystemPrompt?: string;
streamParams?: AgentStreamParams;
/** Messaging context for delivery routing. */
// ─── Messaging context (used by both runtimes for routing/context) ──────────
/** Messaging channel identifier (e.g., "telegram", "discord"). */
messageChannel?: string;
/** Message provider identifier (e.g., "telegram", "discord"). */
messageProvider?: string;
/** Agent account identifier for multi-account routing. */
agentAccountId?: string;
/** Delivery target (e.g. telegram:group:123:topic:456). */
messageTo?: string;
/** Thread/topic identifier for routing replies to the originating thread. */
messageThreadId?: string | number;
/** Group id for channel-level tool policy resolution. */
groupId?: string | null;
/** Group channel label (e.g. #general). */
groupChannel?: string | null;
/** Group space label (e.g. guild/team id). */
groupSpace?: string | null;
/** Parent session key for subagent policy inheritance. */
spawnedBy?: string | null;
/** Current channel ID for auto-threading (Slack). */
currentChannelId?: string;
/** Current thread timestamp for auto-threading (Slack). */
currentThreadTs?: string;
/** Reply-to mode for Slack auto-threading. */
replyToMode?: "off" | "first" | "all";
/** Mutable ref to track if a reply was sent (for "first" mode). */
hasRepliedRef?: { value: boolean };
// ─── Sender context (both runtimes can use for system prompt enrichment) ────
/** Sender identifier. */
senderId?: string | null;
/** Sender display name. */
senderName?: string | null;
/** Sender username. */
senderUsername?: string | null;
/** Sender E.164 phone number. */
senderE164?: string | null;
// ─── Generalized fields (applicable to both runtimes) ───────────────────────
/** Reasoning level for streaming reasoning events. */
reasoningLevel?: ReasoningLevel;
/** Tool result format (markdown or plain). */
toolResultFormat?: ToolResultFormat;
/** Block reply break mode (text_end or message_end). */
blockReplyBreak?: "text_end" | "message_end";
/** Block reply chunking settings. */
blockReplyChunking?: BlockReplyChunking;
/** Callback to determine if tool results should be emitted. */
shouldEmitToolResult?: () => boolean;
/** Callback to determine if tool output should be emitted. */
shouldEmitToolOutput?: () => boolean;
/** Owner phone numbers for access control. */
ownerNumbers?: string[];
// ─── Runtime-specific option bags ───────────────────────────────────────────
/** Pi runtime-specific options. */
piOptions?: PiRuntimeOptions;
/** CCSDK runtime-specific options. */
ccsdkOptions?: CcSdkRuntimeOptions;
} & AgentRuntimeCallbacks;
/**

View File

@ -1,75 +1,305 @@
import { describe, it, expect } from "vitest";
import { extractTextFromClaudeAgentSdkEvent } from "./extract.js";
import { extractFromClaudeAgentSdkEvent } from "./extract.js";
describe("extractTextFromClaudeAgentSdkEvent", () => {
it("extracts text from direct text field", () => {
expect(extractTextFromClaudeAgentSdkEvent({ text: "Hello" })).toBe("Hello");
describe("extractFromClaudeAgentSdkEvent", () => {
describe("text extraction", () => {
it("extracts text from direct text field", () => {
expect(extractFromClaudeAgentSdkEvent({ text: "Hello" }).text).toBe("Hello");
});
it("extracts text from delta field", () => {
expect(extractFromClaudeAgentSdkEvent({ delta: "World" }).text).toBe("World");
});
it("extracts text from string input", () => {
expect(extractFromClaudeAgentSdkEvent("Direct string").text).toBe("Direct string");
});
it("extracts text from content array with text blocks", () => {
const event = {
content: [
{ type: "text", text: "First" },
{ type: "text", text: "Second" },
],
};
expect(extractFromClaudeAgentSdkEvent(event).text).toBe("First\nSecond");
});
it("extracts text from nested message object", () => {
const event = {
message: {
content: [{ type: "text", text: "Nested text" }],
},
};
expect(extractFromClaudeAgentSdkEvent(event).text).toBe("Nested text");
});
it("extracts text from nested data object", () => {
const event = {
data: {
text: "Data text",
},
};
expect(extractFromClaudeAgentSdkEvent(event).text).toBe("Data text");
});
it("extracts text from deeply nested delta", () => {
const event = {
delta: {
text: "Deep delta text",
},
};
expect(extractFromClaudeAgentSdkEvent(event).text).toBe("Deep delta text");
});
it("returns undefined text for null/undefined input", () => {
expect(extractFromClaudeAgentSdkEvent(null).text).toBeUndefined();
expect(extractFromClaudeAgentSdkEvent(undefined).text).toBeUndefined();
});
it("returns undefined text for empty text", () => {
expect(extractFromClaudeAgentSdkEvent({ text: "" }).text).toBeUndefined();
expect(extractFromClaudeAgentSdkEvent({ text: " " }).text).toBeUndefined();
});
it("returns undefined text for non-object input", () => {
expect(extractFromClaudeAgentSdkEvent(123).text).toBeUndefined();
expect(extractFromClaudeAgentSdkEvent(true).text).toBeUndefined();
});
it("handles array of strings in content", () => {
const event = {
content: ["First string", "Second string"],
};
expect(extractFromClaudeAgentSdkEvent(event).text).toBe("First string\nSecond string");
});
it("does NOT extract text from thinking field", () => {
// Thinking content should be in the thinking field, not text
expect(
extractFromClaudeAgentSdkEvent({ thinking: "Internal reasoning" }).text,
).toBeUndefined();
});
it("extracts text field even when thinking field is also present", () => {
const event = {
text: "Main text",
thinking: "Internal reasoning",
};
const result = extractFromClaudeAgentSdkEvent(event);
expect(result.text).toBe("Main text");
expect(result.thinking).toBe("Internal reasoning");
});
});
it("extracts text from delta field", () => {
expect(extractTextFromClaudeAgentSdkEvent({ delta: "World" })).toBe("World");
describe("thinking extraction", () => {
it("extracts thinking from direct thinking field", () => {
expect(extractFromClaudeAgentSdkEvent({ thinking: "My thoughts" }).thinking).toBe(
"My thoughts",
);
});
it("extracts thinking from content_block with type=thinking", () => {
const event = {
type: "content_block_start",
content_block: {
type: "thinking",
thinking: "Block thinking content",
},
};
expect(extractFromClaudeAgentSdkEvent(event).thinking).toBe("Block thinking content");
});
it("extracts thinking from delta with type=thinking_delta", () => {
const event = {
type: "content_block_delta",
delta: {
type: "thinking_delta",
thinking: "Delta thinking content",
},
};
expect(extractFromClaudeAgentSdkEvent(event).thinking).toBe("Delta thinking content");
});
it("extracts thinking from delta.thinking directly", () => {
const event = {
delta: {
thinking: "Direct delta thinking",
},
};
expect(extractFromClaudeAgentSdkEvent(event).thinking).toBe("Direct delta thinking");
});
it("extracts thinking from nested message.thinking", () => {
const event = {
message: {
thinking: "Message thinking content",
},
};
expect(extractFromClaudeAgentSdkEvent(event).thinking).toBe("Message thinking content");
});
it("extracts thinking from nested data.thinking", () => {
const event = {
data: {
thinking: "Data thinking content",
},
};
expect(extractFromClaudeAgentSdkEvent(event).thinking).toBe("Data thinking content");
});
it("returns undefined thinking for null/undefined input", () => {
expect(extractFromClaudeAgentSdkEvent(null).thinking).toBeUndefined();
expect(extractFromClaudeAgentSdkEvent(undefined).thinking).toBeUndefined();
});
it("returns undefined thinking for empty thinking", () => {
expect(extractFromClaudeAgentSdkEvent({ thinking: "" }).thinking).toBeUndefined();
expect(extractFromClaudeAgentSdkEvent({ thinking: " " }).thinking).toBeUndefined();
});
it("returns undefined thinking for non-object input", () => {
expect(extractFromClaudeAgentSdkEvent(123).thinking).toBeUndefined();
expect(extractFromClaudeAgentSdkEvent("string").thinking).toBeUndefined();
expect(extractFromClaudeAgentSdkEvent(true).thinking).toBeUndefined();
});
it("returns undefined thinking when content_block type is not thinking", () => {
const event = {
type: "content_block_start",
content_block: {
type: "text",
text: "Regular text",
},
};
expect(extractFromClaudeAgentSdkEvent(event).thinking).toBeUndefined();
});
it("returns undefined thinking when delta type is not thinking_delta", () => {
const event = {
delta: {
type: "text_delta",
text: "Regular delta",
},
};
expect(extractFromClaudeAgentSdkEvent(event).thinking).toBeUndefined();
});
it("returns undefined thinking when no thinking-related fields exist", () => {
const event = {
type: "assistant",
text: "Regular assistant text",
message: {
content: [{ type: "text", text: "Content text" }],
},
};
expect(extractFromClaudeAgentSdkEvent(event).thinking).toBeUndefined();
});
it("handles SDK thinking_delta stream event", () => {
const event = {
type: "thinking_delta",
thinking: "Incremental thinking...",
};
expect(extractFromClaudeAgentSdkEvent(event).thinking).toBe("Incremental thinking...");
});
});
it("extracts text from string input", () => {
expect(extractTextFromClaudeAgentSdkEvent("Direct string")).toBe("Direct string");
});
describe("combined text and thinking extraction", () => {
it("extracts both thinking and text when both are present", () => {
const event = {
thinking: "My internal reasoning",
text: "My visible response",
};
const result = extractFromClaudeAgentSdkEvent(event);
expect(result.thinking).toBe("My internal reasoning");
expect(result.text).toBe("My visible response");
});
it("extracts text from content array with text blocks", () => {
const event = {
content: [
{ type: "text", text: "First" },
{ type: "text", text: "Second" },
],
};
expect(extractTextFromClaudeAgentSdkEvent(event)).toBe("First\nSecond");
});
it("extracts thinking from content_block_delta with thinking_delta (text undefined)", () => {
const event = {
type: "content_block_delta",
index: 0,
delta: {
type: "thinking_delta",
thinking: "Streaming thinking content...",
},
};
const result = extractFromClaudeAgentSdkEvent(event);
expect(result.thinking).toBe("Streaming thinking content...");
expect(result.text).toBeUndefined();
});
it("extracts text from nested message object", () => {
const event = {
message: {
content: [{ type: "text", text: "Nested text" }],
},
};
expect(extractTextFromClaudeAgentSdkEvent(event)).toBe("Nested text");
});
it("extracts text from content_block_delta with text_delta (thinking undefined)", () => {
const event = {
type: "content_block_delta",
index: 1,
delta: {
type: "text_delta",
text: "Streaming text content...",
},
};
const result = extractFromClaudeAgentSdkEvent(event);
expect(result.thinking).toBeUndefined();
expect(result.text).toBe("Streaming text content...");
});
it("extracts text from nested data object", () => {
const event = {
data: {
text: "Data text",
},
};
expect(extractTextFromClaudeAgentSdkEvent(event)).toBe("Data text");
});
it("separates thinking and text from different events in a stream", () => {
const thinkingEvent = {
type: "content_block_delta",
delta: { type: "thinking_delta", thinking: "Let me think..." },
};
const textEvent = {
type: "content_block_delta",
delta: { type: "text_delta", text: "Here is my answer" },
};
it("extracts text from deeply nested delta", () => {
const event = {
delta: {
text: "Deep delta text",
},
};
expect(extractTextFromClaudeAgentSdkEvent(event)).toBe("Deep delta text");
});
const thinkingResult = extractFromClaudeAgentSdkEvent(thinkingEvent);
expect(thinkingResult.thinking).toBe("Let me think...");
expect(thinkingResult.text).toBeUndefined();
it("returns undefined for null/undefined input", () => {
expect(extractTextFromClaudeAgentSdkEvent(null)).toBeUndefined();
expect(extractTextFromClaudeAgentSdkEvent(undefined)).toBeUndefined();
});
const textResult = extractFromClaudeAgentSdkEvent(textEvent);
expect(textResult.thinking).toBeUndefined();
expect(textResult.text).toBe("Here is my answer");
});
it("returns undefined for empty text", () => {
expect(extractTextFromClaudeAgentSdkEvent({ text: "" })).toBeUndefined();
expect(extractTextFromClaudeAgentSdkEvent({ text: " " })).toBeUndefined();
});
it("extracts thinking from content array with thinking blocks", () => {
const event = {
content: [
{ type: "thinking", thinking: "Internal thought process" },
{ type: "text", text: "Visible response to user" },
],
};
const result = extractFromClaudeAgentSdkEvent(event);
expect(result.thinking).toBe("Internal thought process");
expect(result.text).toBe("Visible response to user");
});
it("returns undefined for non-object input", () => {
expect(extractTextFromClaudeAgentSdkEvent(123)).toBeUndefined();
expect(extractTextFromClaudeAgentSdkEvent(true)).toBeUndefined();
});
it("handles assistant message event with content array", () => {
const event = {
type: "assistant",
message: {
content: [
{ type: "thinking", thinking: "Internal thought process" },
{ type: "text", text: "Visible response to user" },
],
},
};
const result = extractFromClaudeAgentSdkEvent(event);
// Text extractor should find the text block
expect(result.text).toBe("Visible response to user");
// Thinking from content array should also be extracted
expect(result.thinking).toBe("Internal thought process");
});
it("handles array of strings in content", () => {
const event = {
content: ["First string", "Second string"],
};
expect(extractTextFromClaudeAgentSdkEvent(event)).toBe("First string\nSecond string");
it("returns empty result for unrecognized event structure", () => {
const event = {
type: "unknown",
someOtherField: "value",
};
const result = extractFromClaudeAgentSdkEvent(event);
expect(result.text).toBeUndefined();
expect(result.thinking).toBeUndefined();
});
});
});

View File

@ -8,6 +8,21 @@ function isRecord(value: unknown): value is Record<string, unknown> {
return !!value && typeof value === "object" && !Array.isArray(value);
}
/**
* Result of extracting content from a Claude Agent SDK event.
* Both fields may be present if the event contains both text and thinking content.
*/
export type SdkEventExtraction = {
/** Assistant text content (visible to user). */
text?: string;
/** Thinking/reasoning content (internal reasoning, may be shown as breadcrumbs). */
thinking?: string;
};
/**
* Extract text content from a value, recursively checking common structures.
* Does NOT extract thinking content - that's handled separately.
*/
function extractTextFromContent(value: unknown): string | undefined {
if (typeof value === "string") return value;
@ -20,6 +35,8 @@ function extractTextFromContent(value: unknown): string | undefined {
continue;
}
if (!isRecord(entry)) continue;
// Only extract from text-type blocks, not thinking blocks
if (entry.type === "thinking") continue;
const text = entry.text;
if (typeof text === "string" && text.trim()) parts.push(text);
}
@ -51,35 +68,126 @@ function extractTextFromContent(value: unknown): string | undefined {
}
/**
* Best-effort extraction of human-readable text from Claude Agent SDK events.
* We keep this defensive because the SDK event shapes may evolve.
* Extract thinking content from a value, checking common SDK structures.
*/
export function extractTextFromClaudeAgentSdkEvent(event: unknown): string | undefined {
if (typeof event === "string") return event;
if (!isRecord(event)) return undefined;
function extractThinkingFromValue(value: unknown): string | undefined {
if (!isRecord(value)) return undefined;
// Direct text-ish fields.
const direct = extractTextFromContent(event);
if (direct) return direct;
// Direct thinking field
const thinking = value.thinking;
if (typeof thinking === "string" && thinking.trim()) return thinking;
// Common wrapper shapes: {message:{...}}, {data:{...}}.
const message = event.message;
const fromMessage = extractTextFromContent(message);
if (fromMessage) return fromMessage;
const data = event.data;
const fromData = extractTextFromContent(data);
if (fromData) return fromData;
// Nested message/data objects with content.
if (isRecord(message)) {
const fromMessageContent = extractTextFromContent(message.content);
if (fromMessageContent) return fromMessageContent;
// content_block with type="thinking"
const contentBlock = value.content_block as Record<string, unknown> | undefined;
if (isRecord(contentBlock) && contentBlock.type === "thinking") {
const blockThinking = contentBlock.thinking;
if (typeof blockThinking === "string" && blockThinking.trim()) return blockThinking;
}
if (isRecord(data)) {
const fromDataContent = extractTextFromContent(data.content);
if (fromDataContent) return fromDataContent;
// delta with type="thinking_delta"
const delta = value.delta as Record<string, unknown> | undefined;
if (isRecord(delta)) {
if (delta.type === "thinking_delta") {
const deltaThinking = delta.thinking;
if (typeof deltaThinking === "string" && deltaThinking.trim()) return deltaThinking;
}
// Also check direct thinking on delta (without type check)
const directDeltaThinking = delta.thinking;
if (typeof directDeltaThinking === "string" && directDeltaThinking.trim())
return directDeltaThinking;
}
// Check content array for thinking blocks
const content = value.content;
if (Array.isArray(content)) {
for (const entry of content) {
if (!isRecord(entry)) continue;
if (entry.type === "thinking") {
const entryThinking = entry.thinking;
if (typeof entryThinking === "string" && entryThinking.trim()) return entryThinking;
}
}
}
return undefined;
}
/**
* Unified extraction of both text and thinking content from a Claude Agent SDK event.
*
* This is the preferred extraction function - it returns both text and thinking
* content if present, allowing callers to handle both in a single call.
*
* @param event - The SDK event to extract content from
* @returns Object with optional `text` and `thinking` fields
*/
export function extractFromClaudeAgentSdkEvent(event: unknown): SdkEventExtraction {
const result: SdkEventExtraction = {};
if (typeof event === "string") {
result.text = event;
return result;
}
if (!isRecord(event)) return result;
// Extract thinking from the event
const thinking = extractThinkingFromValue(event);
if (thinking) result.thinking = thinking;
// Also check nested message/data for thinking
if (!result.thinking) {
const message = event.message as Record<string, unknown> | undefined;
if (isRecord(message)) {
const msgThinking = extractThinkingFromValue(message);
if (msgThinking) result.thinking = msgThinking;
}
}
if (!result.thinking) {
const data = event.data as Record<string, unknown> | undefined;
if (isRecord(data)) {
const dataThinking = extractThinkingFromValue(data);
if (dataThinking) result.thinking = dataThinking;
}
}
// Extract text from the event (separate from thinking)
const direct = extractTextFromContent(event);
if (direct) {
result.text = direct;
return result;
}
// Common wrapper shapes: {message:{...}}, {data:{...}}.
const message = event.message;
const fromMessage = extractTextFromContent(message);
if (fromMessage) {
result.text = fromMessage;
return result;
}
const data = event.data;
const fromData = extractTextFromContent(data);
if (fromData) {
result.text = fromData;
return result;
}
// Nested message/data objects with content.
if (isRecord(message)) {
const fromMessageContent = extractTextFromContent(message.content);
if (fromMessageContent) {
result.text = fromMessageContent;
return result;
}
}
if (isRecord(data)) {
const fromDataContent = extractTextFromContent(data.content);
if (fromDataContent) {
result.text = fromDataContent;
return result;
}
}
return result;
}

View File

@ -36,7 +36,7 @@ export { buildMoltbotSdkHooks, type SdkHooksConfig, type SdkHookEventName } from
// Event utilities
export { isSdkTerminalToolEventType } from "./sdk-event-checks.js";
export { extractTextFromClaudeAgentSdkEvent } from "./extract.js";
export { extractFromClaudeAgentSdkEvent, type SdkEventExtraction } from "./extract.js";
export {
bridgeMoltbotToolsToMcpServer,
bridgeMoltbotToolsSync,

View File

@ -16,6 +16,44 @@ import { createSubsystemLogger } from "../../logging/subsystem.js";
const log = createSubsystemLogger("agents/claude-agent-sdk");
/**
* Mask a token for logging - shows length and first/last 4 chars.
*/
function maskToken(token: string | undefined): string {
if (!token) return "(empty)";
if (token.length <= 12) return `${"*".repeat(token.length)} (length: ${token.length})`;
const first = token.slice(0, 4);
const last = token.slice(-4);
return `${first}...${"*".repeat(Math.min(8, token.length - 8))}...${last} (length: ${token.length})`;
}
/**
* Log the resolved provider config with masked credentials.
*/
function logProviderConfig(config: SdkProviderConfig, source: string): void {
const envKeys = config.env ? Object.keys(config.env) : [];
const maskedEnv: Record<string, string> = {};
if (config.env) {
for (const [key, value] of Object.entries(config.env)) {
if (key.includes("KEY") || key.includes("TOKEN") || key.includes("SECRET")) {
maskedEnv[key] = maskToken(value);
} else {
maskedEnv[key] = value ?? "(undefined)";
}
}
}
log.debug("[CCSDK-PROVIDER] Provider config resolved", {
source,
providerName: config.name,
envKeys,
maskedEnv,
model: config.model,
maxTurns: config.maxTurns,
});
}
/**
* Build provider config for direct Anthropic API access.
*/
@ -38,6 +76,8 @@ export function buildAnthropicSdkProvider(apiKey: string): SdkProviderConfig {
* environment or via the CLI's native auth mechanism.
*/
export function buildClaudeCliSdkProvider(): SdkProviderConfig | null {
log.debug("[CCSDK-PROVIDER] buildClaudeCliSdkProvider called");
// Read cached credentials from Claude Code CLI
const credentials = readClaudeCliCredentialsCached({
allowKeychainPrompt: false,
@ -45,38 +85,54 @@ export function buildClaudeCliSdkProvider(): SdkProviderConfig | null {
});
if (!credentials) {
log.debug("No Claude CLI credentials found");
log.debug("[CCSDK-PROVIDER] No Claude CLI credentials found from cache");
return null;
}
log.debug("[CCSDK-PROVIDER] Claude CLI credentials loaded", {
type: credentials.type,
provider: credentials.provider,
expiresAt: new Date(credentials.expires).toISOString(),
isExpired: credentials.expires < Date.now(),
msUntilExpiry: credentials.expires - Date.now(),
});
// For OAuth credentials, use the access token as ANTHROPIC_AUTH_TOKEN
if (credentials.type === "oauth") {
// Check if token is expired
if (credentials.expires < Date.now()) {
log.warn("Claude CLI credentials expired", {
log.warn("[CCSDK-PROVIDER] Claude CLI credentials EXPIRED", {
expiresAt: new Date(credentials.expires).toISOString(),
expiredAgo: Date.now() - credentials.expires,
});
// Still return the config - the SDK may handle refresh internally
}
return {
const config: SdkProviderConfig = {
name: "Claude CLI (subscription)",
env: {
ANTHROPIC_AUTH_TOKEN: credentials.access,
},
};
logProviderConfig(config, "claude-cli-oauth");
return config;
}
// For token-type credentials (less common)
if (credentials.type === "token") {
return {
const config: SdkProviderConfig = {
name: "Claude CLI (token)",
env: {
ANTHROPIC_AUTH_TOKEN: credentials.token,
},
};
logProviderConfig(config, "claude-cli-token");
return config;
}
log.debug("[CCSDK-PROVIDER] Unknown credential type", {
type: (credentials as { type: string }).type,
});
return null;
}
@ -85,28 +141,107 @@ export function buildClaudeCliSdkProvider(): SdkProviderConfig | null {
*
* z.AI uses Anthropic-compatible API with a different base URL.
*/
export function buildZaiSdkProvider(authToken: string): SdkProviderConfig {
return {
name: "z.AI",
env: {
ANTHROPIC_AUTH_TOKEN: authToken,
// z.AI may use a custom base URL - leave unset to use default
// The SDK or z.AI gateway handles routing
},
export function buildZaiSdkProvider(
authToken: string,
options?: {
baseUrl?: string;
defaultModel?: string;
haikuModel?: string;
sonnetModel?: string;
opusModel?: string;
},
): SdkProviderConfig {
const env: SdkProviderEnv = {
ANTHROPIC_AUTH_TOKEN: authToken,
};
// Set base URL if provided (z.AI may use a custom endpoint)
if (options?.baseUrl) {
env.ANTHROPIC_BASE_URL = options.baseUrl;
}
// Set model tier defaults for z.AI
if (options?.haikuModel) {
env.ANTHROPIC_DEFAULT_HAIKU_MODEL = options.haikuModel;
}
if (options?.sonnetModel) {
env.ANTHROPIC_DEFAULT_SONNET_MODEL = options.sonnetModel;
}
if (options?.opusModel) {
env.ANTHROPIC_DEFAULT_OPUS_MODEL = options.opusModel;
}
const config: SdkProviderConfig = {
name: "z.AI",
env,
model: options?.defaultModel,
};
log.debug("[CCSDK-PROVIDER] Built z.AI provider config", {
hasBaseUrl: Boolean(options?.baseUrl),
baseUrl: options?.baseUrl ?? "(default)",
defaultModel: options?.defaultModel ?? "(sdk default)",
haikuModel: options?.haikuModel ?? "(sdk default)",
sonnetModel: options?.sonnetModel ?? "(sdk default)",
opusModel: options?.opusModel ?? "(sdk default)",
authTokenMasked: maskToken(authToken),
});
return config;
}
/**
* Build provider config for OpenRouter (Anthropic-compatible).
*
* OpenRouter requires explicit model names since their model IDs differ
* from Anthropic's native IDs.
*/
export function buildOpenRouterSdkProvider(apiKey: string): SdkProviderConfig {
return {
name: "OpenRouter (Anthropic-compatible)",
env: {
ANTHROPIC_BASE_URL: "https://openrouter.ai/api/v1",
ANTHROPIC_API_KEY: apiKey,
},
export function buildOpenRouterSdkProvider(
apiKey: string,
options?: {
baseUrl?: string;
defaultModel?: string;
haikuModel?: string;
sonnetModel?: string;
opusModel?: string;
},
): SdkProviderConfig {
const env: SdkProviderEnv = {
ANTHROPIC_BASE_URL: options?.baseUrl ?? "https://openrouter.ai/api/v1",
ANTHROPIC_API_KEY: apiKey,
};
// OpenRouter uses different model naming - set explicit model IDs
// Default OpenRouter model names for Anthropic models:
// - anthropic/claude-3-5-haiku-20241022
// - anthropic/claude-sonnet-4-20250514
// - anthropic/claude-opus-4-20250514
if (options?.haikuModel) {
env.ANTHROPIC_DEFAULT_HAIKU_MODEL = options.haikuModel;
}
if (options?.sonnetModel) {
env.ANTHROPIC_DEFAULT_SONNET_MODEL = options.sonnetModel;
}
if (options?.opusModel) {
env.ANTHROPIC_DEFAULT_OPUS_MODEL = options.opusModel;
}
const config: SdkProviderConfig = {
name: "OpenRouter (Anthropic-compatible)",
env,
model: options?.defaultModel,
};
log.debug("[CCSDK-PROVIDER] Built OpenRouter provider config", {
baseUrl: env.ANTHROPIC_BASE_URL,
defaultModel: options?.defaultModel ?? "(sdk default)",
haikuModel: options?.haikuModel ?? "(sdk default)",
sonnetModel: options?.sonnetModel ?? "(sdk default)",
opusModel: options?.opusModel ?? "(sdk default)",
apiKeyMasked: maskToken(apiKey),
});
return config;
}
/**
@ -153,68 +288,104 @@ export function resolveProviderConfig(options?: {
baseUrl?: string;
useCliCredentials?: boolean;
}): SdkProviderConfig {
log.debug("[CCSDK-PROVIDER] resolveProviderConfig called", {
hasApiKey: Boolean(options?.apiKey),
apiKeyMasked: maskToken(options?.apiKey),
hasAuthToken: Boolean(options?.authToken),
authTokenMasked: maskToken(options?.authToken),
baseUrl: options?.baseUrl ?? "(default)",
useCliCredentials: options?.useCliCredentials ?? true,
});
// 1. Explicit API key takes precedence
if (options?.apiKey) {
log.debug("[CCSDK-PROVIDER] Using explicit API key");
const config = buildAnthropicSdkProvider(options.apiKey);
if (options.baseUrl && config.env) {
config.env.ANTHROPIC_BASE_URL = options.baseUrl;
}
logProviderConfig(config, "explicit-api-key");
return config;
}
// 2. Explicit auth token (OAuth/subscription)
if (options?.authToken) {
log.debug("[CCSDK-PROVIDER] Using explicit auth token");
const env: SdkProviderEnv = {
ANTHROPIC_AUTH_TOKEN: options.authToken,
};
if (options.baseUrl) {
env.ANTHROPIC_BASE_URL = options.baseUrl;
}
return {
const config: SdkProviderConfig = {
name: "Anthropic (auth token)",
env,
};
logProviderConfig(config, "explicit-auth-token");
return config;
}
// 3. Try Claude CLI credentials if enabled (default: true)
if (options?.useCliCredentials !== false) {
log.debug("[CCSDK-PROVIDER] Attempting Claude CLI credentials");
const cliConfig = buildClaudeCliSdkProvider();
if (cliConfig) {
log.debug("[CCSDK-PROVIDER] Using Claude CLI credentials", {
providerName: cliConfig.name,
});
if (options?.baseUrl && cliConfig.env) {
cliConfig.env.ANTHROPIC_BASE_URL = options.baseUrl;
}
return cliConfig;
}
log.debug("[CCSDK-PROVIDER] Claude CLI credentials not available");
}
// 4. Check environment variables
const envApiKey = process.env.ANTHROPIC_API_KEY;
const envAuthToken = process.env.ANTHROPIC_AUTH_TOKEN;
log.debug("[CCSDK-PROVIDER] Checking environment variables", {
hasEnvApiKey: Boolean(envApiKey),
envApiKeyMasked: maskToken(envApiKey),
hasEnvAuthToken: Boolean(envAuthToken),
envAuthTokenMasked: maskToken(envAuthToken),
hasEnvBaseUrl: Boolean(process.env.ANTHROPIC_BASE_URL),
});
if (envApiKey) {
return {
const config: SdkProviderConfig = {
name: "Anthropic (env)",
env: {
ANTHROPIC_API_KEY: envApiKey,
ANTHROPIC_BASE_URL: options?.baseUrl ?? process.env.ANTHROPIC_BASE_URL,
},
};
logProviderConfig(config, "env-api-key");
return config;
}
if (envAuthToken) {
return {
const config: SdkProviderConfig = {
name: "Anthropic (env auth token)",
env: {
ANTHROPIC_AUTH_TOKEN: envAuthToken,
ANTHROPIC_BASE_URL: options?.baseUrl ?? process.env.ANTHROPIC_BASE_URL,
},
};
logProviderConfig(config, "env-auth-token");
return config;
}
// 5. Return empty config - SDK will use its own credential resolution
log.debug("No explicit credentials provided, SDK will use native auth");
return {
log.warn("[CCSDK-PROVIDER] No credentials found - SDK will use native auth (may fail)", {
triedCliCredentials: options?.useCliCredentials !== false,
triedEnvVars: true,
});
const config: SdkProviderConfig = {
name: "Anthropic (SDK native)",
env: {},
};
logProviderConfig(config, "sdk-native-fallback");
return config;
}

View File

@ -2,6 +2,7 @@
* Claude Agent SDK runtime implementation.
*
* Implements the AgentRuntime interface using the Claude Agent SDK for execution.
* Uses both shared generalized fields and CCSDK-specific options from ccsdkOptions bag.
*/
import type { MoltbotConfig } from "../../config/config.js";
@ -20,8 +21,11 @@ import {
} from "./sdk-session-transcript.js";
import { createSubsystemLogger } from "../../logging/subsystem.js";
import type { SdkConversationTurn, SdkRunnerResult } from "./types.js";
import { createMoltbotCodingTools } from "../pi-tools.js";
import { resolveMoltbotAgentDir } from "../agent-paths.js";
import { resolveModelAuthMode } from "../model-auth.js";
const log = createSubsystemLogger("agents/claude-agent-sdk");
const log = createSubsystemLogger("agents/ccsdk-runtime");
export type CcSdkAgentRuntimeContext = {
/** Moltbot configuration. */
@ -90,11 +94,8 @@ function extractAgentId(sessionKey?: string): string {
* Resolve user timezone from config.
*/
function resolveTimezone(config?: MoltbotConfig): string | undefined {
// Check for explicit timezone in config
const tz = config?.agents?.defaults?.userTimezone;
if (tz) return tz;
// Fallback to environment
return typeof process !== "undefined" ? process.env.TZ : undefined;
}
@ -115,7 +116,6 @@ function extractSkillNames(
* Convert an SdkRunnerResult into an AgentRuntimeResult.
*/
function adaptSdkResult(result: SdkRunnerResult, sessionId: string): AgentRuntimeResult {
// Map SDK usage stats to the Pi-embedded format.
const usage = result.meta.usage
? {
input: result.meta.usage.inputTokens,
@ -140,9 +140,6 @@ function adaptSdkResult(result: SdkRunnerResult, sessionId: string): AgentRuntim
model: result.meta.model ?? "default",
usage,
},
// SDK runner errors are rendered as text payloads with isError=true.
// Avoid mapping to Pi-specific error kinds (context/compaction) because
// downstream recovery logic would treat them incorrectly.
error: undefined,
},
didSendViaMessagingTool: result.didSendViaMessagingTool,
@ -161,17 +158,15 @@ function adaptSdkResult(result: SdkRunnerResult, sessionId: string): AgentRuntim
* - AWS Bedrock and Google Vertex AI backends
*/
export function createCcSdkAgentRuntime(context?: CcSdkAgentRuntimeContext): AgentRuntime {
// Pre-check SDK availability
if (!isSdkAvailable()) {
log.warn("Claude Agent SDK not available - runtime will fail on first run");
}
// Resolve provider configuration from context
const providerConfig = resolveProviderConfig({
apiKey: context?.apiKey,
authToken: context?.authToken,
baseUrl: context?.baseUrl,
useCliCredentials: true, // Enable Claude CLI credential resolution
useCliCredentials: true,
});
return {
@ -182,10 +177,19 @@ export function createCcSdkAgentRuntime(context?: CcSdkAgentRuntimeContext): Age
const effectiveConfig = params.config ?? context?.config;
const agentId = extractAgentId(params.sessionKey);
log.debug("CCSDK runtime run", {
// Extract CCSDK-specific options from the options bag
const ccsdkOpts = params.ccsdkOptions ?? {};
// Resolve effective model (from params or provider config)
const effectiveModel = params.model
? `${params.provider ?? "anthropic"}/${params.model}`
: (ccsdkOpts.modelTiers?.sonnet ?? context?.ccsdkConfig?.models?.sonnet ?? "default");
log.info("Starting CCSDK Agent session", {
sessionId: params.sessionId,
runId: params.runId,
provider: providerConfig.name,
model: effectiveModel,
agentId,
thinkLevel: params.thinkLevel,
verboseLevel: params.verboseLevel,
@ -200,8 +204,32 @@ export function createCcSdkAgentRuntime(context?: CcSdkAgentRuntimeContext): Age
});
}
// Build tools for this run (similar to how Pi runtime builds tools in attempt.ts)
// If pre-built tools are provided via context, use those instead
const tools: AnyAgentTool[] =
context?.tools ??
createMoltbotCodingTools({
messageProvider: params.messageChannel ?? params.messageProvider,
agentAccountId: params.agentAccountId,
messageTo: params.messageTo,
messageThreadId: params.messageThreadId,
sessionKey: params.sessionKey,
agentDir: params.agentDir ?? resolveMoltbotAgentDir(),
workspaceDir: params.workspaceDir,
config: effectiveConfig,
abortSignal: params.abortSignal,
modelProvider: params.provider,
modelId: params.model,
modelAuthMode: resolveModelAuthMode(params.provider ?? "anthropic"),
currentChannelId: params.currentChannelId,
currentThreadTs: params.currentThreadTs,
groupId: params.groupId,
});
log.debug("Built tools for CCSDK run", { toolCount: tools.length });
const sdkResult = await runSdkAgent({
// ─── Session & Identity ──────────────────────────────────────────────
// ─── Core shared params (spread) ────────────────────────────────────────
runId: params.runId,
sessionId: params.sessionId,
sessionKey: params.sessionKey,
@ -209,35 +237,38 @@ export function createCcSdkAgentRuntime(context?: CcSdkAgentRuntimeContext): Age
workspaceDir: params.workspaceDir,
agentDir: params.agentDir,
agentId,
// ─── Configuration ───────────────────────────────────────────────────
config: effectiveConfig,
prompt: params.prompt,
model: params.model ? `${params.provider ?? "anthropic"}/${params.model}` : undefined,
providerConfig,
timeoutMs: params.timeoutMs,
abortSignal: params.abortSignal,
extraSystemPrompt: params.extraSystemPrompt,
// ─── Model Behavior ──────────────────────────────────────────────────
// ─── Messaging & sender context (shared) ────────────────────────────────
messageChannel: params.messageChannel,
senderId: params.senderId,
senderName: params.senderName,
senderUsername: params.senderUsername,
senderE164: params.senderE164,
// ─── Transformed/SDK-specific fields ────────────────────────────────────
model: params.model ? `${params.provider ?? "anthropic"}/${params.model}` : undefined,
providerConfig,
reasoningLevel: mapThinkLevel(params.thinkLevel),
verboseLevel: mapVerboseLevel(params.verboseLevel),
// ─── System Prompt Context ───────────────────────────────────────────
extraSystemPrompt: params.extraSystemPrompt,
timezone: resolveTimezone(effectiveConfig),
messageChannel: params.messageChannel,
skills: extractSkillNames(params.skillsSnapshot),
// ─── SDK Options ─────────────────────────────────────────────────────
hooksEnabled: context?.ccsdkConfig?.hooksEnabled,
sdkOptions: context?.ccsdkConfig?.options,
modelTiers: context?.ccsdkConfig?.models,
// ─── CCSDK options from ccsdkOptions bag ────────────────────────────────
hooksEnabled: ccsdkOpts.hooksEnabled ?? context?.ccsdkConfig?.hooksEnabled,
sdkOptions: ccsdkOpts.sdkOptions ?? context?.ccsdkConfig?.options,
modelTiers: ccsdkOpts.modelTiers ?? context?.ccsdkConfig?.models,
claudeSessionId: ccsdkOpts.claudeSessionId,
forkSession: ccsdkOpts.forkSession,
thinkingLevel: params.thinkLevel,
conversationHistory,
tools: context?.tools,
tools,
// ─── Streaming Callbacks ─────────────────────────────────────────────
// Wrap callbacks to adapt SDK payload shapes to AgentRuntimeRunParams signatures
// ─── Streaming callbacks (adapted to SDK payload shapes) ────────────────
onPartialReply: params.onPartialReply
? (payload) => params.onPartialReply?.({ text: payload.text })
: undefined,
@ -255,9 +286,17 @@ export function createCcSdkAgentRuntime(context?: CcSdkAgentRuntimeContext): Age
onAgentEvent: params.onAgentEvent,
});
log.debug("[CCSDK-RUNTIME] Callbacks passed to runSdkAgent", {
hasOnPartialReply: Boolean(params.onPartialReply),
hasOnBlockReply: Boolean(params.onBlockReply),
hasOnBlockReplyFlush: Boolean(params.onBlockReplyFlush),
hasOnReasoningStream: Boolean(params.onReasoningStream),
hasOnToolResult: Boolean(params.onToolResult),
hasOnAgentEvent: Boolean(params.onAgentEvent),
});
// Persist session transcript for multi-turn continuity.
if (params.sessionFile) {
// Record tool calls with structured format (matching pi-agent).
if (sdkResult.completedToolCalls && sdkResult.completedToolCalls.length > 0) {
appendSdkToolCallsToSessionTranscript({
sessionFile: params.sessionFile,
@ -265,7 +304,6 @@ export function createCcSdkAgentRuntime(context?: CcSdkAgentRuntimeContext): Age
});
}
// Record user/assistant text turn pair.
appendSdkTurnPairToSessionTranscript({
sessionFile: params.sessionFile,
prompt: params.prompt,

View File

@ -61,28 +61,58 @@ export function isSdkAvailable(): boolean {
*/
export async function loadClaudeAgentSdk(): Promise<ClaudeAgentSdkModule> {
if (sdkModule) {
log.debug("[CCSDK-LOADER] Returning cached SDK module");
return sdkModule;
}
if (loadAttempted && loadError) {
log.debug("[CCSDK-LOADER] Previous load attempt failed, re-throwing cached error");
throw loadError;
}
loadAttempted = true;
log.debug("[CCSDK-LOADER] Attempting to load Claude Agent SDK");
try {
// Dynamic import to avoid bundling issues
// The SDK package name is resolved at runtime
const moduleName = "@anthropic-ai/claude-agent-sdk";
const loadStart = Date.now();
sdkModule = (await import(/* @vite-ignore */ moduleName)) as ClaudeAgentSdkModule;
log.info("Claude Agent SDK loaded successfully");
const loadDuration = Date.now() - loadStart;
// Log SDK module details for debugging
const moduleKeys = Object.keys(sdkModule);
const hasQuery = typeof sdkModule.query === "function";
log.info("[CCSDK-LOADER] Claude Agent SDK loaded successfully", {
loadDurationMs: loadDuration,
hasQueryFunction: hasQuery,
moduleExports: moduleKeys.slice(0, 10),
totalExports: moduleKeys.length,
});
if (!hasQuery) {
log.error("[CCSDK-LOADER] SDK loaded but query function is missing!", {
moduleKeys,
queryType: typeof sdkModule.query,
});
}
return sdkModule;
} catch (err) {
const errorMessage = err instanceof Error ? err.message : String(err);
const errorStack = err instanceof Error ? err.stack : undefined;
log.error("[CCSDK-LOADER] Failed to load Claude Agent SDK", {
error: errorMessage,
stack: errorStack,
});
loadError = new Error(
"Claude Agent SDK not installed. Install with: npm install @anthropic-ai/claude-agent-sdk",
);
loadError.cause = err;
log.warn("Failed to load Claude Agent SDK", { error: String(err) });
throw loadError;
}
}

View File

@ -206,4 +206,224 @@ describe("runSdkAgent", () => {
expect(result.meta.truncated).toBe(true);
expect(result.payloads[0]?.text).toContain("[Output truncated]");
});
describe("callback wiring", () => {
it("calls onBlockReply with assistant text content", async () => {
const blockReplies: Array<{ text?: string }> = [];
const onBlockReply = vi.fn((payload: { text?: string }) => {
blockReplies.push(payload);
});
mockQuery.mockImplementationOnce(async function* () {
yield { type: "assistant", text: "First chunk" };
yield { type: "assistant", text: "Second chunk" };
yield { type: "result", result: "Final result" };
});
await runSdkAgent({
...baseParams,
onBlockReply,
});
expect(onBlockReply).toHaveBeenCalled();
expect(blockReplies.length).toBeGreaterThan(0);
// Should have received text content
expect(blockReplies.some((r) => r.text && r.text.length > 0)).toBe(true);
});
it("calls onBlockReplyFlush after stream completion", async () => {
const onBlockReply = vi.fn();
const onBlockReplyFlush = vi.fn();
let flushCalledAfterReply = false;
mockQuery.mockImplementationOnce(async function* () {
yield { type: "assistant", text: "Hello world" };
yield { type: "result", result: "Hello world" };
});
await runSdkAgent({
...baseParams,
onBlockReply: async (payload) => {
onBlockReply(payload);
},
onBlockReplyFlush: async () => {
// Verify flush is called after at least one block reply
flushCalledAfterReply = onBlockReply.mock.calls.length > 0;
onBlockReplyFlush();
},
});
expect(onBlockReplyFlush).toHaveBeenCalledTimes(1);
expect(flushCalledAfterReply).toBe(true);
});
it("calls onReasoningStream with thinking content", async () => {
const thinkingChunks: string[] = [];
const onReasoningStream = vi.fn((payload: { text?: string }) => {
if (payload.text) thinkingChunks.push(payload.text);
});
mockQuery.mockImplementationOnce(async function* () {
yield { type: "thinking", thinking: "Let me think..." };
yield { type: "thinking_delta", thinking: "Analyzing..." };
yield { type: "assistant", text: "Here is my answer" };
yield { type: "result", result: "Here is my answer" };
});
const result = await runSdkAgent({
...baseParams,
thinkingLevel: "high",
onReasoningStream,
});
expect(onReasoningStream).toHaveBeenCalled();
expect(thinkingChunks.length).toBeGreaterThan(0);
expect(thinkingChunks.some((t) => t.includes("think") || t.includes("Analyzing"))).toBe(true);
// Final text should not contain thinking
expect(result.payloads[0]?.text).toBe("Here is my answer");
});
it("extracts both text and thinking from content_block events", async () => {
const thinkingChunks: string[] = [];
const textChunks: string[] = [];
const onReasoningStream = vi.fn((payload: { text?: string }) => {
if (payload.text) thinkingChunks.push(payload.text);
});
const onBlockReply = vi.fn((payload: { text?: string }) => {
if (payload.text) textChunks.push(payload.text);
});
mockQuery.mockImplementationOnce(async function* () {
// Emit thinking via content_block
yield {
type: "content_block_start",
content_block: { type: "thinking", thinking: "Internal reasoning process" },
};
yield {
type: "content_block_delta",
delta: { type: "thinking_delta", thinking: "More thinking..." },
};
// Emit text via content_block
yield {
type: "content_block_delta",
delta: { type: "text_delta", text: "Visible response" },
};
yield { type: "result", result: "Visible response" };
});
await runSdkAgent({
...baseParams,
thinkingLevel: "high",
onReasoningStream,
onBlockReply,
});
// Thinking should be streamed separately
expect(thinkingChunks.length).toBeGreaterThan(0);
expect(thinkingChunks.some((t) => t.includes("reasoning") || t.includes("thinking"))).toBe(
true,
);
// Text should be streamed separately
expect(textChunks.length).toBeGreaterThan(0);
expect(textChunks.some((t) => t.includes("Visible"))).toBe(true);
});
it("handles event with both thinking and text fields", async () => {
const thinkingChunks: string[] = [];
const textChunks: string[] = [];
const onReasoningStream = vi.fn((payload: { text?: string }) => {
if (payload.text) thinkingChunks.push(payload.text);
});
const onBlockReply = vi.fn((payload: { text?: string }) => {
if (payload.text) textChunks.push(payload.text);
});
// Some SDK versions might emit both in one event
mockQuery.mockImplementationOnce(async function* () {
yield {
type: "assistant",
thinking: "My thought process",
text: "My visible answer",
};
yield { type: "result", result: "My visible answer" };
});
await runSdkAgent({
...baseParams,
thinkingLevel: "high",
onReasoningStream,
onBlockReply,
});
// Both should be extracted and sent to appropriate callbacks
expect(thinkingChunks).toContain("My thought process");
expect(textChunks.some((t) => t.includes("My visible answer"))).toBe(true);
});
it("does not call onReasoningStream when callback not provided", async () => {
// This test verifies that if no onReasoningStream is provided,
// thinking content doesn't cause errors or unexpected behavior
mockQuery.mockImplementationOnce(async function* () {
yield { type: "thinking", thinking: "Internal thinking" };
yield { type: "assistant", text: "Response" };
yield { type: "result", result: "Response" };
});
const result = await runSdkAgent({
...baseParams,
thinkingLevel: "high",
// No onReasoningStream provided
});
// Should complete successfully
expect(result.payloads[0]?.text).toBe("Response");
expect(result.payloads[0]?.isError).toBeUndefined();
});
it("does not call onBlockReplyFlush when callback not provided", async () => {
mockQuery.mockImplementationOnce(async function* () {
yield { type: "assistant", text: "Hello" };
yield { type: "result", result: "Hello" };
});
const result = await runSdkAgent({
...baseParams,
onBlockReply: vi.fn(),
// No onBlockReplyFlush provided
});
// Should complete successfully
expect(result.payloads[0]?.text).toBe("Hello");
});
it("calls onPartialReply and onBlockReply in correct order", async () => {
const callOrder: string[] = [];
const onPartialReply = vi.fn(() => {
callOrder.push("partial");
});
const onBlockReply = vi.fn(() => {
callOrder.push("block");
});
mockQuery.mockImplementationOnce(async function* () {
yield { type: "assistant", text: "Streaming text" };
yield { type: "result", result: "Streaming text" };
});
await runSdkAgent({
...baseParams,
onPartialReply,
onBlockReply,
});
// Both should be called
expect(onPartialReply).toHaveBeenCalled();
expect(onBlockReply).toHaveBeenCalled();
// Partial should be called before or at same time as block
const firstPartial = callOrder.indexOf("partial");
const firstBlock = callOrder.indexOf("block");
expect(firstPartial).toBeLessThanOrEqual(firstBlock);
});
});
});

View File

@ -17,7 +17,7 @@
import { createSubsystemLogger } from "../../logging/subsystem.js";
import { bridgeMoltbotToolsToMcpServer } from "./tool-bridge.js";
import type { SdkRunnerQueryOptions } from "./tool-bridge.types.js";
import { extractTextFromClaudeAgentSdkEvent } from "./extract.js";
import { extractFromClaudeAgentSdkEvent } from "./extract.js";
import { loadClaudeAgentSdk } from "./sdk-loader.js";
import { buildHistorySystemPromptSuffix } from "./sdk-history.js";
import { isSdkTerminalToolEventType } from "./sdk-event-checks.js";
@ -29,6 +29,7 @@ import type {
SdkUsageStats,
SdkCompletedToolCall,
SdkProviderEnv,
SdkProviderConfig,
} from "./types.js";
import type { ThinkLevel } from "../../auto-reply/thinking.js";
import type { CcSdkModelTiers } from "../../config/types.agents.js";
@ -141,27 +142,256 @@ const DEFAULT_MAX_EXTRACTED_CHARS = 120_000;
const DEFAULT_MCP_SERVER_NAME = "moltbot";
const DEFAULT_MAX_TURNS = 50;
// ---------------------------------------------------------------------------
// Debug helpers
// ---------------------------------------------------------------------------
/**
* Mask a token for logging - shows length and first/last 4 chars.
*/
function maskToken(token: string | undefined): string {
if (!token) return "(empty)";
if (token.length <= 12) return `${"*".repeat(token.length)} (length: ${token.length})`;
const first = token.slice(0, 4);
const last = token.slice(-4);
return `${first}...${"*".repeat(Math.min(8, token.length - 8))}...${last} (length: ${token.length})`;
}
/**
* Log authentication-related environment variables (masked).
*/
function logAuthEnvVars(env: Record<string, string>, prefix: string): void {
const authKeys = [
"ANTHROPIC_API_KEY",
"ANTHROPIC_AUTH_TOKEN",
"ANTHROPIC_BASE_URL",
"CLAUDE_CODE_USE_BEDROCK",
"CLAUDE_CODE_USE_VERTEX",
];
const authEnv: Record<string, string> = {};
for (const key of authKeys) {
if (env[key]) {
authEnv[key] = key.includes("KEY") || key.includes("TOKEN") ? maskToken(env[key]) : env[key];
}
}
log.debug(`${prefix} Auth environment variables`, {
authEnv,
totalEnvKeys: Object.keys(env).length,
});
}
/**
* Detect potential auth-related error patterns in response text.
*/
function detectAuthErrorPatterns(text: string): {
hasAuthError: boolean;
patterns: string[];
} {
const patterns: string[] = [];
const lowerText = text.toLowerCase();
// Common auth error patterns
if (lowerText.includes("unauthorized") || lowerText.includes("401")) {
patterns.push("unauthorized/401");
}
if (lowerText.includes("invalid api key") || lowerText.includes("invalid_api_key")) {
patterns.push("invalid_api_key");
}
if (lowerText.includes("authentication") && lowerText.includes("failed")) {
patterns.push("authentication_failed");
}
if (lowerText.includes("expired") && (lowerText.includes("token") || lowerText.includes("key"))) {
patterns.push("expired_credentials");
}
if (lowerText.includes("permission") && lowerText.includes("denied")) {
patterns.push("permission_denied");
}
if (lowerText.includes("rate limit") || lowerText.includes("ratelimit")) {
patterns.push("rate_limit");
}
if (
lowerText.includes("quota") &&
(lowerText.includes("exceeded") || lowerText.includes("limit"))
) {
patterns.push("quota_exceeded");
}
// Check for suspiciously short responses that might indicate auth issues
if (text.length >= 100 && text.length <= 200) {
patterns.push("suspicious_short_response");
}
return {
hasAuthError: patterns.length > 0,
patterns,
};
}
// ---------------------------------------------------------------------------
// Model tier environment helpers
// ---------------------------------------------------------------------------
/**
* Provider types that require different model name formats.
*/
type SdkProviderType = "anthropic-native" | "openrouter" | "zai" | "bedrock" | "vertex" | "other";
/**
* Detect the provider type from the provider config.
*/
function detectProviderType(providerConfig?: SdkProviderConfig): SdkProviderType {
if (!providerConfig) return "anthropic-native";
const name = providerConfig.name?.toLowerCase() ?? "";
const baseUrl = providerConfig.env?.ANTHROPIC_BASE_URL?.toLowerCase() ?? "";
// Check for OpenRouter
if (name.includes("openrouter") || baseUrl.includes("openrouter")) {
return "openrouter";
}
// Check for z.AI
if (name.includes("z.ai") || name.includes("zai")) {
return "zai";
}
// Check for Bedrock
if (name.includes("bedrock") || providerConfig.env?.CLAUDE_CODE_USE_BEDROCK === "1") {
return "bedrock";
}
// Check for Vertex
if (name.includes("vertex") || providerConfig.env?.CLAUDE_CODE_USE_VERTEX === "1") {
return "vertex";
}
// Check for SDK native (no auth token/key provided, SDK handles auth)
if (name.includes("sdk native")) {
return "anthropic-native";
}
// Check for direct Anthropic (has API key or auth token but no custom base URL)
if (
(providerConfig.env?.ANTHROPIC_API_KEY || providerConfig.env?.ANTHROPIC_AUTH_TOKEN) &&
!baseUrl
) {
return "anthropic-native";
}
return "other";
}
/**
* Normalize a model name based on the target provider.
*
* Different providers expect different model name formats:
* - Anthropic native: "claude-opus-4-5" (no prefix)
* - OpenRouter: "anthropic/claude-opus-4-5" (with prefix)
* - z.AI: varies, may need prefix or not
* - Bedrock/Vertex: use their own model ID formats
*/
function normalizeModelNameForProvider(modelName: string, providerType: SdkProviderType): string {
if (!modelName) return modelName;
const hasAnthropicPrefix = modelName.startsWith("anthropic/");
switch (providerType) {
case "anthropic-native":
// Strip "anthropic/" prefix for direct Anthropic API
if (hasAnthropicPrefix) {
const stripped = modelName.slice("anthropic/".length);
log.debug("[CCSDK-MODEL] Stripped anthropic/ prefix for native API", {
original: modelName,
normalized: stripped,
providerType,
});
return stripped;
}
return modelName;
case "openrouter":
// OpenRouter expects "anthropic/model-name" format
// Keep as-is if already prefixed, add prefix if not
if (!hasAnthropicPrefix && modelName.startsWith("claude-")) {
const prefixed = `anthropic/${modelName}`;
log.debug("[CCSDK-MODEL] Added anthropic/ prefix for OpenRouter", {
original: modelName,
normalized: prefixed,
providerType,
});
return prefixed;
}
return modelName;
case "zai":
// z.AI uses completely different model names (e.g., "glm-4.7")
// The model name should come from config already in z.AI format
// Warn if we see Anthropic-formatted model names - likely a config issue
if (hasAnthropicPrefix || modelName.startsWith("claude-")) {
log.warn(
"[CCSDK-MODEL] z.AI provider detected but model name looks like Anthropic format",
{
modelName,
providerType,
hint: "For z.AI, configure model tiers with z.AI model names (e.g., 'glm-4.7'), not Anthropic names",
},
);
}
// Pass through unchanged
return modelName;
case "bedrock":
case "vertex":
// Bedrock/Vertex have their own model ID formats
// For now, pass through - may need specific handling later
log.debug("[CCSDK-MODEL] Passing model name through for cloud provider", {
modelName,
providerType,
});
return modelName;
default:
// Unknown provider - pass through unchanged
return modelName;
}
}
/**
* Build environment variables for model tier configuration.
*
* The Claude Code SDK uses these environment variables to select models
* for different task complexity tiers.
*
* Model names are normalized based on the target provider.
*/
function buildModelTierEnv(modelTiers?: CcSdkModelTiers): SdkProviderEnv {
function buildModelTierEnv(
modelTiers: CcSdkModelTiers | undefined,
providerConfig: SdkProviderConfig | undefined,
): SdkProviderEnv {
const env: SdkProviderEnv = {};
if (modelTiers?.haiku) {
env.ANTHROPIC_DEFAULT_HAIKU_MODEL = modelTiers.haiku;
if (!modelTiers) return env;
const providerType = detectProviderType(providerConfig);
log.debug("[CCSDK-MODEL] Building model tier env", {
providerType,
providerName: providerConfig?.name,
rawTiers: modelTiers,
});
if (modelTiers.haiku) {
env.ANTHROPIC_DEFAULT_HAIKU_MODEL = normalizeModelNameForProvider(
modelTiers.haiku,
providerType,
);
}
if (modelTiers?.sonnet) {
env.ANTHROPIC_DEFAULT_SONNET_MODEL = modelTiers.sonnet;
if (modelTiers.sonnet) {
env.ANTHROPIC_DEFAULT_SONNET_MODEL = normalizeModelNameForProvider(
modelTiers.sonnet,
providerType,
);
}
if (modelTiers?.opus) {
env.ANTHROPIC_DEFAULT_OPUS_MODEL = modelTiers.opus;
if (modelTiers.opus) {
env.ANTHROPIC_DEFAULT_OPUS_MODEL = normalizeModelNameForProvider(modelTiers.opus, providerType);
}
return env;
@ -196,10 +426,11 @@ async function coerceAsyncIterable(value: unknown): Promise<AsyncIterable<unknow
* - "result": terminal event with final output
* - "assistant": assistant message text (partial or complete)
* - "tool": tool execution event
* - "thinking": reasoning/thinking text (extended thinking)
* - "system": lifecycle/diagnostic event
* - "unknown": unrecognized shape
*/
type EventKind = "result" | "assistant" | "tool" | "system" | "unknown";
type EventKind = "result" | "assistant" | "tool" | "thinking" | "system" | "unknown";
function classifyEvent(event: unknown): { kind: EventKind; event: unknown } {
if (!isRecord(event)) return { kind: "unknown", event };
@ -219,6 +450,20 @@ function classifyEvent(event: unknown): { kind: EventKind; event: unknown } {
return { kind: "tool", event };
}
// Thinking/reasoning events (extended thinking).
if (type === "thinking" || type === "thinking_delta") {
return { kind: "thinking", event };
}
// content_block_start with thinking content block
if (type === "content_block_start") {
const contentBlock = (event as Record<string, unknown>).content_block as
| Record<string, unknown>
| undefined;
if (contentBlock?.type === "thinking") {
return { kind: "thinking", event };
}
}
// System/lifecycle events.
if (type === "system" || type === "agent_start" || type === "agent_end" || type === "error") {
return { kind: "system", event };
@ -280,6 +525,22 @@ function applySdkOptionsOverrides(
// Prompt building
// ---------------------------------------------------------------------------
/**
* SDK user message shape for AsyncIterable prompts.
*/
type SdkUserMessage = {
type: "user";
message: {
role: "user";
content: string;
};
};
/**
* SDK prompt type the SDK accepts either a plain string or an AsyncIterable of messages.
*/
type SdkPrompt = string | AsyncIterable<SdkUserMessage>;
/**
* Build the SDK prompt.
*
@ -293,23 +554,27 @@ function applySdkOptionsOverrides(
function buildSdkPrompt(params: {
prompt: string;
systemPrompt?: string;
}): string | AsyncIterable<{ type: "user"; message: { role: "user"; content: string } }> {
// When there's no system prompt override, a plain string works (no mcpServers
// requirement for plain string prompts is only for some SDK versions).
// We always use the async iterable form for consistency and forward compat.
useMcpServers?: boolean;
}): SdkPrompt {
const userContent = params.prompt;
async function* generateMessages() {
yield {
type: "user" as const,
message: {
role: "user" as const,
content: userContent,
},
};
// When using MCP servers, the SDK requires an AsyncIterable of messages.
// When not using MCP servers, a plain string is more efficient.
if (params.useMcpServers) {
async function* generateMessages(): AsyncIterable<SdkUserMessage> {
yield {
type: "user" as const,
message: {
role: "user" as const,
content: userContent,
},
};
}
return generateMessages();
}
return generateMessages();
// Plain string prompt when MCP servers are not in use.
return userContent;
}
// ---------------------------------------------------------------------------
@ -327,6 +592,46 @@ export async function runSdkAgent(params: SdkRunnerParams): Promise<SdkRunnerRes
const mcpServerName = params.mcpServerName ?? DEFAULT_MCP_SERVER_NAME;
const hooksEnabled = params.hooksEnabled === true;
// Log comprehensive run parameters for debugging
log.info("[CCSDK-RUN] Starting SDK agent run", {
runId: params.runId,
sessionId: params.sessionId,
sessionKey: params.sessionKey,
agentId: params.agentId,
promptLength: params.prompt.length,
promptPreview: params.prompt.slice(0, 200),
mcpServerName,
hooksEnabled,
timeoutMs: params.timeoutMs,
thinkingLevel: params.thinkingLevel,
maxTurns: params.maxTurns,
});
// Log provider configuration
log.debug("[CCSDK-RUN] Provider configuration", {
providerName: params.providerConfig?.name ?? "(not set)",
providerModel: params.providerConfig?.model ?? "(default)",
providerMaxTurns: params.providerConfig?.maxTurns ?? "(default)",
providerEnvKeys: params.providerConfig?.env ? Object.keys(params.providerConfig.env) : [],
});
// Log model tiers if configured
if (params.modelTiers) {
log.debug("[CCSDK-RUN] Model tiers configured", {
haiku: params.modelTiers.haiku ?? "(default)",
sonnet: params.modelTiers.sonnet ?? "(default)",
opus: params.modelTiers.opus ?? "(default)",
});
}
// Log session resume state
if (params.claudeSessionId) {
log.debug("[CCSDK-RUN] Session resume enabled", {
claudeSessionId: params.claudeSessionId,
forkSession: params.forkSession ?? false,
});
}
const emitEvent = (stream: string, data: Record<string, unknown>) => {
try {
void Promise.resolve(params.onAgentEvent?.({ stream, data })).catch(() => {
@ -513,7 +818,11 @@ export async function runSdkAgent(params: SdkRunnerParams): Promise<SdkRunnerRes
if (params.forkSession) {
sdkOptions.forkSession = true;
}
log.debug(`Using native session resume with ID: ${params.claudeSessionId}`);
log.debug("Resuming CCSDK session", {
claudeSessionId: params.claudeSessionId,
forkSession: params.forkSession ?? false,
sessionId: params.sessionId,
});
}
// System prompt (with Moltbot-specific additions and conversation history suffix).
@ -525,6 +834,11 @@ export async function runSdkAgent(params: SdkRunnerParams): Promise<SdkRunnerRes
messageChannel: params.messageChannel,
channelHints: params.channelHints,
skills: params.skills,
// Sender context for system prompt enrichment
senderId: params.senderId,
senderName: params.senderName,
senderUsername: params.senderUsername,
senderE164: params.senderE164,
});
// Skip history injection when using native session resume.
const historySuffix = useNativeSessionResume
@ -538,25 +852,64 @@ export async function runSdkAgent(params: SdkRunnerParams): Promise<SdkRunnerRes
sdkOptions.systemPrompt = combinedSystemPrompt;
}
// Provider env overrides (z.AI, custom endpoints, etc.) and model tier config.
const envOverrides: Record<string, string> = {};
// Build environment for the SDK process.
// Always inherit the parent process environment (critical for PATH, HOME, etc.
// when running as a LaunchAgent or daemon), then apply overrides.
const sdkEnv: Record<string, string> = {};
// Add provider env vars.
// Log inherited auth env vars BEFORE we override them
log.debug("[CCSDK-ENV] Checking inherited auth env vars from parent process", {
hasAnthropicApiKey: Boolean(process.env.ANTHROPIC_API_KEY),
inheritedApiKeyMasked: maskToken(process.env.ANTHROPIC_API_KEY),
hasAnthropicAuthToken: Boolean(process.env.ANTHROPIC_AUTH_TOKEN),
inheritedAuthTokenMasked: maskToken(process.env.ANTHROPIC_AUTH_TOKEN),
hasAnthropicBaseUrl: Boolean(process.env.ANTHROPIC_BASE_URL),
inheritedBaseUrl: process.env.ANTHROPIC_BASE_URL ?? "(unset)",
claudeCodeEntrypoint: process.env.CLAUDE_CODE_ENTRYPOINT ?? "(unset)",
claudeCodeIndicator: process.env.CLAUDECODE ?? "(unset)",
});
// Inherit parent process environment.
for (const [key, value] of Object.entries(process.env)) {
if (value !== undefined) sdkEnv[key] = value;
}
// Add provider env vars (z.AI, custom endpoints, etc.).
if (params.providerConfig?.env) {
log.debug("[CCSDK-ENV] Applying provider config env overrides", {
providerName: params.providerConfig.name,
providerEnvKeys: Object.keys(params.providerConfig.env),
});
for (const [key, value] of Object.entries(params.providerConfig.env)) {
if (value !== undefined) envOverrides[key] = value;
if (value !== undefined) {
const wasOverwritten = sdkEnv[key] !== undefined && sdkEnv[key] !== value;
if (wasOverwritten) {
log.debug("[CCSDK-ENV] Overwriting inherited env var", {
key,
oldValueMasked:
key.includes("KEY") || key.includes("TOKEN") ? maskToken(sdkEnv[key]) : sdkEnv[key],
newValueMasked: key.includes("KEY") || key.includes("TOKEN") ? maskToken(value) : value,
});
}
sdkEnv[key] = value;
}
}
}
// Add model tier env vars.
const modelTierEnv = buildModelTierEnv(params.modelTiers);
// Add model tier env vars (normalized based on target provider).
const modelTierEnv = buildModelTierEnv(params.modelTiers, params.providerConfig);
if (Object.keys(modelTierEnv).length > 0) {
log.debug("[CCSDK-ENV] Applying model tier env vars", { modelTierEnv });
}
for (const [key, value] of Object.entries(modelTierEnv)) {
if (value !== undefined) envOverrides[key] = value;
if (value !== undefined) sdkEnv[key] = value;
}
if (Object.keys(envOverrides).length > 0) {
sdkOptions.env = envOverrides;
}
// Log final auth env state
logAuthEnvVars(sdkEnv, "[CCSDK-ENV] Final SDK");
// Always pass the full environment to the SDK.
sdkOptions.env = sdkEnv;
// Model override from provider config.
if (params.providerConfig?.model) {
@ -583,9 +936,14 @@ export async function runSdkAgent(params: SdkRunnerParams): Promise<SdkRunnerRes
// Step 4: Build the prompt
// -------------------------------------------------------------------------
// Use AsyncIterable prompt format when MCP servers are configured (SDK requirement).
const hasMcpServers = Boolean(
sdkOptions.mcpServers && Object.keys(sdkOptions.mcpServers).length > 0,
);
const prompt = buildSdkPrompt({
prompt: effectivePrompt,
systemPrompt: combinedSystemPrompt,
useMcpServers: hasMcpServers,
});
// -------------------------------------------------------------------------
@ -628,13 +986,27 @@ export async function runSdkAgent(params: SdkRunnerParams): Promise<SdkRunnerRes
try {
emitEvent("sdk", { type: "query_start" });
log.debug("Starting SDK query", {
promptFormat: hasMcpServers ? "AsyncIterable" : "string",
promptLength: typeof prompt === "string" ? prompt.length : undefined,
hasSystemPrompt: Boolean(sdkOptions.systemPrompt),
hasMcpServers,
mcpServerNames: sdkOptions.mcpServers ? Object.keys(sdkOptions.mcpServers) : [],
allowedToolsCount: sdkOptions.allowedTools?.length ?? 0,
model: sdkOptions.model,
maxTurns: sdkOptions.maxTurns,
hasResume: Boolean(sdkOptions.resume),
});
const stream = await coerceAsyncIterable(
sdk.query({
prompt: prompt as string, // The SDK accepts both string and AsyncIterable
prompt, // SDK accepts both string and AsyncIterable<unknown>
options: sdkOptions as Record<string, unknown>,
}),
);
log.debug("SDK query returned stream, beginning iteration");
for await (const event of stream) {
// Check abort before processing each event.
if (combinedAbort.aborted) {
@ -645,6 +1017,20 @@ export async function runSdkAgent(params: SdkRunnerParams): Promise<SdkRunnerRes
eventCount += 1;
// Debug: log each event received (type and key fields only)
if (isRecord(event)) {
const eventType = event.type ?? "no-type";
const hasText = Boolean(event.text || event.delta || event.content || event.message);
const hasSessionId = Boolean(event.session_id);
log.debug("SDK event received", {
eventCount,
type: eventType,
hasText,
hasSessionId,
keys: Object.keys(event).slice(0, 10),
});
}
// Best-effort assistant message boundary detection.
// Some SDK versions emit `type: "message_start"`; otherwise, we fall back
// to calling this once when we see the first assistant text.
@ -687,7 +1073,9 @@ export async function runSdkAgent(params: SdkRunnerParams): Promise<SdkRunnerRes
: record && typeof record.toolCallId === "string"
? record.toolCallId
: undefined;
const toolText = extractTextFromClaudeAgentSdkEvent(event);
// Extract text once for the entire tool handling block.
const toolExtraction = extractFromClaudeAgentSdkEvent(event);
const toolText = toolExtraction.text;
const isError =
record && typeof record.is_error === "boolean"
? record.is_error
@ -744,15 +1132,11 @@ export async function runSdkAgent(params: SdkRunnerParams): Promise<SdkRunnerRes
isError,
...(toolText ? { resultText: toolText } : {}),
});
}
if (!hooksEnabled && kind === "tool" && params.onToolResult) {
const toolText = extractTextFromClaudeAgentSdkEvent(event);
if (toolText) {
// Emit tool result via callback (using toolText already extracted above).
if (params.onToolResult && toolText) {
try {
// Only emit tool results for terminal tool events to match Pi semantics more closely.
const record = isRecord(event) ? (event as Record<string, unknown>) : undefined;
const type = record && typeof record.type === "string" ? record.type : "";
if (isSdkTerminalToolEventType(type)) await params.onToolResult({ text: toolText });
} catch {
// Don't break the stream on callback errors.
@ -768,6 +1152,15 @@ export async function runSdkAgent(params: SdkRunnerParams): Promise<SdkRunnerRes
// Handle terminal result event.
if (kind === "result" && isRecord(event)) {
const result = event.result;
const subtype = event.subtype;
log.debug("Received result event, ending stream", {
eventCount,
hasResult: result !== undefined,
resultType: typeof result,
resultLength: typeof result === "string" ? result.length : undefined,
subtype,
hasError: Boolean(event.error),
});
if (typeof result === "string") {
resultText = result;
}
@ -777,7 +1170,6 @@ export async function runSdkAgent(params: SdkRunnerParams): Promise<SdkRunnerRes
usage = resultUsage;
}
// Also check for error results.
const subtype = event.subtype;
if (subtype === "error" && typeof event.error === "string") {
log.warn(`SDK returned error result: ${event.error}`);
}
@ -802,12 +1194,51 @@ export async function runSdkAgent(params: SdkRunnerParams): Promise<SdkRunnerRes
}
}
// Extract text from assistant messages.
if (kind === "assistant" || kind === "unknown") {
const text = extractTextFromClaudeAgentSdkEvent(event);
if (!text) continue;
// Extract BOTH text and thinking from events using unified extraction.
// This handles events that may contain one or both types of content.
if (kind === "assistant" || kind === "unknown" || kind === "thinking") {
const extraction = extractFromClaudeAgentSdkEvent(event);
const trimmed = text.trimEnd();
// Handle thinking/reasoning content (for breadcrumbs).
if (extraction.thinking) {
log.debug("[CCSDK-STREAM] Thinking content extracted", {
eventCount,
kind,
textLength: extraction.thinking.length,
textPreview: extraction.thinking.slice(0, 100),
hasOnReasoningStream: Boolean(params.onReasoningStream),
});
if (params.onReasoningStream) {
try {
log.debug("[CCSDK-STREAM] Calling onReasoningStream", {
textLength: extraction.thinking.length,
});
await params.onReasoningStream({ text: extraction.thinking });
} catch (err) {
log.debug("[CCSDK-STREAM] onReasoningStream error", { error: String(err) });
}
} else {
log.debug(
"[CCSDK-STREAM] No onReasoningStream callback - thinking text will not be streamed",
);
}
// Also emit as agent event for logging/debugging
emitEvent("thinking", { text: extraction.thinking });
}
// Handle assistant text content.
if (!extraction.text) {
log.debug("[CCSDK-STREAM] No text extracted from event", {
eventCount,
kind,
eventKeys: isRecord(event) ? Object.keys(event).slice(0, 15) : [],
hasOnBlockReply: Boolean(params.onBlockReply),
hadThinking: Boolean(extraction.thinking),
});
continue;
}
const trimmed = extraction.text.trimEnd();
if (!trimmed) continue;
if (!didAssistantMessageStart) {
@ -832,17 +1263,42 @@ export async function runSdkAgent(params: SdkRunnerParams): Promise<SdkRunnerRes
assistantSoFar = chunks.join("\n\n");
const delta = assistantSoFar.startsWith(prev) ? assistantSoFar.slice(prev.length) : trimmed;
log.debug("[CCSDK-STREAM] Extracted assistant text", {
eventCount,
chunkLength: trimmed.length,
totalExtracted: extractedChars,
totalChunks: chunks.length,
hasOnBlockReply: Boolean(params.onBlockReply),
hasOnPartialReply: Boolean(params.onPartialReply),
});
emitEvent("assistant", { text: assistantSoFar, delta });
// Stream partial reply.
// Stream partial reply (for live text updates in UI).
if (params.onPartialReply) {
try {
log.debug("[CCSDK-STREAM] Calling onPartialReply", {
textLength: assistantSoFar.length,
});
await params.onPartialReply({ text: assistantSoFar });
} catch {
// Don't break the stream on callback errors.
} catch (err) {
log.debug("[CCSDK-STREAM] onPartialReply error", { error: String(err) });
}
}
// Stream block reply (for block-based reply pipeline).
// This enables the reply pipeline to send incremental block updates.
if (params.onBlockReply) {
try {
log.debug("[CCSDK-STREAM] Calling onBlockReply", { textLength: assistantSoFar.length });
await params.onBlockReply({ text: assistantSoFar });
} catch (err) {
log.debug("[CCSDK-STREAM] onBlockReply error", { error: String(err) });
}
} else {
log.debug("[CCSDK-STREAM] onBlockReply not provided - skipping");
}
// Truncate if we've extracted too much text.
if (extractedChars >= DEFAULT_MAX_EXTRACTED_CHARS) {
truncated = true;
@ -851,6 +1307,16 @@ export async function runSdkAgent(params: SdkRunnerParams): Promise<SdkRunnerRes
}
}
}
// Log when the stream ends naturally (not via break from result/abort/truncate)
log.debug("SDK event stream ended", {
eventCount,
extractedChars,
aborted,
truncated,
hasResultText: Boolean(resultText),
chunksCount: chunks.length,
});
} catch (err) {
if (timeoutId) clearTimeout(timeoutId);
@ -939,7 +1405,49 @@ export async function runSdkAgent(params: SdkRunnerParams): Promise<SdkRunnerRes
const text = (resultText ?? chunks.join("\n\n")).trim();
// Log per-turn response characteristics for debugging
log.debug("[CCSDK-TURN] Turn completed - response analysis", {
responseLength: text.length,
eventCount,
extractedChars,
chunksCount: chunks.length,
hasResultText: Boolean(resultText),
aborted,
truncated,
durationMs: Date.now() - startedAt,
});
// Detect potential auth errors in the response
if (text) {
const authCheck = detectAuthErrorPatterns(text);
if (authCheck.hasAuthError) {
log.warn("[CCSDK-TURN] Potential auth-related issue detected in response", {
patterns: authCheck.patterns,
responseLength: text.length,
responsePreview: text.slice(0, 500),
});
}
// Log full response text at debug level for troubleshooting
// Truncate very long responses but log short ones in full
if (text.length <= 1000) {
log.debug("[CCSDK-TURN] Full response text", { text });
} else {
log.debug("[CCSDK-TURN] Response text (truncated for logging)", {
textPreview: text.slice(0, 500),
textSuffix: text.slice(-200),
fullLength: text.length,
});
}
}
if (!text) {
log.warn("[CCSDK-TURN] No text output - possible auth or SDK issue", {
eventCount,
hasResultText: Boolean(resultText),
chunksCount: chunks.length,
aborted,
});
emitEvent("lifecycle", {
phase: "error",
startedAt,
@ -978,13 +1486,40 @@ export async function runSdkAgent(params: SdkRunnerParams): Promise<SdkRunnerRes
const suffix = truncated ? "\n\n[Output truncated]" : "";
const finalText = `${text}${suffix}`;
log.debug("[CCSDK-FINAL] Final result prepared", {
textLength: finalText.length,
textPreview: finalText.slice(0, 200),
hasOnBlockReply: Boolean(params.onBlockReply),
hasOnBlockReplyFlush: Boolean(params.onBlockReplyFlush),
truncated,
aborted,
});
// Emit the final block reply.
if (params.onBlockReply) {
try {
log.debug("[CCSDK-FINAL] Calling final onBlockReply", { textLength: finalText.length });
await params.onBlockReply({ text: finalText });
} catch {
// Don't fail the run on callback errors.
log.debug("[CCSDK-FINAL] Final onBlockReply completed successfully");
} catch (err) {
log.debug("[CCSDK-FINAL] Final onBlockReply error", { error: String(err) });
}
} else {
log.debug("[CCSDK-FINAL] No onBlockReply callback - final text will only be in payloads");
}
// CRITICAL: Flush block reply pipeline to signal message completion.
// Without this, the UI stays stuck on "..." waiting for the flush signal.
if (params.onBlockReplyFlush) {
try {
log.debug("[CCSDK-FINAL] Flushing block reply pipeline");
await params.onBlockReplyFlush();
log.debug("[CCSDK-FINAL] Block reply flush completed");
} catch (err) {
log.debug("[CCSDK-FINAL] Block reply flush error", { error: String(err) });
}
} else {
log.debug("[CCSDK-FINAL] No onBlockReplyFlush callback - UI may stay on typing indicator");
}
emitEvent("sdk", {

View File

@ -53,9 +53,11 @@ export function appendSdkTextTurnToSessionTranscript(params: {
appendJsonlLine({
filePath: params.sessionFile,
value: {
role: params.role,
content: [{ type: "text", text: trimmed }],
timestamp: params.timestamp ?? Date.now(),
message: {
role: params.role,
content: [{ type: "text", text: trimmed }],
timestamp: params.timestamp ?? Date.now(),
},
},
});
} catch (err) {

View File

@ -26,6 +26,14 @@ export type PromptEnricherContext = {
channelHints?: string;
/** Available Moltbot skills. */
skills?: string[];
/** Sender identifier. */
senderId?: string | null;
/** Sender display name. */
senderName?: string | null;
/** Sender username. */
senderUsername?: string | null;
/** Sender E.164 phone number. */
senderE164?: string | null;
/** Additional custom context. */
custom?: Record<string, unknown>;
};
@ -108,11 +116,36 @@ const skillsEnricher: PromptEnricher = (ctx) => {
return `Available Moltbot skills: ${ctx.skills.join(", ")}`;
};
/**
* Sender context enricher.
*
* Provides sender identity information to help the model understand
* who is making the request.
*/
const senderEnricher: PromptEnricher = (ctx) => {
const parts: string[] = [];
if (ctx.senderName) {
parts.push(`Sender name: ${ctx.senderName}`);
}
if (ctx.senderUsername) {
parts.push(`Sender username: @${ctx.senderUsername}`);
}
if (ctx.senderId && !ctx.senderName && !ctx.senderUsername) {
// Only include raw ID if no friendly identifier is available
parts.push(`Sender ID: ${ctx.senderId}`);
}
if (parts.length === 0) return null;
return parts.join("\n");
};
// Register built-in enrichers
registerPromptEnricher(agentIdentityEnricher);
registerPromptEnricher(timezoneEnricher);
registerPromptEnricher(channelEnricher);
registerPromptEnricher(skillsEnricher);
registerPromptEnricher(senderEnricher);
// ─── Main API ────────────────────────────────────────────────────────────────
@ -156,6 +189,10 @@ export function buildSystemPromptAdditionsFromParams(params: {
messageChannel?: string;
channelHints?: string;
skills?: string[];
senderId?: string | null;
senderName?: string | null;
senderUsername?: string | null;
senderE164?: string | null;
}): string | undefined {
return buildSystemPromptAdditions({
agentId: params.agentId ?? params.sessionKey?.split(":")[0],
@ -165,5 +202,9 @@ export function buildSystemPromptAdditionsFromParams(params: {
channel: params.messageChannel,
channelHints: params.channelHints,
skills: params.skills,
senderId: params.senderId,
senderName: params.senderName,
senderUsername: params.senderUsername,
senderE164: params.senderE164,
});
}

View File

@ -150,6 +150,14 @@ export type SdkRunnerParams = {
channelHints?: string;
/** Available Moltbot skills. */
skills?: string[];
/** Sender identifier. */
senderId?: string | null;
/** Sender display name. */
senderName?: string | null;
/** Sender username. */
senderUsername?: string | null;
/** Sender E.164 phone number. */
senderE164?: string | null;
// ─── SDK Options ───────────────────────────────────────────────────────────
/** Enable Claude Code hooks. */

View File

@ -12,6 +12,18 @@ import { resolveUserPath } from "../utils.js";
const log = createSubsystemLogger("agents/auth-profiles");
const CLAUDE_CLI_CREDENTIALS_RELATIVE_PATH = ".claude/.credentials.json";
/**
* Mask a token for logging - shows length and first/last 4 chars.
* Example: "sk-ant-api03-abc...xyz" (length: 108)
*/
function maskToken(token: string | undefined): string {
if (!token) return "(empty)";
if (token.length <= 12) return `${"*".repeat(token.length)} (length: ${token.length})`;
const first = token.slice(0, 4);
const last = token.slice(-4);
return `${first}...${"*".repeat(Math.min(8, token.length - 8))}...${last} (length: ${token.length})`;
}
const CODEX_CLI_AUTH_FILENAME = "auth.json";
const QWEN_CLI_CREDENTIALS_RELATIVE_PATH = ".qwen/oauth_creds.json";
@ -189,22 +201,56 @@ function readQwenCliCredentials(options?: { homeDir?: string }): QwenCliCredenti
function readClaudeCliKeychainCredentials(
execSyncImpl: ExecSyncFn = execSync,
): ClaudeCliCredential | null {
log.debug("[CCSDK-AUTH] Attempting to read credentials from macOS Keychain", {
service: CLAUDE_CLI_KEYCHAIN_SERVICE,
account: CLAUDE_CLI_KEYCHAIN_ACCOUNT,
});
try {
const result = execSyncImpl(
`security find-generic-password -s "${CLAUDE_CLI_KEYCHAIN_SERVICE}" -w`,
{ encoding: "utf8", timeout: 5000, stdio: ["pipe", "pipe", "pipe"] },
);
log.debug("[CCSDK-AUTH] Keychain returned data", {
rawLength: result.length,
});
const data = JSON.parse(result.trim());
const claudeOauth = data?.claudeAiOauth;
if (!claudeOauth || typeof claudeOauth !== "object") return null;
if (!claudeOauth || typeof claudeOauth !== "object") {
log.debug("[CCSDK-AUTH] Keychain data missing claudeAiOauth object");
return null;
}
const accessToken = claudeOauth.accessToken;
const refreshToken = claudeOauth.refreshToken;
const expiresAt = claudeOauth.expiresAt;
if (typeof accessToken !== "string" || !accessToken) return null;
if (typeof expiresAt !== "number" || expiresAt <= 0) return null;
if (typeof accessToken !== "string" || !accessToken) {
log.debug("[CCSDK-AUTH] Keychain accessToken invalid or missing");
return null;
}
if (typeof expiresAt !== "number" || expiresAt <= 0) {
log.debug("[CCSDK-AUTH] Keychain expiresAt invalid", { expiresAt });
return null;
}
const expiresDate = new Date(expiresAt);
const isExpired = expiresAt < Date.now();
const msUntilExpiry = expiresAt - Date.now();
log.debug("[CCSDK-AUTH] Keychain credentials parsed successfully", {
source: "keychain",
hasAccessToken: Boolean(accessToken),
accessTokenMasked: maskToken(accessToken),
hasRefreshToken: Boolean(refreshToken),
refreshTokenMasked: maskToken(refreshToken),
expiresAt: expiresDate.toISOString(),
isExpired,
msUntilExpiry,
type: refreshToken ? "oauth" : "token",
});
if (typeof refreshToken === "string" && refreshToken) {
return {
@ -222,7 +268,10 @@ function readClaudeCliKeychainCredentials(
token: accessToken,
expires: expiresAt,
};
} catch {
} catch (err) {
log.debug("[CCSDK-AUTH] Keychain read failed", {
error: err instanceof Error ? err.message : String(err),
});
return null;
}
}
@ -234,30 +283,89 @@ export function readClaudeCliCredentials(options?: {
execSync?: ExecSyncFn;
}): ClaudeCliCredential | null {
const platform = options?.platform ?? process.platform;
log.debug("[CCSDK-AUTH] readClaudeCliCredentials called", {
platform,
allowKeychainPrompt: options?.allowKeychainPrompt,
homeDir: options?.homeDir ?? "(default)",
});
// Try macOS Keychain first
if (platform === "darwin" && options?.allowKeychainPrompt !== false) {
log.debug("[CCSDK-AUTH] Attempting macOS Keychain read");
const keychainCreds = readClaudeCliKeychainCredentials(options?.execSync);
if (keychainCreds) {
log.info("read anthropic credentials from claude cli keychain", {
log.info("[CCSDK-AUTH] Successfully read credentials from macOS Keychain", {
type: keychainCreds.type,
source: "keychain",
accessTokenMasked:
keychainCreds.type === "oauth"
? maskToken(keychainCreds.access)
: maskToken(keychainCreds.token),
});
return keychainCreds;
}
log.debug("[CCSDK-AUTH] Keychain read returned null, falling back to file");
}
// Fall back to file-based credentials
const credPath = resolveClaudeCliCredentialsPath(options?.homeDir);
log.debug("[CCSDK-AUTH] Attempting to read credentials from file", {
path: credPath,
});
const raw = loadJsonFile(credPath);
if (!raw || typeof raw !== "object") return null;
if (!raw || typeof raw !== "object") {
log.debug("[CCSDK-AUTH] Credentials file not found or invalid", {
path: credPath,
rawType: typeof raw,
});
return null;
}
const data = raw as Record<string, unknown>;
const claudeOauth = data.claudeAiOauth as Record<string, unknown> | undefined;
if (!claudeOauth || typeof claudeOauth !== "object") return null;
if (!claudeOauth || typeof claudeOauth !== "object") {
log.debug("[CCSDK-AUTH] Credentials file missing claudeAiOauth", {
path: credPath,
topLevelKeys: Object.keys(data),
});
return null;
}
const accessToken = claudeOauth.accessToken;
const refreshToken = claudeOauth.refreshToken;
const expiresAt = claudeOauth.expiresAt;
if (typeof accessToken !== "string" || !accessToken) return null;
if (typeof expiresAt !== "number" || expiresAt <= 0) return null;
if (typeof accessToken !== "string" || !accessToken) {
log.debug("[CCSDK-AUTH] Credentials file accessToken invalid", {
path: credPath,
});
return null;
}
if (typeof expiresAt !== "number" || expiresAt <= 0) {
log.debug("[CCSDK-AUTH] Credentials file expiresAt invalid", {
path: credPath,
expiresAt,
});
return null;
}
const expiresDate = new Date(expiresAt);
const isExpired = expiresAt < Date.now();
const msUntilExpiry = expiresAt - Date.now();
log.info("[CCSDK-AUTH] Successfully read credentials from file", {
source: "file",
path: credPath,
accessTokenMasked: maskToken(accessToken as string),
hasRefreshToken: Boolean(refreshToken),
refreshTokenMasked: maskToken(refreshToken as string | undefined),
expiresAt: expiresDate.toISOString(),
isExpired,
msUntilExpiry,
type: refreshToken ? "oauth" : "token",
});
if (typeof refreshToken === "string" && refreshToken) {
return {

View File

@ -45,15 +45,17 @@ export function resolveAgentRuntimeKind(config: MoltbotConfig, agentId: string):
*
* @param config - Moltbot configuration
* @param agentId - Agent identifier
* @param forceKind - Optional runtime kind to force (overrides config resolution)
* @returns The appropriate AgentRuntime instance
*/
export async function createAgentRuntime(
config: MoltbotConfig,
agentId: string,
forceKind?: AgentRuntimeKind,
): Promise<AgentRuntime> {
const runtimeKind = resolveAgentRuntimeKind(config, agentId);
const runtimeKind = forceKind ?? resolveAgentRuntimeKind(config, agentId);
log.debug("Creating agent runtime", { agentId, runtime: runtimeKind });
log.debug("Creating agent runtime", { agentId, runtime: runtimeKind, forced: !!forceKind });
if (runtimeKind === "ccsdk") {
// Dynamically import to avoid loading SDK when not needed

View File

@ -2,11 +2,15 @@
* Pi Agent runtime implementation.
*
* Wraps the existing runEmbeddedPiAgent function to conform to the AgentRuntime interface.
* Uses both shared generalized fields and Pi-specific options from piOptions bag.
*/
import type { AgentRuntime, AgentRuntimeRunParams, AgentRuntimeResult } from "./agent-runtime.js";
import { createSubsystemLogger } from "../logging/subsystem.js";
import { runEmbeddedPiAgent } from "./pi-embedded.js";
const log = createSubsystemLogger("agents/pi-runtime");
/**
* Create a Pi Agent runtime instance.
*
@ -19,49 +23,24 @@ export function createPiAgentRuntime(): AgentRuntime {
displayName: "Pi Agent",
async run(params: AgentRuntimeRunParams): Promise<AgentRuntimeResult> {
return runEmbeddedPiAgent({
log.info("Starting Pi Agent session", {
sessionId: params.sessionId,
sessionKey: params.sessionKey,
sessionFile: params.sessionFile,
workspaceDir: params.workspaceDir,
agentDir: params.agentDir,
config: params.config,
skillsSnapshot: params.skillsSnapshot,
prompt: params.prompt,
images: params.images,
provider: params.provider,
model: params.model,
authProfileId: params.authProfileId,
authProfileIdSource: params.authProfileIdSource,
thinkLevel: params.thinkLevel,
verboseLevel: params.verboseLevel,
timeoutMs: params.timeoutMs,
runId: params.runId,
lane: params.lane,
abortSignal: params.abortSignal,
extraSystemPrompt: params.extraSystemPrompt,
streamParams: params.streamParams,
// Messaging context
messageChannel: params.messageChannel,
agentAccountId: params.agentAccountId,
messageTo: params.messageTo,
messageThreadId: params.messageThreadId,
groupId: params.groupId,
groupChannel: params.groupChannel,
groupSpace: params.groupSpace,
spawnedBy: params.spawnedBy,
currentChannelId: params.currentChannelId,
currentThreadTs: params.currentThreadTs,
replyToMode: params.replyToMode,
hasRepliedRef: params.hasRepliedRef,
// Callbacks
onPartialReply: params.onPartialReply,
onAssistantMessageStart: params.onAssistantMessageStart,
onBlockReply: params.onBlockReply,
onBlockReplyFlush: params.onBlockReplyFlush,
onReasoningStream: params.onReasoningStream,
onToolResult: params.onToolResult,
onAgentEvent: params.onAgentEvent,
provider: params.provider ?? "default",
model: params.model ?? "default",
});
// Extract Pi-specific options from the options bag
const piOpts = params.piOptions ?? {};
// Spread shared params directly, then add Pi-specific options from the bag
return runEmbeddedPiAgent({
...params,
// Pi-specific options from piOptions bag override any shared fields
enforceFinalTag: piOpts.enforceFinalTag,
execOverrides: piOpts.execOverrides,
bashElevated: piOpts.bashElevated,
clientTools: piOpts.clientTools,
});
},
};

View File

@ -0,0 +1,413 @@
/**
* Unified Agent Runner.
*
* Provides a single entry point for agent execution with runtime-outer, model-inner
* failover composition. This ensures consistent runtime selection and failover behavior
* across all agent execution paths.
*/
import type { MoltbotConfig } from "../config/config.js";
import { resolveAgentIdFromSessionKey } from "../config/sessions.js";
import { createSubsystemLogger } from "../logging/subsystem.js";
import type {
AgentRuntime,
AgentRuntimeKind,
AgentRuntimeRunParams,
AgentRuntimeResult,
} from "./agent-runtime.js";
import { resolveAgentModelFallbacksOverride } from "./agent-scope.js";
import {
coerceToFailoverError,
describeFailoverError,
isFailoverError,
isTimeoutError,
} from "./failover-error.js";
import { createAgentRuntime, resolveAgentRuntimeKind } from "./main-agent-runtime-factory.js";
import {
buildModelAliasIndex,
modelKey,
parseModelRef,
resolveConfiguredModelRef,
resolveModelRefFromString,
} from "./model-selection.js";
import { DEFAULT_MODEL, DEFAULT_PROVIDER } from "./defaults.js";
import {
ensureAuthProfileStore,
isProfileInCooldown,
resolveAuthProfileOrder,
} from "./auth-profiles.js";
import { isReasoningTagProvider } from "../utils/provider-utils.js";
const log = createSubsystemLogger("agents/unified-runner");
/** Model candidate for fallback. */
type ModelCandidate = {
provider: string;
model: string;
};
/** Record of a single failover attempt. */
export type UnifiedFallbackAttempt = {
runtime: AgentRuntimeKind;
provider: string;
model: string;
error: string;
reason?: string;
status?: number;
code?: string;
};
/** Result of unified agent run. */
export type UnifiedAgentRunResult = {
result: AgentRuntimeResult;
runtime: AgentRuntimeKind;
provider: string;
model: string;
attempts: UnifiedFallbackAttempt[];
};
/** Parameters for unified agent runner (superset of AgentRuntimeRunParams). */
export type UnifiedAgentRunParams = AgentRuntimeRunParams & {
/** Optional explicit fallbacks list; when provided (even empty), replaces agents.defaults.model.fallbacks. */
fallbacksOverride?: string[];
/** Callback when a model is selected (before each attempt). */
onModelSelected?: (info: {
runtime: AgentRuntimeKind;
provider: string;
model: string;
attempt: number;
total: number;
}) => void | Promise<void>;
/** Error callback for each failed attempt. */
onError?: (attempt: {
runtime: AgentRuntimeKind;
provider: string;
model: string;
error: unknown;
attempt: number;
total: number;
}) => void | Promise<void>;
};
function isAbortError(err: unknown): boolean {
if (!err || typeof err !== "object") return false;
if (isFailoverError(err)) return false;
const name = "name" in err ? String(err.name) : "";
return name === "AbortError";
}
function shouldRethrowAbort(err: unknown): boolean {
return isAbortError(err) && !isTimeoutError(err);
}
function buildAllowedModelKeys(
cfg: MoltbotConfig | undefined,
defaultProvider: string,
): Set<string> | null {
const rawAllowlist = (() => {
const modelMap = cfg?.agents?.defaults?.models ?? {};
return Object.keys(modelMap);
})();
if (rawAllowlist.length === 0) return null;
const keys = new Set<string>();
for (const raw of rawAllowlist) {
const parsed = parseModelRef(String(raw ?? ""), defaultProvider);
if (!parsed) continue;
keys.add(modelKey(parsed.provider, parsed.model));
}
return keys.size > 0 ? keys : null;
}
function resolveFallbackCandidates(params: {
cfg: MoltbotConfig | undefined;
provider: string;
model: string;
fallbacksOverride?: string[];
}): ModelCandidate[] {
const primary = params.cfg
? resolveConfiguredModelRef({
cfg: params.cfg,
defaultProvider: DEFAULT_PROVIDER,
defaultModel: DEFAULT_MODEL,
})
: null;
const defaultProvider = primary?.provider ?? DEFAULT_PROVIDER;
const defaultModel = primary?.model ?? DEFAULT_MODEL;
const provider = String(params.provider ?? "").trim() || defaultProvider;
const model = String(params.model ?? "").trim() || defaultModel;
const aliasIndex = buildModelAliasIndex({
cfg: params.cfg ?? {},
defaultProvider,
});
const allowlist = buildAllowedModelKeys(params.cfg, defaultProvider);
const seen = new Set<string>();
const candidates: ModelCandidate[] = [];
const addCandidate = (candidate: ModelCandidate, enforceAllowlist: boolean) => {
if (!candidate.provider || !candidate.model) return;
const key = modelKey(candidate.provider, candidate.model);
if (seen.has(key)) return;
if (enforceAllowlist && allowlist && !allowlist.has(key)) return;
seen.add(key);
candidates.push(candidate);
};
addCandidate({ provider, model }, false);
const modelFallbacks = (() => {
if (params.fallbacksOverride !== undefined) return params.fallbacksOverride;
const model = params.cfg?.agents?.defaults?.model as
| { fallbacks?: string[] }
| string
| undefined;
if (model && typeof model === "object") return model.fallbacks ?? [];
return [];
})();
for (const raw of modelFallbacks) {
const resolved = resolveModelRefFromString({
raw: String(raw ?? ""),
defaultProvider,
aliasIndex,
});
if (!resolved) continue;
addCandidate(resolved.ref, true);
}
if (params.fallbacksOverride === undefined && primary?.provider && primary.model) {
addCandidate({ provider: primary.provider, model: primary.model }, false);
}
return candidates;
}
/**
* Resolve runtime failover chain.
*
* Returns the primary runtime first, followed by the opposite runtime as fallback.
*/
function resolveRuntimeFailoverChain(primaryRuntime: AgentRuntimeKind): AgentRuntimeKind[] {
if (primaryRuntime === "ccsdk") {
return ["ccsdk", "pi"];
}
return ["pi", "ccsdk"];
}
/**
* Run agent with unified failover.
*
* Implements runtime-outer, model-inner failover composition:
* - First tries all model candidates with the primary runtime
* - If all fail, switches to the fallback runtime and tries model candidates again
*
* @param params - Unified agent run parameters
* @returns Result with runtime, provider, model, and attempt history
*/
export async function runAgentWithUnifiedFailover(
params: UnifiedAgentRunParams,
): Promise<UnifiedAgentRunResult> {
const config = params.config;
const agentId = resolveAgentIdFromSessionKey(params.sessionKey) ?? "main";
// Resolve primary runtime and build failover chain
const primaryRuntime = config ? resolveAgentRuntimeKind(config, agentId) : "pi";
const runtimeChain = resolveRuntimeFailoverChain(primaryRuntime);
// Resolve model fallback candidates
const fallbacksOverride =
params.fallbacksOverride ??
(config ? resolveAgentModelFallbacksOverride(config, agentId) : undefined);
const modelCandidates = resolveFallbackCandidates({
cfg: config,
provider: params.provider ?? DEFAULT_PROVIDER,
model: params.model ?? DEFAULT_MODEL,
fallbacksOverride,
});
const authStore = config
? ensureAuthProfileStore(params.agentDir, { allowKeychainPrompt: false })
: null;
const attempts: UnifiedFallbackAttempt[] = [];
let lastError: unknown;
let attemptIndex = 0;
const totalAttempts = runtimeChain.length * modelCandidates.length;
log.info("Starting unified agent run", {
sessionId: params.sessionId,
runId: params.runId,
agentId,
primaryRuntime,
provider: params.provider ?? DEFAULT_PROVIDER,
model: params.model ?? DEFAULT_MODEL,
modelCandidatesCount: modelCandidates.length,
});
// Runtime-outer loop
for (const runtimeKind of runtimeChain) {
let runtime: AgentRuntime;
try {
// Force the specific runtime kind we want in the failover chain
runtime = await createAgentRuntime(config ?? ({} as MoltbotConfig), agentId, runtimeKind);
// If the runtime kind doesn't match what we wanted (e.g., CCSDK unavailable),
// skip to the next runtime in the chain
if (runtime.kind !== runtimeKind) {
log.debug("Runtime kind mismatch, skipping", {
requested: runtimeKind,
actual: runtime.kind,
});
continue;
}
} catch (err) {
log.warn("Failed to create runtime, trying next", {
runtime: runtimeKind,
error: err instanceof Error ? err.message : String(err),
});
continue;
}
log.debug("Trying runtime", { runtime: runtimeKind, displayName: runtime.displayName });
// Model-inner loop
for (const candidate of modelCandidates) {
attemptIndex += 1;
// Check auth profile cooldown
if (authStore) {
const profileIds = resolveAuthProfileOrder({
cfg: config,
store: authStore,
provider: candidate.provider,
});
const isAnyProfileAvailable = profileIds.some((id) => !isProfileInCooldown(authStore, id));
if (profileIds.length > 0 && !isAnyProfileAvailable) {
attempts.push({
runtime: runtimeKind,
provider: candidate.provider,
model: candidate.model,
error: `Provider ${candidate.provider} is in cooldown (all profiles unavailable)`,
reason: "rate_limit",
});
continue;
}
}
try {
log.debug("Attempting model", {
runtime: runtimeKind,
provider: candidate.provider,
model: candidate.model,
attempt: attemptIndex,
total: totalAttempts,
});
// Notify model selection before attempt
await params.onModelSelected?.({
runtime: runtimeKind,
provider: candidate.provider,
model: candidate.model,
attempt: attemptIndex,
total: totalAttempts,
});
// Determine if this is a different provider than originally requested
const originalProvider = params.provider ?? DEFAULT_PROVIDER;
const providerChanged = candidate.provider !== originalProvider;
// Drop authProfileId when provider changes (auth profile is provider-specific)
const effectiveAuthProfileId = providerChanged ? undefined : params.authProfileId;
const effectiveAuthProfileIdSource = providerChanged
? undefined
: params.authProfileIdSource;
// Compute enforceFinalTag based on current provider's reasoning tag support
const baseEnforceFinalTag = params.piOptions?.enforceFinalTag ?? false;
const currentProviderRequiresReasoningTags = isReasoningTagProvider(candidate.provider);
const effectiveEnforceFinalTag =
baseEnforceFinalTag || currentProviderRequiresReasoningTags;
const result = await runtime.run({
...params,
provider: candidate.provider,
model: candidate.model,
authProfileId: effectiveAuthProfileId,
authProfileIdSource: effectiveAuthProfileIdSource,
// Update piOptions with computed enforceFinalTag
piOptions: params.piOptions
? { ...params.piOptions, enforceFinalTag: effectiveEnforceFinalTag }
: { enforceFinalTag: effectiveEnforceFinalTag },
});
log.info("Unified agent run completed", {
sessionId: params.sessionId,
runId: params.runId,
runtime: runtimeKind,
provider: candidate.provider,
model: candidate.model,
attemptsCount: attempts.length,
});
return {
result,
runtime: runtimeKind,
provider: candidate.provider,
model: candidate.model,
attempts,
};
} catch (err) {
if (shouldRethrowAbort(err)) throw err;
const normalized =
coerceToFailoverError(err, {
provider: candidate.provider,
model: candidate.model,
}) ?? err;
if (!isFailoverError(normalized)) throw err;
lastError = normalized;
const described = describeFailoverError(normalized);
attempts.push({
runtime: runtimeKind,
provider: candidate.provider,
model: candidate.model,
error: described.message,
reason: described.reason,
status: described.status,
code: described.code,
});
await params.onError?.({
runtime: runtimeKind,
provider: candidate.provider,
model: candidate.model,
error: normalized,
attempt: attemptIndex,
total: totalAttempts,
});
}
}
}
// All attempts exhausted
if (attempts.length <= 1 && lastError) throw lastError;
const summary =
attempts.length > 0
? attempts
.map(
(attempt) =>
`${attempt.runtime}:${attempt.provider}/${attempt.model}: ${attempt.error}${
attempt.reason ? ` (${attempt.reason})` : ""
}`,
)
.join(" | ")
: "unknown";
throw new Error(
`All runtimes and models failed (${attempts.length || totalAttempts}): ${summary}`,
{ cause: lastError instanceof Error ? lastError : undefined },
);
}

View File

@ -0,0 +1,151 @@
/**
* Tests for double-send prevention in the agent runner pipeline.
*
* When block streaming is enabled, responses should NOT be sent both as:
* 1. Block replies (incremental streaming during the run)
* 2. Final payloads (after the run completes)
*
* This test verifies the deduplication logic works correctly.
*/
import { describe, expect, it } from "vitest";
import { buildReplyPayloads } from "./agent-runner-payloads.js";
import { createPayloadKey } from "./payload-normalization.js";
import type { ReplyPayload } from "../types.js";
describe("agent-runner double-send prevention", () => {
describe("directlySentBlockKeys deduplication", () => {
it("should filter out final payloads that were already sent via direct block reply", () => {
// Simulate CCSDK flow: block reply was sent directly (no pipeline)
const sentText = "Hello, this is the response!";
const directlySentBlockKeys = new Set<string>();
// This is what happens in agent-runner-execution.ts when onBlockReply is called
const blockPayload: ReplyPayload = { text: sentText };
directlySentBlockKeys.add(createPayloadKey(blockPayload));
// Now the same text comes back as a final payload
const finalPayloads: ReplyPayload[] = [{ text: sentText }];
const result = buildReplyPayloads({
payloads: finalPayloads,
isHeartbeat: false,
didLogHeartbeatStrip: false,
blockStreamingEnabled: false, // Direct callback mode, not pipeline
blockReplyPipeline: null,
directlySentBlockKeys,
replyToMode: "first",
currentMessageId: "msg-123",
});
// The final payload should be filtered out since it was already sent
expect(result.replyPayloads).toHaveLength(0);
});
it("should NOT filter out final payloads with different text", () => {
const directlySentBlockKeys = new Set<string>();
// Block reply was sent with partial text
const blockPayload: ReplyPayload = { text: "Hello" };
directlySentBlockKeys.add(createPayloadKey(blockPayload));
// Final payload has more text
const finalPayloads: ReplyPayload[] = [{ text: "Hello, world!" }];
const result = buildReplyPayloads({
payloads: finalPayloads,
isHeartbeat: false,
didLogHeartbeatStrip: false,
blockStreamingEnabled: false,
blockReplyPipeline: null,
directlySentBlockKeys,
replyToMode: "first",
currentMessageId: "msg-123",
});
// The final payload should NOT be filtered since text differs
expect(result.replyPayloads).toHaveLength(1);
expect(result.replyPayloads[0]?.text).toBe("Hello, world!");
});
it("should handle CCSDK cumulative streaming pattern", () => {
// CCSDK calls onBlockReply with cumulative text each time:
// Call 1: "Hello"
// Call 2: "Hello, how"
// Call 3: "Hello, how are you?"
// Final result: "Hello, how are you?"
const directlySentBlockKeys = new Set<string>();
// Each cumulative block reply is tracked
directlySentBlockKeys.add(createPayloadKey({ text: "Hello" }));
directlySentBlockKeys.add(createPayloadKey({ text: "Hello, how" }));
directlySentBlockKeys.add(createPayloadKey({ text: "Hello, how are you?" }));
// Final payload matches the last cumulative text
const finalPayloads: ReplyPayload[] = [{ text: "Hello, how are you?" }];
const result = buildReplyPayloads({
payloads: finalPayloads,
isHeartbeat: false,
didLogHeartbeatStrip: false,
blockStreamingEnabled: false,
blockReplyPipeline: null,
directlySentBlockKeys,
replyToMode: "first",
currentMessageId: "msg-123",
});
// Final should be filtered since it matches the last cumulative block
expect(result.replyPayloads).toHaveLength(0);
});
});
describe("whitespace normalization", () => {
it("should match payloads with trailing whitespace differences", () => {
const directlySentBlockKeys = new Set<string>();
// Block reply has trailing whitespace
directlySentBlockKeys.add(createPayloadKey({ text: "Hello \n" }));
// Final payload has no trailing whitespace
const finalPayloads: ReplyPayload[] = [{ text: "Hello" }];
const result = buildReplyPayloads({
payloads: finalPayloads,
isHeartbeat: false,
didLogHeartbeatStrip: false,
blockStreamingEnabled: false,
blockReplyPipeline: null,
directlySentBlockKeys,
replyToMode: "first",
currentMessageId: "msg-123",
});
// Should be filtered since trimmed text matches
expect(result.replyPayloads).toHaveLength(0);
});
});
describe("payload key creation", () => {
it("should create identical keys for identical payloads", () => {
const payload1: ReplyPayload = { text: "Hello" };
const payload2: ReplyPayload = { text: "Hello" };
expect(createPayloadKey(payload1)).toBe(createPayloadKey(payload2));
});
it("should create different keys for different text", () => {
const payload1: ReplyPayload = { text: "Hello" };
const payload2: ReplyPayload = { text: "Goodbye" };
expect(createPayloadKey(payload1)).not.toBe(createPayloadKey(payload2));
});
it("should normalize whitespace in key creation", () => {
const payload1: ReplyPayload = { text: "Hello \n\t" };
const payload2: ReplyPayload = { text: "Hello" };
// Keys should match after trimming
expect(createPayloadKey(payload1)).toBe(createPayloadKey(payload2));
});
});
});

View File

@ -0,0 +1,292 @@
/**
* Tests for agent-runner-execution callback wiring.
*
* These tests verify that callbacks (onBlockReply, onBlockReplyFlush, onReasoningStream)
* are correctly wired through to the runtime, regardless of block streaming configuration.
*/
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
import type { TemplateContext } from "../templating.js";
import type { FollowupRun, QueueSettings } from "./queue.js";
import { createMockTypingController } from "./test-helpers.js";
// Track what callbacks are passed to the unified runner
let capturedCallbacks: {
onBlockReply?: unknown;
onBlockReplyFlush?: unknown;
onReasoningStream?: unknown;
onPartialReply?: unknown;
} = {};
// Mock the unified runner to capture callbacks
vi.mock("../../agents/unified-agent-runner.js", () => ({
runAgentWithUnifiedFailover: vi.fn(async (params) => {
capturedCallbacks = {
onBlockReply: params.onBlockReply,
onBlockReplyFlush: params.onBlockReplyFlush,
onReasoningStream: params.onReasoningStream,
onPartialReply: params.onPartialReply,
};
return {
result: {
payloads: [{ text: "Test response" }],
meta: {},
},
runtime: "pi",
provider: "anthropic",
model: "claude",
attempts: [],
};
}),
}));
// Mock model fallback
vi.mock("../../agents/model-fallback.js", () => ({
runWithModelFallback: async ({
provider,
model,
run,
}: {
provider: string;
model: string;
run: (provider: string, model: string) => Promise<unknown>;
}) => ({
result: await run(provider, model),
provider,
model,
}),
}));
// Mock Pi embedded
vi.mock("../../agents/pi-embedded.js", () => ({
queueEmbeddedPiMessage: vi.fn().mockReturnValue(false),
runEmbeddedPiAgent: vi.fn(async () => ({
payloads: [{ text: "Pi response" }],
meta: {},
})),
}));
// Mock queue
vi.mock("./queue.js", async () => {
const actual = await vi.importActual<typeof import("./queue.js")>("./queue.js");
return {
...actual,
enqueueFollowupRun: vi.fn(),
scheduleFollowupDrain: vi.fn(),
};
});
import { runReplyAgent } from "./agent-runner.js";
describe("agent-runner-execution callback wiring", () => {
beforeEach(() => {
vi.clearAllMocks();
capturedCallbacks = {};
});
afterEach(() => {
vi.resetAllMocks();
});
const createBaseParams = (overrides: {
blockStreamingEnabled?: boolean;
blockReplyPipeline?: unknown;
onBlockReply?: () => void;
onReasoningStream?: () => void;
}) => {
const typing = createMockTypingController();
const sessionCtx = {
Provider: "discord",
OriginatingTo: "channel:C1",
AccountId: "primary",
MessageSid: "msg",
} as unknown as TemplateContext;
const resolvedQueue = { mode: "interrupt" } as unknown as QueueSettings;
const followupRun = {
prompt: "hello",
summaryLine: "hello",
enqueuedAt: Date.now(),
run: {
sessionId: "session",
sessionKey: "main",
messageProvider: "discord",
sessionFile: "/tmp/session.jsonl",
workspaceDir: "/tmp",
config: {
agents: {
defaults: {
blockStreamingCoalesce: {
minChars: 1,
maxChars: 200,
idleMs: 0,
},
},
},
},
skillsSnapshot: {},
provider: "anthropic",
model: "claude",
thinkLevel: "high",
verboseLevel: "off",
elevatedLevel: "off",
bashElevated: {
enabled: false,
allowed: false,
defaultLevel: "off",
},
timeoutMs: 1_000,
blockReplyBreak: "text_end",
},
} as unknown as FollowupRun;
return {
commandBody: "hello",
followupRun,
queueKey: "main",
resolvedQueue,
shouldSteer: false,
shouldFollowup: false,
isActive: false,
isStreaming: false,
opts: {
onBlockReply: overrides.onBlockReply,
onReasoningStream: overrides.onReasoningStream,
},
typing,
sessionCtx,
defaultModel: "anthropic/claude-opus-4-5",
resolvedVerboseLevel: "off" as const,
isNewSession: false,
blockStreamingEnabled: overrides.blockStreamingEnabled ?? false,
blockReplyChunking: {
minChars: 1,
maxChars: 200,
breakPreference: "paragraph" as const,
},
resolvedBlockStreamingBreak: "text_end" as const,
shouldInjectGroupIntro: false,
typingMode: "instant" as const,
};
};
describe("onBlockReply forwarding", () => {
it("provides onBlockReply when blockStreamingEnabled=true and callback exists", async () => {
const onBlockReply = vi.fn();
const params = createBaseParams({ blockStreamingEnabled: true, onBlockReply });
await runReplyAgent(params);
// Callback should be wired through
expect(capturedCallbacks.onBlockReply).toBeDefined();
expect(typeof capturedCallbacks.onBlockReply).toBe("function");
});
it("provides onBlockReply when blockStreamingEnabled=false and callback exists", async () => {
const onBlockReply = vi.fn();
const params = createBaseParams({ blockStreamingEnabled: false, onBlockReply });
await runReplyAgent(params);
// Callback should STILL be wired through (critical for CCSDK)
expect(capturedCallbacks.onBlockReply).toBeDefined();
expect(typeof capturedCallbacks.onBlockReply).toBe("function");
});
it("does not provide onBlockReply when no callback exists", async () => {
const params = createBaseParams({ blockStreamingEnabled: true });
await runReplyAgent(params);
// No callback provided, so none should be wired
expect(capturedCallbacks.onBlockReply).toBeUndefined();
});
});
describe("onBlockReplyFlush forwarding", () => {
it("provides onBlockReplyFlush when blockStreamingEnabled=true and onBlockReply exists", async () => {
const onBlockReply = vi.fn();
const params = createBaseParams({ blockStreamingEnabled: true, onBlockReply });
await runReplyAgent(params);
// Flush callback should be provided
expect(capturedCallbacks.onBlockReplyFlush).toBeDefined();
expect(typeof capturedCallbacks.onBlockReplyFlush).toBe("function");
});
it("provides onBlockReplyFlush when blockStreamingEnabled=false but onBlockReply exists", async () => {
const onBlockReply = vi.fn();
const params = createBaseParams({ blockStreamingEnabled: false, onBlockReply });
await runReplyAgent(params);
// Flush callback should STILL be provided (critical for CCSDK message completion)
expect(capturedCallbacks.onBlockReplyFlush).toBeDefined();
expect(typeof capturedCallbacks.onBlockReplyFlush).toBe("function");
});
it("does not provide onBlockReplyFlush when no onBlockReply callback exists", async () => {
const params = createBaseParams({ blockStreamingEnabled: true });
await runReplyAgent(params);
// No block reply callback means no flush needed
expect(capturedCallbacks.onBlockReplyFlush).toBeUndefined();
});
});
describe("onReasoningStream forwarding", () => {
it("provides onReasoningStream when callback exists", async () => {
const onReasoningStream = vi.fn();
const params = createBaseParams({ blockStreamingEnabled: false, onReasoningStream });
await runReplyAgent(params);
// Reasoning stream callback should be wired through
expect(capturedCallbacks.onReasoningStream).toBeDefined();
expect(typeof capturedCallbacks.onReasoningStream).toBe("function");
});
it("does not provide onReasoningStream when no callback exists and typing mode is not thinking", async () => {
const params = createBaseParams({ blockStreamingEnabled: false });
// Default typing mode is "instant", not "thinking"
await runReplyAgent(params);
// No reasoning stream callback expected
expect(capturedCallbacks.onReasoningStream).toBeUndefined();
});
});
describe("callback combination scenarios", () => {
it("provides all callbacks when all are specified", async () => {
const onBlockReply = vi.fn();
const onReasoningStream = vi.fn();
const params = createBaseParams({
blockStreamingEnabled: true,
onBlockReply,
onReasoningStream,
});
await runReplyAgent(params);
// All callbacks should be wired
expect(capturedCallbacks.onBlockReply).toBeDefined();
expect(capturedCallbacks.onBlockReplyFlush).toBeDefined();
expect(capturedCallbacks.onReasoningStream).toBeDefined();
});
it("CCSDK scenario: blockStreamingEnabled=false with onBlockReply", async () => {
// This is the critical CCSDK scenario where block streaming is disabled
// but the CCSDK runtime still sends text through onBlockReply callbacks
const onBlockReply = vi.fn();
const params = createBaseParams({ blockStreamingEnabled: false, onBlockReply });
await runReplyAgent(params);
// Both onBlockReply and onBlockReplyFlush must be provided
expect(capturedCallbacks.onBlockReply).toBeDefined();
expect(capturedCallbacks.onBlockReplyFlush).toBeDefined();
});
});
});

View File

@ -3,9 +3,12 @@ import fs from "node:fs";
import { resolveAgentModelFallbacksOverride } from "../../agents/agent-scope.js";
import { runCliAgent } from "../../agents/cli-runner.js";
import { getCliSessionId } from "../../agents/cli-session.js";
import { runWithModelFallback } from "../../agents/model-fallback.js";
import { isCliProvider } from "../../agents/model-selection.js";
import { runEmbeddedPiAgent } from "../../agents/pi-embedded.js";
import {
runAgentWithUnifiedFailover,
type UnifiedAgentRunResult,
} from "../../agents/unified-agent-runner.js";
import {
isCompactionFailureError,
isContextOverflowError,
@ -32,7 +35,8 @@ import type { VerboseLevel } from "../thinking.js";
import { isSilentReplyText, SILENT_REPLY_TOKEN } from "../tokens.js";
import type { GetReplyOptions, ReplyPayload } from "../types.js";
import { buildThreadingToolContext, resolveEnforceFinalTag } from "./agent-runner-utils.js";
import { createBlockReplyPayloadKey, type BlockReplyPipeline } from "./block-reply-pipeline.js";
import type { BlockReplyPipeline } from "./block-reply-pipeline.js";
import { createPayloadKey } from "./payload-normalization.js";
import type { FollowupRun } from "./queue.js";
import { parseReplyDirectives } from "./reply-directives.js";
import { applyReplyTagsToPayload, isRenderablePayload } from "./reply-payloads.js";
@ -97,6 +101,12 @@ export async function runAgentTurnWithFallback(params: {
let fallbackModel = params.followupRun.run.model;
let didResetAfterCompactionFailure = false;
// Check if primary provider is a CLI provider - these bypass the runtime abstraction
const primaryIsCliProvider = isCliProvider(
params.followupRun.run.provider,
params.followupRun.run.config,
);
while (true) {
try {
const allowPartialStream = !(
@ -134,296 +144,319 @@ export async function runAgentTurnWithFallback(params: {
};
const blockReplyPipeline = params.blockReplyPipeline;
const onToolResult = params.opts?.onToolResult;
const fallbackResult = await runWithModelFallback({
cfg: params.followupRun.run.config,
provider: params.followupRun.run.provider,
model: params.followupRun.run.model,
agentDir: params.followupRun.run.agentDir,
fallbacksOverride: resolveAgentModelFallbacksOverride(
params.followupRun.run.config,
resolveAgentIdFromSessionKey(params.followupRun.run.sessionKey),
),
run: (provider, model) => {
// Notify that model selection is complete (including after fallback).
// This allows responsePrefix template interpolation with the actual model.
params.opts?.onModelSelected?.({
provider,
model,
// CLI provider path - bypass runtime abstraction
if (primaryIsCliProvider) {
const startedAt = Date.now();
emitAgentEvent({
runId,
stream: "lifecycle",
data: {
phase: "start",
startedAt,
},
});
params.opts?.onModelSelected?.({
provider: params.followupRun.run.provider,
model: params.followupRun.run.model,
thinkLevel: params.followupRun.run.thinkLevel,
});
const cliSessionId = getCliSessionId(
params.getActiveSessionEntry(),
params.followupRun.run.provider,
);
try {
runResult = await runCliAgent({
sessionId: params.followupRun.run.sessionId,
sessionKey: params.sessionKey,
sessionFile: params.followupRun.run.sessionFile,
workspaceDir: params.followupRun.run.workspaceDir,
config: params.followupRun.run.config,
prompt: params.commandBody,
provider: params.followupRun.run.provider,
model: params.followupRun.run.model,
thinkLevel: params.followupRun.run.thinkLevel,
timeoutMs: params.followupRun.run.timeoutMs,
runId,
extraSystemPrompt: params.followupRun.run.extraSystemPrompt,
ownerNumbers: params.followupRun.run.ownerNumbers,
cliSessionId,
images: params.opts?.images,
});
if (isCliProvider(provider, params.followupRun.run.config)) {
const startedAt = Date.now();
// CLI backends don't emit streaming assistant events
const cliText = runResult.payloads?.[0]?.text?.trim();
if (cliText) {
emitAgentEvent({
runId,
stream: "lifecycle",
data: {
phase: "start",
startedAt,
},
stream: "assistant",
data: { text: cliText },
});
const cliSessionId = getCliSessionId(params.getActiveSessionEntry(), provider);
return runCliAgent({
sessionId: params.followupRun.run.sessionId,
sessionKey: params.sessionKey,
sessionFile: params.followupRun.run.sessionFile,
workspaceDir: params.followupRun.run.workspaceDir,
config: params.followupRun.run.config,
prompt: params.commandBody,
}
emitAgentEvent({
runId,
stream: "lifecycle",
data: {
phase: "end",
startedAt,
endedAt: Date.now(),
},
});
fallbackProvider = params.followupRun.run.provider;
fallbackModel = params.followupRun.run.model;
} catch (err) {
emitAgentEvent({
runId,
stream: "lifecycle",
data: {
phase: "error",
startedAt,
endedAt: Date.now(),
error: err instanceof Error ? err.message : String(err),
},
});
throw err;
}
} else {
// Non-CLI path - use unified runtime with failover
const threadingContext = buildThreadingToolContext({
sessionCtx: params.sessionCtx,
config: params.followupRun.run.config,
hasRepliedRef: params.opts?.hasRepliedRef,
});
const toolResultFormat = (() => {
const channel = resolveMessageChannel(
params.sessionCtx.Surface,
params.sessionCtx.Provider,
);
if (!channel) return "markdown" as const;
return isMarkdownCapableMessageChannel(channel)
? ("markdown" as const)
: ("plain" as const);
})();
let unifiedResult: UnifiedAgentRunResult;
logVerbose(
`[CCSDK-EXEC] Calling runAgentWithUnifiedFailover. hasOnBlockReply=${Boolean(params.opts?.onBlockReply)}, blockStreamingEnabled=${params.blockStreamingEnabled}, hasBlockReplyPipeline=${Boolean(params.blockReplyPipeline)}, hasOnReasoningStream=${Boolean(params.opts?.onReasoningStream)}, shouldStartOnReasoning=${params.typingSignals.shouldStartOnReasoning}`,
);
unifiedResult = await runAgentWithUnifiedFailover({
// Core params
sessionId: params.followupRun.run.sessionId,
sessionKey: params.sessionKey,
sessionFile: params.followupRun.run.sessionFile,
workspaceDir: params.followupRun.run.workspaceDir,
agentDir: params.followupRun.run.agentDir,
config: params.followupRun.run.config,
skillsSnapshot: params.followupRun.run.skillsSnapshot,
prompt: params.commandBody,
images: params.opts?.images,
extraSystemPrompt: params.followupRun.run.extraSystemPrompt,
provider: params.followupRun.run.provider,
model: params.followupRun.run.model,
authProfileId: params.followupRun.run.authProfileId,
authProfileIdSource: params.followupRun.run.authProfileIdSource,
thinkLevel: params.followupRun.run.thinkLevel,
verboseLevel: params.followupRun.run.verboseLevel,
timeoutMs: params.followupRun.run.timeoutMs,
runId,
abortSignal: params.opts?.abortSignal,
// Messaging context
messageProvider: params.sessionCtx.Provider?.trim().toLowerCase() || undefined,
agentAccountId: params.sessionCtx.AccountId,
messageTo: params.sessionCtx.OriginatingTo ?? params.sessionCtx.To,
messageThreadId: params.sessionCtx.MessageThreadId ?? undefined,
groupId: resolveGroupSessionKey(params.sessionCtx)?.id,
groupChannel:
params.sessionCtx.GroupChannel?.trim() ?? params.sessionCtx.GroupSubject?.trim(),
groupSpace: params.sessionCtx.GroupSpace?.trim() ?? undefined,
...threadingContext,
// Sender context
senderId: params.sessionCtx.SenderId?.trim() || undefined,
senderName: params.sessionCtx.SenderName?.trim() || undefined,
senderUsername: params.sessionCtx.SenderUsername?.trim() || undefined,
senderE164: params.sessionCtx.SenderE164?.trim() || undefined,
// Generalized fields
reasoningLevel: params.followupRun.run.reasoningLevel,
toolResultFormat,
blockReplyBreak: params.resolvedBlockStreamingBreak,
blockReplyChunking: params.blockReplyChunking,
shouldEmitToolResult: params.shouldEmitToolResult,
shouldEmitToolOutput: params.shouldEmitToolOutput,
ownerNumbers: params.followupRun.run.ownerNumbers,
// Pi-specific options
piOptions: {
enforceFinalTag: resolveEnforceFinalTag(
params.followupRun.run,
params.followupRun.run.provider,
),
execOverrides: params.followupRun.run.execOverrides,
bashElevated: params.followupRun.run.bashElevated,
},
// Fallback config
fallbacksOverride: resolveAgentModelFallbacksOverride(
params.followupRun.run.config,
resolveAgentIdFromSessionKey(params.followupRun.run.sessionKey),
),
// Model selection callback
onModelSelected: ({ provider, model }) => {
params.opts?.onModelSelected?.({
provider,
model,
thinkLevel: params.followupRun.run.thinkLevel,
timeoutMs: params.followupRun.run.timeoutMs,
runId,
extraSystemPrompt: params.followupRun.run.extraSystemPrompt,
ownerNumbers: params.followupRun.run.ownerNumbers,
cliSessionId,
images: params.opts?.images,
})
.then((result) => {
// CLI backends don't emit streaming assistant events, so we need to
// emit one with the final text so server-chat can populate its buffer
// and send the response to TUI/WebSocket clients.
const cliText = result.payloads?.[0]?.text?.trim();
if (cliText) {
emitAgentEvent({
runId,
stream: "assistant",
data: { text: cliText },
});
}
emitAgentEvent({
runId,
stream: "lifecycle",
data: {
phase: "end",
startedAt,
endedAt: Date.now(),
},
});
},
// Streaming callbacks
onPartialReply: allowPartialStream
? async (payload) => {
const textForTyping = await handlePartialForTyping(payload);
if (!params.opts?.onPartialReply || textForTyping === undefined) return;
await params.opts.onPartialReply({
text: textForTyping,
mediaUrls: payload.mediaUrls,
});
return result;
})
.catch((err) => {
emitAgentEvent({
runId,
stream: "lifecycle",
data: {
phase: "error",
startedAt,
endedAt: Date.now(),
error: err instanceof Error ? err.message : String(err),
},
});
throw err;
});
}
const authProfileId =
provider === params.followupRun.run.provider
? params.followupRun.run.authProfileId
: undefined;
return runEmbeddedPiAgent({
sessionId: params.followupRun.run.sessionId,
sessionKey: params.sessionKey,
messageProvider: params.sessionCtx.Provider?.trim().toLowerCase() || undefined,
agentAccountId: params.sessionCtx.AccountId,
messageTo: params.sessionCtx.OriginatingTo ?? params.sessionCtx.To,
messageThreadId: params.sessionCtx.MessageThreadId ?? undefined,
groupId: resolveGroupSessionKey(params.sessionCtx)?.id,
groupChannel:
params.sessionCtx.GroupChannel?.trim() ?? params.sessionCtx.GroupSubject?.trim(),
groupSpace: params.sessionCtx.GroupSpace?.trim() ?? undefined,
senderId: params.sessionCtx.SenderId?.trim() || undefined,
senderName: params.sessionCtx.SenderName?.trim() || undefined,
senderUsername: params.sessionCtx.SenderUsername?.trim() || undefined,
senderE164: params.sessionCtx.SenderE164?.trim() || undefined,
// Provider threading context for tool auto-injection
...buildThreadingToolContext({
sessionCtx: params.sessionCtx,
config: params.followupRun.run.config,
hasRepliedRef: params.opts?.hasRepliedRef,
}),
sessionFile: params.followupRun.run.sessionFile,
workspaceDir: params.followupRun.run.workspaceDir,
agentDir: params.followupRun.run.agentDir,
config: params.followupRun.run.config,
skillsSnapshot: params.followupRun.run.skillsSnapshot,
prompt: params.commandBody,
extraSystemPrompt: params.followupRun.run.extraSystemPrompt,
ownerNumbers: params.followupRun.run.ownerNumbers,
enforceFinalTag: resolveEnforceFinalTag(params.followupRun.run, provider),
provider,
model,
authProfileId,
authProfileIdSource: authProfileId
? params.followupRun.run.authProfileIdSource
: undefined,
thinkLevel: params.followupRun.run.thinkLevel,
verboseLevel: params.followupRun.run.verboseLevel,
reasoningLevel: params.followupRun.run.reasoningLevel,
execOverrides: params.followupRun.run.execOverrides,
toolResultFormat: (() => {
const channel = resolveMessageChannel(
params.sessionCtx.Surface,
params.sessionCtx.Provider,
);
if (!channel) return "markdown";
return isMarkdownCapableMessageChannel(channel) ? "markdown" : "plain";
})(),
bashElevated: params.followupRun.run.bashElevated,
timeoutMs: params.followupRun.run.timeoutMs,
runId,
images: params.opts?.images,
abortSignal: params.opts?.abortSignal,
blockReplyBreak: params.resolvedBlockStreamingBreak,
blockReplyChunking: params.blockReplyChunking,
onPartialReply: allowPartialStream
}
: undefined,
onAssistantMessageStart: async () => {
await params.typingSignals.signalMessageStart();
},
onReasoningStream:
params.typingSignals.shouldStartOnReasoning || params.opts?.onReasoningStream
? async (payload) => {
const textForTyping = await handlePartialForTyping(payload);
if (!params.opts?.onPartialReply || textForTyping === undefined) return;
await params.opts.onPartialReply({
text: textForTyping,
await params.typingSignals.signalReasoningDelta();
await params.opts?.onReasoningStream?.({
text: payload.text,
mediaUrls: payload.mediaUrls,
});
}
: undefined,
onAssistantMessageStart: async () => {
await params.typingSignals.signalMessageStart();
},
onReasoningStream:
params.typingSignals.shouldStartOnReasoning || params.opts?.onReasoningStream
? async (payload) => {
await params.typingSignals.signalReasoningDelta();
await params.opts?.onReasoningStream?.({
text: payload.text,
mediaUrls: payload.mediaUrls,
});
}
: undefined,
onAgentEvent: async (evt) => {
// Trigger typing when tools start executing.
// Must await to ensure typing indicator starts before tool summaries are emitted.
if (evt.stream === "tool") {
const phase = typeof evt.data.phase === "string" ? evt.data.phase : "";
if (phase === "start" || phase === "update") {
await params.typingSignals.signalToolStart();
}
onAgentEvent: async (evt) => {
// Trigger typing when tools start executing.
if (evt.stream === "tool") {
const phase = typeof evt.data.phase === "string" ? evt.data.phase : "";
if (phase === "start" || phase === "update") {
await params.typingSignals.signalToolStart();
}
// Track auto-compaction completion
if (evt.stream === "compaction") {
const phase = typeof evt.data.phase === "string" ? evt.data.phase : "";
const willRetry = Boolean(evt.data.willRetry);
if (phase === "end" && !willRetry) {
autoCompactionCompleted = true;
}
}
// Track auto-compaction completion
if (evt.stream === "compaction") {
const phase = typeof evt.data.phase === "string" ? evt.data.phase : "";
const willRetry = Boolean(evt.data.willRetry);
if (phase === "end" && !willRetry) {
autoCompactionCompleted = true;
}
},
// Always pass onBlockReply so flushBlockReplyBuffer works before tool execution,
// even when regular block streaming is disabled. The handler sends directly
// via opts.onBlockReply when the pipeline isn't available.
onBlockReply: params.opts?.onBlockReply
? async (payload) => {
const { text, skip } = normalizeStreamingText(payload);
const hasPayloadMedia = (payload.mediaUrls?.length ?? 0) > 0;
if (skip && !hasPayloadMedia) return;
const currentMessageId =
params.sessionCtx.MessageSidFull ?? params.sessionCtx.MessageSid;
const taggedPayload = applyReplyTagsToPayload(
{
text,
mediaUrls: payload.mediaUrls,
mediaUrl: payload.mediaUrls?.[0],
replyToId: payload.replyToId,
replyToTag: payload.replyToTag,
replyToCurrent: payload.replyToCurrent,
},
currentMessageId,
);
// Let through payloads with audioAsVoice flag even if empty (need to track it)
if (!isRenderablePayload(taggedPayload) && !payload.audioAsVoice) return;
const parsed = parseReplyDirectives(taggedPayload.text ?? "", {
currentMessageId,
silentToken: SILENT_REPLY_TOKEN,
});
const cleaned = parsed.text || undefined;
const hasRenderableMedia =
Boolean(taggedPayload.mediaUrl) || (taggedPayload.mediaUrls?.length ?? 0) > 0;
// Skip empty payloads unless they have audioAsVoice flag (need to track it)
if (
!cleaned &&
!hasRenderableMedia &&
!payload.audioAsVoice &&
!parsed.audioAsVoice
)
return;
if (parsed.isSilent && !hasRenderableMedia) return;
}
},
onBlockReply: params.opts?.onBlockReply
? async (payload) => {
const { text, skip } = normalizeStreamingText(payload);
const hasPayloadMedia = (payload.mediaUrls?.length ?? 0) > 0;
if (skip && !hasPayloadMedia) return;
const currentMessageId =
params.sessionCtx.MessageSidFull ?? params.sessionCtx.MessageSid;
const taggedPayload = applyReplyTagsToPayload(
{
text,
mediaUrls: payload.mediaUrls,
mediaUrl: payload.mediaUrls?.[0],
replyToId: payload.replyToId,
replyToTag: payload.replyToTag,
replyToCurrent: payload.replyToCurrent,
},
currentMessageId,
);
if (!isRenderablePayload(taggedPayload) && !payload.audioAsVoice) return;
const parsed = parseReplyDirectives(taggedPayload.text ?? "", {
currentMessageId,
silentToken: SILENT_REPLY_TOKEN,
});
const cleaned = parsed.text || undefined;
const hasRenderableMedia =
Boolean(taggedPayload.mediaUrl) || (taggedPayload.mediaUrls?.length ?? 0) > 0;
if (
!cleaned &&
!hasRenderableMedia &&
!payload.audioAsVoice &&
!parsed.audioAsVoice
)
return;
if (parsed.isSilent && !hasRenderableMedia) return;
const blockPayload: ReplyPayload = params.applyReplyToMode({
...taggedPayload,
text: cleaned,
audioAsVoice: Boolean(parsed.audioAsVoice || payload.audioAsVoice),
replyToId: taggedPayload.replyToId ?? parsed.replyToId,
replyToTag: taggedPayload.replyToTag || parsed.replyToTag,
replyToCurrent: taggedPayload.replyToCurrent || parsed.replyToCurrent,
const blockPayload: ReplyPayload = params.applyReplyToMode({
...taggedPayload,
text: cleaned,
audioAsVoice: Boolean(parsed.audioAsVoice || payload.audioAsVoice),
replyToId: taggedPayload.replyToId ?? parsed.replyToId,
replyToTag: taggedPayload.replyToTag || parsed.replyToTag,
replyToCurrent: taggedPayload.replyToCurrent || parsed.replyToCurrent,
});
void params.typingSignals
.signalTextDelta(cleaned ?? taggedPayload.text)
.catch((err) => {
logVerbose(`block reply typing signal failed: ${String(err)}`);
});
void params.typingSignals
.signalTextDelta(cleaned ?? taggedPayload.text)
.catch((err) => {
logVerbose(`block reply typing signal failed: ${String(err)}`);
});
// Use pipeline if available (block streaming enabled), otherwise send directly
if (params.blockStreamingEnabled && params.blockReplyPipeline) {
params.blockReplyPipeline.enqueue(blockPayload);
} else if (params.blockStreamingEnabled) {
// Send directly when flushing before tool execution (no pipeline but streaming enabled).
// Track sent key to avoid duplicate in final payloads.
directlySentBlockKeys.add(createBlockReplyPayloadKey(blockPayload));
await params.opts?.onBlockReply?.(blockPayload);
}
// When streaming is disabled entirely, blocks are accumulated in final text instead.
if (params.blockStreamingEnabled && params.blockReplyPipeline) {
params.blockReplyPipeline.enqueue(blockPayload);
} else {
// Direct send: either block streaming enabled without pipeline,
// or block streaming disabled with callback (e.g., CCSDK runtime).
directlySentBlockKeys.add(createPayloadKey(blockPayload));
await params.opts?.onBlockReply?.(blockPayload);
}
: undefined,
onBlockReplyFlush:
params.blockStreamingEnabled && blockReplyPipeline
}
: undefined,
onBlockReplyFlush:
params.blockStreamingEnabled && blockReplyPipeline
? async () => {
await blockReplyPipeline.flush({ force: true });
}
: params.opts?.onBlockReply
? async () => {
await blockReplyPipeline.flush({ force: true });
// No-op flush when block reply callback was provided but no pipeline.
// This signals message completion to runtimes like CCSDK.
}
: undefined,
shouldEmitToolResult: params.shouldEmitToolResult,
shouldEmitToolOutput: params.shouldEmitToolOutput,
onToolResult: onToolResult
? (payload) => {
// `subscribeEmbeddedPiSession` may invoke tool callbacks without awaiting them.
// If a tool callback starts typing after the run finalized, we can end up with
// a typing loop that never sees a matching markRunComplete(). Track and drain.
const task = (async () => {
const { text, skip } = normalizeStreamingText(payload);
if (skip) return;
await params.typingSignals.signalTextDelta(text);
await onToolResult({
text,
mediaUrls: payload.mediaUrls,
});
})()
.catch((err) => {
logVerbose(`tool result delivery failed: ${String(err)}`);
})
.finally(() => {
params.pendingToolTasks.delete(task);
});
params.pendingToolTasks.add(task);
}
: undefined,
});
},
});
runResult = fallbackResult.result;
fallbackProvider = fallbackResult.provider;
fallbackModel = fallbackResult.model;
onToolResult: onToolResult
? (payload) => {
const task = (async () => {
const { text, skip } = normalizeStreamingText(payload);
if (skip) return;
await params.typingSignals.signalTextDelta(text);
await onToolResult({
text,
mediaUrls: payload.mediaUrls,
});
})()
.catch((err) => {
logVerbose(`tool result delivery failed: ${String(err)}`);
})
.finally(() => {
params.pendingToolTasks.delete(task);
});
params.pendingToolTasks.add(task);
}
: undefined,
});
runResult = unifiedResult.result;
fallbackProvider = unifiedResult.provider;
fallbackModel = unifiedResult.model;
}
// Some embedded runs surface context overflow as an error payload instead of throwing.
// Treat those as a session-level failure and auto-recover by starting a fresh session.
const embeddedError = runResult.meta?.error;
if (
embeddedError &&
@ -498,7 +531,6 @@ export async function runAgentTurnWithFallback(params: {
);
try {
// Delete transcript file if it exists
if (corruptedSessionId) {
const transcriptPath = resolveSessionTranscriptPath(corruptedSessionId);
try {
@ -508,10 +540,8 @@ export async function runAgentTurnWithFallback(params: {
}
}
// Keep the in-memory snapshot consistent with the on-disk store reset.
delete params.activeSessionStore[sessionKey];
// Remove session entry from store using a fresh, locked snapshot.
await updateSessionStore(params.storePath, (store) => {
delete store[sessionKey];
});

View File

@ -1,8 +1,7 @@
import crypto from "node:crypto";
import { resolveAgentModelFallbacksOverride } from "../../agents/agent-scope.js";
import { runWithModelFallback } from "../../agents/model-fallback.js";
import { isCliProvider } from "../../agents/model-selection.js";
import { runEmbeddedPiAgent } from "../../agents/pi-embedded.js";
import { runAgentWithUnifiedFailover } from "../../agents/unified-agent-runner.js";
import { resolveSandboxConfigForAgent, resolveSandboxRuntimeStatus } from "../../agents/sandbox.js";
import type { MoltbotConfig } from "../../config/config.js";
import {
@ -87,70 +86,77 @@ export async function runMemoryFlushIfNeeded(params: {
]
.filter(Boolean)
.join("\n\n");
// Build threading context for tool auto-injection
const threadingContext = buildThreadingToolContext({
sessionCtx: params.sessionCtx,
config: params.followupRun.run.config,
hasRepliedRef: params.opts?.hasRepliedRef,
});
try {
await runWithModelFallback({
cfg: params.followupRun.run.config,
await runAgentWithUnifiedFailover({
// Core params
sessionId: params.followupRun.run.sessionId,
sessionKey: params.sessionKey,
sessionFile: params.followupRun.run.sessionFile,
workspaceDir: params.followupRun.run.workspaceDir,
agentDir: params.followupRun.run.agentDir,
config: params.followupRun.run.config,
skillsSnapshot: params.followupRun.run.skillsSnapshot,
prompt: memoryFlushSettings.prompt,
extraSystemPrompt: flushSystemPrompt,
provider: params.followupRun.run.provider,
model: params.followupRun.run.model,
agentDir: params.followupRun.run.agentDir,
authProfileId: params.followupRun.run.authProfileId,
authProfileIdSource: params.followupRun.run.authProfileIdSource,
thinkLevel: params.followupRun.run.thinkLevel,
verboseLevel: params.followupRun.run.verboseLevel,
timeoutMs: params.followupRun.run.timeoutMs,
runId: flushRunId,
// Messaging context
messageProvider: params.sessionCtx.Provider?.trim().toLowerCase() || undefined,
agentAccountId: params.sessionCtx.AccountId,
messageTo: params.sessionCtx.OriginatingTo ?? params.sessionCtx.To,
messageThreadId: params.sessionCtx.MessageThreadId ?? undefined,
...threadingContext,
// Sender context
senderId: params.sessionCtx.SenderId?.trim() || undefined,
senderName: params.sessionCtx.SenderName?.trim() || undefined,
senderUsername: params.sessionCtx.SenderUsername?.trim() || undefined,
senderE164: params.sessionCtx.SenderE164?.trim() || undefined,
// Generalized fields
reasoningLevel: params.followupRun.run.reasoningLevel,
ownerNumbers: params.followupRun.run.ownerNumbers,
// Pi-specific options
piOptions: {
enforceFinalTag: resolveEnforceFinalTag(
params.followupRun.run,
params.followupRun.run.provider,
),
execOverrides: params.followupRun.run.execOverrides,
bashElevated: params.followupRun.run.bashElevated,
},
// Fallback config
fallbacksOverride: resolveAgentModelFallbacksOverride(
params.followupRun.run.config,
resolveAgentIdFromSessionKey(params.followupRun.run.sessionKey),
),
run: (provider, model) => {
const authProfileId =
provider === params.followupRun.run.provider
? params.followupRun.run.authProfileId
: undefined;
return runEmbeddedPiAgent({
sessionId: params.followupRun.run.sessionId,
sessionKey: params.sessionKey,
messageProvider: params.sessionCtx.Provider?.trim().toLowerCase() || undefined,
agentAccountId: params.sessionCtx.AccountId,
messageTo: params.sessionCtx.OriginatingTo ?? params.sessionCtx.To,
messageThreadId: params.sessionCtx.MessageThreadId ?? undefined,
// Provider threading context for tool auto-injection
...buildThreadingToolContext({
sessionCtx: params.sessionCtx,
config: params.followupRun.run.config,
hasRepliedRef: params.opts?.hasRepliedRef,
}),
senderId: params.sessionCtx.SenderId?.trim() || undefined,
senderName: params.sessionCtx.SenderName?.trim() || undefined,
senderUsername: params.sessionCtx.SenderUsername?.trim() || undefined,
senderE164: params.sessionCtx.SenderE164?.trim() || undefined,
sessionFile: params.followupRun.run.sessionFile,
workspaceDir: params.followupRun.run.workspaceDir,
agentDir: params.followupRun.run.agentDir,
config: params.followupRun.run.config,
skillsSnapshot: params.followupRun.run.skillsSnapshot,
prompt: memoryFlushSettings.prompt,
extraSystemPrompt: flushSystemPrompt,
ownerNumbers: params.followupRun.run.ownerNumbers,
enforceFinalTag: resolveEnforceFinalTag(params.followupRun.run, provider),
provider,
model,
authProfileId,
authProfileIdSource: authProfileId
? params.followupRun.run.authProfileIdSource
: undefined,
thinkLevel: params.followupRun.run.thinkLevel,
verboseLevel: params.followupRun.run.verboseLevel,
reasoningLevel: params.followupRun.run.reasoningLevel,
execOverrides: params.followupRun.run.execOverrides,
bashElevated: params.followupRun.run.bashElevated,
timeoutMs: params.followupRun.run.timeoutMs,
runId: flushRunId,
onAgentEvent: (evt) => {
if (evt.stream === "compaction") {
const phase = typeof evt.data.phase === "string" ? evt.data.phase : "";
const willRetry = Boolean(evt.data.willRetry);
if (phase === "end" && !willRetry) {
memoryCompactionCompleted = true;
}
}
},
});
// Callbacks
onAgentEvent: (evt) => {
if (evt.stream === "compaction") {
const phase = typeof evt.data.phase === "string" ? evt.data.phase : "";
const willRetry = Boolean(evt.data.willRetry);
if (phase === "end" && !willRetry) {
memoryCompactionCompleted = true;
}
}
},
});
let memoryFlushCompactionCount =

View File

@ -5,7 +5,8 @@ import type { OriginatingChannelType } from "../templating.js";
import { SILENT_REPLY_TOKEN } from "../tokens.js";
import type { ReplyPayload } from "../types.js";
import { formatBunFetchSocketError, isBunFetchSocketError } from "./agent-runner-utils.js";
import { createBlockReplyPayloadKey, type BlockReplyPipeline } from "./block-reply-pipeline.js";
import type { BlockReplyPipeline } from "./block-reply-pipeline.js";
import { createPayloadKey } from "./payload-normalization.js";
import { parseReplyDirectives } from "./reply-directives.js";
import {
applyReplyThreading,
@ -107,7 +108,7 @@ export function buildReplyPayloads(params: {
? dedupedPayloads.filter((payload) => !params.blockReplyPipeline?.hasSentPayload(payload))
: params.directlySentBlockKeys?.size
? dedupedPayloads.filter(
(payload) => !params.directlySentBlockKeys!.has(createBlockReplyPayloadKey(payload)),
(payload) => !params.directlySentBlockKeys!.has(createPayloadKey(payload)),
)
: dedupedPayloads;
const replyPayloads = suppressMessagingToolReplies ? [] : filteredPayloads;

View File

@ -5,22 +5,40 @@ import { createMockTypingController } from "./test-helpers.js";
const runEmbeddedPiAgentMock = vi.fn();
vi.mock("../../agents/model-fallback.js", () => ({
runWithModelFallback: async ({
run,
}: {
run: (provider: string, model: string) => Promise<unknown>;
}) => ({
// Force a cross-provider fallback candidate
result: await run("openai-codex", "gpt-5.2"),
provider: "openai-codex",
model: "gpt-5.2",
}),
vi.mock("../../agents/unified-agent-runner.js", () => ({
runAgentWithUnifiedFailover: async (params: {
provider?: string;
model?: string;
authProfileId?: string;
authProfileIdSource?: string;
}) => {
// Simulate a cross-provider fallback - unified runner should drop authProfileId
// when provider changes from anthropic to openai-codex
const originalProvider = params.provider ?? "anthropic";
const fallbackProvider = "openai-codex";
const fallbackModel = "gpt-5.2";
const providerChanged = fallbackProvider !== originalProvider;
const result = await runEmbeddedPiAgentMock({
...params,
provider: fallbackProvider,
model: fallbackModel,
// Drop auth profile when provider changes (the fix being tested)
authProfileId: providerChanged ? undefined : params.authProfileId,
authProfileIdSource: providerChanged ? undefined : params.authProfileIdSource,
});
return {
result,
runtime: "pi",
provider: fallbackProvider,
model: fallbackModel,
attempts: [],
};
},
}));
vi.mock("../../agents/pi-embedded.js", () => ({
queueEmbeddedPiMessage: vi.fn().mockReturnValue(false),
runEmbeddedPiAgent: (params: unknown) => runEmbeddedPiAgentMock(params),
}));
vi.mock("./queue.js", async () => {

View File

@ -7,19 +7,14 @@ import type { FollowupRun, QueueSettings } from "./queue.js";
import { createMockTypingController } from "./test-helpers.js";
const runEmbeddedPiAgentMock = vi.fn();
const runWithModelFallbackMock = vi.fn();
const runAgentWithUnifiedFailoverMock = vi.fn();
vi.mock("../../agents/model-fallback.js", () => ({
runWithModelFallback: (params: {
provider: string;
model: string;
run: (provider: string, model: string) => Promise<unknown>;
}) => runWithModelFallbackMock(params),
vi.mock("../../agents/unified-agent-runner.js", () => ({
runAgentWithUnifiedFailover: (params: unknown) => runAgentWithUnifiedFailoverMock(params),
}));
vi.mock("../../agents/pi-embedded.js", () => ({
queueEmbeddedPiMessage: vi.fn().mockReturnValue(false),
runEmbeddedPiAgent: (params: unknown) => runEmbeddedPiAgentMock(params),
}));
vi.mock("./queue.js", async () => {
@ -108,20 +103,31 @@ function createRun(params?: {
describe("runReplyAgent fallback reasoning tags", () => {
beforeEach(() => {
runEmbeddedPiAgentMock.mockReset();
runWithModelFallbackMock.mockReset();
runAgentWithUnifiedFailoverMock.mockReset();
});
it("enforces <final> when the fallback provider requires reasoning tags", async () => {
runEmbeddedPiAgentMock.mockResolvedValueOnce({
payloads: [{ text: "ok" }],
meta: {},
});
runWithModelFallbackMock.mockImplementationOnce(
async ({ run }: { run: (provider: string, model: string) => Promise<unknown> }) => ({
result: await run("google-antigravity", "gemini-3"),
provider: "google-antigravity",
model: "gemini-3",
}),
// Simulate what the unified runner does: when falling back to google-antigravity,
// it should set enforceFinalTag=true because that provider requires reasoning tags
runAgentWithUnifiedFailoverMock.mockImplementationOnce(
async (params: EmbeddedPiAgentParams & { piOptions?: { enforceFinalTag?: boolean } }) => {
// The unified runner computes enforceFinalTag based on the current provider's
// reasoning tag requirements. For google-antigravity, it should be true.
const enforceFinalTag = true; // google-antigravity requires reasoning tags
runEmbeddedPiAgentMock({
...params,
provider: "google-antigravity",
model: "gemini-3",
enforceFinalTag,
});
return {
result: { payloads: [{ text: "ok" }], meta: {} },
runtime: "pi",
provider: "google-antigravity",
model: "gemini-3",
attempts: [],
};
},
);
await createRun();
@ -131,18 +137,27 @@ describe("runReplyAgent fallback reasoning tags", () => {
});
it("enforces <final> during memory flush on fallback providers", async () => {
runEmbeddedPiAgentMock.mockImplementation(async (params: EmbeddedPiAgentParams) => {
if (params.prompt === DEFAULT_MEMORY_FLUSH_PROMPT) {
return { payloads: [], meta: {} };
}
return { payloads: [{ text: "ok" }], meta: {} };
});
runWithModelFallbackMock.mockImplementation(
async ({ run }: { run: (provider: string, model: string) => Promise<unknown> }) => ({
result: await run("google-antigravity", "gemini-3"),
provider: "google-antigravity",
model: "gemini-3",
}),
// Simulate unified runner behavior for both main call and memory flush call
runAgentWithUnifiedFailoverMock.mockImplementation(
async (params: EmbeddedPiAgentParams & { piOptions?: { enforceFinalTag?: boolean } }) => {
// The unified runner computes enforceFinalTag based on the current provider's
// reasoning tag requirements. For google-antigravity, it should be true.
const enforceFinalTag = true; // google-antigravity requires reasoning tags
runEmbeddedPiAgentMock({
...params,
provider: "google-antigravity",
model: "gemini-3",
enforceFinalTag,
});
const isFlush = params.prompt === DEFAULT_MEMORY_FLUSH_PROMPT;
return {
result: { payloads: isFlush ? [] : [{ text: "ok" }], meta: {} },
runtime: "pi",
provider: "google-antigravity",
model: "gemini-3",
attempts: [],
};
},
);
await createRun({

View File

@ -2,6 +2,7 @@ import { logVerbose } from "../../globals.js";
import type { ReplyPayload } from "../types.js";
import { createBlockReplyCoalescer } from "./block-reply-coalescer.js";
import type { BlockStreamingCoalescing } from "./block-streaming.js";
import { createPayloadKey } from "./payload-normalization.js";
export type BlockReplyPipeline = {
enqueue: (payload: ReplyPayload) => void;
@ -34,18 +35,15 @@ export function createAudioAsVoiceBuffer(params: {
};
}
/**
* Create a stable string key for a payload for deduplication.
*
* @deprecated Use `createPayloadKey` from `payload-normalization.ts` directly.
* This function is kept for backward compatibility but delegates to the
* centralized implementation for consistent key generation.
*/
export function createBlockReplyPayloadKey(payload: ReplyPayload): string {
const text = payload.text?.trim() ?? "";
const mediaList = payload.mediaUrls?.length
? payload.mediaUrls
: payload.mediaUrl
? [payload.mediaUrl]
: [];
return JSON.stringify({
text,
mediaList,
replyToId: payload.replyToId ?? null,
});
return createPayloadKey(payload);
}
const withTimeout = async <T>(

View File

@ -2,8 +2,10 @@ import crypto from "node:crypto";
import { resolveAgentModelFallbacksOverride } from "../../agents/agent-scope.js";
import { lookupContextTokens } from "../../agents/context.js";
import { DEFAULT_CONTEXT_TOKENS } from "../../agents/defaults.js";
import { runWithModelFallback } from "../../agents/model-fallback.js";
import { runEmbeddedPiAgent } from "../../agents/pi-embedded.js";
import {
runAgentWithUnifiedFailover,
type UnifiedAgentRunResult,
} from "../../agents/unified-agent-runner.js";
import { resolveAgentIdFromSessionKey, type SessionEntry } from "../../config/sessions.js";
import type { TypingMode } from "../../config/types.js";
import { logVerbose } from "../../globals.js";
@ -121,76 +123,81 @@ export function createFollowupRunner(params: {
});
}
let autoCompactionCompleted = false;
let runResult: Awaited<ReturnType<typeof runEmbeddedPiAgent>>;
let fallbackProvider = queued.run.provider;
let fallbackModel = queued.run.model;
let unifiedResult: UnifiedAgentRunResult;
try {
const fallbackResult = await runWithModelFallback({
cfg: queued.run.config,
unifiedResult = await runAgentWithUnifiedFailover({
// Core params
sessionId: queued.run.sessionId,
sessionKey: queued.run.sessionKey,
sessionFile: queued.run.sessionFile,
workspaceDir: queued.run.workspaceDir,
agentDir: queued.run.agentDir,
config: queued.run.config,
skillsSnapshot: queued.run.skillsSnapshot,
prompt: queued.prompt,
extraSystemPrompt: queued.run.extraSystemPrompt,
provider: queued.run.provider,
model: queued.run.model,
agentDir: queued.run.agentDir,
authProfileId: queued.run.authProfileId,
authProfileIdSource: queued.run.authProfileIdSource,
thinkLevel: queued.run.thinkLevel,
verboseLevel: queued.run.verboseLevel,
timeoutMs: queued.run.timeoutMs,
runId,
// Messaging context
messageProvider: queued.run.messageProvider,
agentAccountId: queued.run.agentAccountId,
messageTo: queued.originatingTo,
messageThreadId: queued.originatingThreadId,
groupId: queued.run.groupId,
groupChannel: queued.run.groupChannel,
groupSpace: queued.run.groupSpace,
// Sender context
senderId: queued.run.senderId,
senderName: queued.run.senderName,
senderUsername: queued.run.senderUsername,
senderE164: queued.run.senderE164,
// Generalized fields
reasoningLevel: queued.run.reasoningLevel,
blockReplyBreak: queued.run.blockReplyBreak,
ownerNumbers: queued.run.ownerNumbers,
// Pi-specific options
piOptions: {
enforceFinalTag: queued.run.enforceFinalTag,
execOverrides: queued.run.execOverrides,
bashElevated: queued.run.bashElevated,
},
// Fallback config
fallbacksOverride: resolveAgentModelFallbacksOverride(
queued.run.config,
resolveAgentIdFromSessionKey(queued.run.sessionKey),
),
run: (provider, model) => {
const authProfileId =
provider === queued.run.provider ? queued.run.authProfileId : undefined;
return runEmbeddedPiAgent({
sessionId: queued.run.sessionId,
sessionKey: queued.run.sessionKey,
messageProvider: queued.run.messageProvider,
agentAccountId: queued.run.agentAccountId,
messageTo: queued.originatingTo,
messageThreadId: queued.originatingThreadId,
groupId: queued.run.groupId,
groupChannel: queued.run.groupChannel,
groupSpace: queued.run.groupSpace,
senderId: queued.run.senderId,
senderName: queued.run.senderName,
senderUsername: queued.run.senderUsername,
senderE164: queued.run.senderE164,
sessionFile: queued.run.sessionFile,
workspaceDir: queued.run.workspaceDir,
config: queued.run.config,
skillsSnapshot: queued.run.skillsSnapshot,
prompt: queued.prompt,
extraSystemPrompt: queued.run.extraSystemPrompt,
ownerNumbers: queued.run.ownerNumbers,
enforceFinalTag: queued.run.enforceFinalTag,
provider,
model,
authProfileId,
authProfileIdSource: authProfileId ? queued.run.authProfileIdSource : undefined,
thinkLevel: queued.run.thinkLevel,
verboseLevel: queued.run.verboseLevel,
reasoningLevel: queued.run.reasoningLevel,
execOverrides: queued.run.execOverrides,
bashElevated: queued.run.bashElevated,
timeoutMs: queued.run.timeoutMs,
runId,
blockReplyBreak: queued.run.blockReplyBreak,
onAgentEvent: (evt) => {
if (evt.stream !== "compaction") return;
const phase = typeof evt.data.phase === "string" ? evt.data.phase : "";
const willRetry = Boolean(evt.data.willRetry);
if (phase === "end" && !willRetry) {
autoCompactionCompleted = true;
}
},
});
// Callbacks
onAgentEvent: (evt) => {
if (evt.stream !== "compaction") return;
const phase = typeof evt.data.phase === "string" ? evt.data.phase : "";
const willRetry = Boolean(evt.data.willRetry);
if (phase === "end" && !willRetry) {
autoCompactionCompleted = true;
}
},
});
runResult = fallbackResult.result;
fallbackProvider = fallbackResult.provider;
fallbackModel = fallbackResult.model;
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
defaultRuntime.error?.(`Followup agent failed before reply: ${message}`);
return;
}
const runResult = unifiedResult.result;
const fallbackProvider = unifiedResult.provider;
const fallbackModel = unifiedResult.model;
if (storePath && sessionKey) {
const usage = runResult.meta.agentMeta?.usage;
const modelUsed = runResult.meta.agentMeta?.model ?? fallbackModel ?? defaultModel;

View File

@ -0,0 +1,101 @@
/**
* Centralized payload normalization for deduplication and key generation.
*
* This module provides a SINGLE source of truth for normalizing reply payloads.
* Both block reply tracking (in agent-runner-execution.ts) and final payload
* filtering (in agent-runner-payloads.ts) MUST use these functions to ensure
* consistent key generation and reliable deduplication.
*
* ## Runtime Contract
*
* All agent runtimes (Pi, CCSDK) must adhere to:
*
* 1. **Final payloads contain user-facing text only**
* - `payloads[].text` must NEVER contain reasoning/thinking content
* - Reasoning must be streamed via `onReasoningStream` callback
* - This ensures gateway/channel delivery never includes internal reasoning
*
* 2. **Block replies use the same normalization**
* - Both block and final payloads go through `normalizePayloadForDedup`
* - This ensures keys always match for deduplication
*
* 3. **Whitespace is normalized**
* - All text is trimmed for consistent key generation
* - Leading/trailing whitespace differences don't break deduplication
*/
import type { ReplyPayload } from "../types.js";
/**
* Normalized payload structure for deduplication.
*
* All fields are in a consistent format suitable for key generation.
*/
export type NormalizedPayload = {
/** Trimmed text content (never reasoning) */
text: string;
/** Normalized media URL list (never undefined, always an array) */
mediaList: string[];
/** Reply threading: target message ID */
replyToId: string | null;
/** Reply threading: tag flag */
replyToTag: boolean;
/** Reply threading: reply to current message flag */
replyToCurrent: boolean;
/** Audio as voice flag */
audioAsVoice: boolean;
};
/**
* Normalize a payload into a consistent structure for deduplication.
*
* This is the SINGLE function used by both:
* - Block reply tracking in `agent-runner-execution.ts`
* - Final payload filtering in `agent-runner-payloads.ts`
*
* Using the same normalization ensures keys always match.
*/
export function normalizePayloadForDedup(payload: ReplyPayload): NormalizedPayload {
// Normalize text: trim whitespace for consistent matching
const text = (payload.text ?? "").trim();
// Normalize media: collapse mediaUrl and mediaUrls into a single list
const mediaList = payload.mediaUrls?.length
? payload.mediaUrls
: payload.mediaUrl
? [payload.mediaUrl]
: [];
return {
text,
mediaList,
replyToId: payload.replyToId ?? null,
replyToTag: Boolean(payload.replyToTag),
replyToCurrent: Boolean(payload.replyToCurrent),
audioAsVoice: Boolean(payload.audioAsVoice),
};
}
/**
* Create a stable string key for a payload for deduplication.
*
* Payloads with the same key are considered duplicates and one will be filtered.
*/
export function createPayloadKey(payload: ReplyPayload): string {
const normalized = normalizePayloadForDedup(payload);
return JSON.stringify({
text: normalized.text,
mediaList: normalized.mediaList,
replyToId: normalized.replyToId,
replyToTag: normalized.replyToTag,
replyToCurrent: normalized.replyToCurrent,
audioAsVoice: normalized.audioAsVoice,
});
}
/**
* Check if two payloads are duplicates based on their normalized keys.
*/
export function arePayloadsDuplicates(a: ReplyPayload, b: ReplyPayload): boolean {
return createPayloadKey(a) === createPayloadKey(b);
}

View File

@ -10,7 +10,6 @@ import { runCliAgent } from "../agents/cli-runner.js";
import { getCliSessionId } from "../agents/cli-session.js";
import { DEFAULT_MODEL, DEFAULT_PROVIDER } from "../agents/defaults.js";
import { loadModelCatalog } from "../agents/model-catalog.js";
import { runWithModelFallback } from "../agents/model-fallback.js";
import {
buildAllowedModelSet,
isCliProvider,
@ -19,6 +18,10 @@ import {
resolveThinkingDefault,
} from "../agents/model-selection.js";
import { runEmbeddedPiAgent } from "../agents/pi-embedded.js";
import {
runAgentWithUnifiedFailover,
type UnifiedAgentRunResult,
} from "../agents/unified-agent-runner.js";
import { buildWorkspaceSkillSnapshot } from "../agents/skills.js";
import { getSkillsSnapshotVersion } from "../agents/skills/refresh.js";
import { resolveAgentTimeoutMs } from "../agents/timeout.js";
@ -378,88 +381,96 @@ export async function agentCommand(
opts.replyChannel ?? opts.channel,
);
const spawnedBy = opts.spawnedBy ?? sessionEntry?.spawnedBy;
const fallbackResult = await runWithModelFallback({
cfg,
provider,
model,
agentDir,
fallbacksOverride: resolveAgentModelFallbacksOverride(cfg, sessionAgentId),
run: (providerOverride, modelOverride) => {
if (isCliProvider(providerOverride, cfg)) {
const cliSessionId = getCliSessionId(sessionEntry, providerOverride);
return runCliAgent({
sessionId,
sessionKey,
sessionFile,
workspaceDir,
config: cfg,
prompt: body,
provider: providerOverride,
model: modelOverride,
thinkLevel: resolvedThinkLevel,
timeoutMs,
runId,
extraSystemPrompt: opts.extraSystemPrompt,
cliSessionId,
images: opts.images,
streamParams: opts.streamParams,
});
}
const authProfileId =
providerOverride === provider ? sessionEntry?.authProfileOverride : undefined;
return runEmbeddedPiAgent({
sessionId,
sessionKey,
messageChannel,
agentAccountId: runContext.accountId,
messageTo: opts.replyTo ?? opts.to,
messageThreadId: opts.threadId,
groupId: runContext.groupId,
groupChannel: runContext.groupChannel,
groupSpace: runContext.groupSpace,
spawnedBy,
currentChannelId: runContext.currentChannelId,
currentThreadTs: runContext.currentThreadTs,
replyToMode: runContext.replyToMode,
hasRepliedRef: runContext.hasRepliedRef,
sessionFile,
workspaceDir,
config: cfg,
skillsSnapshot,
prompt: body,
images: opts.images,
// CLI providers bypass the runtime abstraction
const primaryIsCliProvider = isCliProvider(provider, cfg);
if (primaryIsCliProvider) {
const cliSessionId = getCliSessionId(sessionEntry, provider);
result = await runCliAgent({
sessionId,
sessionKey,
sessionFile,
workspaceDir,
config: cfg,
prompt: body,
provider,
model,
thinkLevel: resolvedThinkLevel,
timeoutMs,
runId,
extraSystemPrompt: opts.extraSystemPrompt,
cliSessionId,
images: opts.images,
streamParams: opts.streamParams,
});
fallbackProvider = provider;
fallbackModel = model;
} else {
// Non-CLI path - use unified runtime with failover
const authProfileId = sessionEntry?.authProfileOverride;
const unifiedResult: UnifiedAgentRunResult = await runAgentWithUnifiedFailover({
// Core params
sessionId,
sessionKey,
sessionFile,
workspaceDir,
agentDir,
config: cfg,
skillsSnapshot,
prompt: body,
images: opts.images,
provider,
model,
authProfileId,
authProfileIdSource: authProfileId ? sessionEntry?.authProfileOverrideSource : undefined,
thinkLevel: resolvedThinkLevel,
verboseLevel: resolvedVerboseLevel,
timeoutMs,
runId,
lane: opts.lane,
abortSignal: opts.abortSignal,
extraSystemPrompt: opts.extraSystemPrompt,
streamParams: opts.streamParams,
// Messaging context
messageChannel,
agentAccountId: runContext.accountId,
messageTo: opts.replyTo ?? opts.to,
messageThreadId: opts.threadId,
groupId: runContext.groupId,
groupChannel: runContext.groupChannel,
groupSpace: runContext.groupSpace,
spawnedBy,
currentChannelId: runContext.currentChannelId,
currentThreadTs: runContext.currentThreadTs,
replyToMode: runContext.replyToMode,
hasRepliedRef: runContext.hasRepliedRef,
// Pi-specific options
piOptions: {
clientTools: opts.clientTools,
provider: providerOverride,
model: modelOverride,
authProfileId,
authProfileIdSource: authProfileId
? sessionEntry?.authProfileOverrideSource
: undefined,
thinkLevel: resolvedThinkLevel,
verboseLevel: resolvedVerboseLevel,
timeoutMs,
runId,
lane: opts.lane,
abortSignal: opts.abortSignal,
extraSystemPrompt: opts.extraSystemPrompt,
streamParams: opts.streamParams,
agentDir,
onAgentEvent: (evt) => {
// Track lifecycle end for fallback emission below.
if (
evt.stream === "lifecycle" &&
typeof evt.data?.phase === "string" &&
(evt.data.phase === "end" || evt.data.phase === "error")
) {
lifecycleEnded = true;
}
},
});
},
});
result = fallbackResult.result;
fallbackProvider = fallbackResult.provider;
fallbackModel = fallbackResult.model;
},
// Fallback config
fallbacksOverride: resolveAgentModelFallbacksOverride(cfg, sessionAgentId),
// Callbacks
onAgentEvent: (evt) => {
// Track lifecycle end for fallback emission below.
if (
evt.stream === "lifecycle" &&
typeof evt.data?.phase === "string" &&
(evt.data.phase === "end" || evt.data.phase === "error")
) {
lifecycleEnded = true;
}
},
});
result = unifiedResult.result;
fallbackProvider = unifiedResult.provider;
fallbackModel = unifiedResult.model;
}
if (!lifecycleEnded) {
emitAgentEvent({
runId,

View File

@ -10,7 +10,6 @@ import { getCliSessionId, setCliSessionId } from "../../agents/cli-session.js";
import { lookupContextTokens } from "../../agents/context.js";
import { DEFAULT_CONTEXT_TOKENS, DEFAULT_MODEL, DEFAULT_PROVIDER } from "../../agents/defaults.js";
import { loadModelCatalog } from "../../agents/model-catalog.js";
import { runWithModelFallback } from "../../agents/model-fallback.js";
import {
getModelRefStatus,
isCliProvider,
@ -20,6 +19,10 @@ import {
resolveThinkingDefault,
} from "../../agents/model-selection.js";
import { runEmbeddedPiAgent } from "../../agents/pi-embedded.js";
import {
runAgentWithUnifiedFailover,
type UnifiedAgentRunResult,
} from "../../agents/unified-agent-runner.js";
import type { MessagingToolSend } from "../../agents/pi-embedded-messaging.js";
import { buildWorkspaceSkillSnapshot } from "../../agents/skills.js";
import { getSkillsSnapshotVersion } from "../../agents/skills/refresh.js";
@ -328,53 +331,59 @@ export async function runCronIsolatedAgentTurn(params: {
verboseLevel: resolvedVerboseLevel,
});
const messageChannel = resolvedDelivery.channel;
const fallbackResult = await runWithModelFallback({
cfg: cfgWithAgentDefaults,
provider,
model,
agentDir,
fallbacksOverride: resolveAgentModelFallbacksOverride(params.cfg, agentId),
run: (providerOverride, modelOverride) => {
if (isCliProvider(providerOverride, cfgWithAgentDefaults)) {
const cliSessionId = getCliSessionId(cronSession.sessionEntry, providerOverride);
return runCliAgent({
sessionId: cronSession.sessionEntry.sessionId,
sessionKey: agentSessionKey,
sessionFile,
workspaceDir,
config: cfgWithAgentDefaults,
prompt: commandBody,
provider: providerOverride,
model: modelOverride,
thinkLevel,
timeoutMs,
runId: cronSession.sessionEntry.sessionId,
cliSessionId,
});
}
return runEmbeddedPiAgent({
sessionId: cronSession.sessionEntry.sessionId,
sessionKey: agentSessionKey,
messageChannel,
agentAccountId: resolvedDelivery.accountId,
sessionFile,
workspaceDir,
config: cfgWithAgentDefaults,
skillsSnapshot,
prompt: commandBody,
lane: params.lane ?? "cron",
provider: providerOverride,
model: modelOverride,
thinkLevel,
verboseLevel: resolvedVerboseLevel,
timeoutMs,
runId: cronSession.sessionEntry.sessionId,
});
},
});
runResult = fallbackResult.result;
fallbackProvider = fallbackResult.provider;
fallbackModel = fallbackResult.model;
// CLI providers bypass the runtime abstraction
const primaryIsCliProvider = isCliProvider(provider, cfgWithAgentDefaults);
if (primaryIsCliProvider) {
const cliSessionId = getCliSessionId(cronSession.sessionEntry, provider);
runResult = await runCliAgent({
sessionId: cronSession.sessionEntry.sessionId,
sessionKey: agentSessionKey,
sessionFile,
workspaceDir,
config: cfgWithAgentDefaults,
prompt: commandBody,
provider,
model,
thinkLevel,
timeoutMs,
runId: cronSession.sessionEntry.sessionId,
cliSessionId,
});
fallbackProvider = provider;
fallbackModel = model;
} else {
// Non-CLI path - use unified runtime with failover
const unifiedResult: UnifiedAgentRunResult = await runAgentWithUnifiedFailover({
// Core params
sessionId: cronSession.sessionEntry.sessionId,
sessionKey: agentSessionKey,
sessionFile,
workspaceDir,
agentDir,
config: cfgWithAgentDefaults,
skillsSnapshot,
prompt: commandBody,
provider,
model,
thinkLevel,
verboseLevel: resolvedVerboseLevel,
timeoutMs,
runId: cronSession.sessionEntry.sessionId,
lane: params.lane ?? "cron",
// Messaging context
messageChannel,
agentAccountId: resolvedDelivery.accountId,
// Fallback config
fallbacksOverride: resolveAgentModelFallbacksOverride(params.cfg, agentId),
});
runResult = unifiedResult.result;
fallbackProvider = unifiedResult.provider;
fallbackModel = unifiedResult.model;
}
} catch (err) {
return { status: "error", error: String(err) };
}

View File

@ -505,14 +505,20 @@ export const chatHandlers: GatewayRequestHandlers = {
},
})
.then(() => {
if (!agentRunStarted) {
const combinedReply = finalReplyParts
.map((part) => part.trim())
.filter(Boolean)
.join("\n\n")
.trim();
let message: Record<string, unknown> | undefined;
if (combinedReply) {
// Always broadcast final result regardless of whether an agent run started.
// Previously this only broadcast for non-agent runs (commands like /status),
// but agent responses also need to be broadcast since block streaming is disabled
// for webchat and there's no other mechanism to notify the UI of completion.
const combinedReply = finalReplyParts
.map((part) => part.trim())
.filter(Boolean)
.join("\n\n")
.trim();
let message: Record<string, unknown> | undefined;
if (combinedReply) {
// Only append to transcript for non-agent runs (commands).
// Agent runs write their own transcripts through the agent runtime layer.
if (!agentRunStarted) {
const { storePath: latestStorePath, entry: latestEntry } = loadSessionEntry(
p.sessionKey,
);
@ -530,23 +536,27 @@ export const chatHandlers: GatewayRequestHandlers = {
context.logGateway.warn(
`webchat transcript append failed: ${appended.error ?? "unknown error"}`,
);
const now = Date.now();
message = {
role: "assistant",
content: [{ type: "text", text: combinedReply }],
timestamp: now,
stopReason: "injected",
usage: { input: 0, output: 0, totalTokens: 0 },
};
}
}
broadcastChatFinal({
context,
runId: clientRunId,
sessionKey: p.sessionKey,
message,
});
// If no transcript message was created (either agent run or append failed),
// create an ephemeral message for the UI broadcast
if (!message) {
const now = Date.now();
message = {
role: "assistant",
content: [{ type: "text", text: combinedReply }],
timestamp: now,
stopReason: agentRunStarted ? "end_turn" : "injected",
usage: { input: 0, output: 0, totalTokens: 0 },
};
}
}
broadcastChatFinal({
context,
runId: clientRunId,
sessionKey: p.sessionKey,
message,
});
context.dedupe.set(`chat:${clientRunId}`, {
ts: Date.now(),
ok: true,