finishing touches on agentic loop integration w/extra logging - will remove later
This commit is contained in:
parent
541650185e
commit
aa4418e496
@ -168,6 +168,7 @@
|
||||
"@mariozechner/pi-ai": "0.49.3",
|
||||
"@mariozechner/pi-coding-agent": "0.49.3",
|
||||
"@mariozechner/pi-tui": "0.49.3",
|
||||
"@modelcontextprotocol/sdk": "^1.25.3",
|
||||
"@mozilla/readability": "^0.6.0",
|
||||
"@sinclair/typebox": "0.34.47",
|
||||
"@slack/bolt": "^4.6.0",
|
||||
|
||||
109
pnpm-lock.yaml
generated
109
pnpm-lock.yaml
generated
@ -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:
|
||||
|
||||
@ -6,8 +6,12 @@
|
||||
|
||||
import type { ImageContent } from "@mariozechner/pi-ai";
|
||||
import type { MoltbotConfig } from "../config/config.js";
|
||||
import type { CcSdkModelTiers } from "../config/types.agents.js";
|
||||
import type { AgentStreamParams } from "../commands/agent/types.js";
|
||||
import type { ThinkLevel, VerboseLevel } from "../auto-reply/thinking.js";
|
||||
import type { ReasoningLevel, ThinkLevel, VerboseLevel } from "../auto-reply/thinking.js";
|
||||
import type { ExecElevatedDefaults, ExecToolDefaults } from "./bash-tools.js";
|
||||
import type { BlockReplyChunking, ToolResultFormat } from "./pi-embedded-subscribe.js";
|
||||
import type { ClientToolDefinition } from "./pi-embedded-runner/run/params.js";
|
||||
import type { SkillSnapshot } from "./skills.js";
|
||||
import type { EmbeddedPiRunResult } from "./pi-embedded-runner/types.js";
|
||||
|
||||
@ -42,8 +46,59 @@ export type AgentRuntimeCallbacks = {
|
||||
onAgentEvent?: (evt: { stream: string; data: Record<string, unknown> }) => void;
|
||||
};
|
||||
|
||||
/** Common parameters for agent run invocations. */
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Runtime-specific option bags
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Pi runtime-specific options.
|
||||
*
|
||||
* These fields are only applicable to the Pi Agent runtime and are not
|
||||
* generalizable to other runtimes.
|
||||
*/
|
||||
export type PiRuntimeOptions = {
|
||||
/** Whether to enforce final XML tag in responses. */
|
||||
enforceFinalTag?: boolean;
|
||||
/** Execution tool overrides (host, security, ask, node). */
|
||||
execOverrides?: Pick<ExecToolDefaults, "host" | "security" | "ask" | "node">;
|
||||
/** Bash elevated execution defaults. */
|
||||
bashElevated?: ExecElevatedDefaults;
|
||||
/** Client-provided tools (OpenResponses hosted tools). */
|
||||
clientTools?: ClientToolDefinition[];
|
||||
};
|
||||
|
||||
/**
|
||||
* CCSDK runtime-specific options.
|
||||
*
|
||||
* These fields are only applicable to the Claude Code SDK runtime.
|
||||
*/
|
||||
export type CcSdkRuntimeOptions = {
|
||||
/** Enable Claude Code lifecycle hooks (pre-tool, post-tool, etc.). */
|
||||
hooksEnabled?: boolean;
|
||||
/** Additional SDK options passed to the runner. */
|
||||
sdkOptions?: Record<string, unknown>;
|
||||
/** 3-tier model configuration for Claude Code SDK. */
|
||||
modelTiers?: CcSdkModelTiers;
|
||||
/** Existing Claude Code session ID for resumption. */
|
||||
claudeSessionId?: string;
|
||||
/** Fork the session instead of continuing it. */
|
||||
forkSession?: boolean;
|
||||
};
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Common parameters
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Common parameters for agent run invocations.
|
||||
*
|
||||
* This type contains:
|
||||
* 1. Core fields required by all runtimes
|
||||
* 2. Generalized fields that both runtimes can use
|
||||
* 3. Runtime-specific option bags for non-generalizable features
|
||||
*/
|
||||
export type AgentRuntimeRunParams = {
|
||||
// ─── Core fields (required by all runtimes) ─────────────────────────────────
|
||||
sessionId: string;
|
||||
sessionKey?: string;
|
||||
sessionFile: string;
|
||||
@ -65,19 +120,66 @@ export type AgentRuntimeRunParams = {
|
||||
abortSignal?: AbortSignal;
|
||||
extraSystemPrompt?: string;
|
||||
streamParams?: AgentStreamParams;
|
||||
/** Messaging context for delivery routing. */
|
||||
|
||||
// ─── Messaging context (used by both runtimes for routing/context) ──────────
|
||||
/** Messaging channel identifier (e.g., "telegram", "discord"). */
|
||||
messageChannel?: string;
|
||||
/** Message provider identifier (e.g., "telegram", "discord"). */
|
||||
messageProvider?: string;
|
||||
/** Agent account identifier for multi-account routing. */
|
||||
agentAccountId?: string;
|
||||
/** Delivery target (e.g. telegram:group:123:topic:456). */
|
||||
messageTo?: string;
|
||||
/** Thread/topic identifier for routing replies to the originating thread. */
|
||||
messageThreadId?: string | number;
|
||||
/** Group id for channel-level tool policy resolution. */
|
||||
groupId?: string | null;
|
||||
/** Group channel label (e.g. #general). */
|
||||
groupChannel?: string | null;
|
||||
/** Group space label (e.g. guild/team id). */
|
||||
groupSpace?: string | null;
|
||||
/** Parent session key for subagent policy inheritance. */
|
||||
spawnedBy?: string | null;
|
||||
/** Current channel ID for auto-threading (Slack). */
|
||||
currentChannelId?: string;
|
||||
/** Current thread timestamp for auto-threading (Slack). */
|
||||
currentThreadTs?: string;
|
||||
/** Reply-to mode for Slack auto-threading. */
|
||||
replyToMode?: "off" | "first" | "all";
|
||||
/** Mutable ref to track if a reply was sent (for "first" mode). */
|
||||
hasRepliedRef?: { value: boolean };
|
||||
|
||||
// ─── Sender context (both runtimes can use for system prompt enrichment) ────
|
||||
/** Sender identifier. */
|
||||
senderId?: string | null;
|
||||
/** Sender display name. */
|
||||
senderName?: string | null;
|
||||
/** Sender username. */
|
||||
senderUsername?: string | null;
|
||||
/** Sender E.164 phone number. */
|
||||
senderE164?: string | null;
|
||||
|
||||
// ─── Generalized fields (applicable to both runtimes) ───────────────────────
|
||||
/** Reasoning level for streaming reasoning events. */
|
||||
reasoningLevel?: ReasoningLevel;
|
||||
/** Tool result format (markdown or plain). */
|
||||
toolResultFormat?: ToolResultFormat;
|
||||
/** Block reply break mode (text_end or message_end). */
|
||||
blockReplyBreak?: "text_end" | "message_end";
|
||||
/** Block reply chunking settings. */
|
||||
blockReplyChunking?: BlockReplyChunking;
|
||||
/** Callback to determine if tool results should be emitted. */
|
||||
shouldEmitToolResult?: () => boolean;
|
||||
/** Callback to determine if tool output should be emitted. */
|
||||
shouldEmitToolOutput?: () => boolean;
|
||||
/** Owner phone numbers for access control. */
|
||||
ownerNumbers?: string[];
|
||||
|
||||
// ─── Runtime-specific option bags ───────────────────────────────────────────
|
||||
/** Pi runtime-specific options. */
|
||||
piOptions?: PiRuntimeOptions;
|
||||
/** CCSDK runtime-specific options. */
|
||||
ccsdkOptions?: CcSdkRuntimeOptions;
|
||||
} & AgentRuntimeCallbacks;
|
||||
|
||||
/**
|
||||
|
||||
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@ -8,6 +8,21 @@ function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return !!value && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of extracting content from a Claude Agent SDK event.
|
||||
* Both fields may be present if the event contains both text and thinking content.
|
||||
*/
|
||||
export type SdkEventExtraction = {
|
||||
/** Assistant text content (visible to user). */
|
||||
text?: string;
|
||||
/** Thinking/reasoning content (internal reasoning, may be shown as breadcrumbs). */
|
||||
thinking?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Extract text content from a value, recursively checking common structures.
|
||||
* Does NOT extract thinking content - that's handled separately.
|
||||
*/
|
||||
function extractTextFromContent(value: unknown): string | undefined {
|
||||
if (typeof value === "string") return value;
|
||||
|
||||
@ -20,6 +35,8 @@ function extractTextFromContent(value: unknown): string | undefined {
|
||||
continue;
|
||||
}
|
||||
if (!isRecord(entry)) continue;
|
||||
// Only extract from text-type blocks, not thinking blocks
|
||||
if (entry.type === "thinking") continue;
|
||||
const text = entry.text;
|
||||
if (typeof text === "string" && text.trim()) parts.push(text);
|
||||
}
|
||||
@ -51,35 +68,126 @@ function extractTextFromContent(value: unknown): string | undefined {
|
||||
}
|
||||
|
||||
/**
|
||||
* Best-effort extraction of human-readable text from Claude Agent SDK events.
|
||||
* We keep this defensive because the SDK event shapes may evolve.
|
||||
* Extract thinking content from a value, checking common SDK structures.
|
||||
*/
|
||||
export function extractTextFromClaudeAgentSdkEvent(event: unknown): string | undefined {
|
||||
if (typeof event === "string") return event;
|
||||
if (!isRecord(event)) return undefined;
|
||||
function extractThinkingFromValue(value: unknown): string | undefined {
|
||||
if (!isRecord(value)) return undefined;
|
||||
|
||||
// Direct text-ish fields.
|
||||
const direct = extractTextFromContent(event);
|
||||
if (direct) return direct;
|
||||
// Direct thinking field
|
||||
const thinking = value.thinking;
|
||||
if (typeof thinking === "string" && thinking.trim()) return thinking;
|
||||
|
||||
// Common wrapper shapes: {message:{...}}, {data:{...}}.
|
||||
const message = event.message;
|
||||
const fromMessage = extractTextFromContent(message);
|
||||
if (fromMessage) return fromMessage;
|
||||
|
||||
const data = event.data;
|
||||
const fromData = extractTextFromContent(data);
|
||||
if (fromData) return fromData;
|
||||
|
||||
// Nested message/data objects with content.
|
||||
if (isRecord(message)) {
|
||||
const fromMessageContent = extractTextFromContent(message.content);
|
||||
if (fromMessageContent) return fromMessageContent;
|
||||
// content_block with type="thinking"
|
||||
const contentBlock = value.content_block as Record<string, unknown> | undefined;
|
||||
if (isRecord(contentBlock) && contentBlock.type === "thinking") {
|
||||
const blockThinking = contentBlock.thinking;
|
||||
if (typeof blockThinking === "string" && blockThinking.trim()) return blockThinking;
|
||||
}
|
||||
if (isRecord(data)) {
|
||||
const fromDataContent = extractTextFromContent(data.content);
|
||||
if (fromDataContent) return fromDataContent;
|
||||
|
||||
// delta with type="thinking_delta"
|
||||
const delta = value.delta as Record<string, unknown> | undefined;
|
||||
if (isRecord(delta)) {
|
||||
if (delta.type === "thinking_delta") {
|
||||
const deltaThinking = delta.thinking;
|
||||
if (typeof deltaThinking === "string" && deltaThinking.trim()) return deltaThinking;
|
||||
}
|
||||
// Also check direct thinking on delta (without type check)
|
||||
const directDeltaThinking = delta.thinking;
|
||||
if (typeof directDeltaThinking === "string" && directDeltaThinking.trim())
|
||||
return directDeltaThinking;
|
||||
}
|
||||
|
||||
// Check content array for thinking blocks
|
||||
const content = value.content;
|
||||
if (Array.isArray(content)) {
|
||||
for (const entry of content) {
|
||||
if (!isRecord(entry)) continue;
|
||||
if (entry.type === "thinking") {
|
||||
const entryThinking = entry.thinking;
|
||||
if (typeof entryThinking === "string" && entryThinking.trim()) return entryThinking;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Unified extraction of both text and thinking content from a Claude Agent SDK event.
|
||||
*
|
||||
* This is the preferred extraction function - it returns both text and thinking
|
||||
* content if present, allowing callers to handle both in a single call.
|
||||
*
|
||||
* @param event - The SDK event to extract content from
|
||||
* @returns Object with optional `text` and `thinking` fields
|
||||
*/
|
||||
export function extractFromClaudeAgentSdkEvent(event: unknown): SdkEventExtraction {
|
||||
const result: SdkEventExtraction = {};
|
||||
|
||||
if (typeof event === "string") {
|
||||
result.text = event;
|
||||
return result;
|
||||
}
|
||||
|
||||
if (!isRecord(event)) return result;
|
||||
|
||||
// Extract thinking from the event
|
||||
const thinking = extractThinkingFromValue(event);
|
||||
if (thinking) result.thinking = thinking;
|
||||
|
||||
// Also check nested message/data for thinking
|
||||
if (!result.thinking) {
|
||||
const message = event.message as Record<string, unknown> | undefined;
|
||||
if (isRecord(message)) {
|
||||
const msgThinking = extractThinkingFromValue(message);
|
||||
if (msgThinking) result.thinking = msgThinking;
|
||||
}
|
||||
}
|
||||
if (!result.thinking) {
|
||||
const data = event.data as Record<string, unknown> | undefined;
|
||||
if (isRecord(data)) {
|
||||
const dataThinking = extractThinkingFromValue(data);
|
||||
if (dataThinking) result.thinking = dataThinking;
|
||||
}
|
||||
}
|
||||
|
||||
// Extract text from the event (separate from thinking)
|
||||
const direct = extractTextFromContent(event);
|
||||
if (direct) {
|
||||
result.text = direct;
|
||||
return result;
|
||||
}
|
||||
|
||||
// Common wrapper shapes: {message:{...}}, {data:{...}}.
|
||||
const message = event.message;
|
||||
const fromMessage = extractTextFromContent(message);
|
||||
if (fromMessage) {
|
||||
result.text = fromMessage;
|
||||
return result;
|
||||
}
|
||||
|
||||
const data = event.data;
|
||||
const fromData = extractTextFromContent(data);
|
||||
if (fromData) {
|
||||
result.text = fromData;
|
||||
return result;
|
||||
}
|
||||
|
||||
// Nested message/data objects with content.
|
||||
if (isRecord(message)) {
|
||||
const fromMessageContent = extractTextFromContent(message.content);
|
||||
if (fromMessageContent) {
|
||||
result.text = fromMessageContent;
|
||||
return result;
|
||||
}
|
||||
}
|
||||
if (isRecord(data)) {
|
||||
const fromDataContent = extractTextFromContent(data.content);
|
||||
if (fromDataContent) {
|
||||
result.text = fromDataContent;
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -16,6 +16,44 @@ import { createSubsystemLogger } from "../../logging/subsystem.js";
|
||||
|
||||
const log = createSubsystemLogger("agents/claude-agent-sdk");
|
||||
|
||||
/**
|
||||
* Mask a token for logging - shows length and first/last 4 chars.
|
||||
*/
|
||||
function maskToken(token: string | undefined): string {
|
||||
if (!token) return "(empty)";
|
||||
if (token.length <= 12) return `${"*".repeat(token.length)} (length: ${token.length})`;
|
||||
const first = token.slice(0, 4);
|
||||
const last = token.slice(-4);
|
||||
return `${first}...${"*".repeat(Math.min(8, token.length - 8))}...${last} (length: ${token.length})`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Log the resolved provider config with masked credentials.
|
||||
*/
|
||||
function logProviderConfig(config: SdkProviderConfig, source: string): void {
|
||||
const envKeys = config.env ? Object.keys(config.env) : [];
|
||||
const maskedEnv: Record<string, string> = {};
|
||||
|
||||
if (config.env) {
|
||||
for (const [key, value] of Object.entries(config.env)) {
|
||||
if (key.includes("KEY") || key.includes("TOKEN") || key.includes("SECRET")) {
|
||||
maskedEnv[key] = maskToken(value);
|
||||
} else {
|
||||
maskedEnv[key] = value ?? "(undefined)";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
log.debug("[CCSDK-PROVIDER] Provider config resolved", {
|
||||
source,
|
||||
providerName: config.name,
|
||||
envKeys,
|
||||
maskedEnv,
|
||||
model: config.model,
|
||||
maxTurns: config.maxTurns,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Build provider config for direct Anthropic API access.
|
||||
*/
|
||||
@ -38,6 +76,8 @@ export function buildAnthropicSdkProvider(apiKey: string): SdkProviderConfig {
|
||||
* environment or via the CLI's native auth mechanism.
|
||||
*/
|
||||
export function buildClaudeCliSdkProvider(): SdkProviderConfig | null {
|
||||
log.debug("[CCSDK-PROVIDER] buildClaudeCliSdkProvider called");
|
||||
|
||||
// Read cached credentials from Claude Code CLI
|
||||
const credentials = readClaudeCliCredentialsCached({
|
||||
allowKeychainPrompt: false,
|
||||
@ -45,38 +85,54 @@ export function buildClaudeCliSdkProvider(): SdkProviderConfig | null {
|
||||
});
|
||||
|
||||
if (!credentials) {
|
||||
log.debug("No Claude CLI credentials found");
|
||||
log.debug("[CCSDK-PROVIDER] No Claude CLI credentials found from cache");
|
||||
return null;
|
||||
}
|
||||
|
||||
log.debug("[CCSDK-PROVIDER] Claude CLI credentials loaded", {
|
||||
type: credentials.type,
|
||||
provider: credentials.provider,
|
||||
expiresAt: new Date(credentials.expires).toISOString(),
|
||||
isExpired: credentials.expires < Date.now(),
|
||||
msUntilExpiry: credentials.expires - Date.now(),
|
||||
});
|
||||
|
||||
// For OAuth credentials, use the access token as ANTHROPIC_AUTH_TOKEN
|
||||
if (credentials.type === "oauth") {
|
||||
// Check if token is expired
|
||||
if (credentials.expires < Date.now()) {
|
||||
log.warn("Claude CLI credentials expired", {
|
||||
log.warn("[CCSDK-PROVIDER] Claude CLI credentials EXPIRED", {
|
||||
expiresAt: new Date(credentials.expires).toISOString(),
|
||||
expiredAgo: Date.now() - credentials.expires,
|
||||
});
|
||||
// Still return the config - the SDK may handle refresh internally
|
||||
}
|
||||
|
||||
return {
|
||||
const config: SdkProviderConfig = {
|
||||
name: "Claude CLI (subscription)",
|
||||
env: {
|
||||
ANTHROPIC_AUTH_TOKEN: credentials.access,
|
||||
},
|
||||
};
|
||||
logProviderConfig(config, "claude-cli-oauth");
|
||||
return config;
|
||||
}
|
||||
|
||||
// For token-type credentials (less common)
|
||||
if (credentials.type === "token") {
|
||||
return {
|
||||
const config: SdkProviderConfig = {
|
||||
name: "Claude CLI (token)",
|
||||
env: {
|
||||
ANTHROPIC_AUTH_TOKEN: credentials.token,
|
||||
},
|
||||
};
|
||||
logProviderConfig(config, "claude-cli-token");
|
||||
return config;
|
||||
}
|
||||
|
||||
log.debug("[CCSDK-PROVIDER] Unknown credential type", {
|
||||
type: (credentials as { type: string }).type,
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
@ -85,28 +141,107 @@ export function buildClaudeCliSdkProvider(): SdkProviderConfig | null {
|
||||
*
|
||||
* z.AI uses Anthropic-compatible API with a different base URL.
|
||||
*/
|
||||
export function buildZaiSdkProvider(authToken: string): SdkProviderConfig {
|
||||
return {
|
||||
name: "z.AI",
|
||||
env: {
|
||||
ANTHROPIC_AUTH_TOKEN: authToken,
|
||||
// z.AI may use a custom base URL - leave unset to use default
|
||||
// The SDK or z.AI gateway handles routing
|
||||
},
|
||||
export function buildZaiSdkProvider(
|
||||
authToken: string,
|
||||
options?: {
|
||||
baseUrl?: string;
|
||||
defaultModel?: string;
|
||||
haikuModel?: string;
|
||||
sonnetModel?: string;
|
||||
opusModel?: string;
|
||||
},
|
||||
): SdkProviderConfig {
|
||||
const env: SdkProviderEnv = {
|
||||
ANTHROPIC_AUTH_TOKEN: authToken,
|
||||
};
|
||||
|
||||
// Set base URL if provided (z.AI may use a custom endpoint)
|
||||
if (options?.baseUrl) {
|
||||
env.ANTHROPIC_BASE_URL = options.baseUrl;
|
||||
}
|
||||
|
||||
// Set model tier defaults for z.AI
|
||||
if (options?.haikuModel) {
|
||||
env.ANTHROPIC_DEFAULT_HAIKU_MODEL = options.haikuModel;
|
||||
}
|
||||
if (options?.sonnetModel) {
|
||||
env.ANTHROPIC_DEFAULT_SONNET_MODEL = options.sonnetModel;
|
||||
}
|
||||
if (options?.opusModel) {
|
||||
env.ANTHROPIC_DEFAULT_OPUS_MODEL = options.opusModel;
|
||||
}
|
||||
|
||||
const config: SdkProviderConfig = {
|
||||
name: "z.AI",
|
||||
env,
|
||||
model: options?.defaultModel,
|
||||
};
|
||||
|
||||
log.debug("[CCSDK-PROVIDER] Built z.AI provider config", {
|
||||
hasBaseUrl: Boolean(options?.baseUrl),
|
||||
baseUrl: options?.baseUrl ?? "(default)",
|
||||
defaultModel: options?.defaultModel ?? "(sdk default)",
|
||||
haikuModel: options?.haikuModel ?? "(sdk default)",
|
||||
sonnetModel: options?.sonnetModel ?? "(sdk default)",
|
||||
opusModel: options?.opusModel ?? "(sdk default)",
|
||||
authTokenMasked: maskToken(authToken),
|
||||
});
|
||||
|
||||
return config;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build provider config for OpenRouter (Anthropic-compatible).
|
||||
*
|
||||
* OpenRouter requires explicit model names since their model IDs differ
|
||||
* from Anthropic's native IDs.
|
||||
*/
|
||||
export function buildOpenRouterSdkProvider(apiKey: string): SdkProviderConfig {
|
||||
return {
|
||||
name: "OpenRouter (Anthropic-compatible)",
|
||||
env: {
|
||||
ANTHROPIC_BASE_URL: "https://openrouter.ai/api/v1",
|
||||
ANTHROPIC_API_KEY: apiKey,
|
||||
},
|
||||
export function buildOpenRouterSdkProvider(
|
||||
apiKey: string,
|
||||
options?: {
|
||||
baseUrl?: string;
|
||||
defaultModel?: string;
|
||||
haikuModel?: string;
|
||||
sonnetModel?: string;
|
||||
opusModel?: string;
|
||||
},
|
||||
): SdkProviderConfig {
|
||||
const env: SdkProviderEnv = {
|
||||
ANTHROPIC_BASE_URL: options?.baseUrl ?? "https://openrouter.ai/api/v1",
|
||||
ANTHROPIC_API_KEY: apiKey,
|
||||
};
|
||||
|
||||
// OpenRouter uses different model naming - set explicit model IDs
|
||||
// Default OpenRouter model names for Anthropic models:
|
||||
// - anthropic/claude-3-5-haiku-20241022
|
||||
// - anthropic/claude-sonnet-4-20250514
|
||||
// - anthropic/claude-opus-4-20250514
|
||||
if (options?.haikuModel) {
|
||||
env.ANTHROPIC_DEFAULT_HAIKU_MODEL = options.haikuModel;
|
||||
}
|
||||
if (options?.sonnetModel) {
|
||||
env.ANTHROPIC_DEFAULT_SONNET_MODEL = options.sonnetModel;
|
||||
}
|
||||
if (options?.opusModel) {
|
||||
env.ANTHROPIC_DEFAULT_OPUS_MODEL = options.opusModel;
|
||||
}
|
||||
|
||||
const config: SdkProviderConfig = {
|
||||
name: "OpenRouter (Anthropic-compatible)",
|
||||
env,
|
||||
model: options?.defaultModel,
|
||||
};
|
||||
|
||||
log.debug("[CCSDK-PROVIDER] Built OpenRouter provider config", {
|
||||
baseUrl: env.ANTHROPIC_BASE_URL,
|
||||
defaultModel: options?.defaultModel ?? "(sdk default)",
|
||||
haikuModel: options?.haikuModel ?? "(sdk default)",
|
||||
sonnetModel: options?.sonnetModel ?? "(sdk default)",
|
||||
opusModel: options?.opusModel ?? "(sdk default)",
|
||||
apiKeyMasked: maskToken(apiKey),
|
||||
});
|
||||
|
||||
return config;
|
||||
}
|
||||
|
||||
/**
|
||||
@ -153,68 +288,104 @@ export function resolveProviderConfig(options?: {
|
||||
baseUrl?: string;
|
||||
useCliCredentials?: boolean;
|
||||
}): SdkProviderConfig {
|
||||
log.debug("[CCSDK-PROVIDER] resolveProviderConfig called", {
|
||||
hasApiKey: Boolean(options?.apiKey),
|
||||
apiKeyMasked: maskToken(options?.apiKey),
|
||||
hasAuthToken: Boolean(options?.authToken),
|
||||
authTokenMasked: maskToken(options?.authToken),
|
||||
baseUrl: options?.baseUrl ?? "(default)",
|
||||
useCliCredentials: options?.useCliCredentials ?? true,
|
||||
});
|
||||
|
||||
// 1. Explicit API key takes precedence
|
||||
if (options?.apiKey) {
|
||||
log.debug("[CCSDK-PROVIDER] Using explicit API key");
|
||||
const config = buildAnthropicSdkProvider(options.apiKey);
|
||||
if (options.baseUrl && config.env) {
|
||||
config.env.ANTHROPIC_BASE_URL = options.baseUrl;
|
||||
}
|
||||
logProviderConfig(config, "explicit-api-key");
|
||||
return config;
|
||||
}
|
||||
|
||||
// 2. Explicit auth token (OAuth/subscription)
|
||||
if (options?.authToken) {
|
||||
log.debug("[CCSDK-PROVIDER] Using explicit auth token");
|
||||
const env: SdkProviderEnv = {
|
||||
ANTHROPIC_AUTH_TOKEN: options.authToken,
|
||||
};
|
||||
if (options.baseUrl) {
|
||||
env.ANTHROPIC_BASE_URL = options.baseUrl;
|
||||
}
|
||||
return {
|
||||
const config: SdkProviderConfig = {
|
||||
name: "Anthropic (auth token)",
|
||||
env,
|
||||
};
|
||||
logProviderConfig(config, "explicit-auth-token");
|
||||
return config;
|
||||
}
|
||||
|
||||
// 3. Try Claude CLI credentials if enabled (default: true)
|
||||
if (options?.useCliCredentials !== false) {
|
||||
log.debug("[CCSDK-PROVIDER] Attempting Claude CLI credentials");
|
||||
const cliConfig = buildClaudeCliSdkProvider();
|
||||
if (cliConfig) {
|
||||
log.debug("[CCSDK-PROVIDER] Using Claude CLI credentials", {
|
||||
providerName: cliConfig.name,
|
||||
});
|
||||
if (options?.baseUrl && cliConfig.env) {
|
||||
cliConfig.env.ANTHROPIC_BASE_URL = options.baseUrl;
|
||||
}
|
||||
return cliConfig;
|
||||
}
|
||||
log.debug("[CCSDK-PROVIDER] Claude CLI credentials not available");
|
||||
}
|
||||
|
||||
// 4. Check environment variables
|
||||
const envApiKey = process.env.ANTHROPIC_API_KEY;
|
||||
const envAuthToken = process.env.ANTHROPIC_AUTH_TOKEN;
|
||||
|
||||
log.debug("[CCSDK-PROVIDER] Checking environment variables", {
|
||||
hasEnvApiKey: Boolean(envApiKey),
|
||||
envApiKeyMasked: maskToken(envApiKey),
|
||||
hasEnvAuthToken: Boolean(envAuthToken),
|
||||
envAuthTokenMasked: maskToken(envAuthToken),
|
||||
hasEnvBaseUrl: Boolean(process.env.ANTHROPIC_BASE_URL),
|
||||
});
|
||||
|
||||
if (envApiKey) {
|
||||
return {
|
||||
const config: SdkProviderConfig = {
|
||||
name: "Anthropic (env)",
|
||||
env: {
|
||||
ANTHROPIC_API_KEY: envApiKey,
|
||||
ANTHROPIC_BASE_URL: options?.baseUrl ?? process.env.ANTHROPIC_BASE_URL,
|
||||
},
|
||||
};
|
||||
logProviderConfig(config, "env-api-key");
|
||||
return config;
|
||||
}
|
||||
|
||||
if (envAuthToken) {
|
||||
return {
|
||||
const config: SdkProviderConfig = {
|
||||
name: "Anthropic (env auth token)",
|
||||
env: {
|
||||
ANTHROPIC_AUTH_TOKEN: envAuthToken,
|
||||
ANTHROPIC_BASE_URL: options?.baseUrl ?? process.env.ANTHROPIC_BASE_URL,
|
||||
},
|
||||
};
|
||||
logProviderConfig(config, "env-auth-token");
|
||||
return config;
|
||||
}
|
||||
|
||||
// 5. Return empty config - SDK will use its own credential resolution
|
||||
log.debug("No explicit credentials provided, SDK will use native auth");
|
||||
return {
|
||||
log.warn("[CCSDK-PROVIDER] No credentials found - SDK will use native auth (may fail)", {
|
||||
triedCliCredentials: options?.useCliCredentials !== false,
|
||||
triedEnvVars: true,
|
||||
});
|
||||
const config: SdkProviderConfig = {
|
||||
name: "Anthropic (SDK native)",
|
||||
env: {},
|
||||
};
|
||||
logProviderConfig(config, "sdk-native-fallback");
|
||||
return config;
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -61,28 +61,58 @@ export function isSdkAvailable(): boolean {
|
||||
*/
|
||||
export async function loadClaudeAgentSdk(): Promise<ClaudeAgentSdkModule> {
|
||||
if (sdkModule) {
|
||||
log.debug("[CCSDK-LOADER] Returning cached SDK module");
|
||||
return sdkModule;
|
||||
}
|
||||
|
||||
if (loadAttempted && loadError) {
|
||||
log.debug("[CCSDK-LOADER] Previous load attempt failed, re-throwing cached error");
|
||||
throw loadError;
|
||||
}
|
||||
|
||||
loadAttempted = true;
|
||||
log.debug("[CCSDK-LOADER] Attempting to load Claude Agent SDK");
|
||||
|
||||
try {
|
||||
// Dynamic import to avoid bundling issues
|
||||
// The SDK package name is resolved at runtime
|
||||
const moduleName = "@anthropic-ai/claude-agent-sdk";
|
||||
const loadStart = Date.now();
|
||||
sdkModule = (await import(/* @vite-ignore */ moduleName)) as ClaudeAgentSdkModule;
|
||||
log.info("Claude Agent SDK loaded successfully");
|
||||
const loadDuration = Date.now() - loadStart;
|
||||
|
||||
// Log SDK module details for debugging
|
||||
const moduleKeys = Object.keys(sdkModule);
|
||||
const hasQuery = typeof sdkModule.query === "function";
|
||||
|
||||
log.info("[CCSDK-LOADER] Claude Agent SDK loaded successfully", {
|
||||
loadDurationMs: loadDuration,
|
||||
hasQueryFunction: hasQuery,
|
||||
moduleExports: moduleKeys.slice(0, 10),
|
||||
totalExports: moduleKeys.length,
|
||||
});
|
||||
|
||||
if (!hasQuery) {
|
||||
log.error("[CCSDK-LOADER] SDK loaded but query function is missing!", {
|
||||
moduleKeys,
|
||||
queryType: typeof sdkModule.query,
|
||||
});
|
||||
}
|
||||
|
||||
return sdkModule;
|
||||
} catch (err) {
|
||||
const errorMessage = err instanceof Error ? err.message : String(err);
|
||||
const errorStack = err instanceof Error ? err.stack : undefined;
|
||||
|
||||
log.error("[CCSDK-LOADER] Failed to load Claude Agent SDK", {
|
||||
error: errorMessage,
|
||||
stack: errorStack,
|
||||
});
|
||||
|
||||
loadError = new Error(
|
||||
"Claude Agent SDK not installed. Install with: npm install @anthropic-ai/claude-agent-sdk",
|
||||
);
|
||||
loadError.cause = err;
|
||||
log.warn("Failed to load Claude Agent SDK", { error: String(err) });
|
||||
throw loadError;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@ -17,7 +17,7 @@
|
||||
import { createSubsystemLogger } from "../../logging/subsystem.js";
|
||||
import { bridgeMoltbotToolsToMcpServer } from "./tool-bridge.js";
|
||||
import type { SdkRunnerQueryOptions } from "./tool-bridge.types.js";
|
||||
import { extractTextFromClaudeAgentSdkEvent } from "./extract.js";
|
||||
import { extractFromClaudeAgentSdkEvent } from "./extract.js";
|
||||
import { loadClaudeAgentSdk } from "./sdk-loader.js";
|
||||
import { buildHistorySystemPromptSuffix } from "./sdk-history.js";
|
||||
import { isSdkTerminalToolEventType } from "./sdk-event-checks.js";
|
||||
@ -29,6 +29,7 @@ import type {
|
||||
SdkUsageStats,
|
||||
SdkCompletedToolCall,
|
||||
SdkProviderEnv,
|
||||
SdkProviderConfig,
|
||||
} from "./types.js";
|
||||
import type { ThinkLevel } from "../../auto-reply/thinking.js";
|
||||
import type { CcSdkModelTiers } from "../../config/types.agents.js";
|
||||
@ -141,27 +142,256 @@ const DEFAULT_MAX_EXTRACTED_CHARS = 120_000;
|
||||
const DEFAULT_MCP_SERVER_NAME = "moltbot";
|
||||
const DEFAULT_MAX_TURNS = 50;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Debug helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Mask a token for logging - shows length and first/last 4 chars.
|
||||
*/
|
||||
function maskToken(token: string | undefined): string {
|
||||
if (!token) return "(empty)";
|
||||
if (token.length <= 12) return `${"*".repeat(token.length)} (length: ${token.length})`;
|
||||
const first = token.slice(0, 4);
|
||||
const last = token.slice(-4);
|
||||
return `${first}...${"*".repeat(Math.min(8, token.length - 8))}...${last} (length: ${token.length})`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Log authentication-related environment variables (masked).
|
||||
*/
|
||||
function logAuthEnvVars(env: Record<string, string>, prefix: string): void {
|
||||
const authKeys = [
|
||||
"ANTHROPIC_API_KEY",
|
||||
"ANTHROPIC_AUTH_TOKEN",
|
||||
"ANTHROPIC_BASE_URL",
|
||||
"CLAUDE_CODE_USE_BEDROCK",
|
||||
"CLAUDE_CODE_USE_VERTEX",
|
||||
];
|
||||
const authEnv: Record<string, string> = {};
|
||||
for (const key of authKeys) {
|
||||
if (env[key]) {
|
||||
authEnv[key] = key.includes("KEY") || key.includes("TOKEN") ? maskToken(env[key]) : env[key];
|
||||
}
|
||||
}
|
||||
log.debug(`${prefix} Auth environment variables`, {
|
||||
authEnv,
|
||||
totalEnvKeys: Object.keys(env).length,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect potential auth-related error patterns in response text.
|
||||
*/
|
||||
function detectAuthErrorPatterns(text: string): {
|
||||
hasAuthError: boolean;
|
||||
patterns: string[];
|
||||
} {
|
||||
const patterns: string[] = [];
|
||||
const lowerText = text.toLowerCase();
|
||||
|
||||
// Common auth error patterns
|
||||
if (lowerText.includes("unauthorized") || lowerText.includes("401")) {
|
||||
patterns.push("unauthorized/401");
|
||||
}
|
||||
if (lowerText.includes("invalid api key") || lowerText.includes("invalid_api_key")) {
|
||||
patterns.push("invalid_api_key");
|
||||
}
|
||||
if (lowerText.includes("authentication") && lowerText.includes("failed")) {
|
||||
patterns.push("authentication_failed");
|
||||
}
|
||||
if (lowerText.includes("expired") && (lowerText.includes("token") || lowerText.includes("key"))) {
|
||||
patterns.push("expired_credentials");
|
||||
}
|
||||
if (lowerText.includes("permission") && lowerText.includes("denied")) {
|
||||
patterns.push("permission_denied");
|
||||
}
|
||||
if (lowerText.includes("rate limit") || lowerText.includes("ratelimit")) {
|
||||
patterns.push("rate_limit");
|
||||
}
|
||||
if (
|
||||
lowerText.includes("quota") &&
|
||||
(lowerText.includes("exceeded") || lowerText.includes("limit"))
|
||||
) {
|
||||
patterns.push("quota_exceeded");
|
||||
}
|
||||
// Check for suspiciously short responses that might indicate auth issues
|
||||
if (text.length >= 100 && text.length <= 200) {
|
||||
patterns.push("suspicious_short_response");
|
||||
}
|
||||
|
||||
return {
|
||||
hasAuthError: patterns.length > 0,
|
||||
patterns,
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Model tier environment helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Provider types that require different model name formats.
|
||||
*/
|
||||
type SdkProviderType = "anthropic-native" | "openrouter" | "zai" | "bedrock" | "vertex" | "other";
|
||||
|
||||
/**
|
||||
* Detect the provider type from the provider config.
|
||||
*/
|
||||
function detectProviderType(providerConfig?: SdkProviderConfig): SdkProviderType {
|
||||
if (!providerConfig) return "anthropic-native";
|
||||
|
||||
const name = providerConfig.name?.toLowerCase() ?? "";
|
||||
const baseUrl = providerConfig.env?.ANTHROPIC_BASE_URL?.toLowerCase() ?? "";
|
||||
|
||||
// Check for OpenRouter
|
||||
if (name.includes("openrouter") || baseUrl.includes("openrouter")) {
|
||||
return "openrouter";
|
||||
}
|
||||
|
||||
// Check for z.AI
|
||||
if (name.includes("z.ai") || name.includes("zai")) {
|
||||
return "zai";
|
||||
}
|
||||
|
||||
// Check for Bedrock
|
||||
if (name.includes("bedrock") || providerConfig.env?.CLAUDE_CODE_USE_BEDROCK === "1") {
|
||||
return "bedrock";
|
||||
}
|
||||
|
||||
// Check for Vertex
|
||||
if (name.includes("vertex") || providerConfig.env?.CLAUDE_CODE_USE_VERTEX === "1") {
|
||||
return "vertex";
|
||||
}
|
||||
|
||||
// Check for SDK native (no auth token/key provided, SDK handles auth)
|
||||
if (name.includes("sdk native")) {
|
||||
return "anthropic-native";
|
||||
}
|
||||
|
||||
// Check for direct Anthropic (has API key or auth token but no custom base URL)
|
||||
if (
|
||||
(providerConfig.env?.ANTHROPIC_API_KEY || providerConfig.env?.ANTHROPIC_AUTH_TOKEN) &&
|
||||
!baseUrl
|
||||
) {
|
||||
return "anthropic-native";
|
||||
}
|
||||
|
||||
return "other";
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize a model name based on the target provider.
|
||||
*
|
||||
* Different providers expect different model name formats:
|
||||
* - Anthropic native: "claude-opus-4-5" (no prefix)
|
||||
* - OpenRouter: "anthropic/claude-opus-4-5" (with prefix)
|
||||
* - z.AI: varies, may need prefix or not
|
||||
* - Bedrock/Vertex: use their own model ID formats
|
||||
*/
|
||||
function normalizeModelNameForProvider(modelName: string, providerType: SdkProviderType): string {
|
||||
if (!modelName) return modelName;
|
||||
|
||||
const hasAnthropicPrefix = modelName.startsWith("anthropic/");
|
||||
|
||||
switch (providerType) {
|
||||
case "anthropic-native":
|
||||
// Strip "anthropic/" prefix for direct Anthropic API
|
||||
if (hasAnthropicPrefix) {
|
||||
const stripped = modelName.slice("anthropic/".length);
|
||||
log.debug("[CCSDK-MODEL] Stripped anthropic/ prefix for native API", {
|
||||
original: modelName,
|
||||
normalized: stripped,
|
||||
providerType,
|
||||
});
|
||||
return stripped;
|
||||
}
|
||||
return modelName;
|
||||
|
||||
case "openrouter":
|
||||
// OpenRouter expects "anthropic/model-name" format
|
||||
// Keep as-is if already prefixed, add prefix if not
|
||||
if (!hasAnthropicPrefix && modelName.startsWith("claude-")) {
|
||||
const prefixed = `anthropic/${modelName}`;
|
||||
log.debug("[CCSDK-MODEL] Added anthropic/ prefix for OpenRouter", {
|
||||
original: modelName,
|
||||
normalized: prefixed,
|
||||
providerType,
|
||||
});
|
||||
return prefixed;
|
||||
}
|
||||
return modelName;
|
||||
|
||||
case "zai":
|
||||
// z.AI uses completely different model names (e.g., "glm-4.7")
|
||||
// The model name should come from config already in z.AI format
|
||||
// Warn if we see Anthropic-formatted model names - likely a config issue
|
||||
if (hasAnthropicPrefix || modelName.startsWith("claude-")) {
|
||||
log.warn(
|
||||
"[CCSDK-MODEL] z.AI provider detected but model name looks like Anthropic format",
|
||||
{
|
||||
modelName,
|
||||
providerType,
|
||||
hint: "For z.AI, configure model tiers with z.AI model names (e.g., 'glm-4.7'), not Anthropic names",
|
||||
},
|
||||
);
|
||||
}
|
||||
// Pass through unchanged
|
||||
return modelName;
|
||||
|
||||
case "bedrock":
|
||||
case "vertex":
|
||||
// Bedrock/Vertex have their own model ID formats
|
||||
// For now, pass through - may need specific handling later
|
||||
log.debug("[CCSDK-MODEL] Passing model name through for cloud provider", {
|
||||
modelName,
|
||||
providerType,
|
||||
});
|
||||
return modelName;
|
||||
|
||||
default:
|
||||
// Unknown provider - pass through unchanged
|
||||
return modelName;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build environment variables for model tier configuration.
|
||||
*
|
||||
* The Claude Code SDK uses these environment variables to select models
|
||||
* for different task complexity tiers.
|
||||
*
|
||||
* Model names are normalized based on the target provider.
|
||||
*/
|
||||
function buildModelTierEnv(modelTiers?: CcSdkModelTiers): SdkProviderEnv {
|
||||
function buildModelTierEnv(
|
||||
modelTiers: CcSdkModelTiers | undefined,
|
||||
providerConfig: SdkProviderConfig | undefined,
|
||||
): SdkProviderEnv {
|
||||
const env: SdkProviderEnv = {};
|
||||
|
||||
if (modelTiers?.haiku) {
|
||||
env.ANTHROPIC_DEFAULT_HAIKU_MODEL = modelTiers.haiku;
|
||||
if (!modelTiers) return env;
|
||||
|
||||
const providerType = detectProviderType(providerConfig);
|
||||
log.debug("[CCSDK-MODEL] Building model tier env", {
|
||||
providerType,
|
||||
providerName: providerConfig?.name,
|
||||
rawTiers: modelTiers,
|
||||
});
|
||||
|
||||
if (modelTiers.haiku) {
|
||||
env.ANTHROPIC_DEFAULT_HAIKU_MODEL = normalizeModelNameForProvider(
|
||||
modelTiers.haiku,
|
||||
providerType,
|
||||
);
|
||||
}
|
||||
if (modelTiers?.sonnet) {
|
||||
env.ANTHROPIC_DEFAULT_SONNET_MODEL = modelTiers.sonnet;
|
||||
if (modelTiers.sonnet) {
|
||||
env.ANTHROPIC_DEFAULT_SONNET_MODEL = normalizeModelNameForProvider(
|
||||
modelTiers.sonnet,
|
||||
providerType,
|
||||
);
|
||||
}
|
||||
if (modelTiers?.opus) {
|
||||
env.ANTHROPIC_DEFAULT_OPUS_MODEL = modelTiers.opus;
|
||||
if (modelTiers.opus) {
|
||||
env.ANTHROPIC_DEFAULT_OPUS_MODEL = normalizeModelNameForProvider(modelTiers.opus, providerType);
|
||||
}
|
||||
|
||||
return env;
|
||||
@ -196,10 +426,11 @@ async function coerceAsyncIterable(value: unknown): Promise<AsyncIterable<unknow
|
||||
* - "result": terminal event with final output
|
||||
* - "assistant": assistant message text (partial or complete)
|
||||
* - "tool": tool execution event
|
||||
* - "thinking": reasoning/thinking text (extended thinking)
|
||||
* - "system": lifecycle/diagnostic event
|
||||
* - "unknown": unrecognized shape
|
||||
*/
|
||||
type EventKind = "result" | "assistant" | "tool" | "system" | "unknown";
|
||||
type EventKind = "result" | "assistant" | "tool" | "thinking" | "system" | "unknown";
|
||||
|
||||
function classifyEvent(event: unknown): { kind: EventKind; event: unknown } {
|
||||
if (!isRecord(event)) return { kind: "unknown", event };
|
||||
@ -219,6 +450,20 @@ function classifyEvent(event: unknown): { kind: EventKind; event: unknown } {
|
||||
return { kind: "tool", event };
|
||||
}
|
||||
|
||||
// Thinking/reasoning events (extended thinking).
|
||||
if (type === "thinking" || type === "thinking_delta") {
|
||||
return { kind: "thinking", event };
|
||||
}
|
||||
// content_block_start with thinking content block
|
||||
if (type === "content_block_start") {
|
||||
const contentBlock = (event as Record<string, unknown>).content_block as
|
||||
| Record<string, unknown>
|
||||
| undefined;
|
||||
if (contentBlock?.type === "thinking") {
|
||||
return { kind: "thinking", event };
|
||||
}
|
||||
}
|
||||
|
||||
// System/lifecycle events.
|
||||
if (type === "system" || type === "agent_start" || type === "agent_end" || type === "error") {
|
||||
return { kind: "system", event };
|
||||
@ -280,6 +525,22 @@ function applySdkOptionsOverrides(
|
||||
// Prompt building
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* SDK user message shape for AsyncIterable prompts.
|
||||
*/
|
||||
type SdkUserMessage = {
|
||||
type: "user";
|
||||
message: {
|
||||
role: "user";
|
||||
content: string;
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* SDK prompt type — the SDK accepts either a plain string or an AsyncIterable of messages.
|
||||
*/
|
||||
type SdkPrompt = string | AsyncIterable<SdkUserMessage>;
|
||||
|
||||
/**
|
||||
* Build the SDK prompt.
|
||||
*
|
||||
@ -293,23 +554,27 @@ function applySdkOptionsOverrides(
|
||||
function buildSdkPrompt(params: {
|
||||
prompt: string;
|
||||
systemPrompt?: string;
|
||||
}): string | AsyncIterable<{ type: "user"; message: { role: "user"; content: string } }> {
|
||||
// When there's no system prompt override, a plain string works (no mcpServers
|
||||
// requirement for plain string prompts is only for some SDK versions).
|
||||
// We always use the async iterable form for consistency and forward compat.
|
||||
useMcpServers?: boolean;
|
||||
}): SdkPrompt {
|
||||
const userContent = params.prompt;
|
||||
|
||||
async function* generateMessages() {
|
||||
yield {
|
||||
type: "user" as const,
|
||||
message: {
|
||||
role: "user" as const,
|
||||
content: userContent,
|
||||
},
|
||||
};
|
||||
// When using MCP servers, the SDK requires an AsyncIterable of messages.
|
||||
// When not using MCP servers, a plain string is more efficient.
|
||||
if (params.useMcpServers) {
|
||||
async function* generateMessages(): AsyncIterable<SdkUserMessage> {
|
||||
yield {
|
||||
type: "user" as const,
|
||||
message: {
|
||||
role: "user" as const,
|
||||
content: userContent,
|
||||
},
|
||||
};
|
||||
}
|
||||
return generateMessages();
|
||||
}
|
||||
|
||||
return generateMessages();
|
||||
// Plain string prompt when MCP servers are not in use.
|
||||
return userContent;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@ -327,6 +592,46 @@ export async function runSdkAgent(params: SdkRunnerParams): Promise<SdkRunnerRes
|
||||
const mcpServerName = params.mcpServerName ?? DEFAULT_MCP_SERVER_NAME;
|
||||
const hooksEnabled = params.hooksEnabled === true;
|
||||
|
||||
// Log comprehensive run parameters for debugging
|
||||
log.info("[CCSDK-RUN] Starting SDK agent run", {
|
||||
runId: params.runId,
|
||||
sessionId: params.sessionId,
|
||||
sessionKey: params.sessionKey,
|
||||
agentId: params.agentId,
|
||||
promptLength: params.prompt.length,
|
||||
promptPreview: params.prompt.slice(0, 200),
|
||||
mcpServerName,
|
||||
hooksEnabled,
|
||||
timeoutMs: params.timeoutMs,
|
||||
thinkingLevel: params.thinkingLevel,
|
||||
maxTurns: params.maxTurns,
|
||||
});
|
||||
|
||||
// Log provider configuration
|
||||
log.debug("[CCSDK-RUN] Provider configuration", {
|
||||
providerName: params.providerConfig?.name ?? "(not set)",
|
||||
providerModel: params.providerConfig?.model ?? "(default)",
|
||||
providerMaxTurns: params.providerConfig?.maxTurns ?? "(default)",
|
||||
providerEnvKeys: params.providerConfig?.env ? Object.keys(params.providerConfig.env) : [],
|
||||
});
|
||||
|
||||
// Log model tiers if configured
|
||||
if (params.modelTiers) {
|
||||
log.debug("[CCSDK-RUN] Model tiers configured", {
|
||||
haiku: params.modelTiers.haiku ?? "(default)",
|
||||
sonnet: params.modelTiers.sonnet ?? "(default)",
|
||||
opus: params.modelTiers.opus ?? "(default)",
|
||||
});
|
||||
}
|
||||
|
||||
// Log session resume state
|
||||
if (params.claudeSessionId) {
|
||||
log.debug("[CCSDK-RUN] Session resume enabled", {
|
||||
claudeSessionId: params.claudeSessionId,
|
||||
forkSession: params.forkSession ?? false,
|
||||
});
|
||||
}
|
||||
|
||||
const emitEvent = (stream: string, data: Record<string, unknown>) => {
|
||||
try {
|
||||
void Promise.resolve(params.onAgentEvent?.({ stream, data })).catch(() => {
|
||||
@ -513,7 +818,11 @@ export async function runSdkAgent(params: SdkRunnerParams): Promise<SdkRunnerRes
|
||||
if (params.forkSession) {
|
||||
sdkOptions.forkSession = true;
|
||||
}
|
||||
log.debug(`Using native session resume with ID: ${params.claudeSessionId}`);
|
||||
log.debug("Resuming CCSDK session", {
|
||||
claudeSessionId: params.claudeSessionId,
|
||||
forkSession: params.forkSession ?? false,
|
||||
sessionId: params.sessionId,
|
||||
});
|
||||
}
|
||||
|
||||
// System prompt (with Moltbot-specific additions and conversation history suffix).
|
||||
@ -525,6 +834,11 @@ export async function runSdkAgent(params: SdkRunnerParams): Promise<SdkRunnerRes
|
||||
messageChannel: params.messageChannel,
|
||||
channelHints: params.channelHints,
|
||||
skills: params.skills,
|
||||
// Sender context for system prompt enrichment
|
||||
senderId: params.senderId,
|
||||
senderName: params.senderName,
|
||||
senderUsername: params.senderUsername,
|
||||
senderE164: params.senderE164,
|
||||
});
|
||||
// Skip history injection when using native session resume.
|
||||
const historySuffix = useNativeSessionResume
|
||||
@ -538,25 +852,64 @@ export async function runSdkAgent(params: SdkRunnerParams): Promise<SdkRunnerRes
|
||||
sdkOptions.systemPrompt = combinedSystemPrompt;
|
||||
}
|
||||
|
||||
// Provider env overrides (z.AI, custom endpoints, etc.) and model tier config.
|
||||
const envOverrides: Record<string, string> = {};
|
||||
// Build environment for the SDK process.
|
||||
// Always inherit the parent process environment (critical for PATH, HOME, etc.
|
||||
// when running as a LaunchAgent or daemon), then apply overrides.
|
||||
const sdkEnv: Record<string, string> = {};
|
||||
|
||||
// Add provider env vars.
|
||||
// Log inherited auth env vars BEFORE we override them
|
||||
log.debug("[CCSDK-ENV] Checking inherited auth env vars from parent process", {
|
||||
hasAnthropicApiKey: Boolean(process.env.ANTHROPIC_API_KEY),
|
||||
inheritedApiKeyMasked: maskToken(process.env.ANTHROPIC_API_KEY),
|
||||
hasAnthropicAuthToken: Boolean(process.env.ANTHROPIC_AUTH_TOKEN),
|
||||
inheritedAuthTokenMasked: maskToken(process.env.ANTHROPIC_AUTH_TOKEN),
|
||||
hasAnthropicBaseUrl: Boolean(process.env.ANTHROPIC_BASE_URL),
|
||||
inheritedBaseUrl: process.env.ANTHROPIC_BASE_URL ?? "(unset)",
|
||||
claudeCodeEntrypoint: process.env.CLAUDE_CODE_ENTRYPOINT ?? "(unset)",
|
||||
claudeCodeIndicator: process.env.CLAUDECODE ?? "(unset)",
|
||||
});
|
||||
|
||||
// Inherit parent process environment.
|
||||
for (const [key, value] of Object.entries(process.env)) {
|
||||
if (value !== undefined) sdkEnv[key] = value;
|
||||
}
|
||||
|
||||
// Add provider env vars (z.AI, custom endpoints, etc.).
|
||||
if (params.providerConfig?.env) {
|
||||
log.debug("[CCSDK-ENV] Applying provider config env overrides", {
|
||||
providerName: params.providerConfig.name,
|
||||
providerEnvKeys: Object.keys(params.providerConfig.env),
|
||||
});
|
||||
for (const [key, value] of Object.entries(params.providerConfig.env)) {
|
||||
if (value !== undefined) envOverrides[key] = value;
|
||||
if (value !== undefined) {
|
||||
const wasOverwritten = sdkEnv[key] !== undefined && sdkEnv[key] !== value;
|
||||
if (wasOverwritten) {
|
||||
log.debug("[CCSDK-ENV] Overwriting inherited env var", {
|
||||
key,
|
||||
oldValueMasked:
|
||||
key.includes("KEY") || key.includes("TOKEN") ? maskToken(sdkEnv[key]) : sdkEnv[key],
|
||||
newValueMasked: key.includes("KEY") || key.includes("TOKEN") ? maskToken(value) : value,
|
||||
});
|
||||
}
|
||||
sdkEnv[key] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add model tier env vars.
|
||||
const modelTierEnv = buildModelTierEnv(params.modelTiers);
|
||||
// Add model tier env vars (normalized based on target provider).
|
||||
const modelTierEnv = buildModelTierEnv(params.modelTiers, params.providerConfig);
|
||||
if (Object.keys(modelTierEnv).length > 0) {
|
||||
log.debug("[CCSDK-ENV] Applying model tier env vars", { modelTierEnv });
|
||||
}
|
||||
for (const [key, value] of Object.entries(modelTierEnv)) {
|
||||
if (value !== undefined) envOverrides[key] = value;
|
||||
if (value !== undefined) sdkEnv[key] = value;
|
||||
}
|
||||
|
||||
if (Object.keys(envOverrides).length > 0) {
|
||||
sdkOptions.env = envOverrides;
|
||||
}
|
||||
// Log final auth env state
|
||||
logAuthEnvVars(sdkEnv, "[CCSDK-ENV] Final SDK");
|
||||
|
||||
// Always pass the full environment to the SDK.
|
||||
sdkOptions.env = sdkEnv;
|
||||
|
||||
// Model override from provider config.
|
||||
if (params.providerConfig?.model) {
|
||||
@ -583,9 +936,14 @@ export async function runSdkAgent(params: SdkRunnerParams): Promise<SdkRunnerRes
|
||||
// Step 4: Build the prompt
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
// Use AsyncIterable prompt format when MCP servers are configured (SDK requirement).
|
||||
const hasMcpServers = Boolean(
|
||||
sdkOptions.mcpServers && Object.keys(sdkOptions.mcpServers).length > 0,
|
||||
);
|
||||
const prompt = buildSdkPrompt({
|
||||
prompt: effectivePrompt,
|
||||
systemPrompt: combinedSystemPrompt,
|
||||
useMcpServers: hasMcpServers,
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
@ -628,13 +986,27 @@ export async function runSdkAgent(params: SdkRunnerParams): Promise<SdkRunnerRes
|
||||
try {
|
||||
emitEvent("sdk", { type: "query_start" });
|
||||
|
||||
log.debug("Starting SDK query", {
|
||||
promptFormat: hasMcpServers ? "AsyncIterable" : "string",
|
||||
promptLength: typeof prompt === "string" ? prompt.length : undefined,
|
||||
hasSystemPrompt: Boolean(sdkOptions.systemPrompt),
|
||||
hasMcpServers,
|
||||
mcpServerNames: sdkOptions.mcpServers ? Object.keys(sdkOptions.mcpServers) : [],
|
||||
allowedToolsCount: sdkOptions.allowedTools?.length ?? 0,
|
||||
model: sdkOptions.model,
|
||||
maxTurns: sdkOptions.maxTurns,
|
||||
hasResume: Boolean(sdkOptions.resume),
|
||||
});
|
||||
|
||||
const stream = await coerceAsyncIterable(
|
||||
sdk.query({
|
||||
prompt: prompt as string, // The SDK accepts both string and AsyncIterable
|
||||
prompt, // SDK accepts both string and AsyncIterable<unknown>
|
||||
options: sdkOptions as Record<string, unknown>,
|
||||
}),
|
||||
);
|
||||
|
||||
log.debug("SDK query returned stream, beginning iteration");
|
||||
|
||||
for await (const event of stream) {
|
||||
// Check abort before processing each event.
|
||||
if (combinedAbort.aborted) {
|
||||
@ -645,6 +1017,20 @@ export async function runSdkAgent(params: SdkRunnerParams): Promise<SdkRunnerRes
|
||||
|
||||
eventCount += 1;
|
||||
|
||||
// Debug: log each event received (type and key fields only)
|
||||
if (isRecord(event)) {
|
||||
const eventType = event.type ?? "no-type";
|
||||
const hasText = Boolean(event.text || event.delta || event.content || event.message);
|
||||
const hasSessionId = Boolean(event.session_id);
|
||||
log.debug("SDK event received", {
|
||||
eventCount,
|
||||
type: eventType,
|
||||
hasText,
|
||||
hasSessionId,
|
||||
keys: Object.keys(event).slice(0, 10),
|
||||
});
|
||||
}
|
||||
|
||||
// Best-effort assistant message boundary detection.
|
||||
// Some SDK versions emit `type: "message_start"`; otherwise, we fall back
|
||||
// to calling this once when we see the first assistant text.
|
||||
@ -687,7 +1073,9 @@ export async function runSdkAgent(params: SdkRunnerParams): Promise<SdkRunnerRes
|
||||
: record && typeof record.toolCallId === "string"
|
||||
? record.toolCallId
|
||||
: undefined;
|
||||
const toolText = extractTextFromClaudeAgentSdkEvent(event);
|
||||
// Extract text once for the entire tool handling block.
|
||||
const toolExtraction = extractFromClaudeAgentSdkEvent(event);
|
||||
const toolText = toolExtraction.text;
|
||||
const isError =
|
||||
record && typeof record.is_error === "boolean"
|
||||
? record.is_error
|
||||
@ -744,15 +1132,11 @@ export async function runSdkAgent(params: SdkRunnerParams): Promise<SdkRunnerRes
|
||||
isError,
|
||||
...(toolText ? { resultText: toolText } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
if (!hooksEnabled && kind === "tool" && params.onToolResult) {
|
||||
const toolText = extractTextFromClaudeAgentSdkEvent(event);
|
||||
if (toolText) {
|
||||
// Emit tool result via callback (using toolText already extracted above).
|
||||
if (params.onToolResult && toolText) {
|
||||
try {
|
||||
// Only emit tool results for terminal tool events to match Pi semantics more closely.
|
||||
const record = isRecord(event) ? (event as Record<string, unknown>) : undefined;
|
||||
const type = record && typeof record.type === "string" ? record.type : "";
|
||||
if (isSdkTerminalToolEventType(type)) await params.onToolResult({ text: toolText });
|
||||
} catch {
|
||||
// Don't break the stream on callback errors.
|
||||
@ -768,6 +1152,15 @@ export async function runSdkAgent(params: SdkRunnerParams): Promise<SdkRunnerRes
|
||||
// Handle terminal result event.
|
||||
if (kind === "result" && isRecord(event)) {
|
||||
const result = event.result;
|
||||
const subtype = event.subtype;
|
||||
log.debug("Received result event, ending stream", {
|
||||
eventCount,
|
||||
hasResult: result !== undefined,
|
||||
resultType: typeof result,
|
||||
resultLength: typeof result === "string" ? result.length : undefined,
|
||||
subtype,
|
||||
hasError: Boolean(event.error),
|
||||
});
|
||||
if (typeof result === "string") {
|
||||
resultText = result;
|
||||
}
|
||||
@ -777,7 +1170,6 @@ export async function runSdkAgent(params: SdkRunnerParams): Promise<SdkRunnerRes
|
||||
usage = resultUsage;
|
||||
}
|
||||
// Also check for error results.
|
||||
const subtype = event.subtype;
|
||||
if (subtype === "error" && typeof event.error === "string") {
|
||||
log.warn(`SDK returned error result: ${event.error}`);
|
||||
}
|
||||
@ -802,12 +1194,51 @@ export async function runSdkAgent(params: SdkRunnerParams): Promise<SdkRunnerRes
|
||||
}
|
||||
}
|
||||
|
||||
// Extract text from assistant messages.
|
||||
if (kind === "assistant" || kind === "unknown") {
|
||||
const text = extractTextFromClaudeAgentSdkEvent(event);
|
||||
if (!text) continue;
|
||||
// Extract BOTH text and thinking from events using unified extraction.
|
||||
// This handles events that may contain one or both types of content.
|
||||
if (kind === "assistant" || kind === "unknown" || kind === "thinking") {
|
||||
const extraction = extractFromClaudeAgentSdkEvent(event);
|
||||
|
||||
const trimmed = text.trimEnd();
|
||||
// Handle thinking/reasoning content (for breadcrumbs).
|
||||
if (extraction.thinking) {
|
||||
log.debug("[CCSDK-STREAM] Thinking content extracted", {
|
||||
eventCount,
|
||||
kind,
|
||||
textLength: extraction.thinking.length,
|
||||
textPreview: extraction.thinking.slice(0, 100),
|
||||
hasOnReasoningStream: Boolean(params.onReasoningStream),
|
||||
});
|
||||
if (params.onReasoningStream) {
|
||||
try {
|
||||
log.debug("[CCSDK-STREAM] Calling onReasoningStream", {
|
||||
textLength: extraction.thinking.length,
|
||||
});
|
||||
await params.onReasoningStream({ text: extraction.thinking });
|
||||
} catch (err) {
|
||||
log.debug("[CCSDK-STREAM] onReasoningStream error", { error: String(err) });
|
||||
}
|
||||
} else {
|
||||
log.debug(
|
||||
"[CCSDK-STREAM] No onReasoningStream callback - thinking text will not be streamed",
|
||||
);
|
||||
}
|
||||
// Also emit as agent event for logging/debugging
|
||||
emitEvent("thinking", { text: extraction.thinking });
|
||||
}
|
||||
|
||||
// Handle assistant text content.
|
||||
if (!extraction.text) {
|
||||
log.debug("[CCSDK-STREAM] No text extracted from event", {
|
||||
eventCount,
|
||||
kind,
|
||||
eventKeys: isRecord(event) ? Object.keys(event).slice(0, 15) : [],
|
||||
hasOnBlockReply: Boolean(params.onBlockReply),
|
||||
hadThinking: Boolean(extraction.thinking),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const trimmed = extraction.text.trimEnd();
|
||||
if (!trimmed) continue;
|
||||
|
||||
if (!didAssistantMessageStart) {
|
||||
@ -832,17 +1263,42 @@ export async function runSdkAgent(params: SdkRunnerParams): Promise<SdkRunnerRes
|
||||
assistantSoFar = chunks.join("\n\n");
|
||||
const delta = assistantSoFar.startsWith(prev) ? assistantSoFar.slice(prev.length) : trimmed;
|
||||
|
||||
log.debug("[CCSDK-STREAM] Extracted assistant text", {
|
||||
eventCount,
|
||||
chunkLength: trimmed.length,
|
||||
totalExtracted: extractedChars,
|
||||
totalChunks: chunks.length,
|
||||
hasOnBlockReply: Boolean(params.onBlockReply),
|
||||
hasOnPartialReply: Boolean(params.onPartialReply),
|
||||
});
|
||||
|
||||
emitEvent("assistant", { text: assistantSoFar, delta });
|
||||
|
||||
// Stream partial reply.
|
||||
// Stream partial reply (for live text updates in UI).
|
||||
if (params.onPartialReply) {
|
||||
try {
|
||||
log.debug("[CCSDK-STREAM] Calling onPartialReply", {
|
||||
textLength: assistantSoFar.length,
|
||||
});
|
||||
await params.onPartialReply({ text: assistantSoFar });
|
||||
} catch {
|
||||
// Don't break the stream on callback errors.
|
||||
} catch (err) {
|
||||
log.debug("[CCSDK-STREAM] onPartialReply error", { error: String(err) });
|
||||
}
|
||||
}
|
||||
|
||||
// Stream block reply (for block-based reply pipeline).
|
||||
// This enables the reply pipeline to send incremental block updates.
|
||||
if (params.onBlockReply) {
|
||||
try {
|
||||
log.debug("[CCSDK-STREAM] Calling onBlockReply", { textLength: assistantSoFar.length });
|
||||
await params.onBlockReply({ text: assistantSoFar });
|
||||
} catch (err) {
|
||||
log.debug("[CCSDK-STREAM] onBlockReply error", { error: String(err) });
|
||||
}
|
||||
} else {
|
||||
log.debug("[CCSDK-STREAM] onBlockReply not provided - skipping");
|
||||
}
|
||||
|
||||
// Truncate if we've extracted too much text.
|
||||
if (extractedChars >= DEFAULT_MAX_EXTRACTED_CHARS) {
|
||||
truncated = true;
|
||||
@ -851,6 +1307,16 @@ export async function runSdkAgent(params: SdkRunnerParams): Promise<SdkRunnerRes
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Log when the stream ends naturally (not via break from result/abort/truncate)
|
||||
log.debug("SDK event stream ended", {
|
||||
eventCount,
|
||||
extractedChars,
|
||||
aborted,
|
||||
truncated,
|
||||
hasResultText: Boolean(resultText),
|
||||
chunksCount: chunks.length,
|
||||
});
|
||||
} catch (err) {
|
||||
if (timeoutId) clearTimeout(timeoutId);
|
||||
|
||||
@ -939,7 +1405,49 @@ export async function runSdkAgent(params: SdkRunnerParams): Promise<SdkRunnerRes
|
||||
|
||||
const text = (resultText ?? chunks.join("\n\n")).trim();
|
||||
|
||||
// Log per-turn response characteristics for debugging
|
||||
log.debug("[CCSDK-TURN] Turn completed - response analysis", {
|
||||
responseLength: text.length,
|
||||
eventCount,
|
||||
extractedChars,
|
||||
chunksCount: chunks.length,
|
||||
hasResultText: Boolean(resultText),
|
||||
aborted,
|
||||
truncated,
|
||||
durationMs: Date.now() - startedAt,
|
||||
});
|
||||
|
||||
// Detect potential auth errors in the response
|
||||
if (text) {
|
||||
const authCheck = detectAuthErrorPatterns(text);
|
||||
if (authCheck.hasAuthError) {
|
||||
log.warn("[CCSDK-TURN] Potential auth-related issue detected in response", {
|
||||
patterns: authCheck.patterns,
|
||||
responseLength: text.length,
|
||||
responsePreview: text.slice(0, 500),
|
||||
});
|
||||
}
|
||||
|
||||
// Log full response text at debug level for troubleshooting
|
||||
// Truncate very long responses but log short ones in full
|
||||
if (text.length <= 1000) {
|
||||
log.debug("[CCSDK-TURN] Full response text", { text });
|
||||
} else {
|
||||
log.debug("[CCSDK-TURN] Response text (truncated for logging)", {
|
||||
textPreview: text.slice(0, 500),
|
||||
textSuffix: text.slice(-200),
|
||||
fullLength: text.length,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (!text) {
|
||||
log.warn("[CCSDK-TURN] No text output - possible auth or SDK issue", {
|
||||
eventCount,
|
||||
hasResultText: Boolean(resultText),
|
||||
chunksCount: chunks.length,
|
||||
aborted,
|
||||
});
|
||||
emitEvent("lifecycle", {
|
||||
phase: "error",
|
||||
startedAt,
|
||||
@ -978,13 +1486,40 @@ export async function runSdkAgent(params: SdkRunnerParams): Promise<SdkRunnerRes
|
||||
const suffix = truncated ? "\n\n[Output truncated]" : "";
|
||||
const finalText = `${text}${suffix}`;
|
||||
|
||||
log.debug("[CCSDK-FINAL] Final result prepared", {
|
||||
textLength: finalText.length,
|
||||
textPreview: finalText.slice(0, 200),
|
||||
hasOnBlockReply: Boolean(params.onBlockReply),
|
||||
hasOnBlockReplyFlush: Boolean(params.onBlockReplyFlush),
|
||||
truncated,
|
||||
aborted,
|
||||
});
|
||||
|
||||
// Emit the final block reply.
|
||||
if (params.onBlockReply) {
|
||||
try {
|
||||
log.debug("[CCSDK-FINAL] Calling final onBlockReply", { textLength: finalText.length });
|
||||
await params.onBlockReply({ text: finalText });
|
||||
} catch {
|
||||
// Don't fail the run on callback errors.
|
||||
log.debug("[CCSDK-FINAL] Final onBlockReply completed successfully");
|
||||
} catch (err) {
|
||||
log.debug("[CCSDK-FINAL] Final onBlockReply error", { error: String(err) });
|
||||
}
|
||||
} else {
|
||||
log.debug("[CCSDK-FINAL] No onBlockReply callback - final text will only be in payloads");
|
||||
}
|
||||
|
||||
// CRITICAL: Flush block reply pipeline to signal message completion.
|
||||
// Without this, the UI stays stuck on "..." waiting for the flush signal.
|
||||
if (params.onBlockReplyFlush) {
|
||||
try {
|
||||
log.debug("[CCSDK-FINAL] Flushing block reply pipeline");
|
||||
await params.onBlockReplyFlush();
|
||||
log.debug("[CCSDK-FINAL] Block reply flush completed");
|
||||
} catch (err) {
|
||||
log.debug("[CCSDK-FINAL] Block reply flush error", { error: String(err) });
|
||||
}
|
||||
} else {
|
||||
log.debug("[CCSDK-FINAL] No onBlockReplyFlush callback - UI may stay on typing indicator");
|
||||
}
|
||||
|
||||
emitEvent("sdk", {
|
||||
|
||||
@ -53,9 +53,11 @@ export function appendSdkTextTurnToSessionTranscript(params: {
|
||||
appendJsonlLine({
|
||||
filePath: params.sessionFile,
|
||||
value: {
|
||||
role: params.role,
|
||||
content: [{ type: "text", text: trimmed }],
|
||||
timestamp: params.timestamp ?? Date.now(),
|
||||
message: {
|
||||
role: params.role,
|
||||
content: [{ type: "text", text: trimmed }],
|
||||
timestamp: params.timestamp ?? Date.now(),
|
||||
},
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
|
||||
@ -26,6 +26,14 @@ export type PromptEnricherContext = {
|
||||
channelHints?: string;
|
||||
/** Available Moltbot skills. */
|
||||
skills?: string[];
|
||||
/** Sender identifier. */
|
||||
senderId?: string | null;
|
||||
/** Sender display name. */
|
||||
senderName?: string | null;
|
||||
/** Sender username. */
|
||||
senderUsername?: string | null;
|
||||
/** Sender E.164 phone number. */
|
||||
senderE164?: string | null;
|
||||
/** Additional custom context. */
|
||||
custom?: Record<string, unknown>;
|
||||
};
|
||||
@ -108,11 +116,36 @@ const skillsEnricher: PromptEnricher = (ctx) => {
|
||||
return `Available Moltbot skills: ${ctx.skills.join(", ")}`;
|
||||
};
|
||||
|
||||
/**
|
||||
* Sender context enricher.
|
||||
*
|
||||
* Provides sender identity information to help the model understand
|
||||
* who is making the request.
|
||||
*/
|
||||
const senderEnricher: PromptEnricher = (ctx) => {
|
||||
const parts: string[] = [];
|
||||
|
||||
if (ctx.senderName) {
|
||||
parts.push(`Sender name: ${ctx.senderName}`);
|
||||
}
|
||||
if (ctx.senderUsername) {
|
||||
parts.push(`Sender username: @${ctx.senderUsername}`);
|
||||
}
|
||||
if (ctx.senderId && !ctx.senderName && !ctx.senderUsername) {
|
||||
// Only include raw ID if no friendly identifier is available
|
||||
parts.push(`Sender ID: ${ctx.senderId}`);
|
||||
}
|
||||
|
||||
if (parts.length === 0) return null;
|
||||
return parts.join("\n");
|
||||
};
|
||||
|
||||
// Register built-in enrichers
|
||||
registerPromptEnricher(agentIdentityEnricher);
|
||||
registerPromptEnricher(timezoneEnricher);
|
||||
registerPromptEnricher(channelEnricher);
|
||||
registerPromptEnricher(skillsEnricher);
|
||||
registerPromptEnricher(senderEnricher);
|
||||
|
||||
// ─── Main API ────────────────────────────────────────────────────────────────
|
||||
|
||||
@ -156,6 +189,10 @@ export function buildSystemPromptAdditionsFromParams(params: {
|
||||
messageChannel?: string;
|
||||
channelHints?: string;
|
||||
skills?: string[];
|
||||
senderId?: string | null;
|
||||
senderName?: string | null;
|
||||
senderUsername?: string | null;
|
||||
senderE164?: string | null;
|
||||
}): string | undefined {
|
||||
return buildSystemPromptAdditions({
|
||||
agentId: params.agentId ?? params.sessionKey?.split(":")[0],
|
||||
@ -165,5 +202,9 @@ export function buildSystemPromptAdditionsFromParams(params: {
|
||||
channel: params.messageChannel,
|
||||
channelHints: params.channelHints,
|
||||
skills: params.skills,
|
||||
senderId: params.senderId,
|
||||
senderName: params.senderName,
|
||||
senderUsername: params.senderUsername,
|
||||
senderE164: params.senderE164,
|
||||
});
|
||||
}
|
||||
|
||||
@ -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. */
|
||||
|
||||
@ -12,6 +12,18 @@ import { resolveUserPath } from "../utils.js";
|
||||
const log = createSubsystemLogger("agents/auth-profiles");
|
||||
|
||||
const CLAUDE_CLI_CREDENTIALS_RELATIVE_PATH = ".claude/.credentials.json";
|
||||
|
||||
/**
|
||||
* Mask a token for logging - shows length and first/last 4 chars.
|
||||
* Example: "sk-ant-api03-abc...xyz" (length: 108)
|
||||
*/
|
||||
function maskToken(token: string | undefined): string {
|
||||
if (!token) return "(empty)";
|
||||
if (token.length <= 12) return `${"*".repeat(token.length)} (length: ${token.length})`;
|
||||
const first = token.slice(0, 4);
|
||||
const last = token.slice(-4);
|
||||
return `${first}...${"*".repeat(Math.min(8, token.length - 8))}...${last} (length: ${token.length})`;
|
||||
}
|
||||
const CODEX_CLI_AUTH_FILENAME = "auth.json";
|
||||
const QWEN_CLI_CREDENTIALS_RELATIVE_PATH = ".qwen/oauth_creds.json";
|
||||
|
||||
@ -189,22 +201,56 @@ function readQwenCliCredentials(options?: { homeDir?: string }): QwenCliCredenti
|
||||
function readClaudeCliKeychainCredentials(
|
||||
execSyncImpl: ExecSyncFn = execSync,
|
||||
): ClaudeCliCredential | null {
|
||||
log.debug("[CCSDK-AUTH] Attempting to read credentials from macOS Keychain", {
|
||||
service: CLAUDE_CLI_KEYCHAIN_SERVICE,
|
||||
account: CLAUDE_CLI_KEYCHAIN_ACCOUNT,
|
||||
});
|
||||
|
||||
try {
|
||||
const result = execSyncImpl(
|
||||
`security find-generic-password -s "${CLAUDE_CLI_KEYCHAIN_SERVICE}" -w`,
|
||||
{ encoding: "utf8", timeout: 5000, stdio: ["pipe", "pipe", "pipe"] },
|
||||
);
|
||||
|
||||
log.debug("[CCSDK-AUTH] Keychain returned data", {
|
||||
rawLength: result.length,
|
||||
});
|
||||
|
||||
const data = JSON.parse(result.trim());
|
||||
const claudeOauth = data?.claudeAiOauth;
|
||||
if (!claudeOauth || typeof claudeOauth !== "object") return null;
|
||||
if (!claudeOauth || typeof claudeOauth !== "object") {
|
||||
log.debug("[CCSDK-AUTH] Keychain data missing claudeAiOauth object");
|
||||
return null;
|
||||
}
|
||||
|
||||
const accessToken = claudeOauth.accessToken;
|
||||
const refreshToken = claudeOauth.refreshToken;
|
||||
const expiresAt = claudeOauth.expiresAt;
|
||||
|
||||
if (typeof accessToken !== "string" || !accessToken) return null;
|
||||
if (typeof expiresAt !== "number" || expiresAt <= 0) return null;
|
||||
if (typeof accessToken !== "string" || !accessToken) {
|
||||
log.debug("[CCSDK-AUTH] Keychain accessToken invalid or missing");
|
||||
return null;
|
||||
}
|
||||
if (typeof expiresAt !== "number" || expiresAt <= 0) {
|
||||
log.debug("[CCSDK-AUTH] Keychain expiresAt invalid", { expiresAt });
|
||||
return null;
|
||||
}
|
||||
|
||||
const expiresDate = new Date(expiresAt);
|
||||
const isExpired = expiresAt < Date.now();
|
||||
const msUntilExpiry = expiresAt - Date.now();
|
||||
|
||||
log.debug("[CCSDK-AUTH] Keychain credentials parsed successfully", {
|
||||
source: "keychain",
|
||||
hasAccessToken: Boolean(accessToken),
|
||||
accessTokenMasked: maskToken(accessToken),
|
||||
hasRefreshToken: Boolean(refreshToken),
|
||||
refreshTokenMasked: maskToken(refreshToken),
|
||||
expiresAt: expiresDate.toISOString(),
|
||||
isExpired,
|
||||
msUntilExpiry,
|
||||
type: refreshToken ? "oauth" : "token",
|
||||
});
|
||||
|
||||
if (typeof refreshToken === "string" && refreshToken) {
|
||||
return {
|
||||
@ -222,7 +268,10 @@ function readClaudeCliKeychainCredentials(
|
||||
token: accessToken,
|
||||
expires: expiresAt,
|
||||
};
|
||||
} catch {
|
||||
} catch (err) {
|
||||
log.debug("[CCSDK-AUTH] Keychain read failed", {
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@ -234,30 +283,89 @@ export function readClaudeCliCredentials(options?: {
|
||||
execSync?: ExecSyncFn;
|
||||
}): ClaudeCliCredential | null {
|
||||
const platform = options?.platform ?? process.platform;
|
||||
|
||||
log.debug("[CCSDK-AUTH] readClaudeCliCredentials called", {
|
||||
platform,
|
||||
allowKeychainPrompt: options?.allowKeychainPrompt,
|
||||
homeDir: options?.homeDir ?? "(default)",
|
||||
});
|
||||
|
||||
// Try macOS Keychain first
|
||||
if (platform === "darwin" && options?.allowKeychainPrompt !== false) {
|
||||
log.debug("[CCSDK-AUTH] Attempting macOS Keychain read");
|
||||
const keychainCreds = readClaudeCliKeychainCredentials(options?.execSync);
|
||||
if (keychainCreds) {
|
||||
log.info("read anthropic credentials from claude cli keychain", {
|
||||
log.info("[CCSDK-AUTH] Successfully read credentials from macOS Keychain", {
|
||||
type: keychainCreds.type,
|
||||
source: "keychain",
|
||||
accessTokenMasked:
|
||||
keychainCreds.type === "oauth"
|
||||
? maskToken(keychainCreds.access)
|
||||
: maskToken(keychainCreds.token),
|
||||
});
|
||||
return keychainCreds;
|
||||
}
|
||||
log.debug("[CCSDK-AUTH] Keychain read returned null, falling back to file");
|
||||
}
|
||||
|
||||
// Fall back to file-based credentials
|
||||
const credPath = resolveClaudeCliCredentialsPath(options?.homeDir);
|
||||
log.debug("[CCSDK-AUTH] Attempting to read credentials from file", {
|
||||
path: credPath,
|
||||
});
|
||||
|
||||
const raw = loadJsonFile(credPath);
|
||||
if (!raw || typeof raw !== "object") return null;
|
||||
if (!raw || typeof raw !== "object") {
|
||||
log.debug("[CCSDK-AUTH] Credentials file not found or invalid", {
|
||||
path: credPath,
|
||||
rawType: typeof raw,
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
const data = raw as Record<string, unknown>;
|
||||
const claudeOauth = data.claudeAiOauth as Record<string, unknown> | undefined;
|
||||
if (!claudeOauth || typeof claudeOauth !== "object") return null;
|
||||
if (!claudeOauth || typeof claudeOauth !== "object") {
|
||||
log.debug("[CCSDK-AUTH] Credentials file missing claudeAiOauth", {
|
||||
path: credPath,
|
||||
topLevelKeys: Object.keys(data),
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
const accessToken = claudeOauth.accessToken;
|
||||
const refreshToken = claudeOauth.refreshToken;
|
||||
const expiresAt = claudeOauth.expiresAt;
|
||||
|
||||
if (typeof accessToken !== "string" || !accessToken) return null;
|
||||
if (typeof expiresAt !== "number" || expiresAt <= 0) return null;
|
||||
if (typeof accessToken !== "string" || !accessToken) {
|
||||
log.debug("[CCSDK-AUTH] Credentials file accessToken invalid", {
|
||||
path: credPath,
|
||||
});
|
||||
return null;
|
||||
}
|
||||
if (typeof expiresAt !== "number" || expiresAt <= 0) {
|
||||
log.debug("[CCSDK-AUTH] Credentials file expiresAt invalid", {
|
||||
path: credPath,
|
||||
expiresAt,
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
const expiresDate = new Date(expiresAt);
|
||||
const isExpired = expiresAt < Date.now();
|
||||
const msUntilExpiry = expiresAt - Date.now();
|
||||
|
||||
log.info("[CCSDK-AUTH] Successfully read credentials from file", {
|
||||
source: "file",
|
||||
path: credPath,
|
||||
accessTokenMasked: maskToken(accessToken as string),
|
||||
hasRefreshToken: Boolean(refreshToken),
|
||||
refreshTokenMasked: maskToken(refreshToken as string | undefined),
|
||||
expiresAt: expiresDate.toISOString(),
|
||||
isExpired,
|
||||
msUntilExpiry,
|
||||
type: refreshToken ? "oauth" : "token",
|
||||
});
|
||||
|
||||
if (typeof refreshToken === "string" && refreshToken) {
|
||||
return {
|
||||
|
||||
@ -45,15 +45,17 @@ export function resolveAgentRuntimeKind(config: MoltbotConfig, agentId: string):
|
||||
*
|
||||
* @param config - Moltbot configuration
|
||||
* @param agentId - Agent identifier
|
||||
* @param forceKind - Optional runtime kind to force (overrides config resolution)
|
||||
* @returns The appropriate AgentRuntime instance
|
||||
*/
|
||||
export async function createAgentRuntime(
|
||||
config: MoltbotConfig,
|
||||
agentId: string,
|
||||
forceKind?: AgentRuntimeKind,
|
||||
): Promise<AgentRuntime> {
|
||||
const runtimeKind = resolveAgentRuntimeKind(config, agentId);
|
||||
const runtimeKind = forceKind ?? resolveAgentRuntimeKind(config, agentId);
|
||||
|
||||
log.debug("Creating agent runtime", { agentId, runtime: runtimeKind });
|
||||
log.debug("Creating agent runtime", { agentId, runtime: runtimeKind, forced: !!forceKind });
|
||||
|
||||
if (runtimeKind === "ccsdk") {
|
||||
// Dynamically import to avoid loading SDK when not needed
|
||||
|
||||
@ -2,11 +2,15 @@
|
||||
* Pi Agent runtime implementation.
|
||||
*
|
||||
* Wraps the existing runEmbeddedPiAgent function to conform to the AgentRuntime interface.
|
||||
* Uses both shared generalized fields and Pi-specific options from piOptions bag.
|
||||
*/
|
||||
|
||||
import type { AgentRuntime, AgentRuntimeRunParams, AgentRuntimeResult } from "./agent-runtime.js";
|
||||
import { createSubsystemLogger } from "../logging/subsystem.js";
|
||||
import { runEmbeddedPiAgent } from "./pi-embedded.js";
|
||||
|
||||
const log = createSubsystemLogger("agents/pi-runtime");
|
||||
|
||||
/**
|
||||
* Create a Pi Agent runtime instance.
|
||||
*
|
||||
@ -19,49 +23,24 @@ export function createPiAgentRuntime(): AgentRuntime {
|
||||
displayName: "Pi Agent",
|
||||
|
||||
async run(params: AgentRuntimeRunParams): Promise<AgentRuntimeResult> {
|
||||
return runEmbeddedPiAgent({
|
||||
log.info("Starting Pi Agent session", {
|
||||
sessionId: params.sessionId,
|
||||
sessionKey: params.sessionKey,
|
||||
sessionFile: params.sessionFile,
|
||||
workspaceDir: params.workspaceDir,
|
||||
agentDir: params.agentDir,
|
||||
config: params.config,
|
||||
skillsSnapshot: params.skillsSnapshot,
|
||||
prompt: params.prompt,
|
||||
images: params.images,
|
||||
provider: params.provider,
|
||||
model: params.model,
|
||||
authProfileId: params.authProfileId,
|
||||
authProfileIdSource: params.authProfileIdSource,
|
||||
thinkLevel: params.thinkLevel,
|
||||
verboseLevel: params.verboseLevel,
|
||||
timeoutMs: params.timeoutMs,
|
||||
runId: params.runId,
|
||||
lane: params.lane,
|
||||
abortSignal: params.abortSignal,
|
||||
extraSystemPrompt: params.extraSystemPrompt,
|
||||
streamParams: params.streamParams,
|
||||
// Messaging context
|
||||
messageChannel: params.messageChannel,
|
||||
agentAccountId: params.agentAccountId,
|
||||
messageTo: params.messageTo,
|
||||
messageThreadId: params.messageThreadId,
|
||||
groupId: params.groupId,
|
||||
groupChannel: params.groupChannel,
|
||||
groupSpace: params.groupSpace,
|
||||
spawnedBy: params.spawnedBy,
|
||||
currentChannelId: params.currentChannelId,
|
||||
currentThreadTs: params.currentThreadTs,
|
||||
replyToMode: params.replyToMode,
|
||||
hasRepliedRef: params.hasRepliedRef,
|
||||
// Callbacks
|
||||
onPartialReply: params.onPartialReply,
|
||||
onAssistantMessageStart: params.onAssistantMessageStart,
|
||||
onBlockReply: params.onBlockReply,
|
||||
onBlockReplyFlush: params.onBlockReplyFlush,
|
||||
onReasoningStream: params.onReasoningStream,
|
||||
onToolResult: params.onToolResult,
|
||||
onAgentEvent: params.onAgentEvent,
|
||||
provider: params.provider ?? "default",
|
||||
model: params.model ?? "default",
|
||||
});
|
||||
|
||||
// Extract Pi-specific options from the options bag
|
||||
const piOpts = params.piOptions ?? {};
|
||||
|
||||
// Spread shared params directly, then add Pi-specific options from the bag
|
||||
return runEmbeddedPiAgent({
|
||||
...params,
|
||||
// Pi-specific options from piOptions bag override any shared fields
|
||||
enforceFinalTag: piOpts.enforceFinalTag,
|
||||
execOverrides: piOpts.execOverrides,
|
||||
bashElevated: piOpts.bashElevated,
|
||||
clientTools: piOpts.clientTools,
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
413
src/agents/unified-agent-runner.ts
Normal file
413
src/agents/unified-agent-runner.ts
Normal file
@ -0,0 +1,413 @@
|
||||
/**
|
||||
* Unified Agent Runner.
|
||||
*
|
||||
* Provides a single entry point for agent execution with runtime-outer, model-inner
|
||||
* failover composition. This ensures consistent runtime selection and failover behavior
|
||||
* across all agent execution paths.
|
||||
*/
|
||||
|
||||
import type { MoltbotConfig } from "../config/config.js";
|
||||
import { resolveAgentIdFromSessionKey } from "../config/sessions.js";
|
||||
import { createSubsystemLogger } from "../logging/subsystem.js";
|
||||
import type {
|
||||
AgentRuntime,
|
||||
AgentRuntimeKind,
|
||||
AgentRuntimeRunParams,
|
||||
AgentRuntimeResult,
|
||||
} from "./agent-runtime.js";
|
||||
import { resolveAgentModelFallbacksOverride } from "./agent-scope.js";
|
||||
import {
|
||||
coerceToFailoverError,
|
||||
describeFailoverError,
|
||||
isFailoverError,
|
||||
isTimeoutError,
|
||||
} from "./failover-error.js";
|
||||
import { createAgentRuntime, resolveAgentRuntimeKind } from "./main-agent-runtime-factory.js";
|
||||
import {
|
||||
buildModelAliasIndex,
|
||||
modelKey,
|
||||
parseModelRef,
|
||||
resolveConfiguredModelRef,
|
||||
resolveModelRefFromString,
|
||||
} from "./model-selection.js";
|
||||
import { DEFAULT_MODEL, DEFAULT_PROVIDER } from "./defaults.js";
|
||||
import {
|
||||
ensureAuthProfileStore,
|
||||
isProfileInCooldown,
|
||||
resolveAuthProfileOrder,
|
||||
} from "./auth-profiles.js";
|
||||
import { isReasoningTagProvider } from "../utils/provider-utils.js";
|
||||
|
||||
const log = createSubsystemLogger("agents/unified-runner");
|
||||
|
||||
/** Model candidate for fallback. */
|
||||
type ModelCandidate = {
|
||||
provider: string;
|
||||
model: string;
|
||||
};
|
||||
|
||||
/** Record of a single failover attempt. */
|
||||
export type UnifiedFallbackAttempt = {
|
||||
runtime: AgentRuntimeKind;
|
||||
provider: string;
|
||||
model: string;
|
||||
error: string;
|
||||
reason?: string;
|
||||
status?: number;
|
||||
code?: string;
|
||||
};
|
||||
|
||||
/** Result of unified agent run. */
|
||||
export type UnifiedAgentRunResult = {
|
||||
result: AgentRuntimeResult;
|
||||
runtime: AgentRuntimeKind;
|
||||
provider: string;
|
||||
model: string;
|
||||
attempts: UnifiedFallbackAttempt[];
|
||||
};
|
||||
|
||||
/** Parameters for unified agent runner (superset of AgentRuntimeRunParams). */
|
||||
export type UnifiedAgentRunParams = AgentRuntimeRunParams & {
|
||||
/** Optional explicit fallbacks list; when provided (even empty), replaces agents.defaults.model.fallbacks. */
|
||||
fallbacksOverride?: string[];
|
||||
/** Callback when a model is selected (before each attempt). */
|
||||
onModelSelected?: (info: {
|
||||
runtime: AgentRuntimeKind;
|
||||
provider: string;
|
||||
model: string;
|
||||
attempt: number;
|
||||
total: number;
|
||||
}) => void | Promise<void>;
|
||||
/** Error callback for each failed attempt. */
|
||||
onError?: (attempt: {
|
||||
runtime: AgentRuntimeKind;
|
||||
provider: string;
|
||||
model: string;
|
||||
error: unknown;
|
||||
attempt: number;
|
||||
total: number;
|
||||
}) => void | Promise<void>;
|
||||
};
|
||||
|
||||
function isAbortError(err: unknown): boolean {
|
||||
if (!err || typeof err !== "object") return false;
|
||||
if (isFailoverError(err)) return false;
|
||||
const name = "name" in err ? String(err.name) : "";
|
||||
return name === "AbortError";
|
||||
}
|
||||
|
||||
function shouldRethrowAbort(err: unknown): boolean {
|
||||
return isAbortError(err) && !isTimeoutError(err);
|
||||
}
|
||||
|
||||
function buildAllowedModelKeys(
|
||||
cfg: MoltbotConfig | undefined,
|
||||
defaultProvider: string,
|
||||
): Set<string> | null {
|
||||
const rawAllowlist = (() => {
|
||||
const modelMap = cfg?.agents?.defaults?.models ?? {};
|
||||
return Object.keys(modelMap);
|
||||
})();
|
||||
if (rawAllowlist.length === 0) return null;
|
||||
const keys = new Set<string>();
|
||||
for (const raw of rawAllowlist) {
|
||||
const parsed = parseModelRef(String(raw ?? ""), defaultProvider);
|
||||
if (!parsed) continue;
|
||||
keys.add(modelKey(parsed.provider, parsed.model));
|
||||
}
|
||||
return keys.size > 0 ? keys : null;
|
||||
}
|
||||
|
||||
function resolveFallbackCandidates(params: {
|
||||
cfg: MoltbotConfig | undefined;
|
||||
provider: string;
|
||||
model: string;
|
||||
fallbacksOverride?: string[];
|
||||
}): ModelCandidate[] {
|
||||
const primary = params.cfg
|
||||
? resolveConfiguredModelRef({
|
||||
cfg: params.cfg,
|
||||
defaultProvider: DEFAULT_PROVIDER,
|
||||
defaultModel: DEFAULT_MODEL,
|
||||
})
|
||||
: null;
|
||||
const defaultProvider = primary?.provider ?? DEFAULT_PROVIDER;
|
||||
const defaultModel = primary?.model ?? DEFAULT_MODEL;
|
||||
const provider = String(params.provider ?? "").trim() || defaultProvider;
|
||||
const model = String(params.model ?? "").trim() || defaultModel;
|
||||
const aliasIndex = buildModelAliasIndex({
|
||||
cfg: params.cfg ?? {},
|
||||
defaultProvider,
|
||||
});
|
||||
const allowlist = buildAllowedModelKeys(params.cfg, defaultProvider);
|
||||
const seen = new Set<string>();
|
||||
const candidates: ModelCandidate[] = [];
|
||||
|
||||
const addCandidate = (candidate: ModelCandidate, enforceAllowlist: boolean) => {
|
||||
if (!candidate.provider || !candidate.model) return;
|
||||
const key = modelKey(candidate.provider, candidate.model);
|
||||
if (seen.has(key)) return;
|
||||
if (enforceAllowlist && allowlist && !allowlist.has(key)) return;
|
||||
seen.add(key);
|
||||
candidates.push(candidate);
|
||||
};
|
||||
|
||||
addCandidate({ provider, model }, false);
|
||||
|
||||
const modelFallbacks = (() => {
|
||||
if (params.fallbacksOverride !== undefined) return params.fallbacksOverride;
|
||||
const model = params.cfg?.agents?.defaults?.model as
|
||||
| { fallbacks?: string[] }
|
||||
| string
|
||||
| undefined;
|
||||
if (model && typeof model === "object") return model.fallbacks ?? [];
|
||||
return [];
|
||||
})();
|
||||
|
||||
for (const raw of modelFallbacks) {
|
||||
const resolved = resolveModelRefFromString({
|
||||
raw: String(raw ?? ""),
|
||||
defaultProvider,
|
||||
aliasIndex,
|
||||
});
|
||||
if (!resolved) continue;
|
||||
addCandidate(resolved.ref, true);
|
||||
}
|
||||
|
||||
if (params.fallbacksOverride === undefined && primary?.provider && primary.model) {
|
||||
addCandidate({ provider: primary.provider, model: primary.model }, false);
|
||||
}
|
||||
|
||||
return candidates;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve runtime failover chain.
|
||||
*
|
||||
* Returns the primary runtime first, followed by the opposite runtime as fallback.
|
||||
*/
|
||||
function resolveRuntimeFailoverChain(primaryRuntime: AgentRuntimeKind): AgentRuntimeKind[] {
|
||||
if (primaryRuntime === "ccsdk") {
|
||||
return ["ccsdk", "pi"];
|
||||
}
|
||||
return ["pi", "ccsdk"];
|
||||
}
|
||||
|
||||
/**
|
||||
* Run agent with unified failover.
|
||||
*
|
||||
* Implements runtime-outer, model-inner failover composition:
|
||||
* - First tries all model candidates with the primary runtime
|
||||
* - If all fail, switches to the fallback runtime and tries model candidates again
|
||||
*
|
||||
* @param params - Unified agent run parameters
|
||||
* @returns Result with runtime, provider, model, and attempt history
|
||||
*/
|
||||
export async function runAgentWithUnifiedFailover(
|
||||
params: UnifiedAgentRunParams,
|
||||
): Promise<UnifiedAgentRunResult> {
|
||||
const config = params.config;
|
||||
const agentId = resolveAgentIdFromSessionKey(params.sessionKey) ?? "main";
|
||||
|
||||
// Resolve primary runtime and build failover chain
|
||||
const primaryRuntime = config ? resolveAgentRuntimeKind(config, agentId) : "pi";
|
||||
const runtimeChain = resolveRuntimeFailoverChain(primaryRuntime);
|
||||
|
||||
// Resolve model fallback candidates
|
||||
const fallbacksOverride =
|
||||
params.fallbacksOverride ??
|
||||
(config ? resolveAgentModelFallbacksOverride(config, agentId) : undefined);
|
||||
const modelCandidates = resolveFallbackCandidates({
|
||||
cfg: config,
|
||||
provider: params.provider ?? DEFAULT_PROVIDER,
|
||||
model: params.model ?? DEFAULT_MODEL,
|
||||
fallbacksOverride,
|
||||
});
|
||||
|
||||
const authStore = config
|
||||
? ensureAuthProfileStore(params.agentDir, { allowKeychainPrompt: false })
|
||||
: null;
|
||||
|
||||
const attempts: UnifiedFallbackAttempt[] = [];
|
||||
let lastError: unknown;
|
||||
let attemptIndex = 0;
|
||||
const totalAttempts = runtimeChain.length * modelCandidates.length;
|
||||
|
||||
log.info("Starting unified agent run", {
|
||||
sessionId: params.sessionId,
|
||||
runId: params.runId,
|
||||
agentId,
|
||||
primaryRuntime,
|
||||
provider: params.provider ?? DEFAULT_PROVIDER,
|
||||
model: params.model ?? DEFAULT_MODEL,
|
||||
modelCandidatesCount: modelCandidates.length,
|
||||
});
|
||||
|
||||
// Runtime-outer loop
|
||||
for (const runtimeKind of runtimeChain) {
|
||||
let runtime: AgentRuntime;
|
||||
try {
|
||||
// Force the specific runtime kind we want in the failover chain
|
||||
runtime = await createAgentRuntime(config ?? ({} as MoltbotConfig), agentId, runtimeKind);
|
||||
|
||||
// If the runtime kind doesn't match what we wanted (e.g., CCSDK unavailable),
|
||||
// skip to the next runtime in the chain
|
||||
if (runtime.kind !== runtimeKind) {
|
||||
log.debug("Runtime kind mismatch, skipping", {
|
||||
requested: runtimeKind,
|
||||
actual: runtime.kind,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
} catch (err) {
|
||||
log.warn("Failed to create runtime, trying next", {
|
||||
runtime: runtimeKind,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
log.debug("Trying runtime", { runtime: runtimeKind, displayName: runtime.displayName });
|
||||
|
||||
// Model-inner loop
|
||||
for (const candidate of modelCandidates) {
|
||||
attemptIndex += 1;
|
||||
|
||||
// Check auth profile cooldown
|
||||
if (authStore) {
|
||||
const profileIds = resolveAuthProfileOrder({
|
||||
cfg: config,
|
||||
store: authStore,
|
||||
provider: candidate.provider,
|
||||
});
|
||||
const isAnyProfileAvailable = profileIds.some((id) => !isProfileInCooldown(authStore, id));
|
||||
|
||||
if (profileIds.length > 0 && !isAnyProfileAvailable) {
|
||||
attempts.push({
|
||||
runtime: runtimeKind,
|
||||
provider: candidate.provider,
|
||||
model: candidate.model,
|
||||
error: `Provider ${candidate.provider} is in cooldown (all profiles unavailable)`,
|
||||
reason: "rate_limit",
|
||||
});
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
log.debug("Attempting model", {
|
||||
runtime: runtimeKind,
|
||||
provider: candidate.provider,
|
||||
model: candidate.model,
|
||||
attempt: attemptIndex,
|
||||
total: totalAttempts,
|
||||
});
|
||||
|
||||
// Notify model selection before attempt
|
||||
await params.onModelSelected?.({
|
||||
runtime: runtimeKind,
|
||||
provider: candidate.provider,
|
||||
model: candidate.model,
|
||||
attempt: attemptIndex,
|
||||
total: totalAttempts,
|
||||
});
|
||||
|
||||
// Determine if this is a different provider than originally requested
|
||||
const originalProvider = params.provider ?? DEFAULT_PROVIDER;
|
||||
const providerChanged = candidate.provider !== originalProvider;
|
||||
|
||||
// Drop authProfileId when provider changes (auth profile is provider-specific)
|
||||
const effectiveAuthProfileId = providerChanged ? undefined : params.authProfileId;
|
||||
const effectiveAuthProfileIdSource = providerChanged
|
||||
? undefined
|
||||
: params.authProfileIdSource;
|
||||
|
||||
// Compute enforceFinalTag based on current provider's reasoning tag support
|
||||
const baseEnforceFinalTag = params.piOptions?.enforceFinalTag ?? false;
|
||||
const currentProviderRequiresReasoningTags = isReasoningTagProvider(candidate.provider);
|
||||
const effectiveEnforceFinalTag =
|
||||
baseEnforceFinalTag || currentProviderRequiresReasoningTags;
|
||||
|
||||
const result = await runtime.run({
|
||||
...params,
|
||||
provider: candidate.provider,
|
||||
model: candidate.model,
|
||||
authProfileId: effectiveAuthProfileId,
|
||||
authProfileIdSource: effectiveAuthProfileIdSource,
|
||||
// Update piOptions with computed enforceFinalTag
|
||||
piOptions: params.piOptions
|
||||
? { ...params.piOptions, enforceFinalTag: effectiveEnforceFinalTag }
|
||||
: { enforceFinalTag: effectiveEnforceFinalTag },
|
||||
});
|
||||
|
||||
log.info("Unified agent run completed", {
|
||||
sessionId: params.sessionId,
|
||||
runId: params.runId,
|
||||
runtime: runtimeKind,
|
||||
provider: candidate.provider,
|
||||
model: candidate.model,
|
||||
attemptsCount: attempts.length,
|
||||
});
|
||||
|
||||
return {
|
||||
result,
|
||||
runtime: runtimeKind,
|
||||
provider: candidate.provider,
|
||||
model: candidate.model,
|
||||
attempts,
|
||||
};
|
||||
} catch (err) {
|
||||
if (shouldRethrowAbort(err)) throw err;
|
||||
|
||||
const normalized =
|
||||
coerceToFailoverError(err, {
|
||||
provider: candidate.provider,
|
||||
model: candidate.model,
|
||||
}) ?? err;
|
||||
|
||||
if (!isFailoverError(normalized)) throw err;
|
||||
|
||||
lastError = normalized;
|
||||
const described = describeFailoverError(normalized);
|
||||
attempts.push({
|
||||
runtime: runtimeKind,
|
||||
provider: candidate.provider,
|
||||
model: candidate.model,
|
||||
error: described.message,
|
||||
reason: described.reason,
|
||||
status: described.status,
|
||||
code: described.code,
|
||||
});
|
||||
|
||||
await params.onError?.({
|
||||
runtime: runtimeKind,
|
||||
provider: candidate.provider,
|
||||
model: candidate.model,
|
||||
error: normalized,
|
||||
attempt: attemptIndex,
|
||||
total: totalAttempts,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// All attempts exhausted
|
||||
if (attempts.length <= 1 && lastError) throw lastError;
|
||||
|
||||
const summary =
|
||||
attempts.length > 0
|
||||
? attempts
|
||||
.map(
|
||||
(attempt) =>
|
||||
`${attempt.runtime}:${attempt.provider}/${attempt.model}: ${attempt.error}${
|
||||
attempt.reason ? ` (${attempt.reason})` : ""
|
||||
}`,
|
||||
)
|
||||
.join(" | ")
|
||||
: "unknown";
|
||||
|
||||
throw new Error(
|
||||
`All runtimes and models failed (${attempts.length || totalAttempts}): ${summary}`,
|
||||
{ cause: lastError instanceof Error ? lastError : undefined },
|
||||
);
|
||||
}
|
||||
151
src/auto-reply/reply/agent-runner-double-send.test.ts
Normal file
151
src/auto-reply/reply/agent-runner-double-send.test.ts
Normal file
@ -0,0 +1,151 @@
|
||||
/**
|
||||
* Tests for double-send prevention in the agent runner pipeline.
|
||||
*
|
||||
* When block streaming is enabled, responses should NOT be sent both as:
|
||||
* 1. Block replies (incremental streaming during the run)
|
||||
* 2. Final payloads (after the run completes)
|
||||
*
|
||||
* This test verifies the deduplication logic works correctly.
|
||||
*/
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { buildReplyPayloads } from "./agent-runner-payloads.js";
|
||||
import { createPayloadKey } from "./payload-normalization.js";
|
||||
import type { ReplyPayload } from "../types.js";
|
||||
|
||||
describe("agent-runner double-send prevention", () => {
|
||||
describe("directlySentBlockKeys deduplication", () => {
|
||||
it("should filter out final payloads that were already sent via direct block reply", () => {
|
||||
// Simulate CCSDK flow: block reply was sent directly (no pipeline)
|
||||
const sentText = "Hello, this is the response!";
|
||||
const directlySentBlockKeys = new Set<string>();
|
||||
|
||||
// This is what happens in agent-runner-execution.ts when onBlockReply is called
|
||||
const blockPayload: ReplyPayload = { text: sentText };
|
||||
directlySentBlockKeys.add(createPayloadKey(blockPayload));
|
||||
|
||||
// Now the same text comes back as a final payload
|
||||
const finalPayloads: ReplyPayload[] = [{ text: sentText }];
|
||||
|
||||
const result = buildReplyPayloads({
|
||||
payloads: finalPayloads,
|
||||
isHeartbeat: false,
|
||||
didLogHeartbeatStrip: false,
|
||||
blockStreamingEnabled: false, // Direct callback mode, not pipeline
|
||||
blockReplyPipeline: null,
|
||||
directlySentBlockKeys,
|
||||
replyToMode: "first",
|
||||
currentMessageId: "msg-123",
|
||||
});
|
||||
|
||||
// The final payload should be filtered out since it was already sent
|
||||
expect(result.replyPayloads).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("should NOT filter out final payloads with different text", () => {
|
||||
const directlySentBlockKeys = new Set<string>();
|
||||
|
||||
// Block reply was sent with partial text
|
||||
const blockPayload: ReplyPayload = { text: "Hello" };
|
||||
directlySentBlockKeys.add(createPayloadKey(blockPayload));
|
||||
|
||||
// Final payload has more text
|
||||
const finalPayloads: ReplyPayload[] = [{ text: "Hello, world!" }];
|
||||
|
||||
const result = buildReplyPayloads({
|
||||
payloads: finalPayloads,
|
||||
isHeartbeat: false,
|
||||
didLogHeartbeatStrip: false,
|
||||
blockStreamingEnabled: false,
|
||||
blockReplyPipeline: null,
|
||||
directlySentBlockKeys,
|
||||
replyToMode: "first",
|
||||
currentMessageId: "msg-123",
|
||||
});
|
||||
|
||||
// The final payload should NOT be filtered since text differs
|
||||
expect(result.replyPayloads).toHaveLength(1);
|
||||
expect(result.replyPayloads[0]?.text).toBe("Hello, world!");
|
||||
});
|
||||
|
||||
it("should handle CCSDK cumulative streaming pattern", () => {
|
||||
// CCSDK calls onBlockReply with cumulative text each time:
|
||||
// Call 1: "Hello"
|
||||
// Call 2: "Hello, how"
|
||||
// Call 3: "Hello, how are you?"
|
||||
// Final result: "Hello, how are you?"
|
||||
const directlySentBlockKeys = new Set<string>();
|
||||
|
||||
// Each cumulative block reply is tracked
|
||||
directlySentBlockKeys.add(createPayloadKey({ text: "Hello" }));
|
||||
directlySentBlockKeys.add(createPayloadKey({ text: "Hello, how" }));
|
||||
directlySentBlockKeys.add(createPayloadKey({ text: "Hello, how are you?" }));
|
||||
|
||||
// Final payload matches the last cumulative text
|
||||
const finalPayloads: ReplyPayload[] = [{ text: "Hello, how are you?" }];
|
||||
|
||||
const result = buildReplyPayloads({
|
||||
payloads: finalPayloads,
|
||||
isHeartbeat: false,
|
||||
didLogHeartbeatStrip: false,
|
||||
blockStreamingEnabled: false,
|
||||
blockReplyPipeline: null,
|
||||
directlySentBlockKeys,
|
||||
replyToMode: "first",
|
||||
currentMessageId: "msg-123",
|
||||
});
|
||||
|
||||
// Final should be filtered since it matches the last cumulative block
|
||||
expect(result.replyPayloads).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("whitespace normalization", () => {
|
||||
it("should match payloads with trailing whitespace differences", () => {
|
||||
const directlySentBlockKeys = new Set<string>();
|
||||
|
||||
// Block reply has trailing whitespace
|
||||
directlySentBlockKeys.add(createPayloadKey({ text: "Hello \n" }));
|
||||
|
||||
// Final payload has no trailing whitespace
|
||||
const finalPayloads: ReplyPayload[] = [{ text: "Hello" }];
|
||||
|
||||
const result = buildReplyPayloads({
|
||||
payloads: finalPayloads,
|
||||
isHeartbeat: false,
|
||||
didLogHeartbeatStrip: false,
|
||||
blockStreamingEnabled: false,
|
||||
blockReplyPipeline: null,
|
||||
directlySentBlockKeys,
|
||||
replyToMode: "first",
|
||||
currentMessageId: "msg-123",
|
||||
});
|
||||
|
||||
// Should be filtered since trimmed text matches
|
||||
expect(result.replyPayloads).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("payload key creation", () => {
|
||||
it("should create identical keys for identical payloads", () => {
|
||||
const payload1: ReplyPayload = { text: "Hello" };
|
||||
const payload2: ReplyPayload = { text: "Hello" };
|
||||
|
||||
expect(createPayloadKey(payload1)).toBe(createPayloadKey(payload2));
|
||||
});
|
||||
|
||||
it("should create different keys for different text", () => {
|
||||
const payload1: ReplyPayload = { text: "Hello" };
|
||||
const payload2: ReplyPayload = { text: "Goodbye" };
|
||||
|
||||
expect(createPayloadKey(payload1)).not.toBe(createPayloadKey(payload2));
|
||||
});
|
||||
|
||||
it("should normalize whitespace in key creation", () => {
|
||||
const payload1: ReplyPayload = { text: "Hello \n\t" };
|
||||
const payload2: ReplyPayload = { text: "Hello" };
|
||||
|
||||
// Keys should match after trimming
|
||||
expect(createPayloadKey(payload1)).toBe(createPayloadKey(payload2));
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,292 @@
|
||||
/**
|
||||
* Tests for agent-runner-execution callback wiring.
|
||||
*
|
||||
* These tests verify that callbacks (onBlockReply, onBlockReplyFlush, onReasoningStream)
|
||||
* are correctly wired through to the runtime, regardless of block streaming configuration.
|
||||
*/
|
||||
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
|
||||
|
||||
import type { TemplateContext } from "../templating.js";
|
||||
import type { FollowupRun, QueueSettings } from "./queue.js";
|
||||
import { createMockTypingController } from "./test-helpers.js";
|
||||
|
||||
// Track what callbacks are passed to the unified runner
|
||||
let capturedCallbacks: {
|
||||
onBlockReply?: unknown;
|
||||
onBlockReplyFlush?: unknown;
|
||||
onReasoningStream?: unknown;
|
||||
onPartialReply?: unknown;
|
||||
} = {};
|
||||
|
||||
// Mock the unified runner to capture callbacks
|
||||
vi.mock("../../agents/unified-agent-runner.js", () => ({
|
||||
runAgentWithUnifiedFailover: vi.fn(async (params) => {
|
||||
capturedCallbacks = {
|
||||
onBlockReply: params.onBlockReply,
|
||||
onBlockReplyFlush: params.onBlockReplyFlush,
|
||||
onReasoningStream: params.onReasoningStream,
|
||||
onPartialReply: params.onPartialReply,
|
||||
};
|
||||
return {
|
||||
result: {
|
||||
payloads: [{ text: "Test response" }],
|
||||
meta: {},
|
||||
},
|
||||
runtime: "pi",
|
||||
provider: "anthropic",
|
||||
model: "claude",
|
||||
attempts: [],
|
||||
};
|
||||
}),
|
||||
}));
|
||||
|
||||
// Mock model fallback
|
||||
vi.mock("../../agents/model-fallback.js", () => ({
|
||||
runWithModelFallback: async ({
|
||||
provider,
|
||||
model,
|
||||
run,
|
||||
}: {
|
||||
provider: string;
|
||||
model: string;
|
||||
run: (provider: string, model: string) => Promise<unknown>;
|
||||
}) => ({
|
||||
result: await run(provider, model),
|
||||
provider,
|
||||
model,
|
||||
}),
|
||||
}));
|
||||
|
||||
// Mock Pi embedded
|
||||
vi.mock("../../agents/pi-embedded.js", () => ({
|
||||
queueEmbeddedPiMessage: vi.fn().mockReturnValue(false),
|
||||
runEmbeddedPiAgent: vi.fn(async () => ({
|
||||
payloads: [{ text: "Pi response" }],
|
||||
meta: {},
|
||||
})),
|
||||
}));
|
||||
|
||||
// Mock queue
|
||||
vi.mock("./queue.js", async () => {
|
||||
const actual = await vi.importActual<typeof import("./queue.js")>("./queue.js");
|
||||
return {
|
||||
...actual,
|
||||
enqueueFollowupRun: vi.fn(),
|
||||
scheduleFollowupDrain: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
import { runReplyAgent } from "./agent-runner.js";
|
||||
|
||||
describe("agent-runner-execution callback wiring", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
capturedCallbacks = {};
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
|
||||
const createBaseParams = (overrides: {
|
||||
blockStreamingEnabled?: boolean;
|
||||
blockReplyPipeline?: unknown;
|
||||
onBlockReply?: () => void;
|
||||
onReasoningStream?: () => void;
|
||||
}) => {
|
||||
const typing = createMockTypingController();
|
||||
const sessionCtx = {
|
||||
Provider: "discord",
|
||||
OriginatingTo: "channel:C1",
|
||||
AccountId: "primary",
|
||||
MessageSid: "msg",
|
||||
} as unknown as TemplateContext;
|
||||
const resolvedQueue = { mode: "interrupt" } as unknown as QueueSettings;
|
||||
const followupRun = {
|
||||
prompt: "hello",
|
||||
summaryLine: "hello",
|
||||
enqueuedAt: Date.now(),
|
||||
run: {
|
||||
sessionId: "session",
|
||||
sessionKey: "main",
|
||||
messageProvider: "discord",
|
||||
sessionFile: "/tmp/session.jsonl",
|
||||
workspaceDir: "/tmp",
|
||||
config: {
|
||||
agents: {
|
||||
defaults: {
|
||||
blockStreamingCoalesce: {
|
||||
minChars: 1,
|
||||
maxChars: 200,
|
||||
idleMs: 0,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
skillsSnapshot: {},
|
||||
provider: "anthropic",
|
||||
model: "claude",
|
||||
thinkLevel: "high",
|
||||
verboseLevel: "off",
|
||||
elevatedLevel: "off",
|
||||
bashElevated: {
|
||||
enabled: false,
|
||||
allowed: false,
|
||||
defaultLevel: "off",
|
||||
},
|
||||
timeoutMs: 1_000,
|
||||
blockReplyBreak: "text_end",
|
||||
},
|
||||
} as unknown as FollowupRun;
|
||||
|
||||
return {
|
||||
commandBody: "hello",
|
||||
followupRun,
|
||||
queueKey: "main",
|
||||
resolvedQueue,
|
||||
shouldSteer: false,
|
||||
shouldFollowup: false,
|
||||
isActive: false,
|
||||
isStreaming: false,
|
||||
opts: {
|
||||
onBlockReply: overrides.onBlockReply,
|
||||
onReasoningStream: overrides.onReasoningStream,
|
||||
},
|
||||
typing,
|
||||
sessionCtx,
|
||||
defaultModel: "anthropic/claude-opus-4-5",
|
||||
resolvedVerboseLevel: "off" as const,
|
||||
isNewSession: false,
|
||||
blockStreamingEnabled: overrides.blockStreamingEnabled ?? false,
|
||||
blockReplyChunking: {
|
||||
minChars: 1,
|
||||
maxChars: 200,
|
||||
breakPreference: "paragraph" as const,
|
||||
},
|
||||
resolvedBlockStreamingBreak: "text_end" as const,
|
||||
shouldInjectGroupIntro: false,
|
||||
typingMode: "instant" as const,
|
||||
};
|
||||
};
|
||||
|
||||
describe("onBlockReply forwarding", () => {
|
||||
it("provides onBlockReply when blockStreamingEnabled=true and callback exists", async () => {
|
||||
const onBlockReply = vi.fn();
|
||||
const params = createBaseParams({ blockStreamingEnabled: true, onBlockReply });
|
||||
|
||||
await runReplyAgent(params);
|
||||
|
||||
// Callback should be wired through
|
||||
expect(capturedCallbacks.onBlockReply).toBeDefined();
|
||||
expect(typeof capturedCallbacks.onBlockReply).toBe("function");
|
||||
});
|
||||
|
||||
it("provides onBlockReply when blockStreamingEnabled=false and callback exists", async () => {
|
||||
const onBlockReply = vi.fn();
|
||||
const params = createBaseParams({ blockStreamingEnabled: false, onBlockReply });
|
||||
|
||||
await runReplyAgent(params);
|
||||
|
||||
// Callback should STILL be wired through (critical for CCSDK)
|
||||
expect(capturedCallbacks.onBlockReply).toBeDefined();
|
||||
expect(typeof capturedCallbacks.onBlockReply).toBe("function");
|
||||
});
|
||||
|
||||
it("does not provide onBlockReply when no callback exists", async () => {
|
||||
const params = createBaseParams({ blockStreamingEnabled: true });
|
||||
|
||||
await runReplyAgent(params);
|
||||
|
||||
// No callback provided, so none should be wired
|
||||
expect(capturedCallbacks.onBlockReply).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("onBlockReplyFlush forwarding", () => {
|
||||
it("provides onBlockReplyFlush when blockStreamingEnabled=true and onBlockReply exists", async () => {
|
||||
const onBlockReply = vi.fn();
|
||||
const params = createBaseParams({ blockStreamingEnabled: true, onBlockReply });
|
||||
|
||||
await runReplyAgent(params);
|
||||
|
||||
// Flush callback should be provided
|
||||
expect(capturedCallbacks.onBlockReplyFlush).toBeDefined();
|
||||
expect(typeof capturedCallbacks.onBlockReplyFlush).toBe("function");
|
||||
});
|
||||
|
||||
it("provides onBlockReplyFlush when blockStreamingEnabled=false but onBlockReply exists", async () => {
|
||||
const onBlockReply = vi.fn();
|
||||
const params = createBaseParams({ blockStreamingEnabled: false, onBlockReply });
|
||||
|
||||
await runReplyAgent(params);
|
||||
|
||||
// Flush callback should STILL be provided (critical for CCSDK message completion)
|
||||
expect(capturedCallbacks.onBlockReplyFlush).toBeDefined();
|
||||
expect(typeof capturedCallbacks.onBlockReplyFlush).toBe("function");
|
||||
});
|
||||
|
||||
it("does not provide onBlockReplyFlush when no onBlockReply callback exists", async () => {
|
||||
const params = createBaseParams({ blockStreamingEnabled: true });
|
||||
|
||||
await runReplyAgent(params);
|
||||
|
||||
// No block reply callback means no flush needed
|
||||
expect(capturedCallbacks.onBlockReplyFlush).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("onReasoningStream forwarding", () => {
|
||||
it("provides onReasoningStream when callback exists", async () => {
|
||||
const onReasoningStream = vi.fn();
|
||||
const params = createBaseParams({ blockStreamingEnabled: false, onReasoningStream });
|
||||
|
||||
await runReplyAgent(params);
|
||||
|
||||
// Reasoning stream callback should be wired through
|
||||
expect(capturedCallbacks.onReasoningStream).toBeDefined();
|
||||
expect(typeof capturedCallbacks.onReasoningStream).toBe("function");
|
||||
});
|
||||
|
||||
it("does not provide onReasoningStream when no callback exists and typing mode is not thinking", async () => {
|
||||
const params = createBaseParams({ blockStreamingEnabled: false });
|
||||
// Default typing mode is "instant", not "thinking"
|
||||
|
||||
await runReplyAgent(params);
|
||||
|
||||
// No reasoning stream callback expected
|
||||
expect(capturedCallbacks.onReasoningStream).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("callback combination scenarios", () => {
|
||||
it("provides all callbacks when all are specified", async () => {
|
||||
const onBlockReply = vi.fn();
|
||||
const onReasoningStream = vi.fn();
|
||||
const params = createBaseParams({
|
||||
blockStreamingEnabled: true,
|
||||
onBlockReply,
|
||||
onReasoningStream,
|
||||
});
|
||||
|
||||
await runReplyAgent(params);
|
||||
|
||||
// All callbacks should be wired
|
||||
expect(capturedCallbacks.onBlockReply).toBeDefined();
|
||||
expect(capturedCallbacks.onBlockReplyFlush).toBeDefined();
|
||||
expect(capturedCallbacks.onReasoningStream).toBeDefined();
|
||||
});
|
||||
|
||||
it("CCSDK scenario: blockStreamingEnabled=false with onBlockReply", async () => {
|
||||
// This is the critical CCSDK scenario where block streaming is disabled
|
||||
// but the CCSDK runtime still sends text through onBlockReply callbacks
|
||||
const onBlockReply = vi.fn();
|
||||
const params = createBaseParams({ blockStreamingEnabled: false, onBlockReply });
|
||||
|
||||
await runReplyAgent(params);
|
||||
|
||||
// Both onBlockReply and onBlockReplyFlush must be provided
|
||||
expect(capturedCallbacks.onBlockReply).toBeDefined();
|
||||
expect(capturedCallbacks.onBlockReplyFlush).toBeDefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -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];
|
||||
});
|
||||
|
||||
@ -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 =
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -5,22 +5,40 @@ import { createMockTypingController } from "./test-helpers.js";
|
||||
|
||||
const runEmbeddedPiAgentMock = vi.fn();
|
||||
|
||||
vi.mock("../../agents/model-fallback.js", () => ({
|
||||
runWithModelFallback: async ({
|
||||
run,
|
||||
}: {
|
||||
run: (provider: string, model: string) => Promise<unknown>;
|
||||
}) => ({
|
||||
// Force a cross-provider fallback candidate
|
||||
result: await run("openai-codex", "gpt-5.2"),
|
||||
provider: "openai-codex",
|
||||
model: "gpt-5.2",
|
||||
}),
|
||||
vi.mock("../../agents/unified-agent-runner.js", () => ({
|
||||
runAgentWithUnifiedFailover: async (params: {
|
||||
provider?: string;
|
||||
model?: string;
|
||||
authProfileId?: string;
|
||||
authProfileIdSource?: string;
|
||||
}) => {
|
||||
// Simulate a cross-provider fallback - unified runner should drop authProfileId
|
||||
// when provider changes from anthropic to openai-codex
|
||||
const originalProvider = params.provider ?? "anthropic";
|
||||
const fallbackProvider = "openai-codex";
|
||||
const fallbackModel = "gpt-5.2";
|
||||
const providerChanged = fallbackProvider !== originalProvider;
|
||||
|
||||
const result = await runEmbeddedPiAgentMock({
|
||||
...params,
|
||||
provider: fallbackProvider,
|
||||
model: fallbackModel,
|
||||
// Drop auth profile when provider changes (the fix being tested)
|
||||
authProfileId: providerChanged ? undefined : params.authProfileId,
|
||||
authProfileIdSource: providerChanged ? undefined : params.authProfileIdSource,
|
||||
});
|
||||
return {
|
||||
result,
|
||||
runtime: "pi",
|
||||
provider: fallbackProvider,
|
||||
model: fallbackModel,
|
||||
attempts: [],
|
||||
};
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("../../agents/pi-embedded.js", () => ({
|
||||
queueEmbeddedPiMessage: vi.fn().mockReturnValue(false),
|
||||
runEmbeddedPiAgent: (params: unknown) => runEmbeddedPiAgentMock(params),
|
||||
}));
|
||||
|
||||
vi.mock("./queue.js", async () => {
|
||||
|
||||
@ -7,19 +7,14 @@ import type { FollowupRun, QueueSettings } from "./queue.js";
|
||||
import { createMockTypingController } from "./test-helpers.js";
|
||||
|
||||
const runEmbeddedPiAgentMock = vi.fn();
|
||||
const runWithModelFallbackMock = vi.fn();
|
||||
const runAgentWithUnifiedFailoverMock = vi.fn();
|
||||
|
||||
vi.mock("../../agents/model-fallback.js", () => ({
|
||||
runWithModelFallback: (params: {
|
||||
provider: string;
|
||||
model: string;
|
||||
run: (provider: string, model: string) => Promise<unknown>;
|
||||
}) => runWithModelFallbackMock(params),
|
||||
vi.mock("../../agents/unified-agent-runner.js", () => ({
|
||||
runAgentWithUnifiedFailover: (params: unknown) => runAgentWithUnifiedFailoverMock(params),
|
||||
}));
|
||||
|
||||
vi.mock("../../agents/pi-embedded.js", () => ({
|
||||
queueEmbeddedPiMessage: vi.fn().mockReturnValue(false),
|
||||
runEmbeddedPiAgent: (params: unknown) => runEmbeddedPiAgentMock(params),
|
||||
}));
|
||||
|
||||
vi.mock("./queue.js", async () => {
|
||||
@ -108,20 +103,31 @@ function createRun(params?: {
|
||||
describe("runReplyAgent fallback reasoning tags", () => {
|
||||
beforeEach(() => {
|
||||
runEmbeddedPiAgentMock.mockReset();
|
||||
runWithModelFallbackMock.mockReset();
|
||||
runAgentWithUnifiedFailoverMock.mockReset();
|
||||
});
|
||||
|
||||
it("enforces <final> when the fallback provider requires reasoning tags", async () => {
|
||||
runEmbeddedPiAgentMock.mockResolvedValueOnce({
|
||||
payloads: [{ text: "ok" }],
|
||||
meta: {},
|
||||
});
|
||||
runWithModelFallbackMock.mockImplementationOnce(
|
||||
async ({ run }: { run: (provider: string, model: string) => Promise<unknown> }) => ({
|
||||
result: await run("google-antigravity", "gemini-3"),
|
||||
provider: "google-antigravity",
|
||||
model: "gemini-3",
|
||||
}),
|
||||
// Simulate what the unified runner does: when falling back to google-antigravity,
|
||||
// it should set enforceFinalTag=true because that provider requires reasoning tags
|
||||
runAgentWithUnifiedFailoverMock.mockImplementationOnce(
|
||||
async (params: EmbeddedPiAgentParams & { piOptions?: { enforceFinalTag?: boolean } }) => {
|
||||
// The unified runner computes enforceFinalTag based on the current provider's
|
||||
// reasoning tag requirements. For google-antigravity, it should be true.
|
||||
const enforceFinalTag = true; // google-antigravity requires reasoning tags
|
||||
runEmbeddedPiAgentMock({
|
||||
...params,
|
||||
provider: "google-antigravity",
|
||||
model: "gemini-3",
|
||||
enforceFinalTag,
|
||||
});
|
||||
return {
|
||||
result: { payloads: [{ text: "ok" }], meta: {} },
|
||||
runtime: "pi",
|
||||
provider: "google-antigravity",
|
||||
model: "gemini-3",
|
||||
attempts: [],
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
await createRun();
|
||||
@ -131,18 +137,27 @@ describe("runReplyAgent fallback reasoning tags", () => {
|
||||
});
|
||||
|
||||
it("enforces <final> during memory flush on fallback providers", async () => {
|
||||
runEmbeddedPiAgentMock.mockImplementation(async (params: EmbeddedPiAgentParams) => {
|
||||
if (params.prompt === DEFAULT_MEMORY_FLUSH_PROMPT) {
|
||||
return { payloads: [], meta: {} };
|
||||
}
|
||||
return { payloads: [{ text: "ok" }], meta: {} };
|
||||
});
|
||||
runWithModelFallbackMock.mockImplementation(
|
||||
async ({ run }: { run: (provider: string, model: string) => Promise<unknown> }) => ({
|
||||
result: await run("google-antigravity", "gemini-3"),
|
||||
provider: "google-antigravity",
|
||||
model: "gemini-3",
|
||||
}),
|
||||
// Simulate unified runner behavior for both main call and memory flush call
|
||||
runAgentWithUnifiedFailoverMock.mockImplementation(
|
||||
async (params: EmbeddedPiAgentParams & { piOptions?: { enforceFinalTag?: boolean } }) => {
|
||||
// The unified runner computes enforceFinalTag based on the current provider's
|
||||
// reasoning tag requirements. For google-antigravity, it should be true.
|
||||
const enforceFinalTag = true; // google-antigravity requires reasoning tags
|
||||
runEmbeddedPiAgentMock({
|
||||
...params,
|
||||
provider: "google-antigravity",
|
||||
model: "gemini-3",
|
||||
enforceFinalTag,
|
||||
});
|
||||
const isFlush = params.prompt === DEFAULT_MEMORY_FLUSH_PROMPT;
|
||||
return {
|
||||
result: { payloads: isFlush ? [] : [{ text: "ok" }], meta: {} },
|
||||
runtime: "pi",
|
||||
provider: "google-antigravity",
|
||||
model: "gemini-3",
|
||||
attempts: [],
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
await createRun({
|
||||
|
||||
@ -2,6 +2,7 @@ import { logVerbose } from "../../globals.js";
|
||||
import type { ReplyPayload } from "../types.js";
|
||||
import { createBlockReplyCoalescer } from "./block-reply-coalescer.js";
|
||||
import type { BlockStreamingCoalescing } from "./block-streaming.js";
|
||||
import { createPayloadKey } from "./payload-normalization.js";
|
||||
|
||||
export type BlockReplyPipeline = {
|
||||
enqueue: (payload: ReplyPayload) => void;
|
||||
@ -34,18 +35,15 @@ export function createAudioAsVoiceBuffer(params: {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a stable string key for a payload for deduplication.
|
||||
*
|
||||
* @deprecated Use `createPayloadKey` from `payload-normalization.ts` directly.
|
||||
* This function is kept for backward compatibility but delegates to the
|
||||
* centralized implementation for consistent key generation.
|
||||
*/
|
||||
export function createBlockReplyPayloadKey(payload: ReplyPayload): string {
|
||||
const text = payload.text?.trim() ?? "";
|
||||
const mediaList = payload.mediaUrls?.length
|
||||
? payload.mediaUrls
|
||||
: payload.mediaUrl
|
||||
? [payload.mediaUrl]
|
||||
: [];
|
||||
return JSON.stringify({
|
||||
text,
|
||||
mediaList,
|
||||
replyToId: payload.replyToId ?? null,
|
||||
});
|
||||
return createPayloadKey(payload);
|
||||
}
|
||||
|
||||
const withTimeout = async <T>(
|
||||
|
||||
@ -2,8 +2,10 @@ import crypto from "node:crypto";
|
||||
import { resolveAgentModelFallbacksOverride } from "../../agents/agent-scope.js";
|
||||
import { lookupContextTokens } from "../../agents/context.js";
|
||||
import { DEFAULT_CONTEXT_TOKENS } from "../../agents/defaults.js";
|
||||
import { runWithModelFallback } from "../../agents/model-fallback.js";
|
||||
import { runEmbeddedPiAgent } from "../../agents/pi-embedded.js";
|
||||
import {
|
||||
runAgentWithUnifiedFailover,
|
||||
type UnifiedAgentRunResult,
|
||||
} from "../../agents/unified-agent-runner.js";
|
||||
import { resolveAgentIdFromSessionKey, type SessionEntry } from "../../config/sessions.js";
|
||||
import type { TypingMode } from "../../config/types.js";
|
||||
import { logVerbose } from "../../globals.js";
|
||||
@ -121,76 +123,81 @@ export function createFollowupRunner(params: {
|
||||
});
|
||||
}
|
||||
let autoCompactionCompleted = false;
|
||||
let runResult: Awaited<ReturnType<typeof runEmbeddedPiAgent>>;
|
||||
let fallbackProvider = queued.run.provider;
|
||||
let fallbackModel = queued.run.model;
|
||||
let unifiedResult: UnifiedAgentRunResult;
|
||||
try {
|
||||
const fallbackResult = await runWithModelFallback({
|
||||
cfg: queued.run.config,
|
||||
unifiedResult = await runAgentWithUnifiedFailover({
|
||||
// Core params
|
||||
sessionId: queued.run.sessionId,
|
||||
sessionKey: queued.run.sessionKey,
|
||||
sessionFile: queued.run.sessionFile,
|
||||
workspaceDir: queued.run.workspaceDir,
|
||||
agentDir: queued.run.agentDir,
|
||||
config: queued.run.config,
|
||||
skillsSnapshot: queued.run.skillsSnapshot,
|
||||
prompt: queued.prompt,
|
||||
extraSystemPrompt: queued.run.extraSystemPrompt,
|
||||
provider: queued.run.provider,
|
||||
model: queued.run.model,
|
||||
agentDir: queued.run.agentDir,
|
||||
authProfileId: queued.run.authProfileId,
|
||||
authProfileIdSource: queued.run.authProfileIdSource,
|
||||
thinkLevel: queued.run.thinkLevel,
|
||||
verboseLevel: queued.run.verboseLevel,
|
||||
timeoutMs: queued.run.timeoutMs,
|
||||
runId,
|
||||
|
||||
// Messaging context
|
||||
messageProvider: queued.run.messageProvider,
|
||||
agentAccountId: queued.run.agentAccountId,
|
||||
messageTo: queued.originatingTo,
|
||||
messageThreadId: queued.originatingThreadId,
|
||||
groupId: queued.run.groupId,
|
||||
groupChannel: queued.run.groupChannel,
|
||||
groupSpace: queued.run.groupSpace,
|
||||
|
||||
// Sender context
|
||||
senderId: queued.run.senderId,
|
||||
senderName: queued.run.senderName,
|
||||
senderUsername: queued.run.senderUsername,
|
||||
senderE164: queued.run.senderE164,
|
||||
|
||||
// Generalized fields
|
||||
reasoningLevel: queued.run.reasoningLevel,
|
||||
blockReplyBreak: queued.run.blockReplyBreak,
|
||||
ownerNumbers: queued.run.ownerNumbers,
|
||||
|
||||
// Pi-specific options
|
||||
piOptions: {
|
||||
enforceFinalTag: queued.run.enforceFinalTag,
|
||||
execOverrides: queued.run.execOverrides,
|
||||
bashElevated: queued.run.bashElevated,
|
||||
},
|
||||
|
||||
// Fallback config
|
||||
fallbacksOverride: resolveAgentModelFallbacksOverride(
|
||||
queued.run.config,
|
||||
resolveAgentIdFromSessionKey(queued.run.sessionKey),
|
||||
),
|
||||
run: (provider, model) => {
|
||||
const authProfileId =
|
||||
provider === queued.run.provider ? queued.run.authProfileId : undefined;
|
||||
return runEmbeddedPiAgent({
|
||||
sessionId: queued.run.sessionId,
|
||||
sessionKey: queued.run.sessionKey,
|
||||
messageProvider: queued.run.messageProvider,
|
||||
agentAccountId: queued.run.agentAccountId,
|
||||
messageTo: queued.originatingTo,
|
||||
messageThreadId: queued.originatingThreadId,
|
||||
groupId: queued.run.groupId,
|
||||
groupChannel: queued.run.groupChannel,
|
||||
groupSpace: queued.run.groupSpace,
|
||||
senderId: queued.run.senderId,
|
||||
senderName: queued.run.senderName,
|
||||
senderUsername: queued.run.senderUsername,
|
||||
senderE164: queued.run.senderE164,
|
||||
sessionFile: queued.run.sessionFile,
|
||||
workspaceDir: queued.run.workspaceDir,
|
||||
config: queued.run.config,
|
||||
skillsSnapshot: queued.run.skillsSnapshot,
|
||||
prompt: queued.prompt,
|
||||
extraSystemPrompt: queued.run.extraSystemPrompt,
|
||||
ownerNumbers: queued.run.ownerNumbers,
|
||||
enforceFinalTag: queued.run.enforceFinalTag,
|
||||
provider,
|
||||
model,
|
||||
authProfileId,
|
||||
authProfileIdSource: authProfileId ? queued.run.authProfileIdSource : undefined,
|
||||
thinkLevel: queued.run.thinkLevel,
|
||||
verboseLevel: queued.run.verboseLevel,
|
||||
reasoningLevel: queued.run.reasoningLevel,
|
||||
execOverrides: queued.run.execOverrides,
|
||||
bashElevated: queued.run.bashElevated,
|
||||
timeoutMs: queued.run.timeoutMs,
|
||||
runId,
|
||||
blockReplyBreak: queued.run.blockReplyBreak,
|
||||
onAgentEvent: (evt) => {
|
||||
if (evt.stream !== "compaction") return;
|
||||
const phase = typeof evt.data.phase === "string" ? evt.data.phase : "";
|
||||
const willRetry = Boolean(evt.data.willRetry);
|
||||
if (phase === "end" && !willRetry) {
|
||||
autoCompactionCompleted = true;
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
// Callbacks
|
||||
onAgentEvent: (evt) => {
|
||||
if (evt.stream !== "compaction") return;
|
||||
const phase = typeof evt.data.phase === "string" ? evt.data.phase : "";
|
||||
const willRetry = Boolean(evt.data.willRetry);
|
||||
if (phase === "end" && !willRetry) {
|
||||
autoCompactionCompleted = true;
|
||||
}
|
||||
},
|
||||
});
|
||||
runResult = fallbackResult.result;
|
||||
fallbackProvider = fallbackResult.provider;
|
||||
fallbackModel = fallbackResult.model;
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
defaultRuntime.error?.(`Followup agent failed before reply: ${message}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const runResult = unifiedResult.result;
|
||||
const fallbackProvider = unifiedResult.provider;
|
||||
const fallbackModel = unifiedResult.model;
|
||||
|
||||
if (storePath && sessionKey) {
|
||||
const usage = runResult.meta.agentMeta?.usage;
|
||||
const modelUsed = runResult.meta.agentMeta?.model ?? fallbackModel ?? defaultModel;
|
||||
|
||||
101
src/auto-reply/reply/payload-normalization.ts
Normal file
101
src/auto-reply/reply/payload-normalization.ts
Normal file
@ -0,0 +1,101 @@
|
||||
/**
|
||||
* Centralized payload normalization for deduplication and key generation.
|
||||
*
|
||||
* This module provides a SINGLE source of truth for normalizing reply payloads.
|
||||
* Both block reply tracking (in agent-runner-execution.ts) and final payload
|
||||
* filtering (in agent-runner-payloads.ts) MUST use these functions to ensure
|
||||
* consistent key generation and reliable deduplication.
|
||||
*
|
||||
* ## Runtime Contract
|
||||
*
|
||||
* All agent runtimes (Pi, CCSDK) must adhere to:
|
||||
*
|
||||
* 1. **Final payloads contain user-facing text only**
|
||||
* - `payloads[].text` must NEVER contain reasoning/thinking content
|
||||
* - Reasoning must be streamed via `onReasoningStream` callback
|
||||
* - This ensures gateway/channel delivery never includes internal reasoning
|
||||
*
|
||||
* 2. **Block replies use the same normalization**
|
||||
* - Both block and final payloads go through `normalizePayloadForDedup`
|
||||
* - This ensures keys always match for deduplication
|
||||
*
|
||||
* 3. **Whitespace is normalized**
|
||||
* - All text is trimmed for consistent key generation
|
||||
* - Leading/trailing whitespace differences don't break deduplication
|
||||
*/
|
||||
|
||||
import type { ReplyPayload } from "../types.js";
|
||||
|
||||
/**
|
||||
* Normalized payload structure for deduplication.
|
||||
*
|
||||
* All fields are in a consistent format suitable for key generation.
|
||||
*/
|
||||
export type NormalizedPayload = {
|
||||
/** Trimmed text content (never reasoning) */
|
||||
text: string;
|
||||
/** Normalized media URL list (never undefined, always an array) */
|
||||
mediaList: string[];
|
||||
/** Reply threading: target message ID */
|
||||
replyToId: string | null;
|
||||
/** Reply threading: tag flag */
|
||||
replyToTag: boolean;
|
||||
/** Reply threading: reply to current message flag */
|
||||
replyToCurrent: boolean;
|
||||
/** Audio as voice flag */
|
||||
audioAsVoice: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Normalize a payload into a consistent structure for deduplication.
|
||||
*
|
||||
* This is the SINGLE function used by both:
|
||||
* - Block reply tracking in `agent-runner-execution.ts`
|
||||
* - Final payload filtering in `agent-runner-payloads.ts`
|
||||
*
|
||||
* Using the same normalization ensures keys always match.
|
||||
*/
|
||||
export function normalizePayloadForDedup(payload: ReplyPayload): NormalizedPayload {
|
||||
// Normalize text: trim whitespace for consistent matching
|
||||
const text = (payload.text ?? "").trim();
|
||||
|
||||
// Normalize media: collapse mediaUrl and mediaUrls into a single list
|
||||
const mediaList = payload.mediaUrls?.length
|
||||
? payload.mediaUrls
|
||||
: payload.mediaUrl
|
||||
? [payload.mediaUrl]
|
||||
: [];
|
||||
|
||||
return {
|
||||
text,
|
||||
mediaList,
|
||||
replyToId: payload.replyToId ?? null,
|
||||
replyToTag: Boolean(payload.replyToTag),
|
||||
replyToCurrent: Boolean(payload.replyToCurrent),
|
||||
audioAsVoice: Boolean(payload.audioAsVoice),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a stable string key for a payload for deduplication.
|
||||
*
|
||||
* Payloads with the same key are considered duplicates and one will be filtered.
|
||||
*/
|
||||
export function createPayloadKey(payload: ReplyPayload): string {
|
||||
const normalized = normalizePayloadForDedup(payload);
|
||||
return JSON.stringify({
|
||||
text: normalized.text,
|
||||
mediaList: normalized.mediaList,
|
||||
replyToId: normalized.replyToId,
|
||||
replyToTag: normalized.replyToTag,
|
||||
replyToCurrent: normalized.replyToCurrent,
|
||||
audioAsVoice: normalized.audioAsVoice,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if two payloads are duplicates based on their normalized keys.
|
||||
*/
|
||||
export function arePayloadsDuplicates(a: ReplyPayload, b: ReplyPayload): boolean {
|
||||
return createPayloadKey(a) === createPayloadKey(b);
|
||||
}
|
||||
@ -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,
|
||||
|
||||
@ -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) };
|
||||
}
|
||||
|
||||
@ -505,14 +505,20 @@ export const chatHandlers: GatewayRequestHandlers = {
|
||||
},
|
||||
})
|
||||
.then(() => {
|
||||
if (!agentRunStarted) {
|
||||
const combinedReply = finalReplyParts
|
||||
.map((part) => part.trim())
|
||||
.filter(Boolean)
|
||||
.join("\n\n")
|
||||
.trim();
|
||||
let message: Record<string, unknown> | undefined;
|
||||
if (combinedReply) {
|
||||
// Always broadcast final result regardless of whether an agent run started.
|
||||
// Previously this only broadcast for non-agent runs (commands like /status),
|
||||
// but agent responses also need to be broadcast since block streaming is disabled
|
||||
// for webchat and there's no other mechanism to notify the UI of completion.
|
||||
const combinedReply = finalReplyParts
|
||||
.map((part) => part.trim())
|
||||
.filter(Boolean)
|
||||
.join("\n\n")
|
||||
.trim();
|
||||
let message: Record<string, unknown> | undefined;
|
||||
if (combinedReply) {
|
||||
// Only append to transcript for non-agent runs (commands).
|
||||
// Agent runs write their own transcripts through the agent runtime layer.
|
||||
if (!agentRunStarted) {
|
||||
const { storePath: latestStorePath, entry: latestEntry } = loadSessionEntry(
|
||||
p.sessionKey,
|
||||
);
|
||||
@ -530,23 +536,27 @@ export const chatHandlers: GatewayRequestHandlers = {
|
||||
context.logGateway.warn(
|
||||
`webchat transcript append failed: ${appended.error ?? "unknown error"}`,
|
||||
);
|
||||
const now = Date.now();
|
||||
message = {
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: combinedReply }],
|
||||
timestamp: now,
|
||||
stopReason: "injected",
|
||||
usage: { input: 0, output: 0, totalTokens: 0 },
|
||||
};
|
||||
}
|
||||
}
|
||||
broadcastChatFinal({
|
||||
context,
|
||||
runId: clientRunId,
|
||||
sessionKey: p.sessionKey,
|
||||
message,
|
||||
});
|
||||
// If no transcript message was created (either agent run or append failed),
|
||||
// create an ephemeral message for the UI broadcast
|
||||
if (!message) {
|
||||
const now = Date.now();
|
||||
message = {
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: combinedReply }],
|
||||
timestamp: now,
|
||||
stopReason: agentRunStarted ? "end_turn" : "injected",
|
||||
usage: { input: 0, output: 0, totalTokens: 0 },
|
||||
};
|
||||
}
|
||||
}
|
||||
broadcastChatFinal({
|
||||
context,
|
||||
runId: clientRunId,
|
||||
sessionKey: p.sessionKey,
|
||||
message,
|
||||
});
|
||||
context.dedupe.set(`chat:${clientRunId}`, {
|
||||
ts: Date.now(),
|
||||
ok: true,
|
||||
|
||||
Loading…
Reference in New Issue
Block a user