From 236596b5ce683d8b594815dcd6024abb6c5f0c4e Mon Sep 17 00:00:00 2001 From: David Garson Date: Wed, 28 Jan 2026 10:39:20 -0700 Subject: [PATCH] feat: sdk-runner implementation w/hooks --- src/agents/claude-agent-sdk/extract.test.ts | 75 ++ src/agents/claude-agent-sdk/extract.ts | 85 ++ src/agents/claude-agent-sdk/index.ts | 19 + .../claude-agent-sdk/provider-config.ts | 4 +- .../claude-agent-sdk/sdk-agent-runtime.ts | 74 +- .../claude-agent-sdk/sdk-event-checks.test.ts | 32 + .../claude-agent-sdk/sdk-event-checks.ts | 9 + .../claude-agent-sdk/sdk-history.test.ts | 85 ++ src/agents/claude-agent-sdk/sdk-history.ts | 116 +++ src/agents/claude-agent-sdk/sdk-hooks.ts | 196 ++++ src/agents/claude-agent-sdk/sdk-loader.ts | 15 +- .../claude-agent-sdk/sdk-runner.test.ts | 209 ++++ src/agents/claude-agent-sdk/sdk-runner.ts | 936 +++++++++++++----- .../claude-agent-sdk/sdk-session-history.ts | 124 +++ .../sdk-session-transcript.ts | 93 ++ src/agents/claude-agent-sdk/types.ts | 129 ++- 16 files changed, 1960 insertions(+), 241 deletions(-) create mode 100644 src/agents/claude-agent-sdk/extract.test.ts create mode 100644 src/agents/claude-agent-sdk/extract.ts create mode 100644 src/agents/claude-agent-sdk/sdk-event-checks.test.ts create mode 100644 src/agents/claude-agent-sdk/sdk-event-checks.ts create mode 100644 src/agents/claude-agent-sdk/sdk-history.test.ts create mode 100644 src/agents/claude-agent-sdk/sdk-history.ts create mode 100644 src/agents/claude-agent-sdk/sdk-hooks.ts create mode 100644 src/agents/claude-agent-sdk/sdk-runner.test.ts create mode 100644 src/agents/claude-agent-sdk/sdk-session-history.ts create mode 100644 src/agents/claude-agent-sdk/sdk-session-transcript.ts diff --git a/src/agents/claude-agent-sdk/extract.test.ts b/src/agents/claude-agent-sdk/extract.test.ts new file mode 100644 index 000000000..3e85bbac0 --- /dev/null +++ b/src/agents/claude-agent-sdk/extract.test.ts @@ -0,0 +1,75 @@ +import { describe, it, expect } from "vitest"; +import { extractTextFromClaudeAgentSdkEvent } from "./extract.js"; + +describe("extractTextFromClaudeAgentSdkEvent", () => { + it("extracts text from direct text field", () => { + expect(extractTextFromClaudeAgentSdkEvent({ text: "Hello" })).toBe("Hello"); + }); + + it("extracts text from delta field", () => { + expect(extractTextFromClaudeAgentSdkEvent({ delta: "World" })).toBe("World"); + }); + + it("extracts text from string input", () => { + expect(extractTextFromClaudeAgentSdkEvent("Direct string")).toBe("Direct string"); + }); + + 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 text from nested message object", () => { + const event = { + message: { + content: [{ type: "text", text: "Nested text" }], + }, + }; + expect(extractTextFromClaudeAgentSdkEvent(event)).toBe("Nested text"); + }); + + it("extracts text from nested data object", () => { + const event = { + data: { + text: "Data text", + }, + }; + expect(extractTextFromClaudeAgentSdkEvent(event)).toBe("Data text"); + }); + + it("extracts text from deeply nested delta", () => { + const event = { + delta: { + text: "Deep delta text", + }, + }; + expect(extractTextFromClaudeAgentSdkEvent(event)).toBe("Deep delta text"); + }); + + it("returns undefined for null/undefined input", () => { + expect(extractTextFromClaudeAgentSdkEvent(null)).toBeUndefined(); + expect(extractTextFromClaudeAgentSdkEvent(undefined)).toBeUndefined(); + }); + + it("returns undefined for empty text", () => { + expect(extractTextFromClaudeAgentSdkEvent({ text: "" })).toBeUndefined(); + expect(extractTextFromClaudeAgentSdkEvent({ text: " " })).toBeUndefined(); + }); + + it("returns undefined for non-object input", () => { + expect(extractTextFromClaudeAgentSdkEvent(123)).toBeUndefined(); + expect(extractTextFromClaudeAgentSdkEvent(true)).toBeUndefined(); + }); + + it("handles array of strings in content", () => { + const event = { + content: ["First string", "Second string"], + }; + expect(extractTextFromClaudeAgentSdkEvent(event)).toBe("First string\nSecond string"); + }); +}); diff --git a/src/agents/claude-agent-sdk/extract.ts b/src/agents/claude-agent-sdk/extract.ts new file mode 100644 index 000000000..38cad41fe --- /dev/null +++ b/src/agents/claude-agent-sdk/extract.ts @@ -0,0 +1,85 @@ +/** + * Text extraction utilities for Claude Agent SDK events. + * + * Provides defensive multi-level extraction to handle evolving SDK event shapes. + */ + +function isRecord(value: unknown): value is Record { + return !!value && typeof value === "object" && !Array.isArray(value); +} + +function extractTextFromContent(value: unknown): string | undefined { + if (typeof value === "string") return value; + + // Common Claude-style content: [{type:"text", text:"..."}] + if (Array.isArray(value)) { + const parts: string[] = []; + for (const entry of value) { + if (typeof entry === "string") { + parts.push(entry); + continue; + } + if (!isRecord(entry)) continue; + const text = entry.text; + if (typeof text === "string" && text.trim()) parts.push(text); + } + const joined = parts.join("\n").trim(); + return joined || undefined; + } + + if (isRecord(value)) { + // Some SDKs emit {text:"..."} or {delta:"..."}. + const text = value.text; + if (typeof text === "string" && text.trim()) return text; + const delta = value.delta; + if (typeof delta === "string" && delta.trim()) return delta; + + // Some SDKs nest deltas (e.g. Claude stream_event: { event: { delta: { text } } }). + const nestedDelta = extractTextFromContent(delta); + if (nestedDelta) return nestedDelta; + + const event = value.event; + const nestedEvent = extractTextFromContent(event); + if (nestedEvent) return nestedEvent; + + const content = value.content; + const nested = extractTextFromContent(content); + if (nested) return nested; + } + + return undefined; +} + +/** + * Best-effort extraction of human-readable text from Claude Agent SDK events. + * We keep this defensive because the SDK event shapes may evolve. + */ +export function extractTextFromClaudeAgentSdkEvent(event: unknown): string | undefined { + if (typeof event === "string") return event; + if (!isRecord(event)) return undefined; + + // Direct text-ish fields. + const direct = extractTextFromContent(event); + if (direct) return direct; + + // 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; + } + if (isRecord(data)) { + const fromDataContent = extractTextFromContent(data.content); + if (fromDataContent) return fromDataContent; + } + + return undefined; +} diff --git a/src/agents/claude-agent-sdk/index.ts b/src/agents/claude-agent-sdk/index.ts index 9991d63b3..0692b038f 100644 --- a/src/agents/claude-agent-sdk/index.ts +++ b/src/agents/claude-agent-sdk/index.ts @@ -18,6 +18,21 @@ export { } from "./provider-config.js"; export { runSdkAgent } from "./sdk-runner.js"; export { createCcSdkAgentRuntime, type CcSdkAgentRuntimeContext } from "./sdk-agent-runtime.js"; + +// History and session management +export { serializeConversationHistory, buildHistorySystemPromptSuffix } from "./sdk-history.js"; +export { readSessionHistory, loadSessionHistoryForSdk } from "./sdk-session-history.js"; +export { + appendSdkTextTurnToSessionTranscript, + appendSdkTurnPairToSessionTranscript, +} from "./sdk-session-transcript.js"; + +// Hooks +export { buildMoltbotSdkHooks, type SdkHooksConfig, type SdkHookEventName } from "./sdk-hooks.js"; + +// Event utilities +export { isSdkTerminalToolEventType } from "./sdk-event-checks.js"; +export { extractTextFromClaudeAgentSdkEvent } from "./extract.js"; export { bridgeMoltbotToolsToMcpServer, bridgeMoltbotToolsSync, @@ -41,13 +56,17 @@ export type { SdkRunnerQueryOptions, } from "./tool-bridge.types.js"; export type { + SdkConversationTurn, SdkDoneEvent, SdkErrorEvent, SdkEvent, SdkEventType, SdkProviderConfig, SdkProviderEnv, + SdkRunnerErrorKind, + SdkRunnerMeta, SdkRunnerParams, + SdkRunnerResult, SdkTextEvent, SdkToolResultEvent, SdkToolUseEvent, diff --git a/src/agents/claude-agent-sdk/provider-config.ts b/src/agents/claude-agent-sdk/provider-config.ts index 721d43a8c..33fb39aa1 100644 --- a/src/agents/claude-agent-sdk/provider-config.ts +++ b/src/agents/claude-agent-sdk/provider-config.ts @@ -156,7 +156,7 @@ export function resolveProviderConfig(options?: { // 1. Explicit API key takes precedence if (options?.apiKey) { const config = buildAnthropicSdkProvider(options.apiKey); - if (options.baseUrl) { + if (options.baseUrl && config.env) { config.env.ANTHROPIC_BASE_URL = options.baseUrl; } return config; @@ -180,7 +180,7 @@ export function resolveProviderConfig(options?: { if (options?.useCliCredentials !== false) { const cliConfig = buildClaudeCliSdkProvider(); if (cliConfig) { - if (options?.baseUrl) { + if (options?.baseUrl && cliConfig.env) { cliConfig.env.ANTHROPIC_BASE_URL = options.baseUrl; } return cliConfig; diff --git a/src/agents/claude-agent-sdk/sdk-agent-runtime.ts b/src/agents/claude-agent-sdk/sdk-agent-runtime.ts index a3b52e466..e16d9f1b7 100644 --- a/src/agents/claude-agent-sdk/sdk-agent-runtime.ts +++ b/src/agents/claude-agent-sdk/sdk-agent-runtime.ts @@ -7,10 +7,14 @@ import type { MoltbotConfig } from "../../config/config.js"; import type { AgentRuntime, AgentRuntimeRunParams, AgentRuntimeResult } from "../agent-runtime.js"; import type { AgentCcSdkConfig } from "../../config/types.agents.js"; +import type { AnyAgentTool } from "../tools/common.js"; import { runSdkAgent } from "./sdk-runner.js"; import { resolveProviderConfig } from "./provider-config.js"; import { isSdkAvailable } from "./sdk-loader.js"; +import { loadSessionHistoryForSdk } from "./sdk-session-history.js"; +import { appendSdkTurnPairToSessionTranscript } from "./sdk-session-transcript.js"; import { createSubsystemLogger } from "../../logging/subsystem.js"; +import type { SdkConversationTurn, SdkRunnerResult } from "./types.js"; const log = createSubsystemLogger("agents/claude-agent-sdk"); @@ -25,8 +29,37 @@ export type CcSdkAgentRuntimeContext = { authToken?: string; /** Custom base URL for API requests. */ baseUrl?: string; + /** Pre-built tools to expose to the agent. */ + tools?: AnyAgentTool[]; + /** Pre-loaded conversation history (if not loading from session file). */ + conversationHistory?: SdkConversationTurn[]; }; +/** + * Convert an SdkRunnerResult into an AgentRuntimeResult. + */ +function adaptSdkResult(result: SdkRunnerResult, sessionId: string): AgentRuntimeResult { + return { + payloads: result.payloads.map((p) => ({ + text: p.text, + isError: p.isError, + })), + meta: { + durationMs: result.meta.durationMs, + aborted: result.meta.aborted, + agentMeta: { + sessionId, + provider: result.meta.provider ?? "sdk", + model: result.meta.model ?? "default", + }, + // 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, + }, + }; +} + /** * Create a Claude Code SDK runtime instance. * @@ -61,7 +94,17 @@ export function createCcSdkAgentRuntime(context?: CcSdkAgentRuntimeContext): Age provider: providerConfig.name, }); - return runSdkAgent({ + // Load conversation history from session file if not provided + let conversationHistory = context?.conversationHistory; + if (!conversationHistory && params.sessionFile) { + conversationHistory = loadSessionHistoryForSdk({ + sessionFile: params.sessionFile, + maxTurns: 20, + }); + } + + const sdkResult = await runSdkAgent({ + runId: params.runId, sessionId: params.sessionId, sessionKey: params.sessionKey, sessionFile: params.sessionFile, @@ -72,14 +115,41 @@ export function createCcSdkAgentRuntime(context?: CcSdkAgentRuntimeContext): Age model: params.model ? `${params.provider}/${params.model}` : undefined, providerConfig, timeoutMs: params.timeoutMs, - runId: params.runId, abortSignal: params.abortSignal, extraSystemPrompt: params.extraSystemPrompt, hooksEnabled: context?.ccsdkConfig?.hooksEnabled, sdkOptions: context?.ccsdkConfig?.options, modelTiers: context?.ccsdkConfig?.models, + conversationHistory, + tools: context?.tools, + + // Forward all callbacks + onPartialReply: params.onPartialReply + ? (payload) => params.onPartialReply?.({ text: payload.text }) + : undefined, + onAssistantMessageStart: params.onAssistantMessageStart, + onBlockReply: params.onBlockReply + ? (payload) => params.onBlockReply?.({ text: payload.text }) + : undefined, + onToolResult: params.onToolResult + ? (payload) => params.onToolResult?.({ text: payload.text }) + : undefined, onAgentEvent: params.onAgentEvent, }); + + // Persist a minimal user/assistant turn pair so SDK main-agent mode has multi-turn continuity. + // This intentionally records only text, not tool call structures. + if (params.sessionFile) { + appendSdkTurnPairToSessionTranscript({ + sessionFile: params.sessionFile, + prompt: params.prompt, + assistantText: sdkResult.payloads.find( + (p) => !p.isError && typeof p.text === "string" && p.text.trim(), + )?.text, + }); + } + + return adaptSdkResult(sdkResult, params.sessionId); }, }; } diff --git a/src/agents/claude-agent-sdk/sdk-event-checks.test.ts b/src/agents/claude-agent-sdk/sdk-event-checks.test.ts new file mode 100644 index 000000000..c30004e75 --- /dev/null +++ b/src/agents/claude-agent-sdk/sdk-event-checks.test.ts @@ -0,0 +1,32 @@ +import { describe, it, expect } from "vitest"; +import { isSdkTerminalToolEventType } from "./sdk-event-checks.js"; + +describe("isSdkTerminalToolEventType", () => { + it("returns true for tool_execution_end", () => { + expect(isSdkTerminalToolEventType("tool_execution_end")).toBe(true); + }); + + it("returns true for tool_result", () => { + expect(isSdkTerminalToolEventType("tool_result")).toBe(true); + }); + + it("returns false for tool_use", () => { + expect(isSdkTerminalToolEventType("tool_use")).toBe(false); + }); + + it("returns false for tool_execution_start", () => { + expect(isSdkTerminalToolEventType("tool_execution_start")).toBe(false); + }); + + it("returns false for other event types", () => { + expect(isSdkTerminalToolEventType("assistant_message")).toBe(false); + expect(isSdkTerminalToolEventType("error")).toBe(false); + expect(isSdkTerminalToolEventType("result")).toBe(false); + }); + + it("returns false for non-string inputs", () => { + expect(isSdkTerminalToolEventType(undefined)).toBe(false); + expect(isSdkTerminalToolEventType(null)).toBe(false); + expect(isSdkTerminalToolEventType(123)).toBe(false); + }); +}); diff --git a/src/agents/claude-agent-sdk/sdk-event-checks.ts b/src/agents/claude-agent-sdk/sdk-event-checks.ts new file mode 100644 index 000000000..17f907f45 --- /dev/null +++ b/src/agents/claude-agent-sdk/sdk-event-checks.ts @@ -0,0 +1,9 @@ +/** + * Event classification helpers for the Claude Agent SDK. + * + * Used to detect terminal tool events in the SDK event stream. + */ + +export function isSdkTerminalToolEventType(type: unknown): boolean { + return type === "tool_execution_end" || type === "tool_result"; +} diff --git a/src/agents/claude-agent-sdk/sdk-history.test.ts b/src/agents/claude-agent-sdk/sdk-history.test.ts new file mode 100644 index 000000000..13bc48f1a --- /dev/null +++ b/src/agents/claude-agent-sdk/sdk-history.test.ts @@ -0,0 +1,85 @@ +import { describe, it, expect } from "vitest"; +import { serializeConversationHistory, buildHistorySystemPromptSuffix } from "./sdk-history.js"; +import type { SdkConversationTurn } from "./types.js"; + +describe("serializeConversationHistory", () => { + it("returns empty string for undefined input", () => { + expect(serializeConversationHistory(undefined)).toBe(""); + }); + + it("returns empty string for empty array", () => { + expect(serializeConversationHistory([])).toBe(""); + }); + + it("serializes a single user turn", () => { + const turns: SdkConversationTurn[] = [{ role: "user", content: "Hello" }]; + const result = serializeConversationHistory(turns); + + expect(result).toContain(""); + expect(result).toContain("[User]:"); + expect(result).toContain("Hello"); + expect(result).toContain(""); + }); + + it("serializes multiple turns", () => { + const turns: SdkConversationTurn[] = [ + { role: "user", content: "Hello" }, + { role: "assistant", content: "Hi there!" }, + ]; + const result = serializeConversationHistory(turns); + + expect(result).toContain("[User]:"); + expect(result).toContain("Hello"); + expect(result).toContain("[Assistant]:"); + expect(result).toContain("Hi there!"); + }); + + it("includes timestamps when provided", () => { + const turns: SdkConversationTurn[] = [ + { role: "user", content: "Hello", timestamp: "2025-01-26T10:00:00Z" }, + ]; + const result = serializeConversationHistory(turns); + + expect(result).toContain("(2025-01-26T10:00:00Z)"); + }); + + it("limits to maxTurns most recent", () => { + const turns: SdkConversationTurn[] = Array.from({ length: 30 }, (_, i) => ({ + role: "user" as const, + content: `Message ${i}`, + })); + const result = serializeConversationHistory(turns, { maxTurns: 5 }); + + // Should note that earlier turns were omitted + expect(result).toContain("earlier turns omitted"); + // Should contain the last 5 messages + expect(result).toContain("Message 25"); + expect(result).toContain("Message 29"); + }); + + it("truncates when content exceeds maxChars", () => { + const longContent = "a".repeat(70000); + const turns: SdkConversationTurn[] = [{ role: "user", content: longContent }]; + const result = serializeConversationHistory(turns, { maxChars: 1000 }); + + expect(result.length).toBeLessThan(1200); // Some overhead for tags + expect(result).toContain("[...truncated]"); + }); +}); + +describe("buildHistorySystemPromptSuffix", () => { + it("returns empty string for no history", () => { + expect(buildHistorySystemPromptSuffix(undefined)).toBe(""); + expect(buildHistorySystemPromptSuffix([])).toBe(""); + }); + + it("builds suffix with instruction header", () => { + const turns: SdkConversationTurn[] = [{ role: "user", content: "Hello" }]; + const result = buildHistorySystemPromptSuffix(turns); + + expect(result).toContain("## Prior Conversation Context"); + expect(result).toContain("conversation history from prior turns"); + expect(result).toContain("do not re-execute"); + expect(result).toContain(""); + }); +}); diff --git a/src/agents/claude-agent-sdk/sdk-history.ts b/src/agents/claude-agent-sdk/sdk-history.ts new file mode 100644 index 000000000..f29ea081e --- /dev/null +++ b/src/agents/claude-agent-sdk/sdk-history.ts @@ -0,0 +1,116 @@ +/** + * Conversation history serialization for the Claude Agent SDK runner. + * + * Since the SDK is stateless (each query() starts fresh), prior conversation + * turns must be serialized into the prompt to provide multi-turn context. + * This module converts structured conversation history into a text block + * that can be prepended to the system prompt or user message. + */ + +import type { SdkConversationTurn } from "./types.js"; + +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- + +/** Max characters of serialized history to include (prevents context overflow). */ +const DEFAULT_MAX_HISTORY_CHARS = 60_000; + +/** Max number of turns to include (most recent). */ +const DEFAULT_MAX_HISTORY_TURNS = 20; + +// --------------------------------------------------------------------------- +// Serialization +// --------------------------------------------------------------------------- + +export type SerializeHistoryOptions = { + /** Max characters of serialized history (default: 60000). */ + maxChars?: number; + /** Max number of turns to include (default: 20). */ + maxTurns?: number; +}; + +/** + * Serialize conversation history into a text block suitable for injection + * into a system prompt or user message. + * + * Returns an empty string if no history is provided. + * + * Format: + * ``` + * + * [User] (2025-01-26T10:00:00Z): + * Hello, how are you? + * + * [Assistant] (2025-01-26T10:00:05Z): + * I'm doing well! How can I help? + * + * ``` + */ +export function serializeConversationHistory( + turns: SdkConversationTurn[] | undefined, + options?: SerializeHistoryOptions, +): string { + if (!turns || turns.length === 0) return ""; + + const maxChars = options?.maxChars ?? DEFAULT_MAX_HISTORY_CHARS; + const maxTurns = options?.maxTurns ?? DEFAULT_MAX_HISTORY_TURNS; + + // Take the most recent turns. + const recentTurns = turns.length > maxTurns ? turns.slice(-maxTurns) : turns; + + const parts: string[] = []; + let totalChars = 0; + + // Build from oldest to newest, but respect char limit. + for (const turn of recentTurns) { + const label = turn.role === "user" ? "User" : "Assistant"; + const timestamp = turn.timestamp ? ` (${turn.timestamp})` : ""; + const header = `[${label}]${timestamp}:`; + const block = `${header}\n${turn.content.trim()}`; + + if (totalChars + block.length + 2 > maxChars) { + // If we haven't added anything yet, truncate this block. + if (parts.length === 0) { + const truncated = block.slice(0, maxChars - 20) + "\n[...truncated]"; + parts.push(truncated); + } + break; + } + + parts.push(block); + totalChars += block.length + 2; // +2 for \n\n separator + } + + if (parts.length === 0) return ""; + + const truncatedNote = + turns.length > recentTurns.length + ? `\n(${turns.length - recentTurns.length} earlier turns omitted)\n\n` + : ""; + + return `${truncatedNote}\n${parts.join("\n\n")}\n`; +} + +/** + * Build a system prompt suffix that includes conversation history context. + * + * This is injected into the system prompt so the agent is aware of prior + * conversation turns. The instruction tells the agent to treat these as + * prior context (not new instructions). + */ +export function buildHistorySystemPromptSuffix( + turns: SdkConversationTurn[] | undefined, + options?: SerializeHistoryOptions, +): string { + const serialized = serializeConversationHistory(turns, options); + if (!serialized) return ""; + + return ( + "\n\n## Prior Conversation Context\n\n" + + "The following is the conversation history from prior turns. " + + "Use this context to maintain continuity but do not re-execute " + + "previously completed actions.\n\n" + + serialized + ); +} diff --git a/src/agents/claude-agent-sdk/sdk-hooks.ts b/src/agents/claude-agent-sdk/sdk-hooks.ts new file mode 100644 index 000000000..7f4a4d278 --- /dev/null +++ b/src/agents/claude-agent-sdk/sdk-hooks.ts @@ -0,0 +1,196 @@ +/** + * Claude Code hook wiring for the SDK runner. + * + * Provides richer lifecycle and tool event signals through the Claude Code + * hooks system, enabling better observability during agent execution. + */ + +import { + extractToolErrorMessage, + extractToolResultText, + sanitizeToolResult, +} from "../pi-embedded-subscribe.tools.js"; +import { normalizeToolName } from "../tool-policy.js"; + +function isRecord(value: unknown): value is Record { + return !!value && typeof value === "object" && !Array.isArray(value); +} + +function normalizeSdkToolName( + raw: string, + mcpServerName: string, +): { name: string; rawName: string } { + const trimmed = raw.trim(); + if (!trimmed) return { name: "tool", rawName: "" }; + const parts = trimmed.split("__"); + const withoutMcpPrefix = + parts.length >= 3 && parts[0] === "mcp" && parts[1] === mcpServerName + ? parts.slice(2).join("__") + : parts.length >= 3 && parts[0] === "mcp" + ? parts.slice(2).join("__") + : trimmed; + return { name: normalizeToolName(withoutMcpPrefix), rawName: trimmed }; +} + +export type SdkHookEventName = + | "PreToolUse" + | "PostToolUse" + | "PostToolUseFailure" + | "Notification" + | "SessionStart" + | "SessionEnd" + | "UserPromptSubmit" + | "Stop" + | "SubagentStart" + | "SubagentStop" + | "PreCompact"; + +export type SdkHookContext = { + session_id?: string; + transcript_path?: string; + cwd?: string; +}; + +export type SdkHookCallback = ( + input: unknown, + toolUseId: unknown, + context: unknown, +) => Promise> | Record; + +export type SdkHookCallbackMatcher = { + matcher?: string; + hooks: SdkHookCallback[]; + timeout?: number; +}; + +export type SdkHooksConfig = Partial>; + +function sanitizeHookToolPayload(value: unknown): unknown { + if (!value || typeof value !== "object") return value; + return sanitizeToolResult(value); +} + +function extractHookToolText(value: unknown): string | undefined { + if (typeof value === "string") { + const trimmed = value.trim(); + return trimmed ? trimmed : undefined; + } + return extractToolResultText(value) ?? undefined; +} + +/** + * Build Claude Code SDK hooks for Moltbot agent execution. + * + * These hooks provide richer lifecycle and tool event signals during + * agent execution, enabling better observability. + */ +export function buildMoltbotSdkHooks(params: { + mcpServerName: string; + emitEvent: (stream: string, data: Record) => void; + onToolResult?: (payload: { text?: string }) => void | Promise; +}): SdkHooksConfig { + const emitHook = (hookEventName: SdkHookEventName, input: unknown, toolUseId: unknown) => { + const payload = isRecord(input) ? input : { input }; + params.emitEvent("hook", { hookEventName, toolUseId, ...payload }); + }; + + const toolStartHook: SdkHookCallback = async (input, toolUseId) => { + emitHook("PreToolUse", input, toolUseId); + + const record = isRecord(input) ? input : undefined; + const rawName = typeof record?.tool_name === "string" ? record.tool_name : ""; + const normalized = normalizeSdkToolName(rawName, params.mcpServerName); + const args = sanitizeHookToolPayload(record?.tool_input); + + params.emitEvent("tool", { + phase: "start", + name: normalized.name, + ...(normalized.rawName ? { rawName: normalized.rawName } : {}), + toolCallId: typeof toolUseId === "string" ? toolUseId : undefined, + args: isRecord(args) ? args : args !== undefined ? { value: args } : undefined, + }); + + return {}; + }; + + const toolResultHook: SdkHookCallback = async (input, toolUseId) => { + emitHook("PostToolUse", input, toolUseId); + + const record = isRecord(input) ? input : undefined; + const rawName = typeof record?.tool_name === "string" ? record.tool_name : ""; + const normalized = normalizeSdkToolName(rawName, params.mcpServerName); + const resultRaw = record?.tool_response; + const sanitized = sanitizeHookToolPayload(resultRaw); + const resultText = extractHookToolText(resultRaw); + + params.emitEvent("tool", { + phase: "result", + name: normalized.name, + ...(normalized.rawName ? { rawName: normalized.rawName } : {}), + toolCallId: typeof toolUseId === "string" ? toolUseId : undefined, + isError: false, + result: sanitized, + ...(resultText ? { resultText } : {}), + }); + + if (resultText && params.onToolResult) { + try { + await params.onToolResult({ text: resultText }); + } catch { + // ignore callback errors + } + } + + return {}; + }; + + const toolFailureHook: SdkHookCallback = async (input, toolUseId) => { + emitHook("PostToolUseFailure", input, toolUseId); + + const record = isRecord(input) ? input : undefined; + const rawName = typeof record?.tool_name === "string" ? record.tool_name : ""; + const normalized = normalizeSdkToolName(rawName, params.mcpServerName); + const error = extractToolErrorMessage(record ?? input); + + params.emitEvent("tool", { + phase: "result", + name: normalized.name, + ...(normalized.rawName ? { rawName: normalized.rawName } : {}), + toolCallId: typeof toolUseId === "string" ? toolUseId : undefined, + isError: true, + ...(error ? { error } : {}), + }); + + if (error && params.onToolResult) { + try { + await params.onToolResult({ text: error }); + } catch { + // ignore callback errors + } + } + + return {}; + }; + + const passthroughHook = + (hookEventName: SdkHookEventName): SdkHookCallback => + async (input, toolUseId, context) => { + void context; + emitHook(hookEventName, input, toolUseId); + return {}; + }; + + return { + PreToolUse: [{ hooks: [toolStartHook] }], + PostToolUse: [{ hooks: [toolResultHook] }], + PostToolUseFailure: [{ hooks: [toolFailureHook] }], + Notification: [{ hooks: [passthroughHook("Notification")] }], + SessionStart: [{ hooks: [passthroughHook("SessionStart")] }], + SessionEnd: [{ hooks: [passthroughHook("SessionEnd")] }], + UserPromptSubmit: [{ hooks: [passthroughHook("UserPromptSubmit")] }], + Stop: [{ hooks: [passthroughHook("Stop")] }], + SubagentStart: [{ hooks: [passthroughHook("SubagentStart")] }], + SubagentStop: [{ hooks: [passthroughHook("SubagentStop")] }], + PreCompact: [{ hooks: [passthroughHook("PreCompact")] }], + }; +} diff --git a/src/agents/claude-agent-sdk/sdk-loader.ts b/src/agents/claude-agent-sdk/sdk-loader.ts index 19aa03ec3..633b7334a 100644 --- a/src/agents/claude-agent-sdk/sdk-loader.ts +++ b/src/agents/claude-agent-sdk/sdk-loader.ts @@ -10,6 +10,17 @@ import { createSubsystemLogger } from "../../logging/subsystem.js"; const log = createSubsystemLogger("agents/claude-agent-sdk"); +/** + * SDK query function signature. + * + * The SDK's query function takes a prompt and options, returning an async iterable + * of events or a promise that resolves to one. + */ +export type SdkQueryFunction = (params: { + prompt: string | AsyncIterable; + options?: Record; +}) => AsyncIterable | Promise>; + /** * SDK module type (generic since the actual types may not be available). * @@ -17,7 +28,9 @@ const log = createSubsystemLogger("agents/claude-agent-sdk"); * Since it's an optional dependency, we use a generic type here. */ export type ClaudeAgentSdkModule = { - // The actual SDK API - shape will vary by version + /** The main query function for running agent turns. */ + query: SdkQueryFunction; + // Other exports - shape will vary by version [key: string]: unknown; }; diff --git a/src/agents/claude-agent-sdk/sdk-runner.test.ts b/src/agents/claude-agent-sdk/sdk-runner.test.ts new file mode 100644 index 000000000..202116819 --- /dev/null +++ b/src/agents/claude-agent-sdk/sdk-runner.test.ts @@ -0,0 +1,209 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; + +// Mock the SDK loader before importing sdk-runner +const mockQuery = vi.fn(); +vi.mock("./sdk-loader.js", () => ({ + loadClaudeAgentSdk: vi.fn(async () => ({ + query: mockQuery, + })), + isSdkAvailable: vi.fn(() => true), +})); + +// Mock the tool bridge +vi.mock("./tool-bridge.js", () => ({ + bridgeMoltbotToolsToMcpServer: vi.fn(async () => ({ + serverConfig: { type: "sdk", name: "moltbot", instance: {} }, + allowedTools: ["mcp__moltbot__test_tool"], + toolCount: 1, + registeredTools: ["test_tool"], + skippedTools: [], + })), +})); + +// Mock the logger +vi.mock("../../logging/subsystem.js", () => ({ + createSubsystemLogger: vi.fn(() => ({ + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + })), +})); + +import { runSdkAgent } from "./sdk-runner.js"; +import type { SdkRunnerParams } from "./types.js"; + +describe("runSdkAgent", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(() => { + vi.resetAllMocks(); + }); + + const baseParams: SdkRunnerParams = { + runId: "test-run-123", + sessionId: "test-session-456", + sessionFile: "/tmp/test-session.jsonl", + workspaceDir: "/tmp/workspace", + prompt: "Hello, world!", + timeoutMs: 5000, + tools: [], + }; + + it("returns sdk_unavailable error when SDK fails to load", async () => { + const { loadClaudeAgentSdk } = await import("./sdk-loader.js"); + vi.mocked(loadClaudeAgentSdk).mockRejectedValueOnce(new Error("SDK not installed")); + + const result = await runSdkAgent(baseParams); + + expect(result.payloads[0]?.isError).toBe(true); + expect(result.payloads[0]?.text).toContain("Claude Agent SDK is not available"); + expect(result.meta.error?.kind).toBe("sdk_unavailable"); + }); + + it("streams assistant text via onPartialReply callback", async () => { + const chunks: string[] = []; + const onPartialReply = vi.fn((payload: { text?: string }) => { + if (payload.text) chunks.push(payload.text); + }); + + // Mock query to emit text events + mockQuery.mockImplementationOnce(async function* () { + yield { type: "text", text: "Hello" }; + yield { type: "text", text: "Hello world" }; + yield { type: "result", result: "Hello world" }; + }); + + const result = await runSdkAgent({ + ...baseParams, + onPartialReply, + }); + + expect(result.payloads[0]?.text).toBe("Hello world"); + expect(onPartialReply).toHaveBeenCalled(); + expect(chunks.length).toBeGreaterThan(0); + }); + + it("emits lifecycle events via onAgentEvent callback", async () => { + const events: Array<{ stream: string; data: Record }> = []; + const onAgentEvent = vi.fn((evt) => events.push(evt)); + + mockQuery.mockImplementationOnce(async function* () { + yield { type: "result", result: "Done" }; + }); + + await runSdkAgent({ + ...baseParams, + onAgentEvent, + }); + + expect(onAgentEvent).toHaveBeenCalled(); + + // Check for start and end lifecycle events + const lifecycleEvents = events.filter((e) => e.stream === "lifecycle"); + expect(lifecycleEvents.some((e) => e.data.phase === "start")).toBe(true); + expect(lifecycleEvents.some((e) => e.data.phase === "end")).toBe(true); + }); + + it("handles timeout detection in error path", async () => { + // This test verifies that the timeout error handling path works. + // When the SDK throws and the timeout signal is aborted, we should + // get a timeout error result. + + // For this test, we just verify the error path handles errors gracefully + mockQuery.mockImplementationOnce(async function* () { + yield { type: "text", text: "Starting" }; // Need at least one yield + throw new Error("simulated SDK error"); + }); + + const result = await runSdkAgent({ + ...baseParams, + timeoutMs: 5000, + }); + + // Should get an error result + expect(result.payloads[0]?.isError).toBe(true); + expect(result.meta.error?.kind).toBe("run_failed"); + }); + + it("handles abort signal from caller", async () => { + const controller = new AbortController(); + + // Abort immediately + controller.abort(); + + mockQuery.mockImplementationOnce(async function* () { + yield { type: "result", result: "Should not reach here" }; + }); + + const result = await runSdkAgent({ + ...baseParams, + abortSignal: controller.signal, + }); + + expect(result.meta.aborted).toBe(true); + }); + + it("returns error payload when no text output is produced", async () => { + mockQuery.mockImplementationOnce(async function* () { + yield { type: "system", data: "Some system event" }; + // No text or result event + }); + + const result = await runSdkAgent(baseParams); + + expect(result.payloads[0]?.isError).toBe(true); + expect(result.payloads[0]?.text).toContain("no text output"); + expect(result.meta.error?.kind).toBe("no_output"); + }); + + it("extracts text from various event shapes", async () => { + mockQuery.mockImplementationOnce(async function* () { + yield { type: "assistant_message", text: "Response text" }; + yield { type: "result" }; + }); + + const result = await runSdkAgent(baseParams); + + expect(result.payloads[0]?.text).toBe("Response text"); + expect(result.payloads[0]?.isError).toBeUndefined(); + }); + + it("includes bridge diagnostics in result meta when tools are provided", async () => { + mockQuery.mockImplementationOnce(async function* () { + yield { type: "result", result: "Done with tools" }; + }); + + const result = await runSdkAgent({ + ...baseParams, + tools: [ + { + name: "test_tool", + description: "A test tool", + parameters: { type: "object" }, + execute: vi.fn(), + }, + ] as any, + }); + + expect(result.meta.bridge).toBeDefined(); + expect(result.meta.bridge?.toolCount).toBe(1); + }); + + it("truncates output when max chars exceeded", async () => { + // Generate a very long text response + const longText = "a".repeat(200_000); + + mockQuery.mockImplementationOnce(async function* () { + yield { type: "text", text: longText }; + yield { type: "result" }; + }); + + const result = await runSdkAgent(baseParams); + + expect(result.meta.truncated).toBe(true); + expect(result.payloads[0]?.text).toContain("[Output truncated]"); + }); +}); diff --git a/src/agents/claude-agent-sdk/sdk-runner.ts b/src/agents/claude-agent-sdk/sdk-runner.ts index c30e2913a..fb7317ac8 100644 --- a/src/agents/claude-agent-sdk/sdk-runner.ts +++ b/src/agents/claude-agent-sdk/sdk-runner.ts @@ -1,276 +1,756 @@ /** - * Claude Agent SDK runner. + * Claude Agent SDK runner — an alternative to the Pi Agent embedded runner + * that uses the Claude Agent SDK as the main agent runtime. * - * Executes agent turns using the Claude Agent SDK, handling authentication, - * event streaming, and result adaptation. + * This runner bridges Moltbot tools into the SDK via MCP, passes the user + * prompt (with optional system prompt), streams events, and returns results + * in a format compatible with Moltbot's reply pipeline. + * + * Key differences from the Pi Agent embedded runner: + * - No multi-turn session management (SDK is stateless per query) + * - No context window compaction (SDK handles its own context) + * - No model registry (model selection via env vars or SDK defaults) + * - Moltbot tools are exposed via in-process MCP server + * - Supports env-based provider switching (Anthropic, z.AI, etc.) */ -import type { SdkRunnerParams, SdkProviderEnv } from "./types.js"; -import type { CcSdkModelTiers } from "../../config/types.agents.js"; -import type { AgentRuntimeResult } from "../agent-runtime.js"; -import { loadClaudeAgentSdk, type ClaudeAgentSdkModule } from "./sdk-loader.js"; -import { resolveProviderConfig } from "./provider-config.js"; 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 { loadClaudeAgentSdk } from "./sdk-loader.js"; +import { buildHistorySystemPromptSuffix } from "./sdk-history.js"; +import { isSdkTerminalToolEventType } from "./sdk-event-checks.js"; +import { buildMoltbotSdkHooks } from "./sdk-hooks.js"; +import { normalizeToolName } from "../tool-policy.js"; +import type { SdkRunnerParams, SdkRunnerResult } from "./types.js"; -const log = createSubsystemLogger("agents/claude-agent-sdk"); +const log = createSubsystemLogger("agents/claude-agent-sdk/sdk-runner"); -/** - * Build environment variables for model tier configuration. - * - * The Claude Code SDK uses these environment variables to select models - * for different task complexity tiers. - */ -function buildModelTierEnv(modelTiers?: CcSdkModelTiers): SdkProviderEnv { - const env: SdkProviderEnv = {}; +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- - if (modelTiers?.haiku) { - env.ANTHROPIC_DEFAULT_HAIKU_MODEL = modelTiers.haiku; +const DEFAULT_MAX_EXTRACTED_CHARS = 120_000; +const DEFAULT_MCP_SERVER_NAME = "moltbot"; +const DEFAULT_MAX_TURNS = 50; + +// --------------------------------------------------------------------------- +// Event classification helpers +// --------------------------------------------------------------------------- + +function isRecord(value: unknown): value is Record { + return !!value && typeof value === "object" && !Array.isArray(value); +} + +function isAsyncIterable(value: unknown): value is AsyncIterable { + return !!value && typeof value === "object" && Symbol.asyncIterator in value; +} + +async function coerceAsyncIterable(value: unknown): Promise> { + if (isAsyncIterable(value)) return value; + if (value instanceof Promise) { + const awaited = await value; + if (isAsyncIterable(awaited)) return awaited; } - if (modelTiers?.sonnet) { - env.ANTHROPIC_DEFAULT_SONNET_MODEL = modelTiers.sonnet; - } - if (modelTiers?.opus) { - env.ANTHROPIC_DEFAULT_OPUS_MODEL = modelTiers.opus; - } - - return env; + throw new Error("Claude Agent SDK query() did not return an async iterable."); } /** - * Run an agent turn using the Claude Agent SDK. + * Classify an SDK event for routing to the appropriate callback. * - * This function: - * 1. Loads the SDK dynamically - * 2. Configures authentication based on available credentials - * 3. Executes the agent turn with the given prompt - * 4. Streams events back to the caller - * 5. Returns results in the standard AgentRuntimeResult format + * The SDK event schema is undocumented and may change. We use defensive + * heuristics to classify events into categories: + * - "result": terminal event with final output + * - "assistant": assistant message text (partial or complete) + * - "tool": tool execution event + * - "system": lifecycle/diagnostic event + * - "unknown": unrecognized shape */ -export async function runSdkAgent(params: SdkRunnerParams): Promise { - const started = Date.now(); +type EventKind = "result" | "assistant" | "tool" | "system" | "unknown"; - // Emit lifecycle start event - params.onAgentEvent?.({ - stream: "lifecycle", - data: { - phase: "start", - runtime: "sdk", - sessionId: params.sessionId, - runId: params.runId, - }, - }); +function classifyEvent(event: unknown): { kind: EventKind; event: unknown } { + if (!isRecord(event)) return { kind: "unknown", event }; - try { - // Load the SDK - const sdk = await loadClaudeAgentSdk(); + const type = event.type as string | undefined; - // Resolve provider configuration - const providerConfig = params.providerConfig ?? resolveProviderConfig(); + // Terminal result event. + if (type === "result") return { kind: "result", event }; - log.info("Starting SDK agent run", { - provider: providerConfig.name, - model: params.model, - sessionId: params.sessionId, - runId: params.runId, - }); + // Tool-related events. + if ( + type === "tool_use" || + type === "tool_result" || + type === "tool_execution_start" || + type === "tool_execution_end" + ) { + return { kind: "tool", event }; + } - // Set up environment for the SDK - const originalEnv = { ...process.env }; - try { - // Apply provider environment variables - for (const [key, value] of Object.entries(providerConfig.env)) { - if (value !== undefined) { - process.env[key] = value; - } - } + // System/lifecycle events. + if (type === "system" || type === "agent_start" || type === "agent_end" || type === "error") { + return { kind: "system", event }; + } - // Apply model tier environment variables - const modelTierEnv = buildModelTierEnv(params.modelTiers); - for (const [key, value] of Object.entries(modelTierEnv)) { - if (value !== undefined) { - process.env[key] = value; - } - } + // Assistant message events (has text content). + if (event.text || event.delta || event.content || event.message) { + return { kind: "assistant", event }; + } - // Build SDK options - const sdkOptions = { - model: params.model, - workingDirectory: params.workspaceDir, - systemPrompt: params.extraSystemPrompt, - timeout: params.timeoutMs, - signal: params.abortSignal, - ...params.sdkOptions, - }; + return { kind: "unknown", event }; +} - // Run the agent - // Note: The actual SDK API may differ - this is a placeholder implementation - // that will need to be updated based on the actual SDK interface - const result = await runSdkAgentInternal(sdk, params.prompt, sdkOptions, { - onEvent: (event) => { - // Forward events to the caller - params.onAgentEvent?.({ - stream: "sdk", - data: event as Record, - }); - }, - }); +function normalizeSdkToolName( + raw: string, + mcpServerName: string, +): { name: string; rawName: string } { + const trimmed = raw.trim(); + if (!trimmed) return { name: "tool", rawName: "" }; + const parts = trimmed.split("__"); + const withoutMcpPrefix = + parts.length >= 3 && parts[0] === "mcp" && parts[1] === mcpServerName + ? parts.slice(2).join("__") + : parts.length >= 3 && parts[0] === "mcp" + ? parts.slice(2).join("__") + : trimmed; + return { name: normalizeToolName(withoutMcpPrefix), rawName: trimmed }; +} - // Build result - const agentResult: AgentRuntimeResult = { - payloads: result.texts.map((text) => ({ text })), - meta: { - durationMs: Date.now() - started, - agentMeta: { - sessionId: params.sessionId, - provider: "anthropic", - model: params.model ?? "claude-sonnet-4-20250514", - usage: result.usage, - }, - aborted: result.aborted, - }, - }; +function applySdkOptionsOverrides( + options: SdkRunnerQueryOptions, + overrides: unknown, +): SdkRunnerQueryOptions { + if (!overrides || typeof overrides !== "object" || Array.isArray(overrides)) return options; - // Emit lifecycle end event - params.onAgentEvent?.({ - stream: "lifecycle", - data: { - phase: "end", - runtime: "sdk", - startedAt: started, - endedAt: Date.now(), - aborted: result.aborted ?? false, - }, - }); + // Moltbot must keep these consistent with its own tool plumbing + prompt building. + const protectedKeys = new Set([ + "cwd", + "mcpServers", + "allowedTools", + "disallowedTools", + "tools", + "env", + "systemPrompt", + "model", + "hooks", + ]); - return agentResult; - } finally { - // Restore original environment - const keysToRestore = [ - ...Object.keys(providerConfig.env), - ...Object.keys(buildModelTierEnv(params.modelTiers)), - ]; - for (const key of keysToRestore) { - if (originalEnv[key] !== undefined) { - process.env[key] = originalEnv[key]; - } else { - delete process.env[key]; - } - } - } - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); + const record = overrides as Record; + const target = options as unknown as Record; + for (const [key, value] of Object.entries(record)) { + if (protectedKeys.has(key)) continue; + target[key] = value; + } + return options; +} - log.error("SDK agent run failed", { - error: errorMessage, - sessionId: params.sessionId, - runId: params.runId, - }); +// --------------------------------------------------------------------------- +// Prompt building +// --------------------------------------------------------------------------- - // Emit lifecycle error event - params.onAgentEvent?.({ - stream: "lifecycle", - data: { - phase: "error", - runtime: "sdk", - startedAt: started, - endedAt: Date.now(), - error: errorMessage, +/** + * Build the SDK prompt. + * + * When using mcpServers, the SDK requires an AsyncIterable + * as the prompt (not a plain string). We generate this from the user message. + * + * The SDK is stateless per query, so conversation history is injected as + * serialized text appended to the system prompt (see buildHistorySystemPromptSuffix). + * This provides multi-turn context without requiring structured message history. + */ +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. + const userContent = params.prompt; + + async function* generateMessages() { + yield { + type: "user" as const, + message: { + role: "user" as const, + content: userContent, }, - }); + }; + } - // Return error result + return generateMessages(); +} + +// --------------------------------------------------------------------------- +// Main runner +// --------------------------------------------------------------------------- + +/** + * Run a single agent turn using the Claude Agent SDK. + * + * This is the main entry point — equivalent to `runEmbeddedPiAgent()` but + * using the Claude Agent SDK instead of the Pi Agent framework. + */ +export async function runSdkAgent(params: SdkRunnerParams): Promise { + const startedAt = Date.now(); + const mcpServerName = params.mcpServerName ?? DEFAULT_MCP_SERVER_NAME; + const hooksEnabled = params.hooksEnabled === true; + + const emitEvent = (stream: string, data: Record) => { + try { + void Promise.resolve(params.onAgentEvent?.({ stream, data })).catch(() => { + // Don't let async callback errors trigger unhandled rejections. + }); + } catch { + // Don't let callback errors break the runner. + } + }; + + emitEvent("lifecycle", { phase: "start", startedAt, runtime: "sdk" }); + emitEvent("sdk", { type: "sdk_runner_start", runId: params.runId }); + + // ------------------------------------------------------------------------- + // Step 1: Load the Claude Agent SDK + // ------------------------------------------------------------------------- + + let sdk; + try { + sdk = await loadClaudeAgentSdk(); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + log.error(`Failed to load Claude Agent SDK: ${message}`); + emitEvent("lifecycle", { + phase: "error", + startedAt, + endedAt: Date.now(), + runtime: "sdk", + error: message, + }); return { payloads: [ { - text: `SDK agent error: ${errorMessage}`, + text: + "Claude Agent SDK is not available. Install @anthropic-ai/claude-agent-sdk " + + "and ensure Claude Code is configured on this machine.\n\n" + + `Error: ${message}`, isError: true, }, ], meta: { - durationMs: Date.now() - started, - agentMeta: { - sessionId: params.sessionId, - provider: "anthropic", - model: params.model ?? "unknown", - }, + durationMs: Date.now() - startedAt, + eventCount: 0, + extractedChars: 0, + truncated: false, + error: { kind: "sdk_unavailable", message }, }, }; } -} -/** - * Internal SDK execution function. - * - * This is a placeholder that needs to be implemented based on the actual - * Claude Agent SDK API. The SDK provides different interfaces for: - * - Simple completions - * - Agentic tool use - * - Streaming responses - */ -async function runSdkAgentInternal( - sdk: ClaudeAgentSdkModule, - _prompt: string, - options: { - model?: string; - workingDirectory?: string; - systemPrompt?: string; - timeout?: number; - signal?: AbortSignal; - }, - _callbacks: { - onEvent?: (event: unknown) => void; - }, -): Promise<{ - texts: string[]; - usage?: { - input?: number; - output?: number; - cacheRead?: number; - cacheWrite?: number; - total?: number; - }; - aborted?: boolean; -}> { - // Placeholder implementation - needs to be updated based on actual SDK API - // The SDK likely provides something like: - // - sdk.createAgent() or sdk.Agent class - // - agent.run(prompt) or agent.chat(prompt) - // - Event streaming via async iterators or callbacks + emitEvent("sdk", { type: "sdk_loaded" }); - const texts: string[] = []; - const usage: - | { - input?: number; - output?: number; - cacheRead?: number; - cacheWrite?: number; - total?: number; - } - | undefined = undefined; - let aborted = false; + // ------------------------------------------------------------------------- + // Step 2: Bridge Moltbot tools to MCP + // ------------------------------------------------------------------------- - try { - // Check if SDK has the expected interface - // This needs to be updated based on actual SDK API - if ("Claude" in sdk || "createAgent" in sdk || "Agent" in sdk) { - // TODO: Implement actual SDK call based on SDK's public API - // For now, throw to indicate SDK integration is not yet complete - throw new Error( - "Claude Agent SDK integration not yet implemented. " + - "Please check SDK documentation for the correct API.", - ); + let bridgeResult; + const tools = params.tools ?? []; + + if (tools.length > 0) { + try { + bridgeResult = await bridgeMoltbotToolsToMcpServer({ + name: mcpServerName, + tools, + abortSignal: params.abortSignal, + }); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + log.error(`Failed to bridge tools to MCP: ${message}`); + emitEvent("lifecycle", { + phase: "error", + startedAt, + endedAt: Date.now(), + runtime: "sdk", + error: message, + }); + return { + payloads: [ + { + text: + "Failed to bridge Moltbot tools to the Claude Agent SDK.\n\n" + `Error: ${message}`, + isError: true, + }, + ], + meta: { + durationMs: Date.now() - startedAt, + eventCount: 0, + extractedChars: 0, + truncated: false, + error: { kind: "mcp_bridge_failed", message }, + }, + }; } - // Fallback: check for simple completion API - throw new Error("Unable to find compatible API in Claude Agent SDK"); - } catch (error) { - if (options.signal?.aborted) { - aborted = true; - texts.push("Agent run was aborted."); - } else { - throw error; + log.info( + `Bridged ${bridgeResult.toolCount} tools to MCP server "${mcpServerName}"` + + (bridgeResult.skippedTools.length > 0 + ? ` (skipped: ${bridgeResult.skippedTools.join(", ")})` + : ""), + ); + emitEvent("sdk", { + type: "tools_bridged", + toolCount: bridgeResult.toolCount, + skipped: bridgeResult.skippedTools, + }); + } + + // ------------------------------------------------------------------------- + // Step 3: Build SDK options + // ------------------------------------------------------------------------- + + const sdkOptions: SdkRunnerQueryOptions = { + cwd: params.workspaceDir, + maxTurns: params.maxTurns ?? params.providerConfig?.maxTurns ?? DEFAULT_MAX_TURNS, + }; + + // MCP server with bridged Moltbot tools. + if (bridgeResult && bridgeResult.toolCount > 0) { + sdkOptions.mcpServers = { + [mcpServerName]: bridgeResult.serverConfig, + }; + sdkOptions.allowedTools = bridgeResult.allowedTools; + } + + // Built-in Claude Code tools (default: none — Moltbot tools only via MCP). + if (params.builtInTools && params.builtInTools.length > 0) { + sdkOptions.tools = params.builtInTools; + // Merge built-in tool names into allowedTools. + sdkOptions.allowedTools = [...(sdkOptions.allowedTools ?? []), ...params.builtInTools]; + } else { + // Disable all built-in tools so only MCP tools are available. + sdkOptions.tools = []; + } + + // Apply optional pass-through options (e.g. settingSources/includePartialMessages). + applySdkOptionsOverrides(sdkOptions, params.sdkOptions); + + // Permission mode. + if (params.permissionMode) { + sdkOptions.permissionMode = params.permissionMode; + } + + // System prompt (with optional conversation history suffix). + const historySuffix = buildHistorySystemPromptSuffix(params.conversationHistory); + const baseSystemPrompt = params.systemPrompt ?? params.extraSystemPrompt; + if (baseSystemPrompt || historySuffix) { + sdkOptions.systemPrompt = (baseSystemPrompt ?? "") + historySuffix; + } + + // Provider env overrides (z.AI, custom endpoints, etc.). + if (params.providerConfig?.env) { + const env: Record = {}; + for (const [key, value] of Object.entries(params.providerConfig.env)) { + if (value !== undefined) env[key] = value; + } + if (Object.keys(env).length > 0) { + sdkOptions.env = env; } } - return { texts, usage, aborted }; + // Model override from provider config. + if (params.providerConfig?.model) { + sdkOptions.model = params.providerConfig.model; + } + + // Hook callbacks (Claude Code hooks; richer tool + lifecycle signals). + if (hooksEnabled) { + sdkOptions.hooks = buildMoltbotSdkHooks({ + mcpServerName, + emitEvent, + onToolResult: params.onToolResult, + }) as unknown as Record; + } + + // ------------------------------------------------------------------------- + // Step 4: Build the prompt + // ------------------------------------------------------------------------- + + const prompt = buildSdkPrompt({ + prompt: params.prompt, + systemPrompt: baseSystemPrompt, + }); + + // ------------------------------------------------------------------------- + // Step 5: Run the SDK query and stream events + // ------------------------------------------------------------------------- + + let eventCount = 0; + let extractedChars = 0; + let truncated = false; + let resultText: string | undefined; + let aborted = false; + const chunks: string[] = []; + let assistantSoFar = ""; + let didAssistantMessageStart = false; + + // Set up timeout if configured. + let timeoutId: ReturnType | undefined; + const timeoutController = new AbortController(); + if (params.timeoutMs && params.timeoutMs > 0) { + timeoutId = setTimeout(() => { + timeoutController.abort(); + }, params.timeoutMs); + } + + // Combine external abort signal with timeout. + const combinedAbort = params.abortSignal + ? combineAbortSignals(params.abortSignal, timeoutController.signal) + : timeoutController.signal; + + try { + emitEvent("sdk", { type: "query_start" }); + + const stream = await coerceAsyncIterable( + sdk.query({ + prompt: prompt as string, // The SDK accepts both string and AsyncIterable + options: sdkOptions as Record, + }), + ); + + for await (const event of stream) { + // Check abort before processing each event. + if (combinedAbort.aborted) { + aborted = true; + log.debug("Aborted during event stream"); + break; + } + + eventCount += 1; + + // 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. + if (!didAssistantMessageStart && isRecord(event) && event.type === "message_start") { + didAssistantMessageStart = true; + try { + void Promise.resolve(params.onAssistantMessageStart?.()).catch((err) => { + log.debug(`onAssistantMessageStart callback error: ${String(err)}`); + }); + } catch (err) { + log.debug(`onAssistantMessageStart callback error: ${String(err)}`); + } + } + + const { kind } = classifyEvent(event); + + // Emit tool results via callback. + if (!hooksEnabled && kind === "tool") { + const record = isRecord(event) ? (event as Record) : undefined; + const type = record && typeof record.type === "string" ? record.type : undefined; + const phase = (() => { + if (type === "tool_execution_start" || type === "tool_use") return "start"; + if (isSdkTerminalToolEventType(type)) return "result"; + return "update"; + })(); + const name = + record && typeof record.name === "string" + ? record.name + : record && typeof record.tool_name === "string" + ? record.tool_name + : undefined; + const normalizedName = name + ? normalizeSdkToolName(name, mcpServerName) + : { name: "tool", rawName: "" }; + const toolCallId = + record && typeof record.id === "string" + ? record.id + : record && typeof record.tool_use_id === "string" + ? record.tool_use_id + : record && typeof record.toolCallId === "string" + ? record.toolCallId + : undefined; + const toolText = extractTextFromClaudeAgentSdkEvent(event); + const isError = + record && typeof record.is_error === "boolean" + ? record.is_error + : record && typeof record.isError === "boolean" + ? record.isError + : Boolean(record?.error); + emitEvent("tool", { + phase, + name: normalizedName.name, + toolCallId, + sdkType: type, + ...(normalizedName.rawName ? { rawName: normalizedName.rawName } : {}), + isError, + ...(toolText ? { resultText: toolText } : {}), + }); + } + + if (!hooksEnabled && kind === "tool" && params.onToolResult) { + const toolText = extractTextFromClaudeAgentSdkEvent(event); + if (toolText) { + try { + // Only emit tool results for terminal tool events to match Pi semantics more closely. + const record = isRecord(event) ? (event as Record) : 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. + } + } + } + + // Emit system/lifecycle events. + if (kind === "system" && isRecord(event)) { + emitEvent("sdk", event as Record); + } + + // Handle terminal result event. + if (kind === "result" && isRecord(event)) { + const result = event.result; + if (typeof result === "string") { + resultText = result; + } + // Also check for error results. + const subtype = event.subtype; + if (subtype === "error" && typeof event.error === "string") { + log.warn(`SDK returned error result: ${event.error}`); + } + break; + } + + // Extract text from assistant messages. + if (kind === "assistant" || kind === "unknown") { + const text = extractTextFromClaudeAgentSdkEvent(event); + if (!text) continue; + + const trimmed = text.trimEnd(); + if (!trimmed) continue; + + if (!didAssistantMessageStart) { + didAssistantMessageStart = true; + try { + void Promise.resolve(params.onAssistantMessageStart?.()).catch((err) => { + log.debug(`onAssistantMessageStart callback error: ${String(err)}`); + }); + } catch (err) { + log.debug(`onAssistantMessageStart callback error: ${String(err)}`); + } + } + + // Dedup: skip if this chunk is identical to or a suffix of the last. + const last = chunks.at(-1); + if (last && (last === trimmed || last.endsWith(trimmed))) continue; + + chunks.push(trimmed); + extractedChars += trimmed.length; + + const prev = assistantSoFar; + assistantSoFar = chunks.join("\n\n"); + const delta = assistantSoFar.startsWith(prev) ? assistantSoFar.slice(prev.length) : trimmed; + + emitEvent("assistant", { text: assistantSoFar, delta }); + + // Stream partial reply. + if (params.onPartialReply) { + try { + await params.onPartialReply({ text: assistantSoFar }); + } catch { + // Don't break the stream on callback errors. + } + } + + // Truncate if we've extracted too much text. + if (extractedChars >= DEFAULT_MAX_EXTRACTED_CHARS) { + truncated = true; + log.debug(`Truncated after ${extractedChars} chars`); + break; + } + } + } + } catch (err) { + if (timeoutId) clearTimeout(timeoutId); + + const message = err instanceof Error ? err.message : String(err); + + // Check if this was a timeout. + if (timeoutController.signal.aborted && !params.abortSignal?.aborted) { + log.warn(`Timed out after ${params.timeoutMs}ms`); + emitEvent("lifecycle", { + phase: "error", + startedAt, + endedAt: Date.now(), + runtime: "sdk", + aborted: true, + error: message, + }); + return { + payloads: [ + { + text: `Agent timed out after ${params.timeoutMs}ms.`, + isError: true, + }, + ], + meta: { + durationMs: Date.now() - startedAt, + provider: params.providerConfig?.name, + eventCount, + extractedChars, + truncated, + aborted: true, + error: { kind: "timeout", message }, + bridge: bridgeResult + ? { + toolCount: bridgeResult.toolCount, + registeredTools: bridgeResult.registeredTools, + skippedTools: bridgeResult.skippedTools, + } + : undefined, + }, + }; + } + + // Check if this was an external abort. + if (params.abortSignal?.aborted) { + aborted = true; + } else { + log.error(`Query failed: ${message}`); + emitEvent("lifecycle", { + phase: "error", + startedAt, + endedAt: Date.now(), + runtime: "sdk", + error: message, + }); + return { + payloads: [ + { + text: `Agent run failed: ${message}`, + isError: true, + }, + ], + meta: { + durationMs: Date.now() - startedAt, + provider: params.providerConfig?.name, + eventCount, + extractedChars, + truncated, + error: { kind: "run_failed", message }, + bridge: bridgeResult + ? { + toolCount: bridgeResult.toolCount, + registeredTools: bridgeResult.registeredTools, + skippedTools: bridgeResult.skippedTools, + } + : undefined, + }, + }; + } + } finally { + if (timeoutId) clearTimeout(timeoutId); + } + + // ------------------------------------------------------------------------- + // Step 6: Build result + // ------------------------------------------------------------------------- + + const text = (resultText ?? chunks.join("\n\n")).trim(); + + if (!text) { + emitEvent("lifecycle", { + phase: "error", + startedAt, + endedAt: Date.now(), + runtime: "sdk", + aborted, + error: "No text output", + }); + return { + payloads: [ + { + text: "Agent completed but produced no text output.", + isError: true, + }, + ], + meta: { + durationMs: Date.now() - startedAt, + provider: params.providerConfig?.name, + eventCount, + extractedChars: 0, + truncated: false, + aborted, + error: aborted ? undefined : { kind: "no_output", message: "No text output" }, + bridge: bridgeResult + ? { + toolCount: bridgeResult.toolCount, + registeredTools: bridgeResult.registeredTools, + skippedTools: bridgeResult.skippedTools, + } + : undefined, + }, + }; + } + + const suffix = truncated ? "\n\n[Output truncated]" : ""; + const finalText = `${text}${suffix}`; + + // Emit the final block reply. + if (params.onBlockReply) { + try { + await params.onBlockReply({ text: finalText }); + } catch { + // Don't fail the run on callback errors. + } + } + + emitEvent("sdk", { + type: "sdk_runner_end", + eventCount, + extractedChars, + truncated, + aborted, + durationMs: Date.now() - startedAt, + }); + emitEvent("lifecycle", { + phase: "end", + startedAt, + endedAt: Date.now(), + runtime: "sdk", + aborted, + truncated, + }); + + return { + payloads: [{ text: finalText }], + meta: { + durationMs: Date.now() - startedAt, + provider: params.providerConfig?.name, + eventCount, + extractedChars, + truncated, + aborted, + bridge: bridgeResult + ? { + toolCount: bridgeResult.toolCount, + registeredTools: bridgeResult.registeredTools, + skippedTools: bridgeResult.skippedTools, + } + : undefined, + }, + }; +} + +// --------------------------------------------------------------------------- +// Utility: combine abort signals +// --------------------------------------------------------------------------- + +function combineAbortSignals(...signals: AbortSignal[]): AbortSignal { + const controller = new AbortController(); + for (const signal of signals) { + if (signal.aborted) { + controller.abort(signal.reason); + return controller.signal; + } + signal.addEventListener("abort", () => controller.abort(signal.reason), { once: true }); + } + return controller.signal; } diff --git a/src/agents/claude-agent-sdk/sdk-session-history.ts b/src/agents/claude-agent-sdk/sdk-session-history.ts new file mode 100644 index 000000000..51c005979 --- /dev/null +++ b/src/agents/claude-agent-sdk/sdk-session-history.ts @@ -0,0 +1,124 @@ +/** + * Session history → SDK conversation turns. + * + * Reads Pi Agent JSONL session transcripts and extracts user/assistant + * text turns for injection into the SDK prompt as conversation context. + */ + +import fs from "node:fs"; +import { createSubsystemLogger } from "../../logging/subsystem.js"; +import type { SdkConversationTurn } from "./types.js"; + +const log = createSubsystemLogger("agents/claude-agent-sdk/sdk-session-history"); + +// --------------------------------------------------------------------------- +// JSONL line parsing +// --------------------------------------------------------------------------- + +type SessionLine = { + role?: string; + content?: string | Array<{ type?: string; text?: string }>; + type?: string; + timestamp?: string; +}; + +/** + * Extract text content from a session line's content field. + * Handles both string content and content block arrays. + */ +function extractTextContent(content: SessionLine["content"]): string | undefined { + if (typeof content === "string") return content; + if (Array.isArray(content)) { + const texts = content + .filter((block) => block.type === "text" && block.text) + .map((block) => block.text!); + return texts.length > 0 ? texts.join("\n") : undefined; + } + return undefined; +} + +// --------------------------------------------------------------------------- +// Public API +// --------------------------------------------------------------------------- + +/** + * Read a Pi Agent session transcript file and extract conversation turns. + * + * Parses the JSONL session file line by line, extracting user and assistant + * text turns. Skips: + * - Session header lines (type: "session_start", etc.) + * - Tool result entries (role: "tool" or type: "toolResult") + * - Lines with no extractable text content + * + * Returns an empty array if the file doesn't exist or is empty. + */ +export function readSessionHistory(sessionFile: string): SdkConversationTurn[] { + if (!fs.existsSync(sessionFile)) { + log.debug(`Session file not found: ${sessionFile}`); + return []; + } + + let raw: string; + try { + raw = fs.readFileSync(sessionFile, "utf-8"); + } catch (err) { + log.debug(`Failed to read session file: ${String(err)}`); + return []; + } + + if (!raw.trim()) return []; + + const turns: SdkConversationTurn[] = []; + + for (const line of raw.split("\n")) { + const trimmed = line.trim(); + if (!trimmed) continue; + + let parsed: SessionLine; + try { + parsed = JSON.parse(trimmed) as SessionLine; + } catch { + log.debug(`Skipping malformed JSON line`); + continue; + } + + // Skip session headers and metadata lines. + if (parsed.type && !parsed.role) continue; + + // Skip tool results. + if (parsed.role === "tool" || parsed.type === "toolResult") continue; + + // Only extract user and assistant turns. + if (parsed.role !== "user" && parsed.role !== "assistant") continue; + + const text = extractTextContent(parsed.content); + if (!text?.trim()) continue; + + turns.push({ + role: parsed.role, + content: text, + timestamp: parsed.timestamp, + }); + } + + return turns; +} + +/** + * Load session history for SDK injection. + * + * Reads the session transcript and caps to the most recent `maxTurns` turns + * to avoid overwhelming the SDK prompt with history. + */ +export function loadSessionHistoryForSdk(params: { + sessionFile: string; + maxTurns?: number; +}): SdkConversationTurn[] { + const maxTurns = params.maxTurns ?? 20; + const turns = readSessionHistory(params.sessionFile); + + if (turns.length <= maxTurns) return turns; + + // Keep the most recent turns. + return turns.slice(-maxTurns); +} diff --git a/src/agents/claude-agent-sdk/sdk-session-transcript.ts b/src/agents/claude-agent-sdk/sdk-session-transcript.ts new file mode 100644 index 000000000..14ebc8ae4 --- /dev/null +++ b/src/agents/claude-agent-sdk/sdk-session-transcript.ts @@ -0,0 +1,93 @@ +/** + * Session transcript persistence for the Claude Agent SDK. + * + * Appends user/assistant turn pairs to the session JSONL file for + * multi-turn continuity when using the SDK main-agent mode. + */ + +import fs from "node:fs"; +import path from "node:path"; +import { createSubsystemLogger } from "../../logging/subsystem.js"; + +const log = createSubsystemLogger("agents/claude-agent-sdk/sdk-session-transcript"); + +type TranscriptRole = "user" | "assistant"; + +function fileEndsWithNewline(filePath: string): boolean { + try { + const stat = fs.statSync(filePath); + if (!stat.isFile() || stat.size === 0) return true; + const fd = fs.openSync(filePath, "r"); + try { + const buffer = Buffer.alloc(1); + fs.readSync(fd, buffer, 0, 1, stat.size - 1); + return buffer[0] === 0x0a; + } finally { + fs.closeSync(fd); + } + } catch { + return true; + } +} + +function appendJsonlLine(params: { filePath: string; value: unknown }) { + fs.mkdirSync(path.dirname(params.filePath), { recursive: true }); + const prefix = + fs.existsSync(params.filePath) && !fileEndsWithNewline(params.filePath) ? "\n" : ""; + fs.appendFileSync(params.filePath, `${prefix}${JSON.stringify(params.value)}\n`, "utf-8"); +} + +/** + * Append a single text turn to the session transcript. + */ +export function appendSdkTextTurnToSessionTranscript(params: { + sessionFile: string; + role: TranscriptRole; + text: string; + timestamp?: number; +}): void { + const trimmed = params.text.trim(); + if (!trimmed) return; + + try { + appendJsonlLine({ + filePath: params.sessionFile, + value: { + role: params.role, + content: [{ type: "text", text: trimmed }], + timestamp: params.timestamp ?? Date.now(), + }, + }); + } catch (err) { + log.debug(`Failed to append transcript: ${String(err)}`); + } +} + +/** + * Append a user/assistant turn pair to the session transcript. + * + * This enables multi-turn continuity for the SDK main-agent mode + * by recording both the user prompt and assistant response. + */ +export function appendSdkTurnPairToSessionTranscript(params: { + sessionFile: string; + prompt: string; + assistantText?: string; + timestamp?: number; +}): void { + const ts = params.timestamp ?? Date.now(); + appendSdkTextTurnToSessionTranscript({ + sessionFile: params.sessionFile, + role: "user", + text: params.prompt, + timestamp: ts, + }); + if (params.assistantText) { + appendSdkTextTurnToSessionTranscript({ + sessionFile: params.sessionFile, + role: "assistant", + text: params.assistantText, + timestamp: ts, + }); + } +} diff --git a/src/agents/claude-agent-sdk/types.ts b/src/agents/claude-agent-sdk/types.ts index 6005040dc..c61246e77 100644 --- a/src/agents/claude-agent-sdk/types.ts +++ b/src/agents/claude-agent-sdk/types.ts @@ -4,6 +4,7 @@ import type { MoltbotConfig } from "../../config/config.js"; import type { CcSdkModelTiers } from "../../config/types.agents.js"; +import type { AnyAgentTool } from "../tools/common.js"; /** Provider environment variables for SDK authentication and model config. */ export type SdkProviderEnv = { @@ -23,20 +24,40 @@ export type SdkProviderEnv = { ANTHROPIC_DEFAULT_SONNET_MODEL?: string; /** Model for complex reasoning (Opus tier). */ ANTHROPIC_DEFAULT_OPUS_MODEL?: string; + /** API timeout in milliseconds. */ + API_TIMEOUT_MS?: string; + /** Generic string keys for custom env vars. */ + [key: string]: string | undefined; }; /** Provider configuration for SDK runner. */ export type SdkProviderConfig = { /** Human-readable provider name. */ - name: string; + name?: string; /** Environment variables to set for authentication. */ - env: SdkProviderEnv; + env?: SdkProviderEnv; /** Model override (if different from config). */ model?: string; + /** Max turns before the SDK stops. */ + maxTurns?: number; +}; + +/** + * A single conversation turn for SDK history serialization. + * These are simplified representations of prior Pi Agent messages, + * stripped of tool results and other internal state. + */ +export type SdkConversationTurn = { + role: "user" | "assistant"; + content: string; + /** Optional ISO timestamp for context ordering. */ + timestamp?: string; }; /** Parameters for SDK runner execution. */ export type SdkRunnerParams = { + /** Unique run identifier. */ + runId: string; /** Session identifier. */ sessionId: string; /** Session key for routing. */ @@ -45,7 +66,7 @@ export type SdkRunnerParams = { sessionFile: string; /** Agent workspace directory. */ workspaceDir: string; - /** Agent data directory. */ + /** Agent data directory (for auth profile resolution). */ agentDir?: string; /** Moltbot configuration. */ config?: MoltbotConfig; @@ -56,11 +77,11 @@ export type SdkRunnerParams = { /** Provider configuration. */ providerConfig?: SdkProviderConfig; /** Timeout in milliseconds. */ - timeoutMs: number; - /** Run identifier for event correlation. */ - runId: string; + timeoutMs?: number; /** Abort signal for cancellation. */ abortSignal?: AbortSignal; + /** System prompt to prepend / inject. */ + systemPrompt?: string; /** Extra system prompt to append. */ extraSystemPrompt?: string; /** Enable Claude Code hooks. */ @@ -69,8 +90,56 @@ export type SdkRunnerParams = { sdkOptions?: Record; /** 3-tier model configuration (haiku/sonnet/opus). */ modelTiers?: CcSdkModelTiers; - /** Called for agent lifecycle events. */ - onAgentEvent?: (evt: { stream: string; data: Record }) => void; + + /** + * Pre-built Moltbot tools to expose to the agent. + * These should already be policy-filtered. + */ + tools?: AnyAgentTool[]; + + /** + * Claude Code built-in tools to enable alongside Moltbot MCP tools. + * Set to `[]` to disable all built-in tools (agent uses only Moltbot tools). + * Set to `["Read", "Bash", ...]` for a curated list. + * Defaults to `[]` (Moltbot tools only via MCP). + */ + builtInTools?: string[]; + + /** Permission mode for the SDK ("default", "acceptEdits", "bypassPermissions"). */ + permissionMode?: string; + + /** Max agent turns before the SDK stops. */ + maxTurns?: number; + + /** + * MCP server name for the bridged Moltbot tools. + * Defaults to "moltbot". + */ + mcpServerName?: string; + + /** + * Prior conversation history to serialize into the SDK prompt. + * Since the SDK is stateless, prior turns are injected as context + * in the system prompt or user message to simulate multi-turn behavior. + */ + conversationHistory?: SdkConversationTurn[]; + + // --- Streaming callbacks --- + + /** Called with partial text as the agent streams a response. */ + onPartialReply?: (payload: { text?: string }) => void | Promise; + + /** Called when the agent starts a new assistant message. */ + onAssistantMessageStart?: () => void | Promise; + + /** Called when the agent completes a block reply. */ + onBlockReply?: (payload: { text?: string }) => void | Promise; + + /** Called when a tool result is produced. */ + onToolResult?: (payload: { text?: string }) => void | Promise; + + /** Called for lifecycle / diagnostic events. */ + onAgentEvent?: (evt: { stream: string; data: Record }) => void | Promise; }; /** SDK event types from the Claude Agent SDK. */ @@ -139,3 +208,47 @@ export type SdkDoneEvent = SdkEvent & { }; }; }; + +// --------------------------------------------------------------------------- +// SDK runner result types +// --------------------------------------------------------------------------- + +/** Error kinds that can occur during SDK execution. */ +export type SdkRunnerErrorKind = + | "sdk_unavailable" + | "mcp_bridge_failed" + | "run_failed" + | "timeout" + | "no_output"; + +/** Metadata from an SDK runner execution. */ +export type SdkRunnerMeta = { + durationMs: number; + provider?: string; + model?: string; + eventCount: number; + extractedChars: number; + truncated: boolean; + aborted?: boolean; + error?: { + kind: SdkRunnerErrorKind; + message: string; + }; + /** Tool bridge diagnostics. */ + bridge?: { + toolCount: number; + registeredTools: string[]; + skippedTools: string[]; + }; +}; + +/** Result from SDK runner execution. */ +export type SdkRunnerResult = { + /** Extracted text payloads from the agent run. */ + payloads: Array<{ + text?: string; + isError?: boolean; + }>; + /** Run metadata. */ + meta: SdkRunnerMeta; +};