Merge remote-tracking branch 'origin/dgarson/agent-runtime-claude-code-support'

Resolved conflicts in claude-agent-sdk module:
- index.ts: export all types from both versions
- types.ts: merge SdkRunnerParams with additional fields (tools, builtInTools,
  permissionMode, maxTurns, mcpServerName, conversationHistory) and keep
  SdkRunnerCallbacks intersection
- sdk-agent-runtime.ts: keep helper functions from both versions, use callback
  wrappers to adapt SDK payload shapes to AgentRuntimeRunParams signatures
- sdk-runner.ts: use incoming version with MCP tool bridging (returns
  SdkRunnerResult as expected by adaptSdkResult)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
David Garson 2026-01-28 11:13:21 -07:00
commit 9050c951f2
16 changed files with 1941 additions and 590 deletions

View File

@ -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");
});
});

View File

@ -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<string, unknown> {
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;
}

View File

@ -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,6 +56,7 @@ export type {
SdkRunnerQueryOptions,
} from "./tool-bridge.types.js";
export type {
SdkConversationTurn,
SdkDoneEvent,
SdkErrorEvent,
SdkEvent,
@ -49,7 +65,10 @@ export type {
SdkProviderEnv,
SdkReasoningLevel,
SdkRunnerCallbacks,
SdkRunnerErrorKind,
SdkRunnerMeta,
SdkRunnerParams,
SdkRunnerResult,
SdkTextEvent,
SdkToolResultEvent,
SdkToolUseEvent,

View File

@ -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;

View File

@ -9,10 +9,14 @@ import type { AgentRuntime, AgentRuntimeRunParams, AgentRuntimeResult } from "..
import type { AgentCcSdkConfig } from "../../config/types.agents.js";
import type { ThinkLevel, VerboseLevel } from "../../auto-reply/thinking.js";
import type { SdkReasoningLevel, SdkVerboseLevel } from "./types.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");
@ -27,6 +31,10 @@ 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[];
};
/**
@ -100,6 +108,31 @@ function extractSkillNames(
return names.length > 0 ? names : undefined;
}
/**
* 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.
*
@ -140,8 +173,18 @@ export function createCcSdkAgentRuntime(context?: CcSdkAgentRuntimeContext): Age
verboseLevel: params.verboseLevel,
});
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({
// ─── Session & Identity ──────────────────────────────────────────────
runId: params.runId,
sessionId: params.sessionId,
sessionKey: params.sessionKey,
sessionFile: params.sessionFile,
@ -155,7 +198,6 @@ export function createCcSdkAgentRuntime(context?: CcSdkAgentRuntimeContext): Age
model: params.model ? `${params.provider ?? "anthropic"}/${params.model}` : undefined,
providerConfig,
timeoutMs: params.timeoutMs,
runId: params.runId,
abortSignal: params.abortSignal,
// ─── Model Behavior ──────────────────────────────────────────────────
@ -172,16 +214,41 @@ export function createCcSdkAgentRuntime(context?: CcSdkAgentRuntimeContext): Age
hooksEnabled: context?.ccsdkConfig?.hooksEnabled,
sdkOptions: context?.ccsdkConfig?.options,
modelTiers: context?.ccsdkConfig?.models,
conversationHistory,
tools: context?.tools,
// ─── Streaming Callbacks ─────────────────────────────────────────────
onPartialReply: params.onPartialReply,
// Wrap callbacks to adapt SDK payload shapes to AgentRuntimeRunParams signatures
onPartialReply: params.onPartialReply
? (payload) => params.onPartialReply?.({ text: payload.text })
: undefined,
onAssistantMessageStart: params.onAssistantMessageStart,
onBlockReply: params.onBlockReply,
onBlockReply: params.onBlockReply
? (payload) => params.onBlockReply?.({ text: payload.text })
: undefined,
onBlockReplyFlush: params.onBlockReplyFlush,
onReasoningStream: params.onReasoningStream,
onToolResult: params.onToolResult,
onReasoningStream: params.onReasoningStream
? (payload) => params.onReasoningStream?.({ 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);
},
};
}

View File

@ -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);
});
});

View File

@ -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";
}

View File

@ -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("<conversation-history>");
expect(result).toContain("[User]:");
expect(result).toContain("Hello");
expect(result).toContain("</conversation-history>");
});
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("<conversation-history>");
});
});

View File

@ -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:
* ```
* <conversation-history>
* [User] (2025-01-26T10:00:00Z):
* Hello, how are you?
*
* [Assistant] (2025-01-26T10:00:05Z):
* I'm doing well! How can I help?
* </conversation-history>
* ```
*/
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 `<conversation-history>${truncatedNote}\n${parts.join("\n\n")}\n</conversation-history>`;
}
/**
* 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
);
}

View File

@ -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<string, unknown> {
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<string, unknown>> | Record<string, unknown>;
export type SdkHookCallbackMatcher = {
matcher?: string;
hooks: SdkHookCallback[];
timeout?: number;
};
export type SdkHooksConfig = Partial<Record<SdkHookEventName, SdkHookCallbackMatcher[]>>;
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<string, unknown>) => void;
onToolResult?: (payload: { text?: string }) => void | Promise<void>;
}): 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")] }],
};
}

View File

@ -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<unknown>;
options?: Record<string, unknown>;
}) => AsyncIterable<unknown> | Promise<AsyncIterable<unknown>>;
/**
* 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;
};

View File

@ -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<string, unknown> }> = [];
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]");
});
});

File diff suppressed because it is too large Load Diff

View File

@ -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);
}

View File

@ -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,
});
}
}

View File

@ -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,16 +24,34 @@ 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;
};
/** Reasoning/thinking level for the agent. */
@ -67,6 +86,8 @@ export type SdkRunnerCallbacks = {
/** Parameters for SDK runner execution. */
export type SdkRunnerParams = {
// ─── Session & Identity ────────────────────────────────────────────────────
/** Unique run identifier. */
runId?: string;
/** Session identifier. */
sessionId: string;
/** Session key for routing. */
@ -75,7 +96,7 @@ export type SdkRunnerParams = {
sessionFile: string;
/** Agent workspace directory. */
workspaceDir: string;
/** Agent data directory. */
/** Agent data directory (for auth profile resolution). */
agentDir?: string;
/** Moltbot agent ID (e.g., "main", "work"). */
agentId?: string;
@ -90,9 +111,7 @@ 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;
@ -117,7 +136,9 @@ export type SdkRunnerParams = {
shouldEmitToolOutput?: (toolName: string) => boolean;
// ─── System Prompt Context ─────────────────────────────────────────────────
/** Extra system prompt to append (legacy). */
/** System prompt to prepend / inject. */
systemPrompt?: string;
/** Extra system prompt to append. */
extraSystemPrompt?: string;
/** User's timezone (e.g., "America/New_York"). */
timezone?: string;
@ -135,6 +156,39 @@ export type SdkRunnerParams = {
sdkOptions?: Record<string, unknown>;
/** 3-tier model configuration (haiku/sonnet/opus). */
modelTiers?: CcSdkModelTiers;
/**
* 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[];
} & SdkRunnerCallbacks;
/** SDK event types from the Claude Agent SDK. */
@ -203,3 +257,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;
};