From aa4418e49680ee7f07d6b200c35a7805698403a5 Mon Sep 17 00:00:00 2001 From: David Garson Date: Wed, 28 Jan 2026 13:32:45 -0700 Subject: [PATCH] finishing touches on agentic loop integration w/extra logging - will remove later --- package.json | 1 + pnpm-lock.yaml | 109 ++- src/agents/agent-runtime.ts | 108 ++- src/agents/claude-agent-sdk/extract.test.ts | 348 ++++++++-- src/agents/claude-agent-sdk/extract.ts | 156 ++++- src/agents/claude-agent-sdk/index.ts | 2 +- .../claude-agent-sdk/provider-config.ts | 219 +++++- .../claude-agent-sdk/sdk-agent-runtime.ts | 100 ++- src/agents/claude-agent-sdk/sdk-loader.ts | 34 +- .../claude-agent-sdk/sdk-runner.test.ts | 220 ++++++ src/agents/claude-agent-sdk/sdk-runner.ts | 639 ++++++++++++++++-- .../sdk-session-transcript.ts | 8 +- src/agents/claude-agent-sdk/system-prompt.ts | 41 ++ src/agents/claude-agent-sdk/types.ts | 8 + src/agents/cli-credentials.ts | 126 +++- src/agents/main-agent-runtime-factory.ts | 6 +- src/agents/pi-agent-runtime.ts | 61 +- src/agents/unified-agent-runner.ts | 413 +++++++++++ .../reply/agent-runner-double-send.test.ts | 151 +++++ ...t-runner-execution.callback-wiring.test.ts | 292 ++++++++ .../reply/agent-runner-execution.ts | 568 ++++++++-------- src/auto-reply/reply/agent-runner-memory.ts | 124 ++-- src/auto-reply/reply/agent-runner-payloads.ts | 5 +- ...gent-runner.authprofileid-fallback.test.ts | 42 +- .../reply/agent-runner.reasoning-tags.test.ts | 77 ++- src/auto-reply/reply/block-reply-pipeline.ts | 20 +- src/auto-reply/reply/followup-runner.ts | 121 ++-- src/auto-reply/reply/payload-normalization.ts | 101 +++ src/commands/agent.ts | 175 ++--- src/cron/isolated-agent/run.ts | 105 +-- src/gateway/server-methods/chat.ts | 54 +- 31 files changed, 3578 insertions(+), 856 deletions(-) create mode 100644 src/agents/unified-agent-runner.ts create mode 100644 src/auto-reply/reply/agent-runner-double-send.test.ts create mode 100644 src/auto-reply/reply/agent-runner-execution.callback-wiring.test.ts create mode 100644 src/auto-reply/reply/payload-normalization.ts diff --git a/package.json b/package.json index 60102793d..c8f0d79f3 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9674b66b1..036676e49 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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: diff --git a/src/agents/agent-runtime.ts b/src/agents/agent-runtime.ts index a68b2ed45..3cc412706 100644 --- a/src/agents/agent-runtime.ts +++ b/src/agents/agent-runtime.ts @@ -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 }) => 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; + /** 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; + /** 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; /** diff --git a/src/agents/claude-agent-sdk/extract.test.ts b/src/agents/claude-agent-sdk/extract.test.ts index 3e85bbac0..b305deb90 100644 --- a/src/agents/claude-agent-sdk/extract.test.ts +++ b/src/agents/claude-agent-sdk/extract.test.ts @@ -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(); + }); }); }); diff --git a/src/agents/claude-agent-sdk/extract.ts b/src/agents/claude-agent-sdk/extract.ts index 38cad41fe..0c2a46d83 100644 --- a/src/agents/claude-agent-sdk/extract.ts +++ b/src/agents/claude-agent-sdk/extract.ts @@ -8,6 +8,21 @@ function isRecord(value: unknown): value is Record { 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 | 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 | 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 | undefined; + if (isRecord(message)) { + const msgThinking = extractThinkingFromValue(message); + if (msgThinking) result.thinking = msgThinking; + } + } + if (!result.thinking) { + const data = event.data as Record | 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; +} diff --git a/src/agents/claude-agent-sdk/index.ts b/src/agents/claude-agent-sdk/index.ts index 7ee5530ba..bc55044a6 100644 --- a/src/agents/claude-agent-sdk/index.ts +++ b/src/agents/claude-agent-sdk/index.ts @@ -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, diff --git a/src/agents/claude-agent-sdk/provider-config.ts b/src/agents/claude-agent-sdk/provider-config.ts index 33fb39aa1..2f382e65b 100644 --- a/src/agents/claude-agent-sdk/provider-config.ts +++ b/src/agents/claude-agent-sdk/provider-config.ts @@ -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 = {}; + + 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; } diff --git a/src/agents/claude-agent-sdk/sdk-agent-runtime.ts b/src/agents/claude-agent-sdk/sdk-agent-runtime.ts index c86fcf1ed..ed635d9ae 100644 --- a/src/agents/claude-agent-sdk/sdk-agent-runtime.ts +++ b/src/agents/claude-agent-sdk/sdk-agent-runtime.ts @@ -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, diff --git a/src/agents/claude-agent-sdk/sdk-loader.ts b/src/agents/claude-agent-sdk/sdk-loader.ts index 633b7334a..ab59d49c8 100644 --- a/src/agents/claude-agent-sdk/sdk-loader.ts +++ b/src/agents/claude-agent-sdk/sdk-loader.ts @@ -61,28 +61,58 @@ export function isSdkAvailable(): boolean { */ export async function loadClaudeAgentSdk(): Promise { 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; } } diff --git a/src/agents/claude-agent-sdk/sdk-runner.test.ts b/src/agents/claude-agent-sdk/sdk-runner.test.ts index 202116819..309cff052 100644 --- a/src/agents/claude-agent-sdk/sdk-runner.test.ts +++ b/src/agents/claude-agent-sdk/sdk-runner.test.ts @@ -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); + }); + }); }); diff --git a/src/agents/claude-agent-sdk/sdk-runner.ts b/src/agents/claude-agent-sdk/sdk-runner.ts index 0eef2bbe0..87dae2872 100644 --- a/src/agents/claude-agent-sdk/sdk-runner.ts +++ b/src/agents/claude-agent-sdk/sdk-runner.ts @@ -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, 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 = {}; + 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).content_block as + | Record + | 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; + /** * 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 { + 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) => { try { void Promise.resolve(params.onAgentEvent?.({ stream, data })).catch(() => { @@ -513,7 +818,11 @@ export async function runSdkAgent(params: SdkRunnerParams): Promise = {}; + // 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 = {}; - // 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 0, + ); const prompt = buildSdkPrompt({ prompt: effectivePrompt, systemPrompt: combinedSystemPrompt, + useMcpServers: hasMcpServers, }); // ------------------------------------------------------------------------- @@ -628,13 +986,27 @@ export async function runSdkAgent(params: SdkRunnerParams): Promise options: sdkOptions as Record, }), ); + 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) : 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= DEFAULT_MAX_EXTRACTED_CHARS) { truncated = true; @@ -851,6 +1307,16 @@ export async function runSdkAgent(params: SdkRunnerParams): Promise; }; @@ -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, }); } diff --git a/src/agents/claude-agent-sdk/types.ts b/src/agents/claude-agent-sdk/types.ts index 11d178ce2..14e7164a4 100644 --- a/src/agents/claude-agent-sdk/types.ts +++ b/src/agents/claude-agent-sdk/types.ts @@ -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. */ diff --git a/src/agents/cli-credentials.ts b/src/agents/cli-credentials.ts index 5ca5629ff..a0f2fb2c5 100644 --- a/src/agents/cli-credentials.ts +++ b/src/agents/cli-credentials.ts @@ -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; const claudeOauth = data.claudeAiOauth as Record | 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 { diff --git a/src/agents/main-agent-runtime-factory.ts b/src/agents/main-agent-runtime-factory.ts index 8046a68c1..7600a6f57 100644 --- a/src/agents/main-agent-runtime-factory.ts +++ b/src/agents/main-agent-runtime-factory.ts @@ -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 { - 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 diff --git a/src/agents/pi-agent-runtime.ts b/src/agents/pi-agent-runtime.ts index 6a7490975..b0acecca0 100644 --- a/src/agents/pi-agent-runtime.ts +++ b/src/agents/pi-agent-runtime.ts @@ -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 { - 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, }); }, }; diff --git a/src/agents/unified-agent-runner.ts b/src/agents/unified-agent-runner.ts new file mode 100644 index 000000000..9f8bc37aa --- /dev/null +++ b/src/agents/unified-agent-runner.ts @@ -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; + /** Error callback for each failed attempt. */ + onError?: (attempt: { + runtime: AgentRuntimeKind; + provider: string; + model: string; + error: unknown; + attempt: number; + total: number; + }) => void | Promise; +}; + +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 | null { + const rawAllowlist = (() => { + const modelMap = cfg?.agents?.defaults?.models ?? {}; + return Object.keys(modelMap); + })(); + if (rawAllowlist.length === 0) return null; + const keys = new Set(); + 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(); + 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 { + 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 }, + ); +} diff --git a/src/auto-reply/reply/agent-runner-double-send.test.ts b/src/auto-reply/reply/agent-runner-double-send.test.ts new file mode 100644 index 000000000..022d3356b --- /dev/null +++ b/src/auto-reply/reply/agent-runner-double-send.test.ts @@ -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(); + + // 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(); + + // 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(); + + // 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(); + + // 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)); + }); + }); +}); diff --git a/src/auto-reply/reply/agent-runner-execution.callback-wiring.test.ts b/src/auto-reply/reply/agent-runner-execution.callback-wiring.test.ts new file mode 100644 index 000000000..486e82e87 --- /dev/null +++ b/src/auto-reply/reply/agent-runner-execution.callback-wiring.test.ts @@ -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; + }) => ({ + 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("./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(); + }); + }); +}); diff --git a/src/auto-reply/reply/agent-runner-execution.ts b/src/auto-reply/reply/agent-runner-execution.ts index f86ecb8a9..2acb065a2 100644 --- a/src/auto-reply/reply/agent-runner-execution.ts +++ b/src/auto-reply/reply/agent-runner-execution.ts @@ -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]; }); diff --git a/src/auto-reply/reply/agent-runner-memory.ts b/src/auto-reply/reply/agent-runner-memory.ts index cbecefccf..fc8f1947a 100644 --- a/src/auto-reply/reply/agent-runner-memory.ts +++ b/src/auto-reply/reply/agent-runner-memory.ts @@ -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 = diff --git a/src/auto-reply/reply/agent-runner-payloads.ts b/src/auto-reply/reply/agent-runner-payloads.ts index 60d6fa763..38d67e140 100644 --- a/src/auto-reply/reply/agent-runner-payloads.ts +++ b/src/auto-reply/reply/agent-runner-payloads.ts @@ -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; diff --git a/src/auto-reply/reply/agent-runner.authprofileid-fallback.test.ts b/src/auto-reply/reply/agent-runner.authprofileid-fallback.test.ts index 419da8d4d..c3a1915bb 100644 --- a/src/auto-reply/reply/agent-runner.authprofileid-fallback.test.ts +++ b/src/auto-reply/reply/agent-runner.authprofileid-fallback.test.ts @@ -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; - }) => ({ - // 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 () => { diff --git a/src/auto-reply/reply/agent-runner.reasoning-tags.test.ts b/src/auto-reply/reply/agent-runner.reasoning-tags.test.ts index 2859439f9..db81baf51 100644 --- a/src/auto-reply/reply/agent-runner.reasoning-tags.test.ts +++ b/src/auto-reply/reply/agent-runner.reasoning-tags.test.ts @@ -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; - }) => 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 when the fallback provider requires reasoning tags", async () => { - runEmbeddedPiAgentMock.mockResolvedValueOnce({ - payloads: [{ text: "ok" }], - meta: {}, - }); - runWithModelFallbackMock.mockImplementationOnce( - async ({ run }: { run: (provider: string, model: string) => Promise }) => ({ - 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 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 }) => ({ - 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({ diff --git a/src/auto-reply/reply/block-reply-pipeline.ts b/src/auto-reply/reply/block-reply-pipeline.ts index 00e08acba..d8fc26946 100644 --- a/src/auto-reply/reply/block-reply-pipeline.ts +++ b/src/auto-reply/reply/block-reply-pipeline.ts @@ -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 ( diff --git a/src/auto-reply/reply/followup-runner.ts b/src/auto-reply/reply/followup-runner.ts index 77edf66e5..490bf332f 100644 --- a/src/auto-reply/reply/followup-runner.ts +++ b/src/auto-reply/reply/followup-runner.ts @@ -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>; - 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; diff --git a/src/auto-reply/reply/payload-normalization.ts b/src/auto-reply/reply/payload-normalization.ts new file mode 100644 index 000000000..eeb3635dd --- /dev/null +++ b/src/auto-reply/reply/payload-normalization.ts @@ -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); +} diff --git a/src/commands/agent.ts b/src/commands/agent.ts index 0e745557c..dafb0d8af 100644 --- a/src/commands/agent.ts +++ b/src/commands/agent.ts @@ -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, diff --git a/src/cron/isolated-agent/run.ts b/src/cron/isolated-agent/run.ts index b299fe9a2..5bf2a3954 100644 --- a/src/cron/isolated-agent/run.ts +++ b/src/cron/isolated-agent/run.ts @@ -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) }; } diff --git a/src/gateway/server-methods/chat.ts b/src/gateway/server-methods/chat.ts index 9010a6f21..a39906fee 100644 --- a/src/gateway/server-methods/chat.ts +++ b/src/gateway/server-methods/chat.ts @@ -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 | 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 | 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,