feat: add ccsdk session adapter
This commit is contained in:
parent
0a2e444b21
commit
e5f7aa936c
345
src/agents/claude-agent-sdk/error-handling.ts
Normal file
345
src/agents/claude-agent-sdk/error-handling.ts
Normal 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,
|
||||||
|
});
|
||||||
|
}
|
||||||
@ -47,8 +47,36 @@ export type {
|
|||||||
SdkEventType,
|
SdkEventType,
|
||||||
SdkProviderConfig,
|
SdkProviderConfig,
|
||||||
SdkProviderEnv,
|
SdkProviderEnv,
|
||||||
|
SdkReasoningLevel,
|
||||||
|
SdkRunnerCallbacks,
|
||||||
SdkRunnerParams,
|
SdkRunnerParams,
|
||||||
SdkTextEvent,
|
SdkTextEvent,
|
||||||
SdkToolResultEvent,
|
SdkToolResultEvent,
|
||||||
SdkToolUseEvent,
|
SdkToolUseEvent,
|
||||||
|
SdkVerboseLevel,
|
||||||
} from "./types.js";
|
} 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";
|
||||||
|
|||||||
@ -7,6 +7,8 @@
|
|||||||
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 { ThinkLevel, VerboseLevel } from "../../auto-reply/thinking.js";
|
||||||
|
import type { SdkReasoningLevel, SdkVerboseLevel } from "./types.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";
|
||||||
@ -27,6 +29,77 @@ export type CcSdkAgentRuntimeContext = {
|
|||||||
baseUrl?: string;
|
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.
|
* Create a Claude Code SDK runtime instance.
|
||||||
*
|
*
|
||||||
@ -55,29 +128,58 @@ export function createCcSdkAgentRuntime(context?: CcSdkAgentRuntimeContext): Age
|
|||||||
displayName: `Claude Code SDK (${providerConfig.name})`,
|
displayName: `Claude Code SDK (${providerConfig.name})`,
|
||||||
|
|
||||||
async run(params: AgentRuntimeRunParams): Promise<AgentRuntimeResult> {
|
async run(params: AgentRuntimeRunParams): Promise<AgentRuntimeResult> {
|
||||||
|
const effectiveConfig = params.config ?? context?.config;
|
||||||
|
const agentId = extractAgentId(params.sessionKey);
|
||||||
|
|
||||||
log.debug("CCSDK runtime run", {
|
log.debug("CCSDK runtime run", {
|
||||||
sessionId: params.sessionId,
|
sessionId: params.sessionId,
|
||||||
runId: params.runId,
|
runId: params.runId,
|
||||||
provider: providerConfig.name,
|
provider: providerConfig.name,
|
||||||
|
agentId,
|
||||||
|
thinkLevel: params.thinkLevel,
|
||||||
|
verboseLevel: params.verboseLevel,
|
||||||
});
|
});
|
||||||
|
|
||||||
return runSdkAgent({
|
return runSdkAgent({
|
||||||
|
// ─── Session & Identity ──────────────────────────────────────────────
|
||||||
sessionId: params.sessionId,
|
sessionId: params.sessionId,
|
||||||
sessionKey: params.sessionKey,
|
sessionKey: params.sessionKey,
|
||||||
sessionFile: params.sessionFile,
|
sessionFile: params.sessionFile,
|
||||||
workspaceDir: params.workspaceDir,
|
workspaceDir: params.workspaceDir,
|
||||||
agentDir: params.agentDir,
|
agentDir: params.agentDir,
|
||||||
config: params.config ?? context?.config,
|
agentId,
|
||||||
|
|
||||||
|
// ─── Configuration ───────────────────────────────────────────────────
|
||||||
|
config: effectiveConfig,
|
||||||
prompt: params.prompt,
|
prompt: params.prompt,
|
||||||
model: params.model ? `${params.provider}/${params.model}` : undefined,
|
model: params.model ? `${params.provider ?? "anthropic"}/${params.model}` : undefined,
|
||||||
providerConfig,
|
providerConfig,
|
||||||
timeoutMs: params.timeoutMs,
|
timeoutMs: params.timeoutMs,
|
||||||
runId: params.runId,
|
runId: params.runId,
|
||||||
abortSignal: params.abortSignal,
|
abortSignal: params.abortSignal,
|
||||||
|
|
||||||
|
// ─── Model Behavior ──────────────────────────────────────────────────
|
||||||
|
reasoningLevel: mapThinkLevel(params.thinkLevel),
|
||||||
|
verboseLevel: mapVerboseLevel(params.verboseLevel),
|
||||||
|
|
||||||
|
// ─── System Prompt Context ───────────────────────────────────────────
|
||||||
extraSystemPrompt: params.extraSystemPrompt,
|
extraSystemPrompt: params.extraSystemPrompt,
|
||||||
|
timezone: resolveTimezone(effectiveConfig),
|
||||||
|
messageChannel: params.messageChannel,
|
||||||
|
skills: extractSkillNames(params.skillsSnapshot),
|
||||||
|
|
||||||
|
// ─── SDK Options ─────────────────────────────────────────────────────
|
||||||
hooksEnabled: context?.ccsdkConfig?.hooksEnabled,
|
hooksEnabled: context?.ccsdkConfig?.hooksEnabled,
|
||||||
sdkOptions: context?.ccsdkConfig?.options,
|
sdkOptions: context?.ccsdkConfig?.options,
|
||||||
modelTiers: context?.ccsdkConfig?.models,
|
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,
|
onAgentEvent: params.onAgentEvent,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|||||||
@ -2,7 +2,7 @@
|
|||||||
* Claude Agent SDK runner.
|
* Claude Agent SDK runner.
|
||||||
*
|
*
|
||||||
* Executes agent turns using the Claude Agent SDK, handling authentication,
|
* 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";
|
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 type { AgentRuntimeResult } from "../agent-runtime.js";
|
||||||
import { loadClaudeAgentSdk, type ClaudeAgentSdkModule } from "./sdk-loader.js";
|
import { loadClaudeAgentSdk, type ClaudeAgentSdkModule } from "./sdk-loader.js";
|
||||||
import { resolveProviderConfig } from "./provider-config.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";
|
import { createSubsystemLogger } from "../../logging/subsystem.js";
|
||||||
|
|
||||||
const log = createSubsystemLogger("agents/claude-agent-sdk");
|
const log = createSubsystemLogger("agents/claude-agent-sdk");
|
||||||
@ -36,30 +44,83 @@ function buildModelTierEnv(modelTiers?: CcSdkModelTiers): SdkProviderEnv {
|
|||||||
return env;
|
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.
|
* Run an agent turn using the Claude Agent SDK.
|
||||||
*
|
*
|
||||||
* This function:
|
* This function:
|
||||||
* 1. Loads the SDK dynamically
|
* 1. Runs before_agent_start hooks to allow context injection
|
||||||
* 2. Configures authentication based on available credentials
|
* 2. Loads the SDK dynamically
|
||||||
* 3. Executes the agent turn with the given prompt
|
* 3. Configures authentication based on available credentials
|
||||||
* 4. Streams events back to the caller
|
* 4. Builds system prompt additions for Moltbot-specific context
|
||||||
* 5. Returns results in the standard AgentRuntimeResult format
|
* 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> {
|
export async function runSdkAgent(params: SdkRunnerParams): Promise<AgentRuntimeResult> {
|
||||||
const started = Date.now();
|
const started = Date.now();
|
||||||
|
const hookRunner = getGlobalHookRunner();
|
||||||
|
|
||||||
// Emit lifecycle start event
|
// Emit lifecycle start event
|
||||||
params.onAgentEvent?.({
|
params.onAgentEvent?.({
|
||||||
stream: "lifecycle",
|
stream: "lifecycle",
|
||||||
data: {
|
data: {
|
||||||
phase: "start",
|
phase: "start",
|
||||||
runtime: "sdk",
|
runtime: "ccsdk",
|
||||||
sessionId: params.sessionId,
|
sessionId: params.sessionId,
|
||||||
runId: params.runId,
|
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 {
|
try {
|
||||||
// Load the SDK
|
// Load the SDK
|
||||||
const sdk = await loadClaudeAgentSdk();
|
const sdk = await loadClaudeAgentSdk();
|
||||||
@ -72,6 +133,7 @@ export async function runSdkAgent(params: SdkRunnerParams): Promise<AgentRuntime
|
|||||||
model: params.model,
|
model: params.model,
|
||||||
sessionId: params.sessionId,
|
sessionId: params.sessionId,
|
||||||
runId: params.runId,
|
runId: params.runId,
|
||||||
|
reasoning: params.reasoningLevel ?? "off",
|
||||||
});
|
});
|
||||||
|
|
||||||
// Set up environment for the SDK
|
// Set up environment for the SDK
|
||||||
@ -92,29 +154,64 @@ export async function runSdkAgent(params: SdkRunnerParams): Promise<AgentRuntime
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build SDK options
|
// Build system prompt additions
|
||||||
const sdkOptions = {
|
const systemPromptAdditions = buildSystemPromptAdditionsFromParams({
|
||||||
model: params.model,
|
agentId: params.agentId,
|
||||||
workingDirectory: params.workspaceDir,
|
sessionKey: params.sessionKey,
|
||||||
systemPrompt: params.extraSystemPrompt,
|
workspaceDir: params.workspaceDir,
|
||||||
timeout: params.timeoutMs,
|
timezone: params.timezone,
|
||||||
signal: params.abortSignal,
|
messageChannel: params.messageChannel,
|
||||||
...params.sdkOptions,
|
channelHints: params.channelHints,
|
||||||
};
|
skills: params.skills,
|
||||||
|
|
||||||
// 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>,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 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
|
// Build result
|
||||||
const agentResult: AgentRuntimeResult = {
|
const agentResult: AgentRuntimeResult = {
|
||||||
payloads: result.texts.map((text) => ({ text })),
|
payloads: result.texts.map((text) => ({ text })),
|
||||||
@ -135,13 +232,34 @@ export async function runSdkAgent(params: SdkRunnerParams): Promise<AgentRuntime
|
|||||||
stream: "lifecycle",
|
stream: "lifecycle",
|
||||||
data: {
|
data: {
|
||||||
phase: "end",
|
phase: "end",
|
||||||
runtime: "sdk",
|
runtime: "ccsdk",
|
||||||
startedAt: started,
|
startedAt: started,
|
||||||
endedAt: Date.now(),
|
endedAt: Date.now(),
|
||||||
aborted: result.aborted ?? false,
|
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;
|
return agentResult;
|
||||||
} finally {
|
} finally {
|
||||||
// Restore original environment
|
// Restore original environment
|
||||||
@ -159,9 +277,11 @@ export async function runSdkAgent(params: SdkRunnerParams): Promise<AgentRuntime
|
|||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||||
|
const errorKind = classifyError(error);
|
||||||
|
|
||||||
log.error("SDK agent run failed", {
|
log.error("SDK agent run failed", {
|
||||||
error: errorMessage,
|
error: errorMessage,
|
||||||
|
errorKind,
|
||||||
sessionId: params.sessionId,
|
sessionId: params.sessionId,
|
||||||
runId: params.runId,
|
runId: params.runId,
|
||||||
});
|
});
|
||||||
@ -171,13 +291,35 @@ export async function runSdkAgent(params: SdkRunnerParams): Promise<AgentRuntime
|
|||||||
stream: "lifecycle",
|
stream: "lifecycle",
|
||||||
data: {
|
data: {
|
||||||
phase: "error",
|
phase: "error",
|
||||||
runtime: "sdk",
|
runtime: "ccsdk",
|
||||||
startedAt: started,
|
startedAt: started,
|
||||||
endedAt: Date.now(),
|
endedAt: Date.now(),
|
||||||
error: errorMessage,
|
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 error result
|
||||||
return {
|
return {
|
||||||
payloads: [
|
payloads: [
|
||||||
@ -193,84 +335,292 @@ export async function runSdkAgent(params: SdkRunnerParams): Promise<AgentRuntime
|
|||||||
provider: "anthropic",
|
provider: "anthropic",
|
||||||
model: params.model ?? "unknown",
|
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.
|
* Internal SDK execution function.
|
||||||
*
|
*
|
||||||
* This is a placeholder that needs to be implemented based on the actual
|
* Calls the Claude Agent SDK's query function and processes the streaming response.
|
||||||
* Claude Agent SDK API. The SDK provides different interfaces for:
|
* The SDK handles:
|
||||||
* - Simple completions
|
* - Session resumption (via sessionId)
|
||||||
* - Agentic tool use
|
* - Workspace context building
|
||||||
* - Streaming responses
|
* - Tool execution
|
||||||
|
* - Conversation history
|
||||||
*/
|
*/
|
||||||
async function runSdkAgentInternal(
|
async function runSdkAgentInternal(
|
||||||
sdk: ClaudeAgentSdkModule,
|
sdk: ClaudeAgentSdkModule,
|
||||||
_prompt: string,
|
prompt: string,
|
||||||
options: {
|
options: {
|
||||||
model?: string;
|
model?: string;
|
||||||
workingDirectory?: string;
|
workingDirectory?: string;
|
||||||
|
sessionId?: string;
|
||||||
systemPrompt?: string;
|
systemPrompt?: string;
|
||||||
timeout?: number;
|
timeout?: number;
|
||||||
signal?: AbortSignal;
|
signal?: AbortSignal;
|
||||||
|
reasoningLevel?: string;
|
||||||
|
maxOutputTokens?: number;
|
||||||
|
maxThinkingTokens?: number;
|
||||||
|
[key: string]: unknown;
|
||||||
},
|
},
|
||||||
_callbacks: {
|
eventHandler: SdkEventHandler,
|
||||||
onEvent?: (event: unknown) => void;
|
): Promise<SdkInternalResult> {
|
||||||
},
|
|
||||||
): 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 texts: string[] = [];
|
||||||
const usage:
|
let usage: SdkInternalResult["usage"];
|
||||||
| {
|
|
||||||
input?: number;
|
|
||||||
output?: number;
|
|
||||||
cacheRead?: number;
|
|
||||||
cacheWrite?: number;
|
|
||||||
total?: number;
|
|
||||||
}
|
|
||||||
| undefined = undefined;
|
|
||||||
let aborted = false;
|
let aborted = false;
|
||||||
|
let error: string | undefined;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Check if SDK has the expected interface
|
// The Claude Agent SDK exports a `query` function that returns an async iterator
|
||||||
// This needs to be updated based on actual SDK API
|
// of events. The exact API shape may vary by SDK version.
|
||||||
if ("Claude" in sdk || "createAgent" in sdk || "Agent" in sdk) {
|
const queryFn = (sdk as { query?: unknown }).query;
|
||||||
// TODO: Implement actual SDK call based on SDK's public API
|
|
||||||
// For now, throw to indicate SDK integration is not yet complete
|
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(
|
throw new Error(
|
||||||
"Claude Agent SDK integration not yet implemented. " +
|
"Claude Agent SDK query function not found. " +
|
||||||
"Please check SDK documentation for the correct API.",
|
"Ensure @anthropic-ai/claude-agent-sdk is installed correctly.",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fallback: check for simple completion API
|
// Build query options
|
||||||
throw new Error("Unable to find compatible API in Claude Agent SDK");
|
const queryOptions = {
|
||||||
} catch (error) {
|
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) {
|
if (options.signal?.aborted) {
|
||||||
aborted = true;
|
aborted = true;
|
||||||
texts.push("Agent run was aborted.");
|
|
||||||
} else {
|
} 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
169
src/agents/claude-agent-sdk/system-prompt.ts
Normal file
169
src/agents/claude-agent-sdk/system-prompt.ts
Normal 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,
|
||||||
|
});
|
||||||
|
}
|
||||||
@ -35,8 +35,38 @@ export type SdkProviderConfig = {
|
|||||||
model?: string;
|
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. */
|
/** Parameters for SDK runner execution. */
|
||||||
export type SdkRunnerParams = {
|
export type SdkRunnerParams = {
|
||||||
|
// ─── Session & Identity ────────────────────────────────────────────────────
|
||||||
/** Session identifier. */
|
/** Session identifier. */
|
||||||
sessionId: string;
|
sessionId: string;
|
||||||
/** Session key for routing. */
|
/** Session key for routing. */
|
||||||
@ -47,6 +77,10 @@ export type SdkRunnerParams = {
|
|||||||
workspaceDir: string;
|
workspaceDir: string;
|
||||||
/** Agent data directory. */
|
/** Agent data directory. */
|
||||||
agentDir?: string;
|
agentDir?: string;
|
||||||
|
/** Moltbot agent ID (e.g., "main", "work"). */
|
||||||
|
agentId?: string;
|
||||||
|
|
||||||
|
// ─── Configuration ─────────────────────────────────────────────────────────
|
||||||
/** Moltbot configuration. */
|
/** Moltbot configuration. */
|
||||||
config?: MoltbotConfig;
|
config?: MoltbotConfig;
|
||||||
/** User prompt/message. */
|
/** User prompt/message. */
|
||||||
@ -61,17 +95,47 @@ export type SdkRunnerParams = {
|
|||||||
runId: string;
|
runId: string;
|
||||||
/** Abort signal for cancellation. */
|
/** Abort signal for cancellation. */
|
||||||
abortSignal?: AbortSignal;
|
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;
|
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. */
|
/** Enable Claude Code hooks. */
|
||||||
hooksEnabled?: boolean;
|
hooksEnabled?: boolean;
|
||||||
/** Additional SDK options. */
|
/** Additional SDK options. */
|
||||||
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. */
|
} & SdkRunnerCallbacks;
|
||||||
onAgentEvent?: (evt: { stream: string; data: Record<string, unknown> }) => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
/** SDK event types from the Claude Agent SDK. */
|
/** SDK event types from the Claude Agent SDK. */
|
||||||
export type SdkEventType =
|
export type SdkEventType =
|
||||||
|
|||||||
502
src/agents/sessions/ccsdk-session-adapter.ts
Normal file
502
src/agents/sessions/ccsdk-session-adapter.ts
Normal 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);
|
||||||
|
}
|
||||||
56
src/agents/sessions/index.ts
Normal file
56
src/agents/sessions/index.ts
Normal 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);
|
||||||
|
}
|
||||||
377
src/agents/sessions/pi-session-adapter.ts
Normal file
377
src/agents/sessions/pi-session-adapter.ts
Normal 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);
|
||||||
|
}
|
||||||
107
src/agents/sessions/session-adapter.ts
Normal file
107
src/agents/sessions/session-adapter.ts
Normal 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;
|
||||||
109
src/agents/sessions/types.ts
Normal file
109
src/agents/sessions/types.ts
Normal 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;
|
||||||
@ -6,6 +6,7 @@ import {
|
|||||||
SandboxBrowserSchema,
|
SandboxBrowserSchema,
|
||||||
SandboxDockerSchema,
|
SandboxDockerSchema,
|
||||||
SandboxPruneSchema,
|
SandboxPruneSchema,
|
||||||
|
validateCcsdkModelProvider,
|
||||||
} from "./zod-schema.agent-runtime.js";
|
} from "./zod-schema.agent-runtime.js";
|
||||||
import {
|
import {
|
||||||
BlockStreamingChunkSchema,
|
BlockStreamingChunkSchema,
|
||||||
@ -174,4 +175,17 @@ export const AgentDefaultsSchema = z
|
|||||||
.optional(),
|
.optional(),
|
||||||
})
|
})
|
||||||
.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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
.optional();
|
.optional();
|
||||||
|
|||||||
@ -416,6 +416,58 @@ export const AgentModelSchema = z.union([
|
|||||||
/** Agent runtime backend discriminant. */
|
/** Agent runtime backend discriminant. */
|
||||||
export const AgentRuntimeKindSchema = z.union([z.literal("pi"), z.literal("ccsdk")]);
|
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. */
|
/** Model tier configuration for Claude Code SDK. */
|
||||||
export const CcSdkModelTiersSchema = z
|
export const CcSdkModelTiersSchema = z
|
||||||
.object({
|
.object({
|
||||||
@ -471,7 +523,20 @@ export const AgentEntrySchema = z
|
|||||||
sandbox: AgentSandboxSchema,
|
sandbox: AgentSandboxSchema,
|
||||||
tools: AgentToolsSchema,
|
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
|
export const ToolsSchema = z
|
||||||
.object({
|
.object({
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user