feat: sdk-runner implementation w/hooks
This commit is contained in:
parent
0a2e444b21
commit
236596b5ce
75
src/agents/claude-agent-sdk/extract.test.ts
Normal file
75
src/agents/claude-agent-sdk/extract.test.ts
Normal 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");
|
||||||
|
});
|
||||||
|
});
|
||||||
85
src/agents/claude-agent-sdk/extract.ts
Normal file
85
src/agents/claude-agent-sdk/extract.ts
Normal 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;
|
||||||
|
}
|
||||||
@ -18,6 +18,21 @@ export {
|
|||||||
} from "./provider-config.js";
|
} from "./provider-config.js";
|
||||||
export { runSdkAgent } from "./sdk-runner.js";
|
export { runSdkAgent } from "./sdk-runner.js";
|
||||||
export { createCcSdkAgentRuntime, type CcSdkAgentRuntimeContext } from "./sdk-agent-runtime.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 {
|
export {
|
||||||
bridgeMoltbotToolsToMcpServer,
|
bridgeMoltbotToolsToMcpServer,
|
||||||
bridgeMoltbotToolsSync,
|
bridgeMoltbotToolsSync,
|
||||||
@ -41,13 +56,17 @@ export type {
|
|||||||
SdkRunnerQueryOptions,
|
SdkRunnerQueryOptions,
|
||||||
} from "./tool-bridge.types.js";
|
} from "./tool-bridge.types.js";
|
||||||
export type {
|
export type {
|
||||||
|
SdkConversationTurn,
|
||||||
SdkDoneEvent,
|
SdkDoneEvent,
|
||||||
SdkErrorEvent,
|
SdkErrorEvent,
|
||||||
SdkEvent,
|
SdkEvent,
|
||||||
SdkEventType,
|
SdkEventType,
|
||||||
SdkProviderConfig,
|
SdkProviderConfig,
|
||||||
SdkProviderEnv,
|
SdkProviderEnv,
|
||||||
|
SdkRunnerErrorKind,
|
||||||
|
SdkRunnerMeta,
|
||||||
SdkRunnerParams,
|
SdkRunnerParams,
|
||||||
|
SdkRunnerResult,
|
||||||
SdkTextEvent,
|
SdkTextEvent,
|
||||||
SdkToolResultEvent,
|
SdkToolResultEvent,
|
||||||
SdkToolUseEvent,
|
SdkToolUseEvent,
|
||||||
|
|||||||
@ -156,7 +156,7 @@ export function resolveProviderConfig(options?: {
|
|||||||
// 1. Explicit API key takes precedence
|
// 1. Explicit API key takes precedence
|
||||||
if (options?.apiKey) {
|
if (options?.apiKey) {
|
||||||
const config = buildAnthropicSdkProvider(options.apiKey);
|
const config = buildAnthropicSdkProvider(options.apiKey);
|
||||||
if (options.baseUrl) {
|
if (options.baseUrl && config.env) {
|
||||||
config.env.ANTHROPIC_BASE_URL = options.baseUrl;
|
config.env.ANTHROPIC_BASE_URL = options.baseUrl;
|
||||||
}
|
}
|
||||||
return config;
|
return config;
|
||||||
@ -180,7 +180,7 @@ export function resolveProviderConfig(options?: {
|
|||||||
if (options?.useCliCredentials !== false) {
|
if (options?.useCliCredentials !== false) {
|
||||||
const cliConfig = buildClaudeCliSdkProvider();
|
const cliConfig = buildClaudeCliSdkProvider();
|
||||||
if (cliConfig) {
|
if (cliConfig) {
|
||||||
if (options?.baseUrl) {
|
if (options?.baseUrl && cliConfig.env) {
|
||||||
cliConfig.env.ANTHROPIC_BASE_URL = options.baseUrl;
|
cliConfig.env.ANTHROPIC_BASE_URL = options.baseUrl;
|
||||||
}
|
}
|
||||||
return cliConfig;
|
return cliConfig;
|
||||||
|
|||||||
@ -7,10 +7,14 @@
|
|||||||
import type { MoltbotConfig } from "../../config/config.js";
|
import type { MoltbotConfig } from "../../config/config.js";
|
||||||
import type { AgentRuntime, AgentRuntimeRunParams, AgentRuntimeResult } from "../agent-runtime.js";
|
import type { AgentRuntime, AgentRuntimeRunParams, AgentRuntimeResult } from "../agent-runtime.js";
|
||||||
import type { AgentCcSdkConfig } from "../../config/types.agents.js";
|
import type { AgentCcSdkConfig } from "../../config/types.agents.js";
|
||||||
|
import type { AnyAgentTool } from "../tools/common.js";
|
||||||
import { runSdkAgent } from "./sdk-runner.js";
|
import { runSdkAgent } from "./sdk-runner.js";
|
||||||
import { resolveProviderConfig } from "./provider-config.js";
|
import { resolveProviderConfig } from "./provider-config.js";
|
||||||
import { isSdkAvailable } from "./sdk-loader.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 { createSubsystemLogger } from "../../logging/subsystem.js";
|
||||||
|
import type { SdkConversationTurn, SdkRunnerResult } from "./types.js";
|
||||||
|
|
||||||
const log = createSubsystemLogger("agents/claude-agent-sdk");
|
const log = createSubsystemLogger("agents/claude-agent-sdk");
|
||||||
|
|
||||||
@ -25,8 +29,37 @@ export type CcSdkAgentRuntimeContext = {
|
|||||||
authToken?: string;
|
authToken?: string;
|
||||||
/** Custom base URL for API requests. */
|
/** Custom base URL for API requests. */
|
||||||
baseUrl?: string;
|
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.
|
* Create a Claude Code SDK runtime instance.
|
||||||
*
|
*
|
||||||
@ -61,7 +94,17 @@ export function createCcSdkAgentRuntime(context?: CcSdkAgentRuntimeContext): Age
|
|||||||
provider: providerConfig.name,
|
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,
|
sessionId: params.sessionId,
|
||||||
sessionKey: params.sessionKey,
|
sessionKey: params.sessionKey,
|
||||||
sessionFile: params.sessionFile,
|
sessionFile: params.sessionFile,
|
||||||
@ -72,14 +115,41 @@ export function createCcSdkAgentRuntime(context?: CcSdkAgentRuntimeContext): Age
|
|||||||
model: params.model ? `${params.provider}/${params.model}` : undefined,
|
model: params.model ? `${params.provider}/${params.model}` : undefined,
|
||||||
providerConfig,
|
providerConfig,
|
||||||
timeoutMs: params.timeoutMs,
|
timeoutMs: params.timeoutMs,
|
||||||
runId: params.runId,
|
|
||||||
abortSignal: params.abortSignal,
|
abortSignal: params.abortSignal,
|
||||||
extraSystemPrompt: params.extraSystemPrompt,
|
extraSystemPrompt: params.extraSystemPrompt,
|
||||||
hooksEnabled: context?.ccsdkConfig?.hooksEnabled,
|
hooksEnabled: context?.ccsdkConfig?.hooksEnabled,
|
||||||
sdkOptions: context?.ccsdkConfig?.options,
|
sdkOptions: context?.ccsdkConfig?.options,
|
||||||
modelTiers: context?.ccsdkConfig?.models,
|
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,
|
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);
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
32
src/agents/claude-agent-sdk/sdk-event-checks.test.ts
Normal file
32
src/agents/claude-agent-sdk/sdk-event-checks.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
9
src/agents/claude-agent-sdk/sdk-event-checks.ts
Normal file
9
src/agents/claude-agent-sdk/sdk-event-checks.ts
Normal 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";
|
||||||
|
}
|
||||||
85
src/agents/claude-agent-sdk/sdk-history.test.ts
Normal file
85
src/agents/claude-agent-sdk/sdk-history.test.ts
Normal 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>");
|
||||||
|
});
|
||||||
|
});
|
||||||
116
src/agents/claude-agent-sdk/sdk-history.ts
Normal file
116
src/agents/claude-agent-sdk/sdk-history.ts
Normal 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
|
||||||
|
);
|
||||||
|
}
|
||||||
196
src/agents/claude-agent-sdk/sdk-hooks.ts
Normal file
196
src/agents/claude-agent-sdk/sdk-hooks.ts
Normal 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")] }],
|
||||||
|
};
|
||||||
|
}
|
||||||
@ -10,6 +10,17 @@ import { createSubsystemLogger } from "../../logging/subsystem.js";
|
|||||||
|
|
||||||
const log = createSubsystemLogger("agents/claude-agent-sdk");
|
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).
|
* 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.
|
* Since it's an optional dependency, we use a generic type here.
|
||||||
*/
|
*/
|
||||||
export type ClaudeAgentSdkModule = {
|
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;
|
[key: string]: unknown;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
209
src/agents/claude-agent-sdk/sdk-runner.test.ts
Normal file
209
src/agents/claude-agent-sdk/sdk-runner.test.ts
Normal 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]");
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -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,
|
* This runner bridges Moltbot tools into the SDK via MCP, passes the user
|
||||||
* event streaming, and result adaptation.
|
* 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 { 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.
|
// Constants
|
||||||
*
|
// ---------------------------------------------------------------------------
|
||||||
* The Claude Code SDK uses these environment variables to select models
|
|
||||||
* for different task complexity tiers.
|
|
||||||
*/
|
|
||||||
function buildModelTierEnv(modelTiers?: CcSdkModelTiers): SdkProviderEnv {
|
|
||||||
const env: SdkProviderEnv = {};
|
|
||||||
|
|
||||||
if (modelTiers?.haiku) {
|
const DEFAULT_MAX_EXTRACTED_CHARS = 120_000;
|
||||||
env.ANTHROPIC_DEFAULT_HAIKU_MODEL = modelTiers.haiku;
|
const DEFAULT_MCP_SERVER_NAME = "moltbot";
|
||||||
|
const DEFAULT_MAX_TURNS = 50;
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Event classification helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||||
|
return !!value && typeof value === "object" && !Array.isArray(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isAsyncIterable(value: unknown): value is AsyncIterable<unknown> {
|
||||||
|
return !!value && typeof value === "object" && Symbol.asyncIterator in value;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function coerceAsyncIterable(value: unknown): Promise<AsyncIterable<unknown>> {
|
||||||
|
if (isAsyncIterable(value)) return value;
|
||||||
|
if (value instanceof Promise) {
|
||||||
|
const awaited = await value;
|
||||||
|
if (isAsyncIterable(awaited)) return awaited;
|
||||||
}
|
}
|
||||||
if (modelTiers?.sonnet) {
|
throw new Error("Claude Agent SDK query() did not return an async iterable.");
|
||||||
env.ANTHROPIC_DEFAULT_SONNET_MODEL = modelTiers.sonnet;
|
|
||||||
}
|
|
||||||
if (modelTiers?.opus) {
|
|
||||||
env.ANTHROPIC_DEFAULT_OPUS_MODEL = modelTiers.opus;
|
|
||||||
}
|
|
||||||
|
|
||||||
return env;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Run an agent turn using the Claude Agent SDK.
|
* Classify an SDK event for routing to the appropriate callback.
|
||||||
*
|
*
|
||||||
* This function:
|
* The SDK event schema is undocumented and may change. We use defensive
|
||||||
* 1. Loads the SDK dynamically
|
* heuristics to classify events into categories:
|
||||||
* 2. Configures authentication based on available credentials
|
* - "result": terminal event with final output
|
||||||
* 3. Executes the agent turn with the given prompt
|
* - "assistant": assistant message text (partial or complete)
|
||||||
* 4. Streams events back to the caller
|
* - "tool": tool execution event
|
||||||
* 5. Returns results in the standard AgentRuntimeResult format
|
* - "system": lifecycle/diagnostic event
|
||||||
|
* - "unknown": unrecognized shape
|
||||||
*/
|
*/
|
||||||
export async function runSdkAgent(params: SdkRunnerParams): Promise<AgentRuntimeResult> {
|
type EventKind = "result" | "assistant" | "tool" | "system" | "unknown";
|
||||||
const started = Date.now();
|
|
||||||
|
|
||||||
// Emit lifecycle start event
|
function classifyEvent(event: unknown): { kind: EventKind; event: unknown } {
|
||||||
params.onAgentEvent?.({
|
if (!isRecord(event)) return { kind: "unknown", event };
|
||||||
stream: "lifecycle",
|
|
||||||
data: {
|
|
||||||
phase: "start",
|
|
||||||
runtime: "sdk",
|
|
||||||
sessionId: params.sessionId,
|
|
||||||
runId: params.runId,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
try {
|
const type = event.type as string | undefined;
|
||||||
// Load the SDK
|
|
||||||
const sdk = await loadClaudeAgentSdk();
|
|
||||||
|
|
||||||
// Resolve provider configuration
|
// Terminal result event.
|
||||||
const providerConfig = params.providerConfig ?? resolveProviderConfig();
|
if (type === "result") return { kind: "result", event };
|
||||||
|
|
||||||
log.info("Starting SDK agent run", {
|
// Tool-related events.
|
||||||
provider: providerConfig.name,
|
if (
|
||||||
model: params.model,
|
type === "tool_use" ||
|
||||||
sessionId: params.sessionId,
|
type === "tool_result" ||
|
||||||
runId: params.runId,
|
type === "tool_execution_start" ||
|
||||||
});
|
type === "tool_execution_end"
|
||||||
|
) {
|
||||||
|
return { kind: "tool", event };
|
||||||
|
}
|
||||||
|
|
||||||
// Set up environment for the SDK
|
// System/lifecycle events.
|
||||||
const originalEnv = { ...process.env };
|
if (type === "system" || type === "agent_start" || type === "agent_end" || type === "error") {
|
||||||
try {
|
return { kind: "system", event };
|
||||||
// Apply provider environment variables
|
}
|
||||||
for (const [key, value] of Object.entries(providerConfig.env)) {
|
|
||||||
if (value !== undefined) {
|
|
||||||
process.env[key] = value;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Apply model tier environment variables
|
// Assistant message events (has text content).
|
||||||
const modelTierEnv = buildModelTierEnv(params.modelTiers);
|
if (event.text || event.delta || event.content || event.message) {
|
||||||
for (const [key, value] of Object.entries(modelTierEnv)) {
|
return { kind: "assistant", event };
|
||||||
if (value !== undefined) {
|
}
|
||||||
process.env[key] = value;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build SDK options
|
return { kind: "unknown", event };
|
||||||
const sdkOptions = {
|
}
|
||||||
model: params.model,
|
|
||||||
workingDirectory: params.workspaceDir,
|
|
||||||
systemPrompt: params.extraSystemPrompt,
|
|
||||||
timeout: params.timeoutMs,
|
|
||||||
signal: params.abortSignal,
|
|
||||||
...params.sdkOptions,
|
|
||||||
};
|
|
||||||
|
|
||||||
// Run the agent
|
function normalizeSdkToolName(
|
||||||
// Note: The actual SDK API may differ - this is a placeholder implementation
|
raw: string,
|
||||||
// that will need to be updated based on the actual SDK interface
|
mcpServerName: string,
|
||||||
const result = await runSdkAgentInternal(sdk, params.prompt, sdkOptions, {
|
): { name: string; rawName: string } {
|
||||||
onEvent: (event) => {
|
const trimmed = raw.trim();
|
||||||
// Forward events to the caller
|
if (!trimmed) return { name: "tool", rawName: "" };
|
||||||
params.onAgentEvent?.({
|
const parts = trimmed.split("__");
|
||||||
stream: "sdk",
|
const withoutMcpPrefix =
|
||||||
data: event as Record<string, unknown>,
|
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
|
function applySdkOptionsOverrides(
|
||||||
const agentResult: AgentRuntimeResult = {
|
options: SdkRunnerQueryOptions,
|
||||||
payloads: result.texts.map((text) => ({ text })),
|
overrides: unknown,
|
||||||
meta: {
|
): SdkRunnerQueryOptions {
|
||||||
durationMs: Date.now() - started,
|
if (!overrides || typeof overrides !== "object" || Array.isArray(overrides)) return options;
|
||||||
agentMeta: {
|
|
||||||
sessionId: params.sessionId,
|
|
||||||
provider: "anthropic",
|
|
||||||
model: params.model ?? "claude-sonnet-4-20250514",
|
|
||||||
usage: result.usage,
|
|
||||||
},
|
|
||||||
aborted: result.aborted,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
// Emit lifecycle end event
|
// Moltbot must keep these consistent with its own tool plumbing + prompt building.
|
||||||
params.onAgentEvent?.({
|
const protectedKeys = new Set([
|
||||||
stream: "lifecycle",
|
"cwd",
|
||||||
data: {
|
"mcpServers",
|
||||||
phase: "end",
|
"allowedTools",
|
||||||
runtime: "sdk",
|
"disallowedTools",
|
||||||
startedAt: started,
|
"tools",
|
||||||
endedAt: Date.now(),
|
"env",
|
||||||
aborted: result.aborted ?? false,
|
"systemPrompt",
|
||||||
},
|
"model",
|
||||||
});
|
"hooks",
|
||||||
|
]);
|
||||||
|
|
||||||
return agentResult;
|
const record = overrides as Record<string, unknown>;
|
||||||
} finally {
|
const target = options as unknown as Record<string, unknown>;
|
||||||
// Restore original environment
|
for (const [key, value] of Object.entries(record)) {
|
||||||
const keysToRestore = [
|
if (protectedKeys.has(key)) continue;
|
||||||
...Object.keys(providerConfig.env),
|
target[key] = value;
|
||||||
...Object.keys(buildModelTierEnv(params.modelTiers)),
|
}
|
||||||
];
|
return options;
|
||||||
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);
|
|
||||||
|
|
||||||
log.error("SDK agent run failed", {
|
// ---------------------------------------------------------------------------
|
||||||
error: errorMessage,
|
// Prompt building
|
||||||
sessionId: params.sessionId,
|
// ---------------------------------------------------------------------------
|
||||||
runId: params.runId,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Emit lifecycle error event
|
/**
|
||||||
params.onAgentEvent?.({
|
* Build the SDK prompt.
|
||||||
stream: "lifecycle",
|
*
|
||||||
data: {
|
* When using mcpServers, the SDK requires an AsyncIterable<SDKUserMessage>
|
||||||
phase: "error",
|
* as the prompt (not a plain string). We generate this from the user message.
|
||||||
runtime: "sdk",
|
*
|
||||||
startedAt: started,
|
* The SDK is stateless per query, so conversation history is injected as
|
||||||
endedAt: Date.now(),
|
* serialized text appended to the system prompt (see buildHistorySystemPromptSuffix).
|
||||||
error: errorMessage,
|
* 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<SdkRunnerResult> {
|
||||||
|
const startedAt = Date.now();
|
||||||
|
const mcpServerName = params.mcpServerName ?? DEFAULT_MCP_SERVER_NAME;
|
||||||
|
const hooksEnabled = params.hooksEnabled === true;
|
||||||
|
|
||||||
|
const emitEvent = (stream: string, data: Record<string, unknown>) => {
|
||||||
|
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 {
|
return {
|
||||||
payloads: [
|
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,
|
isError: true,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
meta: {
|
meta: {
|
||||||
durationMs: Date.now() - started,
|
durationMs: Date.now() - startedAt,
|
||||||
agentMeta: {
|
eventCount: 0,
|
||||||
sessionId: params.sessionId,
|
extractedChars: 0,
|
||||||
provider: "anthropic",
|
truncated: false,
|
||||||
model: params.model ?? "unknown",
|
error: { kind: "sdk_unavailable", message },
|
||||||
},
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
emitEvent("sdk", { type: "sdk_loaded" });
|
||||||
* 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
|
|
||||||
|
|
||||||
const texts: string[] = [];
|
// -------------------------------------------------------------------------
|
||||||
const usage:
|
// Step 2: Bridge Moltbot tools to MCP
|
||||||
| {
|
// -------------------------------------------------------------------------
|
||||||
input?: number;
|
|
||||||
output?: number;
|
|
||||||
cacheRead?: number;
|
|
||||||
cacheWrite?: number;
|
|
||||||
total?: number;
|
|
||||||
}
|
|
||||||
| undefined = undefined;
|
|
||||||
let aborted = false;
|
|
||||||
|
|
||||||
try {
|
let bridgeResult;
|
||||||
// Check if SDK has the expected interface
|
const tools = params.tools ?? [];
|
||||||
// This needs to be updated based on actual SDK API
|
|
||||||
if ("Claude" in sdk || "createAgent" in sdk || "Agent" in sdk) {
|
if (tools.length > 0) {
|
||||||
// TODO: Implement actual SDK call based on SDK's public API
|
try {
|
||||||
// For now, throw to indicate SDK integration is not yet complete
|
bridgeResult = await bridgeMoltbotToolsToMcpServer({
|
||||||
throw new Error(
|
name: mcpServerName,
|
||||||
"Claude Agent SDK integration not yet implemented. " +
|
tools,
|
||||||
"Please check SDK documentation for the correct API.",
|
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
|
log.info(
|
||||||
throw new Error("Unable to find compatible API in Claude Agent SDK");
|
`Bridged ${bridgeResult.toolCount} tools to MCP server "${mcpServerName}"` +
|
||||||
} catch (error) {
|
(bridgeResult.skippedTools.length > 0
|
||||||
if (options.signal?.aborted) {
|
? ` (skipped: ${bridgeResult.skippedTools.join(", ")})`
|
||||||
aborted = true;
|
: ""),
|
||||||
texts.push("Agent run was aborted.");
|
);
|
||||||
} else {
|
emitEvent("sdk", {
|
||||||
throw error;
|
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<string, string> = {};
|
||||||
|
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<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// 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<typeof setTimeout> | 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<string, unknown>,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
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<string, unknown>) : 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<string, unknown>) : undefined;
|
||||||
|
const type = record && typeof record.type === "string" ? record.type : "";
|
||||||
|
if (isSdkTerminalToolEventType(type)) await params.onToolResult({ text: toolText });
|
||||||
|
} catch {
|
||||||
|
// Don't break the stream on callback errors.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Emit system/lifecycle events.
|
||||||
|
if (kind === "system" && isRecord(event)) {
|
||||||
|
emitEvent("sdk", event as Record<string, unknown>);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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;
|
||||||
}
|
}
|
||||||
|
|||||||
124
src/agents/claude-agent-sdk/sdk-session-history.ts
Normal file
124
src/agents/claude-agent-sdk/sdk-session-history.ts
Normal 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);
|
||||||
|
}
|
||||||
93
src/agents/claude-agent-sdk/sdk-session-transcript.ts
Normal file
93
src/agents/claude-agent-sdk/sdk-session-transcript.ts
Normal 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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -4,6 +4,7 @@
|
|||||||
|
|
||||||
import type { MoltbotConfig } from "../../config/config.js";
|
import type { MoltbotConfig } from "../../config/config.js";
|
||||||
import type { CcSdkModelTiers } from "../../config/types.agents.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. */
|
/** Provider environment variables for SDK authentication and model config. */
|
||||||
export type SdkProviderEnv = {
|
export type SdkProviderEnv = {
|
||||||
@ -23,20 +24,40 @@ export type SdkProviderEnv = {
|
|||||||
ANTHROPIC_DEFAULT_SONNET_MODEL?: string;
|
ANTHROPIC_DEFAULT_SONNET_MODEL?: string;
|
||||||
/** Model for complex reasoning (Opus tier). */
|
/** Model for complex reasoning (Opus tier). */
|
||||||
ANTHROPIC_DEFAULT_OPUS_MODEL?: string;
|
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. */
|
/** Provider configuration for SDK runner. */
|
||||||
export type SdkProviderConfig = {
|
export type SdkProviderConfig = {
|
||||||
/** Human-readable provider name. */
|
/** Human-readable provider name. */
|
||||||
name: string;
|
name?: string;
|
||||||
/** Environment variables to set for authentication. */
|
/** Environment variables to set for authentication. */
|
||||||
env: SdkProviderEnv;
|
env?: SdkProviderEnv;
|
||||||
/** Model override (if different from config). */
|
/** Model override (if different from config). */
|
||||||
model?: string;
|
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. */
|
/** Parameters for SDK runner execution. */
|
||||||
export type SdkRunnerParams = {
|
export type SdkRunnerParams = {
|
||||||
|
/** Unique run identifier. */
|
||||||
|
runId: string;
|
||||||
/** Session identifier. */
|
/** Session identifier. */
|
||||||
sessionId: string;
|
sessionId: string;
|
||||||
/** Session key for routing. */
|
/** Session key for routing. */
|
||||||
@ -45,7 +66,7 @@ export type SdkRunnerParams = {
|
|||||||
sessionFile: string;
|
sessionFile: string;
|
||||||
/** Agent workspace directory. */
|
/** Agent workspace directory. */
|
||||||
workspaceDir: string;
|
workspaceDir: string;
|
||||||
/** Agent data directory. */
|
/** Agent data directory (for auth profile resolution). */
|
||||||
agentDir?: string;
|
agentDir?: string;
|
||||||
/** Moltbot configuration. */
|
/** Moltbot configuration. */
|
||||||
config?: MoltbotConfig;
|
config?: MoltbotConfig;
|
||||||
@ -56,11 +77,11 @@ export type SdkRunnerParams = {
|
|||||||
/** Provider configuration. */
|
/** Provider configuration. */
|
||||||
providerConfig?: SdkProviderConfig;
|
providerConfig?: SdkProviderConfig;
|
||||||
/** Timeout in milliseconds. */
|
/** Timeout in milliseconds. */
|
||||||
timeoutMs: number;
|
timeoutMs?: number;
|
||||||
/** Run identifier for event correlation. */
|
|
||||||
runId: string;
|
|
||||||
/** Abort signal for cancellation. */
|
/** Abort signal for cancellation. */
|
||||||
abortSignal?: AbortSignal;
|
abortSignal?: AbortSignal;
|
||||||
|
/** System prompt to prepend / inject. */
|
||||||
|
systemPrompt?: string;
|
||||||
/** Extra system prompt to append. */
|
/** Extra system prompt to append. */
|
||||||
extraSystemPrompt?: string;
|
extraSystemPrompt?: string;
|
||||||
/** Enable Claude Code hooks. */
|
/** Enable Claude Code hooks. */
|
||||||
@ -69,8 +90,56 @@ export type SdkRunnerParams = {
|
|||||||
sdkOptions?: Record<string, unknown>;
|
sdkOptions?: Record<string, unknown>;
|
||||||
/** 3-tier model configuration (haiku/sonnet/opus). */
|
/** 3-tier model configuration (haiku/sonnet/opus). */
|
||||||
modelTiers?: CcSdkModelTiers;
|
modelTiers?: CcSdkModelTiers;
|
||||||
/** Called for agent lifecycle events. */
|
|
||||||
onAgentEvent?: (evt: { stream: string; data: Record<string, unknown> }) => 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<void>;
|
||||||
|
|
||||||
|
/** Called when the agent starts a new assistant message. */
|
||||||
|
onAssistantMessageStart?: () => void | Promise<void>;
|
||||||
|
|
||||||
|
/** Called when the agent completes a block reply. */
|
||||||
|
onBlockReply?: (payload: { text?: string }) => void | Promise<void>;
|
||||||
|
|
||||||
|
/** Called when a tool result is produced. */
|
||||||
|
onToolResult?: (payload: { text?: string }) => void | Promise<void>;
|
||||||
|
|
||||||
|
/** Called for lifecycle / diagnostic events. */
|
||||||
|
onAgentEvent?: (evt: { stream: string; data: Record<string, unknown> }) => void | Promise<void>;
|
||||||
};
|
};
|
||||||
|
|
||||||
/** SDK event types from the Claude Agent SDK. */
|
/** 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;
|
||||||
|
};
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user