feat: add ccsdk session adapter

This commit is contained in:
David Garson 2026-01-28 11:07:45 -07:00
parent 0a2e444b21
commit e5f7aa936c
13 changed files with 2373 additions and 85 deletions

View File

@ -0,0 +1,345 @@
/**
* Error handling and retry logic for the Claude Agent SDK.
*
* Provides error classification, retry strategies, and failover support
* for CCSDK agent runs.
*/
import { createSubsystemLogger } from "../../logging/subsystem.js";
const log = createSubsystemLogger("agents/claude-agent-sdk/error");
/**
* Error classification for CCSDK errors.
*/
export type CcsdkErrorKind =
| "rate_limit"
| "context_overflow"
| "auth_failure"
| "network"
| "timeout"
| "tool_error"
| "unknown";
/**
* Error patterns for classification.
*/
const ERROR_PATTERNS: Array<{ pattern: RegExp; kind: CcsdkErrorKind }> = [
// Rate limiting
{ pattern: /rate.?limit|too many requests|429/i, kind: "rate_limit" },
{ pattern: /overloaded|capacity|throttl/i, kind: "rate_limit" },
{ pattern: /resource has been exhausted/i, kind: "rate_limit" },
// Context overflow
{ pattern: /context.?(?:window|length|overflow)|too.?(?:long|large)/i, kind: "context_overflow" },
{ pattern: /exceeds.*(?:context|token|max)/i, kind: "context_overflow" },
{ pattern: /maximum.*(?:context|token)/i, kind: "context_overflow" },
{ pattern: /prompt.*too.*long/i, kind: "context_overflow" },
// Auth failures
{ pattern: /auth(?:entication|orization)?.*(?:failed|error|invalid)/i, kind: "auth_failure" },
{ pattern: /invalid.*(?:api.?key|token|credential)/i, kind: "auth_failure" },
{ pattern: /401|403|forbidden|unauthorized/i, kind: "auth_failure" },
{ pattern: /permission.*denied/i, kind: "auth_failure" },
// Network errors
{ pattern: /network|connection|connect.*(?:error|failed|refused)/i, kind: "network" },
{ pattern: /ECONNREFUSED|ECONNRESET|ENOTFOUND|EHOSTUNREACH/i, kind: "network" },
{ pattern: /socket.*(?:error|closed|hang)/i, kind: "network" },
{ pattern: /fetch.*failed/i, kind: "network" },
// Timeout
{ pattern: /timeout|timed.?out|deadline.*exceeded/i, kind: "timeout" },
{ pattern: /ETIMEDOUT|ESOCKETTIMEDOUT/i, kind: "timeout" },
{ pattern: /request.*abort/i, kind: "timeout" },
// Tool errors
{ pattern: /tool.*(?:error|failed|execution)/i, kind: "tool_error" },
{ pattern: /mcp.*(?:error|failed)/i, kind: "tool_error" },
];
/**
* Classify an error into a known category.
*/
export function classifyError(error: unknown): CcsdkErrorKind {
if (!error) return "unknown";
// Extract error details
const message = getErrorMessage(error);
const name = getErrorName(error);
const status = getStatusCode(error);
const code = getErrorCode(error);
// Check status codes first
if (status === 429) return "rate_limit";
if (status === 401 || status === 403) return "auth_failure";
if (status === 408) return "timeout";
// Check error codes
if (code === "ETIMEDOUT" || code === "ESOCKETTIMEDOUT") return "timeout";
if (code === "ECONNREFUSED" || code === "ECONNRESET" || code === "ENOTFOUND") return "network";
// Check error name
if (name === "TimeoutError") return "timeout";
if (name === "AbortError") return "timeout";
// Check message patterns
const textToCheck = `${message} ${name} ${code ?? ""}`;
for (const { pattern, kind } of ERROR_PATTERNS) {
if (pattern.test(textToCheck)) {
return kind;
}
}
return "unknown";
}
/**
* Extract error message from unknown error.
*/
function getErrorMessage(error: unknown): string {
if (error instanceof Error) return error.message;
if (typeof error === "string") return error;
if (error && typeof error === "object") {
const msg = (error as { message?: unknown }).message;
if (typeof msg === "string") return msg;
}
return "";
}
/**
* Extract error name from unknown error.
*/
function getErrorName(error: unknown): string {
if (error instanceof Error) return error.name;
if (error && typeof error === "object") {
const name = (error as { name?: unknown }).name;
if (typeof name === "string") return name;
}
return "";
}
/**
* Extract HTTP status code from error.
*/
function getStatusCode(error: unknown): number | undefined {
if (!error || typeof error !== "object") return undefined;
const status =
(error as { status?: unknown }).status ?? (error as { statusCode?: unknown }).statusCode;
if (typeof status === "number") return status;
if (typeof status === "string" && /^\d+$/.test(status)) {
return Number(status);
}
return undefined;
}
/**
* Extract error code from error.
*/
function getErrorCode(error: unknown): string | undefined {
if (!error || typeof error !== "object") return undefined;
const code = (error as { code?: unknown }).code;
if (typeof code === "string" && code.trim()) {
return code.trim();
}
return undefined;
}
/**
* Retry options.
*/
export type RetryOptions = {
/** Maximum number of retry attempts. */
maxRetries: number;
/** Base backoff delay in milliseconds. */
backoffMs: number;
/** Maximum backoff delay in milliseconds. */
maxBackoffMs?: number;
/** Backoff multiplier (default: 2 for exponential backoff). */
backoffMultiplier?: number;
/** Error kinds that should trigger a retry. */
retryOn: CcsdkErrorKind[];
/** Optional abort signal. */
abortSignal?: AbortSignal;
/** Optional callback for retry events. */
onRetry?: (attempt: number, error: unknown, delayMs: number) => void;
};
/**
* Default retry options.
*/
export const DEFAULT_RETRY_OPTIONS: Partial<RetryOptions> = {
maxRetries: 3,
backoffMs: 1000,
maxBackoffMs: 30_000,
backoffMultiplier: 2,
retryOn: ["rate_limit", "network", "timeout"],
};
/**
* Execute a function with retry logic.
*/
export async function withRetry<T>(fn: () => Promise<T>, options: RetryOptions): Promise<T> {
const maxRetries = options.maxRetries;
const backoffMs = options.backoffMs;
const maxBackoffMs = options.maxBackoffMs ?? 30_000;
const multiplier = options.backoffMultiplier ?? 2;
const retryOn = new Set(options.retryOn);
let lastError: unknown;
let delay = backoffMs;
for (let attempt = 0; attempt <= maxRetries; attempt++) {
// Check for abort before each attempt
if (options.abortSignal?.aborted) {
throw new Error("Aborted");
}
try {
return await fn();
} catch (error) {
lastError = error;
const errorKind = classifyError(error);
// Don't retry non-retryable errors
if (!retryOn.has(errorKind)) {
throw error;
}
// Don't retry if we've exhausted attempts
if (attempt >= maxRetries) {
log.warn(`All ${maxRetries} retry attempts exhausted`, {
errorKind,
lastError: getErrorMessage(error),
});
throw error;
}
// Calculate delay with jitter
const jitter = Math.random() * 0.2 * delay; // 0-20% jitter
const effectiveDelay = Math.min(delay + jitter, maxBackoffMs);
log.debug(`Retry attempt ${attempt + 1}/${maxRetries} in ${Math.round(effectiveDelay)}ms`, {
errorKind,
error: getErrorMessage(error),
});
// Notify callback
options.onRetry?.(attempt + 1, error, effectiveDelay);
// Wait before retry
await sleep(effectiveDelay, options.abortSignal);
// Increase delay for next attempt
delay = Math.min(delay * multiplier, maxBackoffMs);
}
}
throw lastError;
}
/**
* Sleep for a duration, respecting abort signal.
*/
async function sleep(ms: number, signal?: AbortSignal): Promise<void> {
return new Promise((resolve, reject) => {
if (signal?.aborted) {
reject(new Error("Aborted"));
return;
}
const timer = setTimeout(resolve, ms);
if (signal) {
const onAbort = () => {
clearTimeout(timer);
reject(new Error("Aborted"));
};
signal.addEventListener("abort", onAbort, { once: true });
}
});
}
/**
* Classify error and determine if it's retryable.
*/
export function isRetryableError(
error: unknown,
retryableKinds: CcsdkErrorKind[] = ["rate_limit", "network", "timeout"],
): boolean {
const kind = classifyError(error);
return retryableKinds.includes(kind);
}
/**
* Get a human-readable description of an error kind.
*/
export function describeErrorKind(kind: CcsdkErrorKind): string {
switch (kind) {
case "rate_limit":
return "Rate limit exceeded";
case "context_overflow":
return "Context window overflow";
case "auth_failure":
return "Authentication failed";
case "network":
return "Network error";
case "timeout":
return "Request timed out";
case "tool_error":
return "Tool execution error";
case "unknown":
default:
return "Unknown error";
}
}
/**
* CCSDK-specific error with classification.
*/
export class CcsdkError extends Error {
readonly kind: CcsdkErrorKind;
readonly status?: number;
readonly code?: string;
constructor(
message: string,
kind: CcsdkErrorKind,
options?: {
status?: number;
code?: string;
cause?: unknown;
},
) {
super(message, { cause: options?.cause });
this.name = "CcsdkError";
this.kind = kind;
this.status = options?.status;
this.code = options?.code;
}
}
/**
* Check if an error is a CcsdkError.
*/
export function isCcsdkError(error: unknown): error is CcsdkError {
return error instanceof CcsdkError;
}
/**
* Coerce an unknown error to a CcsdkError.
*/
export function toCcsdkError(error: unknown): CcsdkError {
if (isCcsdkError(error)) return error;
const kind = classifyError(error);
const message = getErrorMessage(error) || describeErrorKind(kind);
const status = getStatusCode(error);
const code = getErrorCode(error);
return new CcsdkError(message, kind, {
status,
code,
cause: error instanceof Error ? error : undefined,
});
}

View File

@ -47,8 +47,36 @@ export type {
SdkEventType,
SdkProviderConfig,
SdkProviderEnv,
SdkReasoningLevel,
SdkRunnerCallbacks,
SdkRunnerParams,
SdkTextEvent,
SdkToolResultEvent,
SdkToolUseEvent,
SdkVerboseLevel,
} from "./types.js";
// Error handling
export {
classifyError,
CcsdkError,
describeErrorKind,
isCcsdkError,
isRetryableError,
toCcsdkError,
withRetry,
DEFAULT_RETRY_OPTIONS,
type CcsdkErrorKind,
type RetryOptions,
} from "./error-handling.js";
// System prompt
export {
buildSystemPromptAdditions,
buildSystemPromptAdditionsFromParams,
clearPromptEnrichers,
getEnricherCount,
registerPromptEnricher,
type PromptEnricher,
type PromptEnricherContext,
} from "./system-prompt.js";

View File

@ -7,6 +7,8 @@
import type { MoltbotConfig } from "../../config/config.js";
import type { AgentRuntime, AgentRuntimeRunParams, AgentRuntimeResult } from "../agent-runtime.js";
import type { AgentCcSdkConfig } from "../../config/types.agents.js";
import type { ThinkLevel, VerboseLevel } from "../../auto-reply/thinking.js";
import type { SdkReasoningLevel, SdkVerboseLevel } from "./types.js";
import { runSdkAgent } from "./sdk-runner.js";
import { resolveProviderConfig } from "./provider-config.js";
import { isSdkAvailable } from "./sdk-loader.js";
@ -27,6 +29,77 @@ export type CcSdkAgentRuntimeContext = {
baseUrl?: string;
};
/**
* Map ThinkLevel to SDK reasoning level.
*/
function mapThinkLevel(thinkLevel?: ThinkLevel): SdkReasoningLevel {
switch (thinkLevel) {
case "off":
return "off";
case "minimal":
return "minimal";
case "low":
return "low";
case "medium":
return "medium";
case "high":
case "xhigh":
return "high";
default:
return "off";
}
}
/**
* Map VerboseLevel to SDK verbose level.
*/
function mapVerboseLevel(verboseLevel?: VerboseLevel): SdkVerboseLevel {
switch (verboseLevel) {
case "off":
return "off";
case "on":
return "on";
case "full":
return "full";
default:
return "off";
}
}
/**
* Extract agent ID from session key.
*/
function extractAgentId(sessionKey?: string): string {
if (!sessionKey) return "main";
const parts = sessionKey.split(":");
return parts[0] || "main";
}
/**
* Resolve user timezone from config.
*/
function resolveTimezone(config?: MoltbotConfig): string | undefined {
// Check for explicit timezone in config
const tz = config?.agents?.defaults?.userTimezone;
if (tz) return tz;
// Fallback to environment
return typeof process !== "undefined" ? process.env.TZ : undefined;
}
/**
* Extract skill names from snapshot.
*/
function extractSkillNames(
skillsSnapshot?: AgentRuntimeRunParams["skillsSnapshot"],
): string[] | undefined {
if (!skillsSnapshot?.resolvedSkills) return undefined;
const names = skillsSnapshot.resolvedSkills
.map((s) => s.name)
.filter((n): n is string => Boolean(n));
return names.length > 0 ? names : undefined;
}
/**
* Create a Claude Code SDK runtime instance.
*
@ -55,29 +128,58 @@ export function createCcSdkAgentRuntime(context?: CcSdkAgentRuntimeContext): Age
displayName: `Claude Code SDK (${providerConfig.name})`,
async run(params: AgentRuntimeRunParams): Promise<AgentRuntimeResult> {
const effectiveConfig = params.config ?? context?.config;
const agentId = extractAgentId(params.sessionKey);
log.debug("CCSDK runtime run", {
sessionId: params.sessionId,
runId: params.runId,
provider: providerConfig.name,
agentId,
thinkLevel: params.thinkLevel,
verboseLevel: params.verboseLevel,
});
return runSdkAgent({
// ─── Session & Identity ──────────────────────────────────────────────
sessionId: params.sessionId,
sessionKey: params.sessionKey,
sessionFile: params.sessionFile,
workspaceDir: params.workspaceDir,
agentDir: params.agentDir,
config: params.config ?? context?.config,
agentId,
// ─── Configuration ───────────────────────────────────────────────────
config: effectiveConfig,
prompt: params.prompt,
model: params.model ? `${params.provider}/${params.model}` : undefined,
model: params.model ? `${params.provider ?? "anthropic"}/${params.model}` : undefined,
providerConfig,
timeoutMs: params.timeoutMs,
runId: params.runId,
abortSignal: params.abortSignal,
// ─── Model Behavior ──────────────────────────────────────────────────
reasoningLevel: mapThinkLevel(params.thinkLevel),
verboseLevel: mapVerboseLevel(params.verboseLevel),
// ─── System Prompt Context ───────────────────────────────────────────
extraSystemPrompt: params.extraSystemPrompt,
timezone: resolveTimezone(effectiveConfig),
messageChannel: params.messageChannel,
skills: extractSkillNames(params.skillsSnapshot),
// ─── SDK Options ─────────────────────────────────────────────────────
hooksEnabled: context?.ccsdkConfig?.hooksEnabled,
sdkOptions: context?.ccsdkConfig?.options,
modelTiers: context?.ccsdkConfig?.models,
// ─── Streaming Callbacks ─────────────────────────────────────────────
onPartialReply: params.onPartialReply,
onAssistantMessageStart: params.onAssistantMessageStart,
onBlockReply: params.onBlockReply,
onBlockReplyFlush: params.onBlockReplyFlush,
onReasoningStream: params.onReasoningStream,
onToolResult: params.onToolResult,
onAgentEvent: params.onAgentEvent,
});
},

View File

@ -2,7 +2,7 @@
* Claude Agent SDK runner.
*
* Executes agent turns using the Claude Agent SDK, handling authentication,
* event streaming, and result adaptation.
* event streaming, hooks, and result adaptation.
*/
import type { SdkRunnerParams, SdkProviderEnv } from "./types.js";
@ -10,6 +10,14 @@ 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 { buildSystemPromptAdditionsFromParams } from "./system-prompt.js";
import {
classifyError,
withRetry,
DEFAULT_RETRY_OPTIONS,
type CcsdkErrorKind,
} from "./error-handling.js";
import { getGlobalHookRunner } from "../../plugins/hook-runner-global.js";
import { createSubsystemLogger } from "../../logging/subsystem.js";
const log = createSubsystemLogger("agents/claude-agent-sdk");
@ -36,30 +44,83 @@ function buildModelTierEnv(modelTiers?: CcSdkModelTiers): SdkProviderEnv {
return env;
}
/**
* Internal result type from SDK execution.
*/
type SdkInternalResult = {
texts: string[];
usage?: {
input?: number;
output?: number;
cacheRead?: number;
cacheWrite?: number;
total?: number;
};
aborted?: boolean;
error?: string;
};
/**
* SDK event handler for streaming callbacks.
*/
type SdkEventHandler = {
onText?: (text: string) => void;
onThinking?: (text: string) => void;
onToolUse?: (toolName: string, toolId: string, input: unknown) => void;
onToolResult?: (toolId: string, result: string, isError?: boolean) => void;
onError?: (error: string) => void;
onDone?: (usage?: SdkInternalResult["usage"]) => void;
};
/**
* Run an agent turn using the Claude Agent SDK.
*
* This function:
* 1. Loads the SDK dynamically
* 2. Configures authentication based on available credentials
* 3. Executes the agent turn with the given prompt
* 4. Streams events back to the caller
* 5. Returns results in the standard AgentRuntimeResult format
* 1. Runs before_agent_start hooks to allow context injection
* 2. Loads the SDK dynamically
* 3. Configures authentication based on available credentials
* 4. Builds system prompt additions for Moltbot-specific context
* 5. Executes the agent turn with the given prompt
* 6. Streams events back to the caller via callbacks
* 7. Runs agent_end hooks for post-processing
* 8. Returns results in the standard AgentRuntimeResult format
*/
export async function runSdkAgent(params: SdkRunnerParams): Promise<AgentRuntimeResult> {
const started = Date.now();
const hookRunner = getGlobalHookRunner();
// Emit lifecycle start event
params.onAgentEvent?.({
stream: "lifecycle",
data: {
phase: "start",
runtime: "sdk",
runtime: "ccsdk",
sessionId: params.sessionId,
runId: params.runId,
},
});
// ─── Run before_agent_start hooks ──────────────────────────────────────────
let effectivePrompt = params.prompt;
if (hookRunner?.hasHooks("before_agent_start")) {
try {
const hookResult = await hookRunner.runBeforeAgentStart(
{ prompt: params.prompt, messages: [] },
{
agentId: params.agentId ?? params.sessionKey?.split(":")[0] ?? "main",
sessionKey: params.sessionKey,
workspaceDir: params.workspaceDir,
},
);
if (hookResult?.prependContext) {
effectivePrompt = `${hookResult.prependContext}\n\n${params.prompt}`;
log.debug(`hooks: prepended context to prompt (${hookResult.prependContext.length} chars)`);
}
} catch (hookErr) {
log.warn(`before_agent_start hook failed: ${String(hookErr)}`);
}
}
try {
// Load the SDK
const sdk = await loadClaudeAgentSdk();
@ -72,6 +133,7 @@ export async function runSdkAgent(params: SdkRunnerParams): Promise<AgentRuntime
model: params.model,
sessionId: params.sessionId,
runId: params.runId,
reasoning: params.reasoningLevel ?? "off",
});
// Set up environment for the SDK
@ -92,29 +154,64 @@ export async function runSdkAgent(params: SdkRunnerParams): Promise<AgentRuntime
}
}
// Build SDK options
const sdkOptions = {
model: params.model,
workingDirectory: params.workspaceDir,
systemPrompt: params.extraSystemPrompt,
timeout: params.timeoutMs,
signal: params.abortSignal,
...params.sdkOptions,
};
// Run the agent
// Note: The actual SDK API may differ - this is a placeholder implementation
// that will need to be updated based on the actual SDK interface
const result = await runSdkAgentInternal(sdk, params.prompt, sdkOptions, {
onEvent: (event) => {
// Forward events to the caller
params.onAgentEvent?.({
stream: "sdk",
data: event as Record<string, unknown>,
});
},
// Build system prompt additions
const systemPromptAdditions = buildSystemPromptAdditionsFromParams({
agentId: params.agentId,
sessionKey: params.sessionKey,
workspaceDir: params.workspaceDir,
timezone: params.timezone,
messageChannel: params.messageChannel,
channelHints: params.channelHints,
skills: params.skills,
});
// Combine with any legacy extra system prompt
const combinedSystemPrompt =
[systemPromptAdditions, params.extraSystemPrompt].filter(Boolean).join("\n\n") || undefined;
// Build event handler for streaming callbacks
const eventHandler = buildEventHandler(params);
// Run the agent with retry logic
const result = await withRetry(
() =>
runSdkAgentInternal(
sdk,
effectivePrompt,
{
model: params.model,
workingDirectory: params.workspaceDir,
sessionId: params.sessionId,
systemPrompt: combinedSystemPrompt,
timeout: params.timeoutMs,
signal: params.abortSignal,
reasoningLevel: params.reasoningLevel,
maxOutputTokens: params.maxOutputTokens,
maxThinkingTokens: params.maxThinkingTokens,
...params.sdkOptions,
},
eventHandler,
),
{
...DEFAULT_RETRY_OPTIONS,
maxRetries: 2,
backoffMs: 1000,
retryOn: ["rate_limit", "network", "timeout"] as CcsdkErrorKind[],
abortSignal: params.abortSignal,
onRetry: (attempt, error, delayMs) => {
log.warn(`SDK agent retry attempt ${attempt}`, {
error: error instanceof Error ? error.message : String(error),
delayMs,
sessionId: params.sessionId,
});
params.onAgentEvent?.({
stream: "lifecycle",
data: { phase: "retry", attempt, delayMs },
});
},
},
);
// Build result
const agentResult: AgentRuntimeResult = {
payloads: result.texts.map((text) => ({ text })),
@ -135,13 +232,34 @@ export async function runSdkAgent(params: SdkRunnerParams): Promise<AgentRuntime
stream: "lifecycle",
data: {
phase: "end",
runtime: "sdk",
runtime: "ccsdk",
startedAt: started,
endedAt: Date.now(),
aborted: result.aborted ?? false,
},
});
// ─── Run agent_end hooks (fire-and-forget) ─────────────────────────────
if (hookRunner?.hasHooks("agent_end")) {
hookRunner
.runAgentEnd(
{
messages: [],
success: !result.aborted && !result.error,
error: result.error,
durationMs: Date.now() - started,
},
{
agentId: params.agentId ?? params.sessionKey?.split(":")[0] ?? "main",
sessionKey: params.sessionKey,
workspaceDir: params.workspaceDir,
},
)
.catch((err) => {
log.warn(`agent_end hook failed: ${err}`);
});
}
return agentResult;
} finally {
// Restore original environment
@ -159,9 +277,11 @@ export async function runSdkAgent(params: SdkRunnerParams): Promise<AgentRuntime
}
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
const errorKind = classifyError(error);
log.error("SDK agent run failed", {
error: errorMessage,
errorKind,
sessionId: params.sessionId,
runId: params.runId,
});
@ -171,13 +291,35 @@ export async function runSdkAgent(params: SdkRunnerParams): Promise<AgentRuntime
stream: "lifecycle",
data: {
phase: "error",
runtime: "sdk",
runtime: "ccsdk",
startedAt: started,
endedAt: Date.now(),
error: errorMessage,
errorKind,
},
});
// Run agent_end hooks on error
if (hookRunner?.hasHooks("agent_end")) {
hookRunner
.runAgentEnd(
{
messages: [],
success: false,
error: errorMessage,
durationMs: Date.now() - started,
},
{
agentId: params.agentId ?? params.sessionKey?.split(":")[0] ?? "main",
sessionKey: params.sessionKey,
workspaceDir: params.workspaceDir,
},
)
.catch((err) => {
log.warn(`agent_end hook failed: ${err}`);
});
}
// Return error result
return {
payloads: [
@ -193,84 +335,292 @@ export async function runSdkAgent(params: SdkRunnerParams): Promise<AgentRuntime
provider: "anthropic",
model: params.model ?? "unknown",
},
error: {
kind: errorKind === "context_overflow" ? "context_overflow" : "compaction_failure",
message: errorMessage,
},
},
};
}
}
/**
* Build event handler for streaming callbacks.
*/
function buildEventHandler(params: SdkRunnerParams): SdkEventHandler {
let assistantStarted = false;
return {
onText: (text) => {
// Signal assistant message start
if (!assistantStarted) {
assistantStarted = true;
params.onAssistantMessageStart?.();
}
// Stream partial reply
params.onPartialReply?.({ text });
// Forward as block reply if configured
params.onBlockReply?.({ text });
},
onThinking: (text) => {
params.onReasoningStream?.({ text });
},
onToolUse: (toolName, toolId, input) => {
params.onAgentEvent?.({
stream: "tool",
data: {
phase: "start",
toolName,
toolId,
input,
},
});
},
onToolResult: (toolId, result, isError) => {
// Check if we should emit this tool result
const toolName = "unknown"; // Would need to track from onToolUse
if (params.shouldEmitToolResult && !params.shouldEmitToolResult(toolName)) {
return;
}
params.onToolResult?.({ text: result });
params.onAgentEvent?.({
stream: "tool",
data: {
phase: "end",
toolId,
result: params.shouldEmitToolOutput?.(toolName) ? result : undefined,
isError,
},
});
},
onError: (error) => {
params.onAgentEvent?.({
stream: "error",
data: { message: error },
});
},
onDone: (usage) => {
// Flush any pending block replies
params.onBlockReplyFlush?.();
params.onAgentEvent?.({
stream: "done",
data: { usage },
});
},
};
}
/**
* 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
* Calls the Claude Agent SDK's query function and processes the streaming response.
* The SDK handles:
* - Session resumption (via sessionId)
* - Workspace context building
* - Tool execution
* - Conversation history
*/
async function runSdkAgentInternal(
sdk: ClaudeAgentSdkModule,
_prompt: string,
prompt: string,
options: {
model?: string;
workingDirectory?: string;
sessionId?: string;
systemPrompt?: string;
timeout?: number;
signal?: AbortSignal;
reasoningLevel?: string;
maxOutputTokens?: number;
maxThinkingTokens?: number;
[key: string]: unknown;
},
_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
eventHandler: SdkEventHandler,
): Promise<SdkInternalResult> {
const texts: string[] = [];
const usage:
| {
input?: number;
output?: number;
cacheRead?: number;
cacheWrite?: number;
total?: number;
}
| undefined = undefined;
let usage: SdkInternalResult["usage"];
let aborted = false;
let error: string | undefined;
try {
// Check if SDK has the expected interface
// This needs to be updated based on actual SDK API
if ("Claude" in sdk || "createAgent" in sdk || "Agent" in sdk) {
// TODO: Implement actual SDK call based on SDK's public API
// For now, throw to indicate SDK integration is not yet complete
// The Claude Agent SDK exports a `query` function that returns an async iterator
// of events. The exact API shape may vary by SDK version.
const queryFn = (sdk as { query?: unknown }).query;
if (typeof queryFn !== "function") {
// Check for alternative API shapes
const claudeFn = (sdk as { claude?: unknown }).claude;
const agentFn = (sdk as { agent?: unknown }).agent;
if (typeof claudeFn === "function" || typeof agentFn === "function") {
throw new Error(
"Found alternative SDK API. Please update runSdkAgentInternal to use the correct interface.",
);
}
throw new Error(
"Claude Agent SDK integration not yet implemented. " +
"Please check SDK documentation for the correct API.",
"Claude Agent SDK query function not found. " +
"Ensure @anthropic-ai/claude-agent-sdk is installed correctly.",
);
}
// Fallback: check for simple completion API
throw new Error("Unable to find compatible API in Claude Agent SDK");
} catch (error) {
// Build query options
const queryOptions = {
prompt,
options: {
cwd: options.workingDirectory,
model: options.model,
systemPrompt: options.systemPrompt,
maxTurns: 50, // Reasonable default
resume: options.sessionId, // Resume existing session
abortController: options.signal ? { signal: options.signal } : undefined,
// Thinking/reasoning configuration
...(options.reasoningLevel && options.reasoningLevel !== "off"
? {
thinking: {
type: "enabled",
budgetTokens: options.maxThinkingTokens ?? 10000,
},
}
: {}),
...(options.maxOutputTokens ? { maxTokens: options.maxOutputTokens } : {}),
},
};
// Execute query and process events
const response = await (
queryFn as (opts: typeof queryOptions) => Promise<AsyncIterable<unknown>>
)(queryOptions);
// Process streaming events
for await (const event of response) {
if (options.signal?.aborted) {
aborted = true;
break;
}
processEvent(event, texts, eventHandler, (u) => {
usage = u;
});
}
} catch (err) {
if (options.signal?.aborted) {
aborted = true;
texts.push("Agent run was aborted.");
} else {
throw error;
error = err instanceof Error ? err.message : String(err);
eventHandler.onError?.(error);
throw err;
}
}
return { texts, usage, aborted };
// Notify completion
eventHandler.onDone?.(usage);
// Ensure we have at least one text response
if (texts.length === 0 && !aborted && !error) {
texts.push("(No response from agent)");
}
return { texts, usage, aborted, error };
}
/**
* Process a single SDK event.
*/
function processEvent(
event: unknown,
texts: string[],
handler: SdkEventHandler,
setUsage: (u: SdkInternalResult["usage"]) => void,
): void {
if (!event || typeof event !== "object") return;
const evt = event as Record<string, unknown>;
const type = evt.type as string | undefined;
switch (type) {
case "text":
case "content_block_delta": {
const text = (evt.text as string) ?? (evt.delta as { text?: string })?.text;
if (text) {
texts.push(text);
handler.onText?.(text);
}
break;
}
case "thinking":
case "thinking_delta": {
const thinking = (evt.thinking as string) ?? (evt.delta as { thinking?: string })?.thinking;
if (thinking) {
handler.onThinking?.(thinking);
}
break;
}
case "tool_use":
case "tool_use_start": {
const name = evt.name as string;
const id = evt.id as string;
const input = evt.input;
if (name && id) {
handler.onToolUse?.(name, id, input);
}
break;
}
case "tool_result": {
const toolId = (evt.tool_use_id as string) ?? (evt.id as string);
const content = evt.content as string;
const isError = evt.is_error as boolean;
if (toolId) {
handler.onToolResult?.(toolId, content ?? "", isError);
}
break;
}
case "message_stop":
case "done":
case "result": {
const eventUsage = evt.usage as
| {
input_tokens?: number;
output_tokens?: number;
cache_read_input_tokens?: number;
cache_creation_input_tokens?: number;
}
| undefined;
if (eventUsage) {
setUsage({
input: eventUsage.input_tokens,
output: eventUsage.output_tokens,
cacheRead: eventUsage.cache_read_input_tokens,
cacheWrite: eventUsage.cache_creation_input_tokens,
});
}
// Extract final text if present
const resultText = (evt.result as string) ?? (evt.text as string);
if (resultText && !texts.includes(resultText)) {
texts.push(resultText);
handler.onText?.(resultText);
}
break;
}
case "error": {
const errorMsg =
(evt.error as { message?: string })?.message ?? (evt.message as string) ?? "Unknown error";
handler.onError?.(errorMsg);
break;
}
}
}

View File

@ -0,0 +1,169 @@
/**
* System prompt additions for Claude Code SDK.
*
* The SDK builds its own comprehensive system prompt with workspace context,
* tools, git status, etc. This module provides a small extension point for
* adding Moltbot-specific context the SDK can't know about.
*
* These additions are **prepended** to the SDK's native system prompt.
*/
/**
* Context available to prompt enrichers.
*/
export type PromptEnricherContext = {
/** Moltbot agent ID (e.g., "main", "work"). */
agentId?: string;
/** Session key for routing. */
sessionKey?: string;
/** Agent workspace directory. */
workspaceDir?: string;
/** User's timezone (e.g., "America/New_York"). */
timezone?: string;
/** Messaging channel (e.g., "telegram", "slack", "whatsapp"). */
channel?: string;
/** Channel-specific hints or instructions. */
channelHints?: string;
/** Available Moltbot skills. */
skills?: string[];
/** Additional custom context. */
custom?: Record<string, unknown>;
};
/**
* A prompt enricher adds context to the system prompt.
*
* Returns a string to add, or undefined/null to skip.
*/
export type PromptEnricher = (context: PromptEnricherContext) => string | undefined | null;
/**
* Registry of prompt enrichers.
*/
const enrichers: PromptEnricher[] = [];
/**
* Register a prompt enricher.
*
* Enrichers are called in registration order. Each can add a line or
* paragraph of context to the system prompt additions.
*/
export function registerPromptEnricher(enricher: PromptEnricher): void {
enrichers.push(enricher);
}
/**
* Clear all registered enrichers (for testing).
*/
export function clearPromptEnrichers(): void {
enrichers.length = 0;
}
/**
* Get count of registered enrichers (for testing).
*/
export function getEnricherCount(): number {
return enrichers.length;
}
// ─── Built-in Enrichers ──────────────────────────────────────────────────────
/**
* Agent identity enricher.
*/
const agentIdentityEnricher: PromptEnricher = (ctx) => {
if (!ctx.agentId) return null;
return `You are Moltbot agent "${ctx.agentId}".`;
};
/**
* Timezone enricher.
*/
const timezoneEnricher: PromptEnricher = (ctx) => {
if (!ctx.timezone) return null;
return `User timezone: ${ctx.timezone}`;
};
/**
* Channel hints enricher.
*/
const channelEnricher: PromptEnricher = (ctx) => {
if (!ctx.channel && !ctx.channelHints) return null;
const parts: string[] = [];
if (ctx.channel) {
parts.push(`Messaging channel: ${ctx.channel}`);
}
if (ctx.channelHints) {
parts.push(ctx.channelHints);
}
return parts.join("\n");
};
/**
* Skills enricher.
*/
const skillsEnricher: PromptEnricher = (ctx) => {
if (!ctx.skills?.length) return null;
return `Available Moltbot skills: ${ctx.skills.join(", ")}`;
};
// Register built-in enrichers
registerPromptEnricher(agentIdentityEnricher);
registerPromptEnricher(timezoneEnricher);
registerPromptEnricher(channelEnricher);
registerPromptEnricher(skillsEnricher);
// ─── Main API ────────────────────────────────────────────────────────────────
/**
* Build system prompt additions for the Claude Code SDK.
*
* Runs all registered enrichers and combines their output.
* Returns undefined if no additions are needed.
*/
export function buildSystemPromptAdditions(context: PromptEnricherContext): string | undefined {
const parts: string[] = [];
for (const enricher of enrichers) {
try {
const result = enricher(context);
if (result) {
parts.push(result.trim());
}
} catch {
// Skip failed enrichers silently
}
}
if (parts.length === 0) {
return undefined;
}
return parts.join("\n\n");
}
/**
* Build system prompt additions from run parameters.
*
* Convenience wrapper that extracts context from common param shapes.
*/
export function buildSystemPromptAdditionsFromParams(params: {
agentId?: string;
sessionKey?: string;
workspaceDir?: string;
timezone?: string;
messageChannel?: string;
channelHints?: string;
skills?: string[];
}): string | undefined {
return buildSystemPromptAdditions({
agentId: params.agentId ?? params.sessionKey?.split(":")[0],
sessionKey: params.sessionKey,
workspaceDir: params.workspaceDir,
timezone: params.timezone,
channel: params.messageChannel,
channelHints: params.channelHints,
skills: params.skills,
});
}

View File

@ -35,8 +35,38 @@ export type SdkProviderConfig = {
model?: string;
};
/** Reasoning/thinking level for the agent. */
export type SdkReasoningLevel = "off" | "minimal" | "low" | "medium" | "high";
/** Verbose output level for tool results. */
export type SdkVerboseLevel = "off" | "on" | "full";
/** Streaming callbacks for SDK runner. */
export type SdkRunnerCallbacks = {
/** Called when the assistant message starts. */
onAssistantMessageStart?: () => void | Promise<void>;
/** Called when a partial reply chunk is available. */
onPartialReply?: (payload: { text?: string; mediaUrls?: string[] }) => void | Promise<void>;
/** Called for reasoning/thinking stream events. */
onReasoningStream?: (payload: { text?: string }) => void | Promise<void>;
/** Called for block-level reply delivery. */
onBlockReply?: (payload: {
text?: string;
mediaUrls?: string[];
audioAsVoice?: boolean;
replyToId?: string;
}) => void | Promise<void>;
/** Called when block replies should be flushed. */
onBlockReplyFlush?: () => void | Promise<void>;
/** Called when a tool result is available. */
onToolResult?: (payload: { text?: string; mediaUrls?: string[] }) => void | Promise<void>;
/** Called for agent lifecycle events. */
onAgentEvent?: (evt: { stream: string; data: Record<string, unknown> }) => void;
};
/** Parameters for SDK runner execution. */
export type SdkRunnerParams = {
// ─── Session & Identity ────────────────────────────────────────────────────
/** Session identifier. */
sessionId: string;
/** Session key for routing. */
@ -47,6 +77,10 @@ export type SdkRunnerParams = {
workspaceDir: string;
/** Agent data directory. */
agentDir?: string;
/** Moltbot agent ID (e.g., "main", "work"). */
agentId?: string;
// ─── Configuration ─────────────────────────────────────────────────────────
/** Moltbot configuration. */
config?: MoltbotConfig;
/** User prompt/message. */
@ -61,17 +95,47 @@ export type SdkRunnerParams = {
runId: string;
/** Abort signal for cancellation. */
abortSignal?: AbortSignal;
/** Extra system prompt to append. */
// ─── Model Behavior ────────────────────────────────────────────────────────
/** Reasoning/thinking level. */
reasoningLevel?: SdkReasoningLevel;
/** Verbose level for tool output. */
verboseLevel?: SdkVerboseLevel;
/** Maximum output tokens. */
maxOutputTokens?: number;
/** Maximum thinking tokens (when reasoning is enabled). */
maxThinkingTokens?: number;
// ─── Tools ─────────────────────────────────────────────────────────────────
/** Allow elevated bash commands. */
bashElevated?: boolean;
/** Tools to disable. */
disableTools?: string[];
/** Filter for emitting tool results. */
shouldEmitToolResult?: (toolName: string) => boolean;
/** Filter for emitting tool output. */
shouldEmitToolOutput?: (toolName: string) => boolean;
// ─── System Prompt Context ─────────────────────────────────────────────────
/** Extra system prompt to append (legacy). */
extraSystemPrompt?: string;
/** User's timezone (e.g., "America/New_York"). */
timezone?: string;
/** Messaging channel (e.g., "telegram", "slack"). */
messageChannel?: string;
/** Channel-specific hints. */
channelHints?: string;
/** Available Moltbot skills. */
skills?: string[];
// ─── SDK Options ───────────────────────────────────────────────────────────
/** Enable Claude Code hooks. */
hooksEnabled?: boolean;
/** Additional SDK options. */
sdkOptions?: Record<string, unknown>;
/** 3-tier model configuration (haiku/sonnet/opus). */
modelTiers?: CcSdkModelTiers;
/** Called for agent lifecycle events. */
onAgentEvent?: (evt: { stream: string; data: Record<string, unknown> }) => void;
};
} & SdkRunnerCallbacks;
/** SDK event types from the Claude Agent SDK. */
export type SdkEventType =

View File

@ -0,0 +1,502 @@
/**
* Claude Code SDK session adapter.
*
* Reads and writes Claude Code SDK JSONL format with tree-structured messages.
*
* CCSDK JSONL Format:
* ```jsonl
* {
* "type": "assistant",
* "parentUuid": "...",
* "uuid": "...",
* "sessionId": "...",
* "cwd": "/path/to/workspace",
* "version": "2.1.22",
* "gitBranch": "...",
* "slug": "...",
* "timestamp": "...",
* "message": {
* "role": "assistant",
* "content": [
* { "type": "text", "text": "..." },
* { "type": "tool_use", "id": "...", "name": "...", "input": {} },
* { "type": "thinking", "thinking": "..." }
* ],
* "usage": { "input_tokens": ..., "output_tokens": ... }
* }
* }
* ```
*
* Key differences from Pi-Agent:
* - Tree structure (parentUuid/uuid) vs flat list
* - Rich metadata envelope (cwd, gitBranch, slug, version)
* - tool_use in content (not toolCall)
* - Tool results inline in next message
* - Thinking blocks preserved
*/
import fs from "node:fs/promises";
import readline from "node:readline";
import { createReadStream } from "node:fs";
import type { SessionAdapter } from "./session-adapter.js";
import type {
AssistantContent,
NormalizedContent,
NormalizedImageContent,
NormalizedMessage,
NormalizedToolResultContent,
SessionMetadata,
UsageInfo,
} from "./types.js";
/**
* CCSDK content block types.
*/
type CcsdkTextContent = { type: "text"; text: string };
type CcsdkImageContent = {
type: "image";
source: { type: "base64"; media_type: string; data: string };
};
type CcsdkToolUse = {
type: "tool_use";
id: string;
name: string;
input: Record<string, unknown>;
};
type CcsdkToolResult = {
type: "tool_result";
tool_use_id: string;
content: string | Array<CcsdkTextContent | CcsdkImageContent>;
is_error?: boolean;
};
type CcsdkThinking = { type: "thinking"; thinking: string };
type CcsdkContent =
| CcsdkTextContent
| CcsdkImageContent
| CcsdkToolUse
| CcsdkToolResult
| CcsdkThinking;
/**
* CCSDK message structure.
*/
type CcsdkMessage = {
role: "user" | "assistant";
content: string | CcsdkContent[];
usage?: {
input_tokens?: number;
output_tokens?: number;
cache_read_input_tokens?: number;
cache_creation_input_tokens?: number;
};
};
/**
* CCSDK session entry envelope.
*/
type CcsdkEntry = {
type: "user" | "assistant";
uuid: string;
parentUuid?: string;
sessionId: string;
cwd?: string;
version?: string;
gitBranch?: string;
slug?: string;
timestamp?: string;
message: CcsdkMessage;
};
/**
* Options for creating the CCSDK session adapter.
*/
export type CcsdkSessionAdapterOptions = {
sessionId: string;
cwd?: string;
version?: string;
gitBranch?: string;
slug?: string;
};
/**
* Generate a UUID for CCSDK messages.
*/
function generateUuid(): string {
// Simple UUID v4-like generation
return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (c) => {
const r = (Math.random() * 16) | 0;
const v = c === "x" ? r : (r & 0x3) | 0x8;
return v.toString(16);
});
}
/**
* Convert CCSDK content to normalized content.
*/
function normalizeCcsdkContent(content: string | CcsdkContent[]): NormalizedContent[] {
if (typeof content === "string") {
return [{ type: "text", text: content }];
}
return content
.map((block): NormalizedContent | null => {
switch (block.type) {
case "text":
return { type: "text", text: block.text };
case "image":
return {
type: "image",
data: block.source.data,
mimeType: block.source.media_type,
};
case "tool_use":
return {
type: "tool_call",
id: block.id,
name: block.name,
arguments: block.input,
};
case "thinking":
return { type: "thinking", text: block.thinking };
case "tool_result":
// Tool results are handled separately
return null;
default:
return null;
}
})
.filter((b): b is NormalizedContent => b !== null);
}
/**
* Convert normalized assistant content to CCSDK format.
*/
function denormalizeAssistantContent(content: AssistantContent[]): CcsdkContent[] {
return content.map((block): CcsdkContent => {
switch (block.type) {
case "text":
return { type: "text", text: block.text };
case "tool_call":
return {
type: "tool_use",
id: block.id,
name: block.name,
input: block.arguments,
};
case "thinking":
return { type: "thinking", thinking: block.text };
default:
return { type: "text", text: "" };
}
});
}
/**
* CCSDK session adapter implementation.
*/
export class CcsdkSessionAdapter implements SessionAdapter {
readonly format = "ccsdk" as const;
readonly sessionFile: string;
private metadata: SessionMetadata;
private currentParentUuid: string | undefined;
private entries: CcsdkEntry[] = [];
private entriesLoaded = false;
private pendingWrites: CcsdkEntry[] = [];
constructor(sessionFile: string, options: CcsdkSessionAdapterOptions) {
this.sessionFile = sessionFile;
this.metadata = {
sessionId: options.sessionId,
cwd: options.cwd,
version: options.version,
gitBranch: options.gitBranch,
slug: options.slug,
runtime: "ccsdk",
};
}
getMetadata(): SessionMetadata {
return this.metadata;
}
/**
* Set the parent UUID for tree-structured messages.
*/
setParentId(parentId: string): void {
this.currentParentUuid = parentId;
}
/**
* Load entries from the session file.
*/
private async loadEntries(): Promise<void> {
if (this.entriesLoaded) return;
try {
await fs.access(this.sessionFile);
} catch {
// File doesn't exist yet
this.entriesLoaded = true;
return;
}
const stream = createReadStream(this.sessionFile, { encoding: "utf8" });
const rl = readline.createInterface({ input: stream, crlfDelay: Infinity });
for await (const line of rl) {
const trimmed = line.trim();
if (!trimmed) continue;
try {
const entry = JSON.parse(trimmed) as CcsdkEntry;
if (entry.type === "user" || entry.type === "assistant") {
this.entries.push(entry);
// Track the latest UUID as potential parent
if (entry.uuid) {
this.currentParentUuid = entry.uuid;
}
// Update metadata from entries
if (entry.cwd) this.metadata.cwd = entry.cwd;
if (entry.version) this.metadata.version = entry.version;
if (entry.gitBranch) this.metadata.gitBranch = entry.gitBranch;
if (entry.slug) this.metadata.slug = entry.slug;
}
} catch {
// Skip malformed lines
}
}
this.entriesLoaded = true;
}
async loadHistory(): Promise<NormalizedMessage[]> {
await this.loadEntries();
const messages: NormalizedMessage[] = [];
for (const entry of this.entries) {
const content = normalizeCcsdkContent(entry.message.content);
// Extract tool results from content (they're inline in CCSDK)
const contentArr = Array.isArray(entry.message.content) ? entry.message.content : [];
const toolResults = contentArr.filter((c): c is CcsdkToolResult => c.type === "tool_result");
// Add the main message
if (entry.type === "user") {
messages.push({
id: entry.uuid,
role: "user",
content,
timestamp: entry.timestamp ? new Date(entry.timestamp).getTime() : undefined,
metadata: {
parentUuid: entry.parentUuid,
cwd: entry.cwd,
gitBranch: entry.gitBranch,
},
});
} else if (entry.type === "assistant") {
messages.push({
id: entry.uuid,
role: "assistant",
content,
timestamp: entry.timestamp ? new Date(entry.timestamp).getTime() : undefined,
metadata: {
parentUuid: entry.parentUuid,
cwd: entry.cwd,
gitBranch: entry.gitBranch,
usage: entry.message.usage,
},
});
}
// Add tool results as separate messages for consistency
for (const tr of toolResults) {
const resultContent =
typeof tr.content === "string"
? [{ type: "text" as const, text: tr.content }]
: tr.content.map((c) => {
if (c.type === "text") {
return { type: "text" as const, text: c.text };
}
return {
type: "image" as const,
data: c.source.data,
mimeType: c.source.media_type,
};
});
messages.push({
id: `${entry.uuid}-tr-${tr.tool_use_id}`,
role: "tool_result",
content: {
type: "tool_result",
toolCallId: tr.tool_use_id,
content: resultContent,
isError: tr.is_error,
},
});
}
}
return messages;
}
async appendUserMessage(content: string, images?: NormalizedImageContent[]): Promise<string> {
const uuid = generateUuid();
const messageContent: CcsdkContent[] = [{ type: "text", text: content }];
if (images && images.length > 0) {
for (const img of images) {
messageContent.push({
type: "image",
source: {
type: "base64",
media_type: img.mimeType,
data: img.data,
},
});
}
}
const entry: CcsdkEntry = {
type: "user",
uuid,
parentUuid: this.currentParentUuid,
sessionId: this.metadata.sessionId,
cwd: this.metadata.cwd,
version: this.metadata.version,
gitBranch: this.metadata.gitBranch,
slug: this.metadata.slug,
timestamp: new Date().toISOString(),
message: {
role: "user",
content: messageContent,
},
};
this.entries.push(entry);
this.pendingWrites.push(entry);
this.currentParentUuid = uuid;
return uuid;
}
async appendAssistantMessage(content: AssistantContent[], usage?: UsageInfo): Promise<string> {
const uuid = generateUuid();
const ccsdkContent = denormalizeAssistantContent(content);
const entry: CcsdkEntry = {
type: "assistant",
uuid,
parentUuid: this.currentParentUuid,
sessionId: this.metadata.sessionId,
cwd: this.metadata.cwd,
version: this.metadata.version,
gitBranch: this.metadata.gitBranch,
slug: this.metadata.slug,
timestamp: new Date().toISOString(),
message: {
role: "assistant",
content: ccsdkContent,
usage: usage
? {
input_tokens: usage.inputTokens,
output_tokens: usage.outputTokens,
cache_read_input_tokens: usage.cacheReadTokens,
cache_creation_input_tokens: usage.cacheWriteTokens,
}
: undefined,
},
};
this.entries.push(entry);
this.pendingWrites.push(entry);
this.currentParentUuid = uuid;
return uuid;
}
async appendToolResult(
toolCallId: string,
result: NormalizedToolResultContent,
isError?: boolean,
): Promise<string> {
// In CCSDK format, tool results are typically part of the next user message
// For standalone storage, we create a synthetic user message with the tool result
const uuid = generateUuid();
const toolResultContent: CcsdkToolResult = {
type: "tool_result",
tool_use_id: toolCallId,
content: result.content.map((c) => {
if (c.type === "text") {
return { type: "text" as const, text: c.text };
}
return {
type: "image" as const,
source: {
type: "base64" as const,
media_type: c.mimeType,
data: c.data,
},
};
}),
is_error: isError ?? result.isError,
};
const entry: CcsdkEntry = {
type: "user",
uuid,
parentUuid: this.currentParentUuid,
sessionId: this.metadata.sessionId,
cwd: this.metadata.cwd,
version: this.metadata.version,
timestamp: new Date().toISOString(),
message: {
role: "user",
content: [toolResultContent],
},
};
this.entries.push(entry);
this.pendingWrites.push(entry);
this.currentParentUuid = uuid;
return uuid;
}
async flush(): Promise<void> {
if (this.pendingWrites.length === 0) return;
// Ensure directory exists
const dir = this.sessionFile.substring(0, this.sessionFile.lastIndexOf("/"));
if (dir) {
await fs.mkdir(dir, { recursive: true });
}
// Append entries as JSONL
const lines = this.pendingWrites.map((entry) => JSON.stringify(entry)).join("\n") + "\n";
await fs.appendFile(this.sessionFile, lines, "utf8");
this.pendingWrites = [];
}
async close(): Promise<void> {
await this.flush();
this.entries = [];
this.entriesLoaded = false;
}
}
/**
* Create a CCSDK session adapter.
*/
export function createCcsdkSessionAdapter(
sessionFile: string,
options: CcsdkSessionAdapterOptions,
): CcsdkSessionAdapter {
return new CcsdkSessionAdapter(sessionFile, options);
}

View File

@ -0,0 +1,56 @@
/**
* Session adapter module.
*
* Provides a unified interface for reading and writing session history
* across different runtime formats (Pi-Agent, Claude Code SDK).
*/
export type { SessionAdapter, SessionAdapterFactory } from "./session-adapter.js";
export type {
AssistantContent,
NormalizedContent,
NormalizedImageContent,
NormalizedMessage,
NormalizedTextContent,
NormalizedThinking,
NormalizedToolCall,
NormalizedToolResultContent,
SessionMetadata,
UsageInfo,
} from "./types.js";
export {
createPiSessionAdapter,
PiSessionAdapter,
type PiSessionAdapterOptions,
} from "./pi-session-adapter.js";
export {
createCcsdkSessionAdapter,
CcsdkSessionAdapter,
type CcsdkSessionAdapterOptions,
} from "./ccsdk-session-adapter.js";
import type { SessionAdapter } from "./session-adapter.js";
import { createPiSessionAdapter } from "./pi-session-adapter.js";
import { createCcsdkSessionAdapter } from "./ccsdk-session-adapter.js";
/**
* Factory function to create the appropriate session adapter.
*/
export function createSessionAdapter(
runtime: "pi-agent" | "ccsdk",
sessionFile: string,
options: {
sessionId: string;
cwd?: string;
version?: string;
gitBranch?: string;
slug?: string;
},
): SessionAdapter {
if (runtime === "ccsdk") {
return createCcsdkSessionAdapter(sessionFile, options);
}
return createPiSessionAdapter(sessionFile, options);
}

View File

@ -0,0 +1,377 @@
/**
* Pi-Agent session adapter.
*
* Wraps the pi-coding-agent SessionManager to provide a unified session interface.
* Pi-Agent uses a flat JSONL format with session header and message entries.
*
* Pi-Agent JSONL Format:
* ```jsonl
* {"type":"session","version":"1","id":"...","cwd":"..."}
* {"type":"message","message":{"role":"user","content":"..."}}
* {"type":"message","message":{"role":"assistant","content":[{"type":"text","text":"..."},{"type":"toolCall","id":"...","name":"...","arguments":{}}]}}
* {"type":"message","message":{"role":"toolResult","toolCallId":"...","content":[{"type":"text","text":"..."}]}}
* ```
*/
import type { SessionAdapter } from "./session-adapter.js";
import type {
AssistantContent,
NormalizedContent,
NormalizedImageContent,
NormalizedMessage,
NormalizedToolResultContent,
SessionMetadata,
UsageInfo,
} from "./types.js";
/**
* Pi-Agent message content types.
*/
type PiTextContent = { type: "text"; text: string };
type PiImageContent = { type: "image"; data: string; mimeType: string };
type PiToolCall = {
type: "toolCall" | "toolUse" | "functionCall";
id: string;
name: string;
arguments: Record<string, unknown>;
};
type PiToolResult = {
type: "toolResult";
toolCallId: string;
content: Array<PiTextContent | PiImageContent>;
isError?: boolean;
};
type PiContent = PiTextContent | PiImageContent | PiToolCall | PiToolResult;
/**
* Pi-Agent message structure.
*/
type PiMessage = {
role: "user" | "assistant" | "toolResult";
content: string | PiContent[];
};
/**
* Pi-Agent session entry types.
*/
type PiSessionHeader = {
type: "session";
version?: string;
id?: string;
cwd?: string;
};
type PiMessageEntry = {
type: "message";
id?: string;
parentId?: string;
message: PiMessage;
};
type PiEntry = PiSessionHeader | PiMessageEntry;
/**
* SessionManager interface (subset we need from pi-coding-agent).
*/
interface PiSessionManager {
sessionId: string;
fileEntries: PiEntry[];
appendMessage(message: PiMessage): void;
buildSessionContext(): { messages: PiMessage[] };
getLeafEntry(): PiEntry | null;
branch(parentId: string): void;
resetLeaf(): void;
}
/**
* Options for creating the Pi-Agent session adapter.
*/
export type PiSessionAdapterOptions = {
sessionId: string;
cwd?: string;
/** Existing SessionManager instance to wrap. */
sessionManager?: PiSessionManager;
};
/**
* Generate a unique message ID.
*/
function generateMessageId(): string {
return `pi-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
}
/**
* Convert Pi-Agent content to normalized content.
*/
function normalizePiContent(content: string | PiContent[]): NormalizedContent[] {
if (typeof content === "string") {
return [{ type: "text", text: content }];
}
return content
.map((block): NormalizedContent | null => {
switch (block.type) {
case "text":
return { type: "text", text: block.text };
case "image":
return {
type: "image",
data: block.data,
mimeType: block.mimeType,
};
case "toolCall":
case "toolUse":
case "functionCall":
return {
type: "tool_call",
id: block.id,
name: block.name,
arguments: block.arguments,
};
default:
return null;
}
})
.filter((b): b is NormalizedContent => b !== null);
}
/**
* Convert normalized assistant content to Pi-Agent format.
*/
function denormalizeAssistantContent(content: AssistantContent[]): PiContent[] {
return content.map((block): PiContent => {
switch (block.type) {
case "text":
return { type: "text", text: block.text };
case "tool_call":
return {
type: "toolCall",
id: block.id,
name: block.name,
arguments: block.arguments,
};
case "thinking":
// Thinking blocks are typically not persisted in Pi-Agent format
// Convert to text with marker
return { type: "text", text: `<thinking>${block.text}</thinking>` };
default:
return { type: "text", text: "" };
}
});
}
/**
* Pi-Agent session adapter implementation.
*/
export class PiSessionAdapter implements SessionAdapter {
readonly format = "pi-agent" as const;
readonly sessionFile: string;
private sessionManager: PiSessionManager | null;
private metadata: SessionMetadata;
private pendingWrites: PiEntry[] = [];
constructor(sessionFile: string, options: PiSessionAdapterOptions) {
this.sessionFile = sessionFile;
this.sessionManager = options.sessionManager ?? null;
this.metadata = {
sessionId: options.sessionId,
cwd: options.cwd,
version: "1",
runtime: "pi-agent",
};
}
/**
* Set the SessionManager instance (for use with existing pi-coding-agent setup).
*/
setSessionManager(sm: PiSessionManager): void {
this.sessionManager = sm;
// Update metadata from SessionManager
const header = sm.fileEntries.find((e): e is PiSessionHeader => e.type === "session");
if (header) {
this.metadata.sessionId = header.id ?? this.metadata.sessionId;
this.metadata.cwd = header.cwd ?? this.metadata.cwd;
this.metadata.version = header.version ?? this.metadata.version;
}
}
getMetadata(): SessionMetadata {
return this.metadata;
}
async loadHistory(): Promise<NormalizedMessage[]> {
if (!this.sessionManager) {
return [];
}
const context = this.sessionManager.buildSessionContext();
const messages: NormalizedMessage[] = [];
for (let i = 0; i < context.messages.length; i++) {
const msg = context.messages[i];
const id = generateMessageId();
if (msg.role === "user") {
messages.push({
id,
role: "user",
content: normalizePiContent(msg.content),
});
} else if (msg.role === "assistant") {
messages.push({
id,
role: "assistant",
content: normalizePiContent(msg.content),
});
} else if (msg.role === "toolResult") {
// Tool results in Pi-Agent are separate messages
const content = msg.content as PiContent[];
const toolResult = content.find((c): c is PiToolResult => c.type === "toolResult");
if (toolResult) {
messages.push({
id,
role: "tool_result",
content: {
type: "tool_result",
toolCallId: toolResult.toolCallId,
content: toolResult.content.map((c) => ({
type: c.type as "text" | "image",
...(c.type === "text" ? { text: (c as PiTextContent).text } : {}),
...(c.type === "image"
? {
data: (c as PiImageContent).data,
mimeType: (c as PiImageContent).mimeType,
}
: {}),
})) as Array<
{ type: "text"; text: string } | { type: "image"; data: string; mimeType: string }
>,
isError: toolResult.isError,
},
});
}
}
}
return messages;
}
async appendUserMessage(content: string, images?: NormalizedImageContent[]): Promise<string> {
const id = generateMessageId();
const messageContent: PiContent[] = [{ type: "text", text: content }];
if (images && images.length > 0) {
for (const img of images) {
messageContent.push({
type: "image",
data: img.data,
mimeType: img.mimeType,
});
}
}
const message: PiMessage = {
role: "user",
content: messageContent,
};
if (this.sessionManager) {
this.sessionManager.appendMessage(message);
} else {
this.pendingWrites.push({
type: "message",
id,
message,
});
}
return id;
}
async appendAssistantMessage(content: AssistantContent[], _usage?: UsageInfo): Promise<string> {
const id = generateMessageId();
const piContent = denormalizeAssistantContent(content);
const message: PiMessage = {
role: "assistant",
content: piContent,
};
if (this.sessionManager) {
this.sessionManager.appendMessage(message);
} else {
this.pendingWrites.push({
type: "message",
id,
message,
});
}
return id;
}
async appendToolResult(
toolCallId: string,
result: NormalizedToolResultContent,
isError?: boolean,
): Promise<string> {
const id = generateMessageId();
const toolResultContent: PiContent[] = [
{
type: "toolResult",
toolCallId,
content: result.content.map((c) => {
if (c.type === "text") {
return { type: "text" as const, text: c.text };
}
return {
type: "image" as const,
data: c.data,
mimeType: c.mimeType,
};
}),
isError: isError ?? result.isError,
},
];
const message: PiMessage = {
role: "toolResult",
content: toolResultContent,
};
if (this.sessionManager) {
this.sessionManager.appendMessage(message);
} else {
this.pendingWrites.push({
type: "message",
id,
message,
});
}
return id;
}
async flush(): Promise<void> {
// SessionManager handles flushing internally
// Nothing to do here for the wrapped case
}
async close(): Promise<void> {
// SessionManager cleanup is handled externally
this.sessionManager = null;
this.pendingWrites = [];
}
}
/**
* Create a Pi-Agent session adapter.
*/
export function createPiSessionAdapter(
sessionFile: string,
options: PiSessionAdapterOptions,
): PiSessionAdapter {
return new PiSessionAdapter(sessionFile, options);
}

View File

@ -0,0 +1,107 @@
/**
* Abstract session adapter interface.
*
* Provides a unified interface for reading and writing session history
* across different runtime formats (Pi-Agent JSONL, Claude Code SDK JSONL).
*
* Each runtime reads/writes its own JSONL format. No cross-format conversion.
*/
import type {
AssistantContent,
NormalizedImageContent,
NormalizedMessage,
NormalizedToolResultContent,
SessionMetadata,
UsageInfo,
} from "./types.js";
/**
* Abstract interface for session persistence.
*
* Implementations handle reading/writing session history in their native format.
*/
export interface SessionAdapter {
/** Runtime format discriminant. */
readonly format: "pi-agent" | "ccsdk";
/** Path to the session file. */
readonly sessionFile: string;
// ─── Reading ───────────────────────────────────────────────────────────────
/**
* Load conversation history from the session file.
* Returns messages in normalized format for internal processing.
*/
loadHistory(): Promise<NormalizedMessage[]>;
/**
* Get session metadata (ID, cwd, version, etc.).
*/
getMetadata(): SessionMetadata;
// ─── Writing ───────────────────────────────────────────────────────────────
/**
* Append a user message to the session.
* @param content Text content of the message.
* @param images Optional image attachments.
* @returns Message ID.
*/
appendUserMessage(content: string, images?: NormalizedImageContent[]): Promise<string>;
/**
* Append an assistant message to the session.
* @param content Array of content blocks (text, tool calls, thinking).
* @param usage Optional token usage information.
* @returns Message ID.
*/
appendAssistantMessage(content: AssistantContent[], usage?: UsageInfo): Promise<string>;
/**
* Append a tool result to the session.
* @param toolCallId ID of the tool call this is a result for.
* @param result Tool result content.
* @param isError Whether this is an error result.
* @returns Message ID.
*/
appendToolResult(
toolCallId: string,
result: NormalizedToolResultContent,
isError?: boolean,
): Promise<string>;
// ─── CCSDK-specific (no-op for pi-agent) ───────────────────────────────────
/**
* Set the parent message ID for tree-structured sessions.
* CCSDK uses parentUuid/uuid for conversation threading.
* No-op for Pi-Agent adapter.
*/
setParentId?(parentId: string): void;
// ─── Lifecycle ─────────────────────────────────────────────────────────────
/**
* Flush any pending writes to disk.
*/
flush(): Promise<void>;
/**
* Close the session adapter and release resources.
*/
close(): Promise<void>;
}
/**
* Factory function to create the appropriate session adapter.
*/
export type SessionAdapterFactory = (
runtime: "pi-agent" | "ccsdk",
sessionFile: string,
options?: {
sessionId?: string;
cwd?: string;
},
) => SessionAdapter;

View File

@ -0,0 +1,109 @@
/**
* Shared types for session adapters.
*
* These types provide a normalized representation of conversation history
* that can be converted to/from runtime-specific formats.
*/
/**
* Normalized text content block.
*/
export type NormalizedTextContent = {
type: "text";
text: string;
};
/**
* Normalized image content block.
*/
export type NormalizedImageContent = {
type: "image";
data: string;
mimeType: string;
};
/**
* Normalized tool call content block.
*/
export type NormalizedToolCall = {
type: "tool_call";
id: string;
name: string;
arguments: Record<string, unknown>;
};
/**
* Normalized thinking/reasoning content block.
*/
export type NormalizedThinking = {
type: "thinking";
text: string;
};
/**
* Union of all normalized content block types.
*/
export type NormalizedContent =
| NormalizedTextContent
| NormalizedImageContent
| NormalizedToolCall
| NormalizedThinking;
/**
* Normalized tool result content.
*/
export type NormalizedToolResultContent = {
type: "tool_result";
toolCallId: string;
content: Array<NormalizedTextContent | NormalizedImageContent>;
isError?: boolean;
};
/**
* Normalized message format for internal processing.
*/
export type NormalizedMessage = {
/** Unique identifier for the message. */
id: string;
/** Message role. */
role: "user" | "assistant" | "tool_result";
/** Message content blocks. */
content: NormalizedContent[] | NormalizedToolResultContent;
/** Timestamp of the message. */
timestamp?: number;
/** Additional metadata. */
metadata?: Record<string, unknown>;
};
/**
* Session metadata.
*/
export type SessionMetadata = {
/** Session ID. */
sessionId: string;
/** Working directory when the session was created. */
cwd?: string;
/** Session format version. */
version?: string;
/** Git branch (CCSDK specific). */
gitBranch?: string;
/** Session slug (CCSDK specific). */
slug?: string;
/** Runtime type. */
runtime: "pi-agent" | "ccsdk";
};
/**
* Usage information for a message.
*/
export type UsageInfo = {
inputTokens?: number;
outputTokens?: number;
cacheReadTokens?: number;
cacheWriteTokens?: number;
};
/**
* Assistant content types for appending messages.
*/
export type AssistantContent = NormalizedTextContent | NormalizedToolCall | NormalizedThinking;

View File

@ -6,6 +6,7 @@ import {
SandboxBrowserSchema,
SandboxDockerSchema,
SandboxPruneSchema,
validateCcsdkModelProvider,
} from "./zod-schema.agent-runtime.js";
import {
BlockStreamingChunkSchema,
@ -174,4 +175,17 @@ export const AgentDefaultsSchema = z
.optional(),
})
.strict()
.superRefine((value, ctx) => {
// Validate CCSDK runtime requires compatible model provider
if (value.runtime === "ccsdk") {
const error = validateCcsdkModelProvider(value.model);
if (error) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["model"],
message: error,
});
}
}
})
.optional();

View File

@ -416,6 +416,58 @@ export const AgentModelSchema = z.union([
/** Agent runtime backend discriminant. */
export const AgentRuntimeKindSchema = z.union([z.literal("pi"), z.literal("ccsdk")]);
/**
* Providers compatible with the Claude Code SDK (ccsdk) runtime.
*
* These providers expose Anthropic-compatible API endpoints:
* - anthropic: Direct Anthropic API
* - zai: z.AI subscription (Anthropic-compatible, supports non-Claude models like GLM-4.7)
* - openrouter: OpenRouter (Anthropic-compatible proxy, supports many models)
*
* Note: Bedrock and Vertex AI are supported by CCSDK but via environment variables
* (CLAUDE_CODE_USE_BEDROCK=1, CLAUDE_CODE_USE_VERTEX=1), not as provider prefixes.
*/
export const CCSDK_COMPATIBLE_PROVIDERS = new Set(["anthropic", "zai", "openrouter"]);
/**
* Extract provider from a model string (e.g., "anthropic/claude-sonnet-4" -> "anthropic").
* Returns null if no provider prefix is found.
*/
function extractProviderFromModelString(model: string): string | null {
const trimmed = model.trim();
if (!trimmed) return null;
const slash = trimmed.indexOf("/");
if (slash === -1) return null;
return trimmed.slice(0, slash).trim().toLowerCase() || null;
}
/**
* Validate that a model is compatible with the CCSDK runtime.
* Returns an error message if invalid, or null if valid.
*/
export function validateCcsdkModelProvider(
model: string | { primary?: string; fallbacks?: string[] } | undefined,
): string | null {
if (!model) return null; // No model specified, will use defaults
const modelsToCheck: string[] = [];
if (typeof model === "string") {
modelsToCheck.push(model);
} else {
if (model.primary) modelsToCheck.push(model.primary);
if (model.fallbacks) modelsToCheck.push(...model.fallbacks);
}
for (const m of modelsToCheck) {
const provider = extractProviderFromModelString(m);
if (provider && !CCSDK_COMPATIBLE_PROVIDERS.has(provider)) {
return `runtime "ccsdk" requires an Anthropic-compatible provider (anthropic, zai, or openrouter), but model "${m}" uses provider "${provider}"`;
}
}
return null;
}
/** Model tier configuration for Claude Code SDK. */
export const CcSdkModelTiersSchema = z
.object({
@ -471,7 +523,20 @@ export const AgentEntrySchema = z
sandbox: AgentSandboxSchema,
tools: AgentToolsSchema,
})
.strict();
.strict()
.superRefine((value, ctx) => {
// Validate CCSDK runtime requires compatible model provider
if (value.runtime === "ccsdk") {
const error = validateCcsdkModelProvider(value.model);
if (error) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["model"],
message: error,
});
}
}
});
export const ToolsSchema = z
.object({