fix: tool fixes and sdk logging reduction
This commit is contained in:
parent
460395baea
commit
7bdbb444e6
@ -26,6 +26,10 @@ describe("error-handling", () => {
|
|||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
vi.useFakeTimers();
|
vi.useFakeTimers();
|
||||||
|
// Mock Math.random to eliminate jitter in retry backoff delays.
|
||||||
|
// The withRetry function adds 0-20% random jitter to delays, which causes
|
||||||
|
// tests using exact timer advancement to timeout when jitter > 0.
|
||||||
|
vi.spyOn(Math, "random").mockReturnValue(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
@ -241,9 +245,8 @@ describe("error-handling", () => {
|
|||||||
|
|
||||||
const promise = withRetry(fn, options);
|
const promise = withRetry(fn, options);
|
||||||
|
|
||||||
// Advance timers for retries
|
// Run all timers to completion (handles all retries)
|
||||||
await vi.advanceTimersByTimeAsync(100);
|
await vi.runAllTimersAsync();
|
||||||
await vi.advanceTimersByTimeAsync(200);
|
|
||||||
|
|
||||||
const result = await promise;
|
const result = await promise;
|
||||||
|
|
||||||
@ -275,13 +278,14 @@ describe("error-handling", () => {
|
|||||||
retryOn: ["rate_limit"],
|
retryOn: ["rate_limit"],
|
||||||
};
|
};
|
||||||
|
|
||||||
const promise = withRetry(fn, options);
|
// Attach rejection handler BEFORE running timers to avoid unhandled rejection
|
||||||
|
const promise = withRetry(fn, options).catch((e) => e);
|
||||||
|
|
||||||
// Advance timers for all retries
|
// Run all timers to completion (exhausts all retries)
|
||||||
await vi.advanceTimersByTimeAsync(100);
|
await vi.runAllTimersAsync();
|
||||||
await vi.advanceTimersByTimeAsync(200);
|
|
||||||
|
|
||||||
await expect(promise).rejects.toMatchObject({ status: 429 });
|
const error = await promise;
|
||||||
|
expect(error).toMatchObject({ status: 429 });
|
||||||
expect(fn).toHaveBeenCalledTimes(3); // Initial + 2 retries
|
expect(fn).toHaveBeenCalledTimes(3); // Initial + 2 retries
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -16,47 +16,6 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import type { SdkProviderConfig, SdkProviderEnv } from "./types.js";
|
import type { SdkProviderConfig, SdkProviderEnv } from "./types.js";
|
||||||
import { createSubsystemLogger } from "../../logging/subsystem.js";
|
|
||||||
|
|
||||||
const log = createSubsystemLogger("agents/claude-agent-sdk");
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Mask a token for logging - shows length and first/last 4 chars.
|
|
||||||
*/
|
|
||||||
function maskToken(token: string | undefined): string {
|
|
||||||
if (!token) return "(empty)";
|
|
||||||
if (token.length <= 12) return `${"*".repeat(token.length)} (length: ${token.length})`;
|
|
||||||
const first = token.slice(0, 4);
|
|
||||||
const last = token.slice(-4);
|
|
||||||
return `${first}...${"*".repeat(Math.min(8, token.length - 8))}...${last} (length: ${token.length})`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Log the resolved provider config with masked credentials.
|
|
||||||
*/
|
|
||||||
function logProviderConfig(config: SdkProviderConfig, source: string): void {
|
|
||||||
const envKeys = config.env ? Object.keys(config.env) : [];
|
|
||||||
const maskedEnv: Record<string, string> = {};
|
|
||||||
|
|
||||||
if (config.env) {
|
|
||||||
for (const [key, value] of Object.entries(config.env)) {
|
|
||||||
if (key.includes("KEY") || key.includes("TOKEN") || key.includes("SECRET")) {
|
|
||||||
maskedEnv[key] = maskToken(value);
|
|
||||||
} else {
|
|
||||||
maskedEnv[key] = value ?? "(undefined)";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
log.debug("[CCSDK-PROVIDER] Provider config resolved", {
|
|
||||||
source,
|
|
||||||
providerName: config.name,
|
|
||||||
envKeys,
|
|
||||||
maskedEnv,
|
|
||||||
model: config.model,
|
|
||||||
maxTurns: config.maxTurns,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Build provider config for direct Anthropic API access.
|
* Build provider config for direct Anthropic API access.
|
||||||
@ -81,15 +40,10 @@ export function buildAnthropicSdkProvider(apiKey: string): SdkProviderConfig {
|
|||||||
* env vars set in their system environment, that's their configuration choice.
|
* env vars set in their system environment, that's their configuration choice.
|
||||||
*/
|
*/
|
||||||
export function buildClaudeCliSdkProvider(): SdkProviderConfig {
|
export function buildClaudeCliSdkProvider(): SdkProviderConfig {
|
||||||
log.debug("[CCSDK-PROVIDER] buildClaudeCliSdkProvider called - using SDK native auth");
|
return {
|
||||||
|
|
||||||
const config: SdkProviderConfig = {
|
|
||||||
name: "Claude CLI (SDK native)",
|
name: "Claude CLI (SDK native)",
|
||||||
env: {},
|
env: {},
|
||||||
};
|
};
|
||||||
|
|
||||||
logProviderConfig(config, "sdk-native-auth");
|
|
||||||
return config;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -127,23 +81,11 @@ export function buildZaiSdkProvider(
|
|||||||
env.ANTHROPIC_DEFAULT_OPUS_MODEL = options.opusModel;
|
env.ANTHROPIC_DEFAULT_OPUS_MODEL = options.opusModel;
|
||||||
}
|
}
|
||||||
|
|
||||||
const config: SdkProviderConfig = {
|
return {
|
||||||
name: "z.AI",
|
name: "z.AI",
|
||||||
env,
|
env,
|
||||||
model: options?.defaultModel,
|
model: options?.defaultModel,
|
||||||
};
|
};
|
||||||
|
|
||||||
log.debug("[CCSDK-PROVIDER] Built z.AI provider config", {
|
|
||||||
hasBaseUrl: Boolean(options?.baseUrl),
|
|
||||||
baseUrl: options?.baseUrl ?? "(default)",
|
|
||||||
defaultModel: options?.defaultModel ?? "(sdk default)",
|
|
||||||
haikuModel: options?.haikuModel ?? "(sdk default)",
|
|
||||||
sonnetModel: options?.sonnetModel ?? "(sdk default)",
|
|
||||||
opusModel: options?.opusModel ?? "(sdk default)",
|
|
||||||
authTokenMasked: maskToken(authToken),
|
|
||||||
});
|
|
||||||
|
|
||||||
return config;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -182,22 +124,11 @@ export function buildOpenRouterSdkProvider(
|
|||||||
env.ANTHROPIC_DEFAULT_OPUS_MODEL = options.opusModel;
|
env.ANTHROPIC_DEFAULT_OPUS_MODEL = options.opusModel;
|
||||||
}
|
}
|
||||||
|
|
||||||
const config: SdkProviderConfig = {
|
return {
|
||||||
name: "OpenRouter (Anthropic-compatible)",
|
name: "OpenRouter (Anthropic-compatible)",
|
||||||
env,
|
env,
|
||||||
model: options?.defaultModel,
|
model: options?.defaultModel,
|
||||||
};
|
};
|
||||||
|
|
||||||
log.debug("[CCSDK-PROVIDER] Built OpenRouter provider config", {
|
|
||||||
baseUrl: env.ANTHROPIC_BASE_URL,
|
|
||||||
defaultModel: options?.defaultModel ?? "(sdk default)",
|
|
||||||
haikuModel: options?.haikuModel ?? "(sdk default)",
|
|
||||||
sonnetModel: options?.sonnetModel ?? "(sdk default)",
|
|
||||||
opusModel: options?.opusModel ?? "(sdk default)",
|
|
||||||
apiKeyMasked: maskToken(apiKey),
|
|
||||||
});
|
|
||||||
|
|
||||||
return config;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -249,48 +180,33 @@ export function resolveProviderConfig(options?: {
|
|||||||
baseUrl?: string;
|
baseUrl?: string;
|
||||||
useCliCredentials?: boolean;
|
useCliCredentials?: boolean;
|
||||||
}): SdkProviderConfig {
|
}): SdkProviderConfig {
|
||||||
log.debug("[CCSDK-PROVIDER] resolveProviderConfig called", {
|
|
||||||
hasApiKey: Boolean(options?.apiKey),
|
|
||||||
apiKeyMasked: maskToken(options?.apiKey),
|
|
||||||
hasAuthToken: Boolean(options?.authToken),
|
|
||||||
authTokenMasked: maskToken(options?.authToken),
|
|
||||||
baseUrl: options?.baseUrl ?? "(default)",
|
|
||||||
useCliCredentials: options?.useCliCredentials ?? true,
|
|
||||||
});
|
|
||||||
|
|
||||||
// 1. Explicit API key from moltbot config takes precedence
|
// 1. Explicit API key from moltbot config takes precedence
|
||||||
if (options?.apiKey) {
|
if (options?.apiKey) {
|
||||||
log.debug("[CCSDK-PROVIDER] Using explicit API key from moltbot config");
|
|
||||||
const config = buildAnthropicSdkProvider(options.apiKey);
|
const config = buildAnthropicSdkProvider(options.apiKey);
|
||||||
if (options.baseUrl && config.env) {
|
if (options.baseUrl && config.env) {
|
||||||
config.env.ANTHROPIC_BASE_URL = options.baseUrl;
|
config.env.ANTHROPIC_BASE_URL = options.baseUrl;
|
||||||
}
|
}
|
||||||
logProviderConfig(config, "explicit-api-key");
|
|
||||||
return config;
|
return config;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Explicit auth token from moltbot config (for z.AI, custom endpoints, etc.)
|
// 2. Explicit auth token from moltbot config (for z.AI, custom endpoints, etc.)
|
||||||
if (options?.authToken) {
|
if (options?.authToken) {
|
||||||
log.debug("[CCSDK-PROVIDER] Using explicit auth token from moltbot config");
|
|
||||||
const env: SdkProviderEnv = {
|
const env: SdkProviderEnv = {
|
||||||
ANTHROPIC_AUTH_TOKEN: options.authToken,
|
ANTHROPIC_AUTH_TOKEN: options.authToken,
|
||||||
};
|
};
|
||||||
if (options.baseUrl) {
|
if (options.baseUrl) {
|
||||||
env.ANTHROPIC_BASE_URL = options.baseUrl;
|
env.ANTHROPIC_BASE_URL = options.baseUrl;
|
||||||
}
|
}
|
||||||
const config: SdkProviderConfig = {
|
return {
|
||||||
name: "Anthropic (auth token)",
|
name: "Anthropic (auth token)",
|
||||||
env,
|
env,
|
||||||
};
|
};
|
||||||
logProviderConfig(config, "explicit-auth-token");
|
|
||||||
return config;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. Default: SDK native auth
|
// 3. Default: SDK native auth
|
||||||
// Let the SDK use its internal credential resolution (keychain → OAuth flow).
|
// Let the SDK use its internal credential resolution (keychain → OAuth flow).
|
||||||
// We inherit the full parent process env - if the user has ANTHROPIC_API_KEY or
|
// We inherit the full parent process env - if the user has ANTHROPIC_API_KEY or
|
||||||
// ANTHROPIC_AUTH_TOKEN set, that's their configuration choice.
|
// ANTHROPIC_AUTH_TOKEN set, that's their configuration choice.
|
||||||
log.debug("[CCSDK-PROVIDER] Using SDK native auth (no explicit credentials configured)");
|
|
||||||
const cliConfig = buildClaudeCliSdkProvider();
|
const cliConfig = buildClaudeCliSdkProvider();
|
||||||
if (options?.baseUrl && cliConfig.env) {
|
if (options?.baseUrl && cliConfig.env) {
|
||||||
cliConfig.env.ANTHROPIC_BASE_URL = options.baseUrl;
|
cliConfig.env.ANTHROPIC_BASE_URL = options.baseUrl;
|
||||||
|
|||||||
@ -214,14 +214,13 @@ export function createCcSdkAgentRuntime(context?: CcSdkAgentRuntimeContext): Age
|
|||||||
? `${params.provider ?? "anthropic"}/${params.model}`
|
? `${params.provider ?? "anthropic"}/${params.model}`
|
||||||
: (ccsdkOpts.modelTiers?.sonnet ?? context?.ccsdkConfig?.models?.sonnet ?? "default");
|
: (ccsdkOpts.modelTiers?.sonnet ?? context?.ccsdkConfig?.models?.sonnet ?? "default");
|
||||||
|
|
||||||
log.info("Starting CCSDK Agent session", {
|
log.debug("Starting CCSDK Agent session", {
|
||||||
sessionId: params.sessionId,
|
sessionId: params.sessionId,
|
||||||
runId: params.runId,
|
runId: params.runId,
|
||||||
provider: providerConfig.name,
|
provider: providerConfig.name,
|
||||||
model: effectiveModel,
|
model: effectiveModel,
|
||||||
agentId,
|
agentId,
|
||||||
thinkLevel: params.thinkLevel,
|
thinkLevel: params.thinkLevel,
|
||||||
verboseLevel: params.verboseLevel,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Load conversation history from session file if not provided
|
// Load conversation history from session file if not provided
|
||||||
@ -316,6 +315,7 @@ export function createCcSdkAgentRuntime(context?: CcSdkAgentRuntimeContext): Age
|
|||||||
verboseLevel: mapVerboseLevel(params.verboseLevel),
|
verboseLevel: mapVerboseLevel(params.verboseLevel),
|
||||||
timezone: resolveTimezone(effectiveConfig),
|
timezone: resolveTimezone(effectiveConfig),
|
||||||
skills: extractSkillNames(params.skillsSnapshot),
|
skills: extractSkillNames(params.skillsSnapshot),
|
||||||
|
skillsPrompt: params.skillsSnapshot?.prompt,
|
||||||
|
|
||||||
// ─── CCSDK options from ccsdkOptions bag ────────────────────────────────
|
// ─── CCSDK options from ccsdkOptions bag ────────────────────────────────
|
||||||
hooksEnabled: ccsdkOpts.hooksEnabled ?? context?.ccsdkConfig?.hooksEnabled,
|
hooksEnabled: ccsdkOpts.hooksEnabled ?? context?.ccsdkConfig?.hooksEnabled,
|
||||||
@ -347,15 +347,6 @@ export function createCcSdkAgentRuntime(context?: CcSdkAgentRuntimeContext): Age
|
|||||||
onAgentEvent: params.onAgentEvent,
|
onAgentEvent: params.onAgentEvent,
|
||||||
});
|
});
|
||||||
|
|
||||||
log.debug("[CCSDK-RUNTIME] Callbacks passed to runSdkAgent", {
|
|
||||||
hasOnPartialReply: Boolean(params.onPartialReply),
|
|
||||||
hasOnBlockReply: Boolean(params.onBlockReply),
|
|
||||||
hasOnBlockReplyFlush: Boolean(params.onBlockReplyFlush),
|
|
||||||
hasOnReasoningStream: Boolean(params.onReasoningStream),
|
|
||||||
hasOnToolResult: Boolean(params.onToolResult),
|
|
||||||
hasOnAgentEvent: Boolean(params.onAgentEvent),
|
|
||||||
});
|
|
||||||
|
|
||||||
// Persist session transcript for multi-turn continuity.
|
// Persist session transcript for multi-turn continuity.
|
||||||
if (params.sessionFile) {
|
if (params.sessionFile) {
|
||||||
if (sdkResult.completedToolCalls && sdkResult.completedToolCalls.length > 0) {
|
if (sdkResult.completedToolCalls && sdkResult.completedToolCalls.length > 0) {
|
||||||
|
|||||||
@ -11,6 +11,9 @@ import {
|
|||||||
sanitizeToolResult,
|
sanitizeToolResult,
|
||||||
} from "../pi-embedded-subscribe.tools.js";
|
} from "../pi-embedded-subscribe.tools.js";
|
||||||
import { normalizeToolName } from "../tool-policy.js";
|
import { normalizeToolName } from "../tool-policy.js";
|
||||||
|
import { createSubsystemLogger } from "../../logging/subsystem.js";
|
||||||
|
|
||||||
|
const log = createSubsystemLogger("agents/claude-agent-sdk/sdk-hooks");
|
||||||
|
|
||||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||||
return !!value && typeof value === "object" && !Array.isArray(value);
|
return !!value && typeof value === "object" && !Array.isArray(value);
|
||||||
@ -152,6 +155,13 @@ export function buildMoltbotSdkHooks(params: {
|
|||||||
const normalized = normalizeSdkToolName(rawName, params.mcpServerName);
|
const normalized = normalizeSdkToolName(rawName, params.mcpServerName);
|
||||||
const error = extractToolErrorMessage(record ?? input);
|
const error = extractToolErrorMessage(record ?? input);
|
||||||
|
|
||||||
|
// Log tool failures at warn level so they appear in logs
|
||||||
|
log.warn("Tool execution failed (via hook)", {
|
||||||
|
tool: normalized.name,
|
||||||
|
toolCallId: typeof toolUseId === "string" ? toolUseId : undefined,
|
||||||
|
error: error?.slice(0, 500) ?? "Unknown error",
|
||||||
|
});
|
||||||
|
|
||||||
params.emitEvent("tool", {
|
params.emitEvent("tool", {
|
||||||
phase: "result",
|
phase: "result",
|
||||||
name: normalized.name,
|
name: normalized.name,
|
||||||
|
|||||||
@ -61,54 +61,29 @@ export function isSdkAvailable(): boolean {
|
|||||||
*/
|
*/
|
||||||
export async function loadClaudeAgentSdk(): Promise<ClaudeAgentSdkModule> {
|
export async function loadClaudeAgentSdk(): Promise<ClaudeAgentSdkModule> {
|
||||||
if (sdkModule) {
|
if (sdkModule) {
|
||||||
log.debug("[CCSDK-LOADER] Returning cached SDK module");
|
|
||||||
return sdkModule;
|
return sdkModule;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (loadAttempted && loadError) {
|
if (loadAttempted && loadError) {
|
||||||
log.debug("[CCSDK-LOADER] Previous load attempt failed, re-throwing cached error");
|
|
||||||
throw loadError;
|
throw loadError;
|
||||||
}
|
}
|
||||||
|
|
||||||
loadAttempted = true;
|
loadAttempted = true;
|
||||||
log.debug("[CCSDK-LOADER] Attempting to load Claude Agent SDK");
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Dynamic import to avoid bundling issues
|
// Dynamic import to avoid bundling issues
|
||||||
// The SDK package name is resolved at runtime
|
|
||||||
const moduleName = "@anthropic-ai/claude-agent-sdk";
|
const moduleName = "@anthropic-ai/claude-agent-sdk";
|
||||||
const loadStart = Date.now();
|
|
||||||
sdkModule = (await import(/* @vite-ignore */ moduleName)) as ClaudeAgentSdkModule;
|
sdkModule = (await import(/* @vite-ignore */ moduleName)) as ClaudeAgentSdkModule;
|
||||||
const loadDuration = Date.now() - loadStart;
|
|
||||||
|
|
||||||
// Log SDK module details for debugging
|
if (typeof sdkModule.query !== "function") {
|
||||||
const moduleKeys = Object.keys(sdkModule);
|
log.error("SDK loaded but query function is missing");
|
||||||
const hasQuery = typeof sdkModule.query === "function";
|
|
||||||
|
|
||||||
log.info("[CCSDK-LOADER] Claude Agent SDK loaded successfully", {
|
|
||||||
loadDurationMs: loadDuration,
|
|
||||||
hasQueryFunction: hasQuery,
|
|
||||||
moduleExports: moduleKeys.slice(0, 10),
|
|
||||||
totalExports: moduleKeys.length,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!hasQuery) {
|
|
||||||
log.error("[CCSDK-LOADER] SDK loaded but query function is missing!", {
|
|
||||||
moduleKeys,
|
|
||||||
queryType: typeof sdkModule.query,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return sdkModule;
|
return sdkModule;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const errorMessage = err instanceof Error ? err.message : String(err);
|
log.error("Failed to load Claude Agent SDK", {
|
||||||
const errorStack = err instanceof Error ? err.stack : undefined;
|
error: err instanceof Error ? err.message : String(err),
|
||||||
|
|
||||||
log.error("[CCSDK-LOADER] Failed to load Claude Agent SDK", {
|
|
||||||
error: errorMessage,
|
|
||||||
stack: errorStack,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
loadError = new Error(
|
loadError = new Error(
|
||||||
"Claude Agent SDK not installed. Install with: npm install @anthropic-ai/claude-agent-sdk",
|
"Claude Agent SDK not installed. Install with: npm install @anthropic-ai/claude-agent-sdk",
|
||||||
);
|
);
|
||||||
|
|||||||
@ -143,90 +143,6 @@ const DEFAULT_MAX_EXTRACTED_CHARS = 120_000;
|
|||||||
const DEFAULT_MCP_SERVER_NAME = "moltbot";
|
const DEFAULT_MCP_SERVER_NAME = "moltbot";
|
||||||
const DEFAULT_MAX_TURNS = 50;
|
const DEFAULT_MAX_TURNS = 50;
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Debug helpers
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Mask a token for logging - shows length and first/last 4 chars.
|
|
||||||
*/
|
|
||||||
function maskToken(token: string | undefined): string {
|
|
||||||
if (!token) return "(empty)";
|
|
||||||
if (token.length <= 12) return `${"*".repeat(token.length)} (length: ${token.length})`;
|
|
||||||
const first = token.slice(0, 4);
|
|
||||||
const last = token.slice(-4);
|
|
||||||
return `${first}...${"*".repeat(Math.min(8, token.length - 8))}...${last} (length: ${token.length})`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Log authentication-related environment variables (masked).
|
|
||||||
*/
|
|
||||||
function logAuthEnvVars(env: Record<string, string>, prefix: string): void {
|
|
||||||
const authKeys = [
|
|
||||||
"ANTHROPIC_API_KEY",
|
|
||||||
"ANTHROPIC_AUTH_TOKEN",
|
|
||||||
"ANTHROPIC_BASE_URL",
|
|
||||||
"CLAUDE_CODE_USE_BEDROCK",
|
|
||||||
"CLAUDE_CODE_USE_VERTEX",
|
|
||||||
];
|
|
||||||
const authEnv: Record<string, string> = {};
|
|
||||||
for (const key of authKeys) {
|
|
||||||
if (env[key]) {
|
|
||||||
authEnv[key] = key.includes("KEY") || key.includes("TOKEN") ? maskToken(env[key]) : env[key];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
log.debug(`${prefix} Auth environment variables`, {
|
|
||||||
authEnv,
|
|
||||||
totalEnvKeys: Object.keys(env).length,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Detect potential auth-related error patterns in response text.
|
|
||||||
*/
|
|
||||||
function detectAuthErrorPatterns(text: string): {
|
|
||||||
hasAuthError: boolean;
|
|
||||||
patterns: string[];
|
|
||||||
} {
|
|
||||||
const patterns: string[] = [];
|
|
||||||
const lowerText = text.toLowerCase();
|
|
||||||
|
|
||||||
// Common auth error patterns
|
|
||||||
if (lowerText.includes("unauthorized") || lowerText.includes("401")) {
|
|
||||||
patterns.push("unauthorized/401");
|
|
||||||
}
|
|
||||||
if (lowerText.includes("invalid api key") || lowerText.includes("invalid_api_key")) {
|
|
||||||
patterns.push("invalid_api_key");
|
|
||||||
}
|
|
||||||
if (lowerText.includes("authentication") && lowerText.includes("failed")) {
|
|
||||||
patterns.push("authentication_failed");
|
|
||||||
}
|
|
||||||
if (lowerText.includes("expired") && (lowerText.includes("token") || lowerText.includes("key"))) {
|
|
||||||
patterns.push("expired_credentials");
|
|
||||||
}
|
|
||||||
if (lowerText.includes("permission") && lowerText.includes("denied")) {
|
|
||||||
patterns.push("permission_denied");
|
|
||||||
}
|
|
||||||
if (lowerText.includes("rate limit") || lowerText.includes("ratelimit")) {
|
|
||||||
patterns.push("rate_limit");
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
lowerText.includes("quota") &&
|
|
||||||
(lowerText.includes("exceeded") || lowerText.includes("limit"))
|
|
||||||
) {
|
|
||||||
patterns.push("quota_exceeded");
|
|
||||||
}
|
|
||||||
// Check for suspiciously short responses that might indicate auth issues
|
|
||||||
if (text.length >= 100 && text.length <= 200) {
|
|
||||||
patterns.push("suspicious_short_response");
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
hasAuthError: patterns.length > 0,
|
|
||||||
patterns,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Model tier environment helpers
|
// Model tier environment helpers
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@ -299,13 +215,7 @@ function normalizeModelNameForProvider(modelName: string, providerType: SdkProvi
|
|||||||
case "anthropic-native":
|
case "anthropic-native":
|
||||||
// Strip "anthropic/" prefix for direct Anthropic API
|
// Strip "anthropic/" prefix for direct Anthropic API
|
||||||
if (hasAnthropicPrefix) {
|
if (hasAnthropicPrefix) {
|
||||||
const stripped = modelName.slice("anthropic/".length);
|
return modelName.slice("anthropic/".length);
|
||||||
log.debug("[CCSDK-MODEL] Stripped anthropic/ prefix for native API", {
|
|
||||||
original: modelName,
|
|
||||||
normalized: stripped,
|
|
||||||
providerType,
|
|
||||||
});
|
|
||||||
return stripped;
|
|
||||||
}
|
}
|
||||||
return modelName;
|
return modelName;
|
||||||
|
|
||||||
@ -313,13 +223,7 @@ function normalizeModelNameForProvider(modelName: string, providerType: SdkProvi
|
|||||||
// OpenRouter expects "anthropic/model-name" format
|
// OpenRouter expects "anthropic/model-name" format
|
||||||
// Keep as-is if already prefixed, add prefix if not
|
// Keep as-is if already prefixed, add prefix if not
|
||||||
if (!hasAnthropicPrefix && modelName.startsWith("claude-")) {
|
if (!hasAnthropicPrefix && modelName.startsWith("claude-")) {
|
||||||
const prefixed = `anthropic/${modelName}`;
|
return `anthropic/${modelName}`;
|
||||||
log.debug("[CCSDK-MODEL] Added anthropic/ prefix for OpenRouter", {
|
|
||||||
original: modelName,
|
|
||||||
normalized: prefixed,
|
|
||||||
providerType,
|
|
||||||
});
|
|
||||||
return prefixed;
|
|
||||||
}
|
}
|
||||||
return modelName;
|
return modelName;
|
||||||
|
|
||||||
@ -328,14 +232,10 @@ function normalizeModelNameForProvider(modelName: string, providerType: SdkProvi
|
|||||||
// The model name should come from config already in z.AI format
|
// The model name should come from config already in z.AI format
|
||||||
// Warn if we see Anthropic-formatted model names - likely a config issue
|
// Warn if we see Anthropic-formatted model names - likely a config issue
|
||||||
if (hasAnthropicPrefix || modelName.startsWith("claude-")) {
|
if (hasAnthropicPrefix || modelName.startsWith("claude-")) {
|
||||||
log.warn(
|
log.warn("z.AI provider detected but model name looks like Anthropic format", {
|
||||||
"[CCSDK-MODEL] z.AI provider detected but model name looks like Anthropic format",
|
modelName,
|
||||||
{
|
hint: "For z.AI, configure model tiers with z.AI model names (e.g., 'glm-4.7'), not Anthropic names",
|
||||||
modelName,
|
});
|
||||||
providerType,
|
|
||||||
hint: "For z.AI, configure model tiers with z.AI model names (e.g., 'glm-4.7'), not Anthropic names",
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
// Pass through unchanged
|
// Pass through unchanged
|
||||||
return modelName;
|
return modelName;
|
||||||
@ -343,11 +243,6 @@ function normalizeModelNameForProvider(modelName: string, providerType: SdkProvi
|
|||||||
case "bedrock":
|
case "bedrock":
|
||||||
case "vertex":
|
case "vertex":
|
||||||
// Bedrock/Vertex have their own model ID formats
|
// Bedrock/Vertex have their own model ID formats
|
||||||
// For now, pass through - may need specific handling later
|
|
||||||
log.debug("[CCSDK-MODEL] Passing model name through for cloud provider", {
|
|
||||||
modelName,
|
|
||||||
providerType,
|
|
||||||
});
|
|
||||||
return modelName;
|
return modelName;
|
||||||
|
|
||||||
default:
|
default:
|
||||||
@ -373,11 +268,6 @@ function buildModelTierEnv(
|
|||||||
if (!modelTiers) return env;
|
if (!modelTiers) return env;
|
||||||
|
|
||||||
const providerType = detectProviderType(providerConfig);
|
const providerType = detectProviderType(providerConfig);
|
||||||
log.debug("[CCSDK-MODEL] Building model tier env", {
|
|
||||||
providerType,
|
|
||||||
providerName: providerConfig?.name,
|
|
||||||
rawTiers: modelTiers,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (modelTiers.haiku) {
|
if (modelTiers.haiku) {
|
||||||
env.ANTHROPIC_DEFAULT_HAIKU_MODEL = normalizeModelNameForProvider(
|
env.ANTHROPIC_DEFAULT_HAIKU_MODEL = normalizeModelNameForProvider(
|
||||||
@ -593,46 +483,6 @@ export async function runSdkAgent(params: SdkRunnerParams): Promise<SdkRunnerRes
|
|||||||
const mcpServerName = params.mcpServerName ?? DEFAULT_MCP_SERVER_NAME;
|
const mcpServerName = params.mcpServerName ?? DEFAULT_MCP_SERVER_NAME;
|
||||||
const hooksEnabled = params.hooksEnabled === true;
|
const hooksEnabled = params.hooksEnabled === true;
|
||||||
|
|
||||||
// Log comprehensive run parameters for debugging
|
|
||||||
log.info("[CCSDK-RUN] Starting SDK agent run", {
|
|
||||||
runId: params.runId,
|
|
||||||
sessionId: params.sessionId,
|
|
||||||
sessionKey: params.sessionKey,
|
|
||||||
agentId: params.agentId,
|
|
||||||
promptLength: params.prompt.length,
|
|
||||||
promptPreview: params.prompt.slice(0, 200),
|
|
||||||
mcpServerName,
|
|
||||||
hooksEnabled,
|
|
||||||
timeoutMs: params.timeoutMs,
|
|
||||||
thinkingLevel: params.thinkingLevel,
|
|
||||||
maxTurns: params.maxTurns,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Log provider configuration
|
|
||||||
log.debug("[CCSDK-RUN] Provider configuration", {
|
|
||||||
providerName: params.providerConfig?.name ?? "(not set)",
|
|
||||||
providerModel: params.providerConfig?.model ?? "(default)",
|
|
||||||
providerMaxTurns: params.providerConfig?.maxTurns ?? "(default)",
|
|
||||||
providerEnvKeys: params.providerConfig?.env ? Object.keys(params.providerConfig.env) : [],
|
|
||||||
});
|
|
||||||
|
|
||||||
// Log model tiers if configured
|
|
||||||
if (params.modelTiers) {
|
|
||||||
log.debug("[CCSDK-RUN] Model tiers configured", {
|
|
||||||
haiku: params.modelTiers.haiku ?? "(default)",
|
|
||||||
sonnet: params.modelTiers.sonnet ?? "(default)",
|
|
||||||
opus: params.modelTiers.opus ?? "(default)",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Log session resume state
|
|
||||||
if (params.claudeSessionId) {
|
|
||||||
log.debug("[CCSDK-RUN] Session resume enabled", {
|
|
||||||
claudeSessionId: params.claudeSessionId,
|
|
||||||
forkSession: params.forkSession ?? false,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Standard agent event streams that should be emitted to the global system.
|
// Standard agent event streams that should be emitted to the global system.
|
||||||
// These are consumed by the Chat UI and other listeners (gateway, diagnostics).
|
// These are consumed by the Chat UI and other listeners (gateway, diagnostics).
|
||||||
const GLOBAL_EVENT_STREAMS = new Set([
|
const GLOBAL_EVENT_STREAMS = new Set([
|
||||||
@ -780,7 +630,7 @@ export async function runSdkAgent(params: SdkRunnerParams): Promise<SdkRunnerRes
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
log.info(
|
log.debug(
|
||||||
`Bridged ${bridgeResult.toolCount} tools to MCP server "${mcpServerName}"` +
|
`Bridged ${bridgeResult.toolCount} tools to MCP server "${mcpServerName}"` +
|
||||||
(bridgeResult.skippedTools.length > 0
|
(bridgeResult.skippedTools.length > 0
|
||||||
? ` (skipped: ${bridgeResult.skippedTools.join(", ")})`
|
? ` (skipped: ${bridgeResult.skippedTools.join(", ")})`
|
||||||
@ -832,11 +682,6 @@ export async function runSdkAgent(params: SdkRunnerParams): Promise<SdkRunnerRes
|
|||||||
if (params.forkSession) {
|
if (params.forkSession) {
|
||||||
sdkOptions.forkSession = true;
|
sdkOptions.forkSession = true;
|
||||||
}
|
}
|
||||||
log.debug("Resuming CCSDK session", {
|
|
||||||
claudeSessionId: params.claudeSessionId,
|
|
||||||
forkSession: params.forkSession ?? false,
|
|
||||||
sessionId: params.sessionId,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// System prompt (with Moltbot-specific additions and conversation history suffix).
|
// System prompt (with Moltbot-specific additions and conversation history suffix).
|
||||||
@ -848,6 +693,7 @@ export async function runSdkAgent(params: SdkRunnerParams): Promise<SdkRunnerRes
|
|||||||
messageChannel: params.messageChannel,
|
messageChannel: params.messageChannel,
|
||||||
channelHints: params.channelHints,
|
channelHints: params.channelHints,
|
||||||
skills: params.skills,
|
skills: params.skills,
|
||||||
|
skillsPrompt: params.skillsPrompt,
|
||||||
// Sender context for system prompt enrichment
|
// Sender context for system prompt enrichment
|
||||||
senderId: params.senderId,
|
senderId: params.senderId,
|
||||||
senderName: params.senderName,
|
senderName: params.senderName,
|
||||||
@ -871,18 +717,6 @@ export async function runSdkAgent(params: SdkRunnerParams): Promise<SdkRunnerRes
|
|||||||
// when running as a LaunchAgent or daemon), then apply overrides.
|
// when running as a LaunchAgent or daemon), then apply overrides.
|
||||||
const sdkEnv: Record<string, string> = {};
|
const sdkEnv: Record<string, string> = {};
|
||||||
|
|
||||||
// Log inherited auth env vars BEFORE we override them
|
|
||||||
log.debug("[CCSDK-ENV] Checking inherited auth env vars from parent process", {
|
|
||||||
hasAnthropicApiKey: Boolean(process.env.ANTHROPIC_API_KEY),
|
|
||||||
inheritedApiKeyMasked: maskToken(process.env.ANTHROPIC_API_KEY),
|
|
||||||
hasAnthropicAuthToken: Boolean(process.env.ANTHROPIC_AUTH_TOKEN),
|
|
||||||
inheritedAuthTokenMasked: maskToken(process.env.ANTHROPIC_AUTH_TOKEN),
|
|
||||||
hasAnthropicBaseUrl: Boolean(process.env.ANTHROPIC_BASE_URL),
|
|
||||||
inheritedBaseUrl: process.env.ANTHROPIC_BASE_URL ?? "(unset)",
|
|
||||||
claudeCodeEntrypoint: process.env.CLAUDE_CODE_ENTRYPOINT ?? "(unset)",
|
|
||||||
claudeCodeIndicator: process.env.CLAUDECODE ?? "(unset)",
|
|
||||||
});
|
|
||||||
|
|
||||||
// Inherit parent process environment.
|
// Inherit parent process environment.
|
||||||
for (const [key, value] of Object.entries(process.env)) {
|
for (const [key, value] of Object.entries(process.env)) {
|
||||||
if (value !== undefined) sdkEnv[key] = value;
|
if (value !== undefined) sdkEnv[key] = value;
|
||||||
@ -891,10 +725,6 @@ export async function runSdkAgent(params: SdkRunnerParams): Promise<SdkRunnerRes
|
|||||||
// Add provider env vars (z.AI, custom endpoints, explicit API key, etc.).
|
// Add provider env vars (z.AI, custom endpoints, explicit API key, etc.).
|
||||||
// These override any inherited values from the parent process.
|
// These override any inherited values from the parent process.
|
||||||
if (params.providerConfig?.env) {
|
if (params.providerConfig?.env) {
|
||||||
log.debug("[CCSDK-ENV] Applying provider config env overrides", {
|
|
||||||
providerName: params.providerConfig.name,
|
|
||||||
providerEnvKeys: Object.keys(params.providerConfig.env),
|
|
||||||
});
|
|
||||||
for (const [key, value] of Object.entries(params.providerConfig.env)) {
|
for (const [key, value] of Object.entries(params.providerConfig.env)) {
|
||||||
if (value !== undefined && value !== "") {
|
if (value !== undefined && value !== "") {
|
||||||
sdkEnv[key] = value;
|
sdkEnv[key] = value;
|
||||||
@ -904,31 +734,10 @@ export async function runSdkAgent(params: SdkRunnerParams): Promise<SdkRunnerRes
|
|||||||
|
|
||||||
// Add model tier env vars (normalized based on target provider).
|
// Add model tier env vars (normalized based on target provider).
|
||||||
const modelTierEnv = buildModelTierEnv(params.modelTiers, params.providerConfig);
|
const modelTierEnv = buildModelTierEnv(params.modelTiers, params.providerConfig);
|
||||||
if (Object.keys(modelTierEnv).length > 0) {
|
|
||||||
log.debug("[CCSDK-ENV] Applying model tier env vars", { modelTierEnv });
|
|
||||||
}
|
|
||||||
for (const [key, value] of Object.entries(modelTierEnv)) {
|
for (const [key, value] of Object.entries(modelTierEnv)) {
|
||||||
if (value !== undefined) sdkEnv[key] = value;
|
if (value !== undefined) sdkEnv[key] = value;
|
||||||
}
|
}
|
||||||
|
|
||||||
// NOTE: We intentionally do NOT set CLAUDE_CONFIG_DIR.
|
|
||||||
// Setting it to a custom directory breaks auth because the SDK scopes
|
|
||||||
// credential resolution (including keychain lookups) to that directory.
|
|
||||||
// The user's `claude login` credentials are associated with ~/.claude,
|
|
||||||
// so we must let the SDK use the default config directory.
|
|
||||||
//
|
|
||||||
// TODO: If per-agent session isolation is needed, we may need to:
|
|
||||||
// 1. Symlink auth state from ~/.claude to the agent's config dir, OR
|
|
||||||
// 2. Use a different mechanism for session isolation that doesn't affect auth
|
|
||||||
if (params.ccsdkConfigDir) {
|
|
||||||
log.debug("[CCSDK-ENV] Ignoring ccsdkConfigDir to preserve auth - using default ~/.claude", {
|
|
||||||
ignoredConfigDir: params.ccsdkConfigDir,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Log final auth env state
|
|
||||||
logAuthEnvVars(sdkEnv, "[CCSDK-ENV] Final SDK");
|
|
||||||
|
|
||||||
// Always pass the full environment to the SDK.
|
// Always pass the full environment to the SDK.
|
||||||
sdkOptions.env = sdkEnv;
|
sdkOptions.env = sdkEnv;
|
||||||
|
|
||||||
@ -950,7 +759,6 @@ export async function runSdkAgent(params: SdkRunnerParams): Promise<SdkRunnerRes
|
|||||||
const thinkingBudget = getThinkingBudget(params.thinkingLevel);
|
const thinkingBudget = getThinkingBudget(params.thinkingLevel);
|
||||||
if (thinkingBudget !== undefined) {
|
if (thinkingBudget !== undefined) {
|
||||||
sdkOptions.budgetTokens = thinkingBudget;
|
sdkOptions.budgetTokens = thinkingBudget;
|
||||||
log.debug(`Set thinking budget to ${thinkingBudget} tokens for level: ${params.thinkingLevel}`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
@ -1005,18 +813,6 @@ export async function runSdkAgent(params: SdkRunnerParams): Promise<SdkRunnerRes
|
|||||||
: timeoutController.signal;
|
: timeoutController.signal;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
log.debug("Starting SDK query", {
|
|
||||||
promptFormat: hasMcpServers ? "AsyncIterable" : "string",
|
|
||||||
promptLength: typeof prompt === "string" ? prompt.length : undefined,
|
|
||||||
hasSystemPrompt: Boolean(sdkOptions.systemPrompt),
|
|
||||||
hasMcpServers,
|
|
||||||
mcpServerNames: sdkOptions.mcpServers ? Object.keys(sdkOptions.mcpServers) : [],
|
|
||||||
allowedToolsCount: sdkOptions.allowedTools?.length ?? 0,
|
|
||||||
model: sdkOptions.model,
|
|
||||||
maxTurns: sdkOptions.maxTurns,
|
|
||||||
hasResume: Boolean(sdkOptions.resume),
|
|
||||||
});
|
|
||||||
|
|
||||||
const stream = await coerceAsyncIterable(
|
const stream = await coerceAsyncIterable(
|
||||||
sdk.query({
|
sdk.query({
|
||||||
prompt, // SDK accepts both string and AsyncIterable<unknown>
|
prompt, // SDK accepts both string and AsyncIterable<unknown>
|
||||||
@ -1024,8 +820,6 @@ export async function runSdkAgent(params: SdkRunnerParams): Promise<SdkRunnerRes
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
log.debug("SDK query returned stream, beginning iteration");
|
|
||||||
|
|
||||||
for await (const event of stream) {
|
for await (const event of stream) {
|
||||||
// Check abort before processing each event.
|
// Check abort before processing each event.
|
||||||
if (combinedAbort.aborted) {
|
if (combinedAbort.aborted) {
|
||||||
@ -1036,20 +830,6 @@ export async function runSdkAgent(params: SdkRunnerParams): Promise<SdkRunnerRes
|
|||||||
|
|
||||||
eventCount += 1;
|
eventCount += 1;
|
||||||
|
|
||||||
// Debug: log each event received (type and key fields only)
|
|
||||||
if (isRecord(event)) {
|
|
||||||
const eventType = event.type ?? "no-type";
|
|
||||||
const hasText = Boolean(event.text || event.delta || event.content || event.message);
|
|
||||||
const hasSessionId = Boolean(event.session_id);
|
|
||||||
log.debug("SDK event received", {
|
|
||||||
eventCount,
|
|
||||||
type: eventType,
|
|
||||||
hasText,
|
|
||||||
hasSessionId,
|
|
||||||
keys: Object.keys(event).slice(0, 10),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Best-effort assistant message boundary detection.
|
// Best-effort assistant message boundary detection.
|
||||||
// Some SDK versions emit `type: "message_start"`; otherwise, we fall back
|
// Some SDK versions emit `type: "message_start"`; otherwise, we fall back
|
||||||
// to calling this once when we see the first assistant text.
|
// to calling this once when we see the first assistant text.
|
||||||
@ -1150,6 +930,15 @@ export async function runSdkAgent(params: SdkRunnerParams): Promise<SdkRunnerRes
|
|||||||
undefined)
|
undefined)
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
|
// Log tool errors at warn level so they appear in logs
|
||||||
|
if (isError && phase === "result") {
|
||||||
|
log.warn("Tool execution failed", {
|
||||||
|
tool: normalizedName.name,
|
||||||
|
toolCallId,
|
||||||
|
error: toolText?.slice(0, 500) ?? "Unknown error",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
emitEvent("tool", {
|
emitEvent("tool", {
|
||||||
phase,
|
phase,
|
||||||
name: normalizedName.name,
|
name: normalizedName.name,
|
||||||
@ -1176,14 +965,6 @@ export async function runSdkAgent(params: SdkRunnerParams): Promise<SdkRunnerRes
|
|||||||
if (kind === "result" && isRecord(event)) {
|
if (kind === "result" && isRecord(event)) {
|
||||||
const result = event.result;
|
const result = event.result;
|
||||||
const subtype = event.subtype;
|
const subtype = event.subtype;
|
||||||
log.debug("Received result event, ending stream", {
|
|
||||||
eventCount,
|
|
||||||
hasResult: result !== undefined,
|
|
||||||
resultType: typeof result,
|
|
||||||
resultLength: typeof result === "string" ? result.length : undefined,
|
|
||||||
subtype,
|
|
||||||
hasError: Boolean(event.error),
|
|
||||||
});
|
|
||||||
if (typeof result === "string") {
|
if (typeof result === "string") {
|
||||||
resultText = result;
|
resultText = result;
|
||||||
}
|
}
|
||||||
@ -1213,7 +994,6 @@ export async function runSdkAgent(params: SdkRunnerParams): Promise<SdkRunnerRes
|
|||||||
const sessionId = event.session_id;
|
const sessionId = event.session_id;
|
||||||
if (typeof sessionId === "string" && sessionId) {
|
if (typeof sessionId === "string" && sessionId) {
|
||||||
extractedSessionId = sessionId;
|
extractedSessionId = sessionId;
|
||||||
log.debug(`Extracted CCSDK session ID: ${sessionId}`);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1224,40 +1004,18 @@ export async function runSdkAgent(params: SdkRunnerParams): Promise<SdkRunnerRes
|
|||||||
|
|
||||||
// Handle thinking/reasoning content (for breadcrumbs).
|
// Handle thinking/reasoning content (for breadcrumbs).
|
||||||
if (extraction.thinking) {
|
if (extraction.thinking) {
|
||||||
log.debug("[CCSDK-STREAM] Thinking content extracted", {
|
|
||||||
eventCount,
|
|
||||||
kind,
|
|
||||||
textLength: extraction.thinking.length,
|
|
||||||
textPreview: extraction.thinking.slice(0, 100),
|
|
||||||
hasOnReasoningStream: Boolean(params.onReasoningStream),
|
|
||||||
});
|
|
||||||
if (params.onReasoningStream) {
|
if (params.onReasoningStream) {
|
||||||
try {
|
try {
|
||||||
log.debug("[CCSDK-STREAM] Calling onReasoningStream", {
|
|
||||||
textLength: extraction.thinking.length,
|
|
||||||
});
|
|
||||||
await params.onReasoningStream({ text: extraction.thinking });
|
await params.onReasoningStream({ text: extraction.thinking });
|
||||||
} catch (err) {
|
} catch {
|
||||||
log.debug("[CCSDK-STREAM] onReasoningStream error", { error: String(err) });
|
// Don't break stream on callback errors
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
log.debug(
|
|
||||||
"[CCSDK-STREAM] No onReasoningStream callback - thinking text will not be streamed",
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
// Also emit as agent event for logging/debugging
|
|
||||||
emitEvent("thinking", { text: extraction.thinking });
|
emitEvent("thinking", { text: extraction.thinking });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle assistant text content.
|
// Handle assistant text content.
|
||||||
if (!extraction.text) {
|
if (!extraction.text) {
|
||||||
log.debug("[CCSDK-STREAM] No text extracted from event", {
|
|
||||||
eventCount,
|
|
||||||
kind,
|
|
||||||
eventKeys: isRecord(event) ? Object.keys(event).slice(0, 15) : [],
|
|
||||||
hasOnBlockReply: Boolean(params.onBlockReply),
|
|
||||||
hadThinking: Boolean(extraction.thinking),
|
|
||||||
});
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1286,60 +1044,33 @@ export async function runSdkAgent(params: SdkRunnerParams): Promise<SdkRunnerRes
|
|||||||
assistantSoFar = chunks.join("\n\n");
|
assistantSoFar = chunks.join("\n\n");
|
||||||
const delta = assistantSoFar.startsWith(prev) ? assistantSoFar.slice(prev.length) : trimmed;
|
const delta = assistantSoFar.startsWith(prev) ? assistantSoFar.slice(prev.length) : trimmed;
|
||||||
|
|
||||||
log.debug("[CCSDK-STREAM] Extracted assistant text", {
|
|
||||||
eventCount,
|
|
||||||
chunkLength: trimmed.length,
|
|
||||||
totalExtracted: extractedChars,
|
|
||||||
totalChunks: chunks.length,
|
|
||||||
hasOnBlockReply: Boolean(params.onBlockReply),
|
|
||||||
hasOnPartialReply: Boolean(params.onPartialReply),
|
|
||||||
});
|
|
||||||
|
|
||||||
emitEvent("assistant", { text: assistantSoFar, delta });
|
emitEvent("assistant", { text: assistantSoFar, delta });
|
||||||
|
|
||||||
// Stream partial reply (for live text updates in UI).
|
// Stream partial reply (for live text updates in UI).
|
||||||
if (params.onPartialReply) {
|
if (params.onPartialReply) {
|
||||||
try {
|
try {
|
||||||
log.debug("[CCSDK-STREAM] Calling onPartialReply", {
|
|
||||||
textLength: assistantSoFar.length,
|
|
||||||
});
|
|
||||||
await params.onPartialReply({ text: assistantSoFar });
|
await params.onPartialReply({ text: assistantSoFar });
|
||||||
} catch (err) {
|
} catch {
|
||||||
log.debug("[CCSDK-STREAM] onPartialReply error", { error: String(err) });
|
// Don't break stream on callback errors
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Stream block reply (for block-based reply pipeline).
|
// Stream block reply (for block-based reply pipeline).
|
||||||
// This enables the reply pipeline to send incremental block updates.
|
|
||||||
if (params.onBlockReply) {
|
if (params.onBlockReply) {
|
||||||
try {
|
try {
|
||||||
log.debug("[CCSDK-STREAM] Calling onBlockReply", { textLength: assistantSoFar.length });
|
|
||||||
await params.onBlockReply({ text: assistantSoFar });
|
await params.onBlockReply({ text: assistantSoFar });
|
||||||
} catch (err) {
|
} catch {
|
||||||
log.debug("[CCSDK-STREAM] onBlockReply error", { error: String(err) });
|
// Don't break stream on callback errors
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
log.debug("[CCSDK-STREAM] onBlockReply not provided - skipping");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Truncate if we've extracted too much text.
|
// Truncate if we've extracted too much text.
|
||||||
if (extractedChars >= DEFAULT_MAX_EXTRACTED_CHARS) {
|
if (extractedChars >= DEFAULT_MAX_EXTRACTED_CHARS) {
|
||||||
truncated = true;
|
truncated = true;
|
||||||
log.debug(`Truncated after ${extractedChars} chars`);
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Log when the stream ends naturally (not via break from result/abort/truncate)
|
|
||||||
log.debug("SDK event stream ended", {
|
|
||||||
eventCount,
|
|
||||||
extractedChars,
|
|
||||||
aborted,
|
|
||||||
truncated,
|
|
||||||
hasResultText: Boolean(resultText),
|
|
||||||
chunksCount: chunks.length,
|
|
||||||
});
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (timeoutId) clearTimeout(timeoutId);
|
if (timeoutId) clearTimeout(timeoutId);
|
||||||
|
|
||||||
@ -1428,49 +1159,7 @@ export async function runSdkAgent(params: SdkRunnerParams): Promise<SdkRunnerRes
|
|||||||
|
|
||||||
const text = (resultText ?? chunks.join("\n\n")).trim();
|
const text = (resultText ?? chunks.join("\n\n")).trim();
|
||||||
|
|
||||||
// Log per-turn response characteristics for debugging
|
|
||||||
log.debug("[CCSDK-TURN] Turn completed - response analysis", {
|
|
||||||
responseLength: text.length,
|
|
||||||
eventCount,
|
|
||||||
extractedChars,
|
|
||||||
chunksCount: chunks.length,
|
|
||||||
hasResultText: Boolean(resultText),
|
|
||||||
aborted,
|
|
||||||
truncated,
|
|
||||||
durationMs: Date.now() - startedAt,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Detect potential auth errors in the response
|
|
||||||
if (text) {
|
|
||||||
const authCheck = detectAuthErrorPatterns(text);
|
|
||||||
if (authCheck.hasAuthError) {
|
|
||||||
log.warn("[CCSDK-TURN] Potential auth-related issue detected in response", {
|
|
||||||
patterns: authCheck.patterns,
|
|
||||||
responseLength: text.length,
|
|
||||||
responsePreview: text.slice(0, 500),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Log full response text at debug level for troubleshooting
|
|
||||||
// Truncate very long responses but log short ones in full
|
|
||||||
if (text.length <= 1000) {
|
|
||||||
log.debug("[CCSDK-TURN] Full response text", { text });
|
|
||||||
} else {
|
|
||||||
log.debug("[CCSDK-TURN] Response text (truncated for logging)", {
|
|
||||||
textPreview: text.slice(0, 500),
|
|
||||||
textSuffix: text.slice(-200),
|
|
||||||
fullLength: text.length,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!text) {
|
if (!text) {
|
||||||
log.warn("[CCSDK-TURN] No text output - possible auth or SDK issue", {
|
|
||||||
eventCount,
|
|
||||||
hasResultText: Boolean(resultText),
|
|
||||||
chunksCount: chunks.length,
|
|
||||||
aborted,
|
|
||||||
});
|
|
||||||
emitEvent("lifecycle", {
|
emitEvent("lifecycle", {
|
||||||
phase: "error",
|
phase: "error",
|
||||||
startedAt,
|
startedAt,
|
||||||
@ -1509,40 +1198,22 @@ export async function runSdkAgent(params: SdkRunnerParams): Promise<SdkRunnerRes
|
|||||||
const suffix = truncated ? "\n\n[Output truncated]" : "";
|
const suffix = truncated ? "\n\n[Output truncated]" : "";
|
||||||
const finalText = `${text}${suffix}`;
|
const finalText = `${text}${suffix}`;
|
||||||
|
|
||||||
log.debug("[CCSDK-FINAL] Final result prepared", {
|
|
||||||
textLength: finalText.length,
|
|
||||||
textPreview: finalText.slice(0, 200),
|
|
||||||
hasOnBlockReply: Boolean(params.onBlockReply),
|
|
||||||
hasOnBlockReplyFlush: Boolean(params.onBlockReplyFlush),
|
|
||||||
truncated,
|
|
||||||
aborted,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Emit the final block reply.
|
// Emit the final block reply.
|
||||||
if (params.onBlockReply) {
|
if (params.onBlockReply) {
|
||||||
try {
|
try {
|
||||||
log.debug("[CCSDK-FINAL] Calling final onBlockReply", { textLength: finalText.length });
|
|
||||||
await params.onBlockReply({ text: finalText });
|
await params.onBlockReply({ text: finalText });
|
||||||
log.debug("[CCSDK-FINAL] Final onBlockReply completed successfully");
|
} catch {
|
||||||
} catch (err) {
|
// Don't break on callback errors
|
||||||
log.debug("[CCSDK-FINAL] Final onBlockReply error", { error: String(err) });
|
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
log.debug("[CCSDK-FINAL] No onBlockReply callback - final text will only be in payloads");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// CRITICAL: Flush block reply pipeline to signal message completion.
|
// Flush block reply pipeline to signal message completion.
|
||||||
// Without this, the UI stays stuck on "..." waiting for the flush signal.
|
|
||||||
if (params.onBlockReplyFlush) {
|
if (params.onBlockReplyFlush) {
|
||||||
try {
|
try {
|
||||||
log.debug("[CCSDK-FINAL] Flushing block reply pipeline");
|
|
||||||
await params.onBlockReplyFlush();
|
await params.onBlockReplyFlush();
|
||||||
log.debug("[CCSDK-FINAL] Block reply flush completed");
|
} catch {
|
||||||
} catch (err) {
|
// Don't break on callback errors
|
||||||
log.debug("[CCSDK-FINAL] Block reply flush error", { error: String(err) });
|
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
log.debug("[CCSDK-FINAL] No onBlockReplyFlush callback - UI may stay on typing indicator");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
emitEvent("lifecycle", {
|
emitEvent("lifecycle", {
|
||||||
|
|||||||
@ -166,7 +166,8 @@ export function appendSdkToolResultToSessionTranscript(params: {
|
|||||||
try {
|
try {
|
||||||
resultContent = JSON.stringify(params.result);
|
resultContent = JSON.stringify(params.result);
|
||||||
} catch {
|
} catch {
|
||||||
resultContent = String(params.result);
|
// Fallback if JSON.stringify fails (e.g., circular references)
|
||||||
|
resultContent = `(unserializable: ${typeof params.result})`;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
resultContent = "(no output)";
|
resultContent = "(no output)";
|
||||||
|
|||||||
@ -24,8 +24,10 @@ export type PromptEnricherContext = {
|
|||||||
channel?: string;
|
channel?: string;
|
||||||
/** Channel-specific hints or instructions. */
|
/** Channel-specific hints or instructions. */
|
||||||
channelHints?: string;
|
channelHints?: string;
|
||||||
/** Available Moltbot skills. */
|
/** Available Moltbot skills (names only). */
|
||||||
skills?: string[];
|
skills?: string[];
|
||||||
|
/** Full formatted skills prompt (preferred over skills). */
|
||||||
|
skillsPrompt?: string;
|
||||||
/** Sender identifier. */
|
/** Sender identifier. */
|
||||||
senderId?: string | null;
|
senderId?: string | null;
|
||||||
/** Sender display name. */
|
/** Sender display name. */
|
||||||
@ -110,8 +112,15 @@ const channelEnricher: PromptEnricher = (ctx) => {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Skills enricher.
|
* Skills enricher.
|
||||||
|
*
|
||||||
|
* Prefers the full skillsPrompt (which includes descriptions) over just names.
|
||||||
*/
|
*/
|
||||||
const skillsEnricher: PromptEnricher = (ctx) => {
|
const skillsEnricher: PromptEnricher = (ctx) => {
|
||||||
|
// Prefer full skills prompt with descriptions
|
||||||
|
if (ctx.skillsPrompt?.trim()) {
|
||||||
|
return ctx.skillsPrompt.trim();
|
||||||
|
}
|
||||||
|
// Fallback to skill names list
|
||||||
if (!ctx.skills?.length) return null;
|
if (!ctx.skills?.length) return null;
|
||||||
return `Available Moltbot skills: ${ctx.skills.join(", ")}`;
|
return `Available Moltbot skills: ${ctx.skills.join(", ")}`;
|
||||||
};
|
};
|
||||||
@ -189,6 +198,7 @@ export function buildSystemPromptAdditionsFromParams(params: {
|
|||||||
messageChannel?: string;
|
messageChannel?: string;
|
||||||
channelHints?: string;
|
channelHints?: string;
|
||||||
skills?: string[];
|
skills?: string[];
|
||||||
|
skillsPrompt?: string;
|
||||||
senderId?: string | null;
|
senderId?: string | null;
|
||||||
senderName?: string | null;
|
senderName?: string | null;
|
||||||
senderUsername?: string | null;
|
senderUsername?: string | null;
|
||||||
@ -202,6 +212,7 @@ export function buildSystemPromptAdditionsFromParams(params: {
|
|||||||
channel: params.messageChannel,
|
channel: params.messageChannel,
|
||||||
channelHints: params.channelHints,
|
channelHints: params.channelHints,
|
||||||
skills: params.skills,
|
skills: params.skills,
|
||||||
|
skillsPrompt: params.skillsPrompt,
|
||||||
senderId: params.senderId,
|
senderId: params.senderId,
|
||||||
senderName: params.senderName,
|
senderName: params.senderName,
|
||||||
senderUsername: params.senderUsername,
|
senderUsername: params.senderUsername,
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||||
|
import { Type } from "@sinclair/typebox";
|
||||||
|
|
||||||
import type { AgentToolResult } from "@mariozechner/pi-agent-core";
|
import type { AgentToolResult } from "@mariozechner/pi-agent-core";
|
||||||
|
|
||||||
@ -18,6 +19,8 @@ vi.mock("../tool-policy.js", () => ({
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
extractJsonSchema,
|
extractJsonSchema,
|
||||||
|
extractZodCompatibleSchema,
|
||||||
|
createZodCompatibleSchema,
|
||||||
convertToolResult,
|
convertToolResult,
|
||||||
wrapToolHandler,
|
wrapToolHandler,
|
||||||
mcpToolName,
|
mcpToolName,
|
||||||
@ -101,6 +104,112 @@ describe("tool-bridge", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("createZodCompatibleSchema", () => {
|
||||||
|
it("creates schema with parse/safeParse methods", () => {
|
||||||
|
const typeboxSchema = {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
name: { type: "string" },
|
||||||
|
},
|
||||||
|
required: ["name"],
|
||||||
|
};
|
||||||
|
|
||||||
|
const zodSchema = createZodCompatibleSchema(typeboxSchema as any);
|
||||||
|
|
||||||
|
expect(typeof zodSchema.parse).toBe("function");
|
||||||
|
expect(typeof zodSchema.safeParse).toBe("function");
|
||||||
|
expect(typeof zodSchema.safeParseAsync).toBe("function");
|
||||||
|
expect(zodSchema._def.typeName).toBe("ZodObject");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("parse returns data for valid input", () => {
|
||||||
|
const typeboxSchema = Type.Object({
|
||||||
|
count: Type.Number(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const zodSchema = createZodCompatibleSchema(typeboxSchema);
|
||||||
|
const result = zodSchema.parse({ count: 42 });
|
||||||
|
|
||||||
|
expect(result).toEqual({ count: 42 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("safeParse returns success for valid input", () => {
|
||||||
|
const typeboxSchema = Type.Object({
|
||||||
|
active: Type.Boolean(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const zodSchema = createZodCompatibleSchema(typeboxSchema);
|
||||||
|
const result = zodSchema.safeParse({ active: true });
|
||||||
|
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(result.data).toEqual({ active: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("safeParse returns error for invalid input", () => {
|
||||||
|
const typeboxSchema = Type.Object({
|
||||||
|
value: Type.String(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const zodSchema = createZodCompatibleSchema(typeboxSchema);
|
||||||
|
// Missing required 'value' property
|
||||||
|
const result = zodSchema.safeParse({});
|
||||||
|
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
expect(result.error).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("safeParseAsync returns promise", async () => {
|
||||||
|
const typeboxSchema = Type.Object({});
|
||||||
|
|
||||||
|
const zodSchema = createZodCompatibleSchema(typeboxSchema);
|
||||||
|
const result = await zodSchema.safeParseAsync({});
|
||||||
|
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("extractZodCompatibleSchema", () => {
|
||||||
|
it("returns Zod-compatible schema for tool with parameters", () => {
|
||||||
|
const tool = {
|
||||||
|
name: "test_tool",
|
||||||
|
parameters: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
query: { type: "string" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as unknown as AnyAgentTool;
|
||||||
|
|
||||||
|
const schema = extractZodCompatibleSchema(tool);
|
||||||
|
|
||||||
|
expect(schema).toBeDefined();
|
||||||
|
expect(typeof schema?.parse).toBe("function");
|
||||||
|
expect(typeof schema?.safeParse).toBe("function");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns undefined for tool without parameters", () => {
|
||||||
|
const tool = {
|
||||||
|
name: "no_params_tool",
|
||||||
|
parameters: undefined,
|
||||||
|
} as unknown as AnyAgentTool;
|
||||||
|
|
||||||
|
const schema = extractZodCompatibleSchema(tool);
|
||||||
|
|
||||||
|
expect(schema).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns undefined for tool with null parameters", () => {
|
||||||
|
const tool = {
|
||||||
|
name: "null_params_tool",
|
||||||
|
parameters: null,
|
||||||
|
} as unknown as AnyAgentTool;
|
||||||
|
|
||||||
|
const schema = extractZodCompatibleSchema(tool);
|
||||||
|
|
||||||
|
expect(schema).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("convertToolResult", () => {
|
describe("convertToolResult", () => {
|
||||||
it("converts text content blocks", () => {
|
it("converts text content blocks", () => {
|
||||||
const result: AgentToolResult<unknown> = {
|
const result: AgentToolResult<unknown> = {
|
||||||
@ -203,6 +312,9 @@ describe("tool-bridge", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("wrapToolHandler", () => {
|
describe("wrapToolHandler", () => {
|
||||||
|
// Mock extra object that MCP SDK passes to handlers
|
||||||
|
const mockExtra = { signal: new AbortController().signal };
|
||||||
|
|
||||||
it("executes tool and converts result", async () => {
|
it("executes tool and converts result", async () => {
|
||||||
const mockExecute = vi.fn().mockResolvedValue({
|
const mockExecute = vi.fn().mockResolvedValue({
|
||||||
content: [{ type: "text", text: "Tool output" }],
|
content: [{ type: "text", text: "Tool output" }],
|
||||||
@ -214,18 +326,18 @@ describe("tool-bridge", () => {
|
|||||||
} as unknown as AnyAgentTool;
|
} as unknown as AnyAgentTool;
|
||||||
|
|
||||||
const handler = wrapToolHandler(tool);
|
const handler = wrapToolHandler(tool);
|
||||||
const result = await handler({ input: "test" });
|
const result = await handler({ input: "test" }, mockExtra);
|
||||||
|
|
||||||
expect(mockExecute).toHaveBeenCalledWith(
|
expect(mockExecute).toHaveBeenCalledWith(
|
||||||
expect.stringContaining("mcp-bridge-test_tool"),
|
expect.stringContaining("mcp-bridge-test_tool"),
|
||||||
{ input: "test" },
|
{ input: "test" },
|
||||||
undefined,
|
mockExtra.signal,
|
||||||
undefined,
|
undefined,
|
||||||
);
|
);
|
||||||
expect(result.content).toEqual([{ type: "text", text: "Tool output" }]);
|
expect(result.content).toEqual([{ type: "text", text: "Tool output" }]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("passes abort signal to tool execute", async () => {
|
it("uses signal from extra when provided", async () => {
|
||||||
const mockExecute = vi.fn().mockResolvedValue({
|
const mockExecute = vi.fn().mockResolvedValue({
|
||||||
content: [{ type: "text", text: "Done" }],
|
content: [{ type: "text", text: "Done" }],
|
||||||
});
|
});
|
||||||
@ -235,14 +347,36 @@ describe("tool-bridge", () => {
|
|||||||
execute: mockExecute,
|
execute: mockExecute,
|
||||||
} as unknown as AnyAgentTool;
|
} as unknown as AnyAgentTool;
|
||||||
|
|
||||||
const controller = new AbortController();
|
const extraController = new AbortController();
|
||||||
const handler = wrapToolHandler(tool, controller.signal);
|
const handler = wrapToolHandler(tool);
|
||||||
await handler({});
|
await handler({}, { signal: extraController.signal });
|
||||||
|
|
||||||
expect(mockExecute).toHaveBeenCalledWith(
|
expect(mockExecute).toHaveBeenCalledWith(
|
||||||
expect.any(String),
|
expect.any(String),
|
||||||
{},
|
{},
|
||||||
controller.signal,
|
extraController.signal,
|
||||||
|
undefined,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to shared abortSignal when extra.signal is undefined", async () => {
|
||||||
|
const mockExecute = vi.fn().mockResolvedValue({
|
||||||
|
content: [{ type: "text", text: "Done" }],
|
||||||
|
});
|
||||||
|
|
||||||
|
const tool = {
|
||||||
|
name: "fallback-signal-tool",
|
||||||
|
execute: mockExecute,
|
||||||
|
} as unknown as AnyAgentTool;
|
||||||
|
|
||||||
|
const sharedController = new AbortController();
|
||||||
|
const handler = wrapToolHandler(tool, sharedController.signal);
|
||||||
|
await handler({}, {} as any);
|
||||||
|
|
||||||
|
expect(mockExecute).toHaveBeenCalledWith(
|
||||||
|
expect.any(String),
|
||||||
|
{},
|
||||||
|
sharedController.signal,
|
||||||
undefined,
|
undefined,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@ -256,7 +390,7 @@ describe("tool-bridge", () => {
|
|||||||
} as unknown as AnyAgentTool;
|
} as unknown as AnyAgentTool;
|
||||||
|
|
||||||
const handler = wrapToolHandler(tool);
|
const handler = wrapToolHandler(tool);
|
||||||
const result = await handler({});
|
const result = await handler({}, mockExtra);
|
||||||
|
|
||||||
expect(result.isError).toBe(true);
|
expect(result.isError).toBe(true);
|
||||||
expect(result.content[0]).toMatchObject({
|
expect(result.content[0]).toMatchObject({
|
||||||
@ -276,7 +410,7 @@ describe("tool-bridge", () => {
|
|||||||
} as unknown as AnyAgentTool;
|
} as unknown as AnyAgentTool;
|
||||||
|
|
||||||
const handler = wrapToolHandler(tool);
|
const handler = wrapToolHandler(tool);
|
||||||
const result = await handler({});
|
const result = await handler({}, mockExtra);
|
||||||
|
|
||||||
expect(result.isError).toBe(true);
|
expect(result.isError).toBe(true);
|
||||||
expect(result.content[0]).toMatchObject({
|
expect(result.content[0]).toMatchObject({
|
||||||
@ -298,7 +432,7 @@ describe("tool-bridge", () => {
|
|||||||
|
|
||||||
const onToolUpdate = vi.fn();
|
const onToolUpdate = vi.fn();
|
||||||
const handler = wrapToolHandler(tool, undefined, onToolUpdate);
|
const handler = wrapToolHandler(tool, undefined, onToolUpdate);
|
||||||
await handler({});
|
await handler({}, mockExtra);
|
||||||
|
|
||||||
expect(onToolUpdate).toHaveBeenCalledWith(
|
expect(onToolUpdate).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
@ -323,7 +457,7 @@ describe("tool-bridge", () => {
|
|||||||
const handler = wrapToolHandler(tool, undefined, onToolUpdate);
|
const handler = wrapToolHandler(tool, undefined, onToolUpdate);
|
||||||
|
|
||||||
// Should not throw even if callback fails
|
// Should not throw even if callback fails
|
||||||
const result = await handler({});
|
const result = await handler({}, mockExtra);
|
||||||
|
|
||||||
expect(result.content[0]).toMatchObject({
|
expect(result.content[0]).toMatchObject({
|
||||||
type: "text",
|
type: "text",
|
||||||
@ -344,8 +478,8 @@ describe("tool-bridge", () => {
|
|||||||
} as unknown as AnyAgentTool;
|
} as unknown as AnyAgentTool;
|
||||||
|
|
||||||
const handler = wrapToolHandler(tool);
|
const handler = wrapToolHandler(tool);
|
||||||
await handler({});
|
await handler({}, mockExtra);
|
||||||
await handler({});
|
await handler({}, mockExtra);
|
||||||
|
|
||||||
expect(toolCallIds[0]).not.toBe(toolCallIds[1]);
|
expect(toolCallIds[0]).not.toBe(toolCallIds[1]);
|
||||||
expect(toolCallIds[0]).toContain("mcp-bridge-unique_id_tool");
|
expect(toolCallIds[0]).toContain("mcp-bridge-unique_id_tool");
|
||||||
@ -381,21 +515,21 @@ describe("tool-bridge", () => {
|
|||||||
|
|
||||||
describe("bridgeMoltbotToolsSync", () => {
|
describe("bridgeMoltbotToolsSync", () => {
|
||||||
// Helper to create a class-like McpServer mock (required because implementation uses `new`)
|
// Helper to create a class-like McpServer mock (required because implementation uses `new`)
|
||||||
function createMockMcpServerClass(toolFn: ReturnType<typeof vi.fn>) {
|
function createMockMcpServerClass(registerToolFn: ReturnType<typeof vi.fn>) {
|
||||||
return class MockMcpServer {
|
return class MockMcpServer {
|
||||||
tool = toolFn;
|
registerTool = registerToolFn;
|
||||||
constructor(_opts: { name: string; version: string }) {
|
constructor(_opts: { name: string; version: string }) {
|
||||||
// Constructor receives options
|
// Constructor receives options
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
it("registers tools with MCP server", () => {
|
it("registers tools with MCP server using registerTool", () => {
|
||||||
const registeredTools: string[] = [];
|
const registeredTools: string[] = [];
|
||||||
const toolFn = vi.fn((name: string) => {
|
const registerToolFn = vi.fn((name: string) => {
|
||||||
registeredTools.push(name);
|
registeredTools.push(name);
|
||||||
});
|
});
|
||||||
const MockMcpServer = createMockMcpServerClass(toolFn);
|
const MockMcpServer = createMockMcpServerClass(registerToolFn);
|
||||||
|
|
||||||
const tools = [
|
const tools = [
|
||||||
{
|
{
|
||||||
@ -421,6 +555,39 @@ describe("tool-bridge", () => {
|
|||||||
expect(result.toolCount).toBe(2);
|
expect(result.toolCount).toBe(2);
|
||||||
expect(result.registeredTools).toEqual(["tool_one", "tool_two"]);
|
expect(result.registeredTools).toEqual(["tool_one", "tool_two"]);
|
||||||
expect(result.skippedTools).toEqual([]);
|
expect(result.skippedTools).toEqual([]);
|
||||||
|
expect(registerToolFn).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("passes config with inputSchema to registerTool", () => {
|
||||||
|
const registerToolFn = vi.fn();
|
||||||
|
const MockMcpServer = createMockMcpServerClass(registerToolFn);
|
||||||
|
|
||||||
|
const tools = [
|
||||||
|
{
|
||||||
|
name: "schema_tool",
|
||||||
|
description: "Tool with schema",
|
||||||
|
parameters: { type: "object", properties: { query: { type: "string" } } },
|
||||||
|
execute: vi.fn(),
|
||||||
|
},
|
||||||
|
] as unknown as AnyAgentTool[];
|
||||||
|
|
||||||
|
bridgeMoltbotToolsSync({
|
||||||
|
name: "test-server",
|
||||||
|
tools,
|
||||||
|
McpServer: MockMcpServer as any,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(registerToolFn).toHaveBeenCalledWith(
|
||||||
|
"schema_tool",
|
||||||
|
expect.objectContaining({
|
||||||
|
description: "Tool with schema",
|
||||||
|
inputSchema: expect.objectContaining({
|
||||||
|
parse: expect.any(Function),
|
||||||
|
safeParse: expect.any(Function),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
expect.any(Function),
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("skips tools with empty names", () => {
|
it("skips tools with empty names", () => {
|
||||||
@ -458,10 +625,10 @@ describe("tool-bridge", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("handles tool registration errors gracefully", () => {
|
it("handles tool registration errors gracefully", () => {
|
||||||
const toolFn = vi.fn((name: string) => {
|
const registerToolFn = vi.fn((name: string) => {
|
||||||
if (name === "bad_tool") throw new Error("Registration failed");
|
if (name === "bad_tool") throw new Error("Registration failed");
|
||||||
});
|
});
|
||||||
const MockMcpServer = createMockMcpServerClass(toolFn);
|
const MockMcpServer = createMockMcpServerClass(registerToolFn);
|
||||||
|
|
||||||
const tools = [
|
const tools = [
|
||||||
{ name: "good_tool", execute: vi.fn() },
|
{ name: "good_tool", execute: vi.fn() },
|
||||||
|
|||||||
@ -18,6 +18,8 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import type { AgentToolResult } from "@mariozechner/pi-agent-core";
|
import type { AgentToolResult } from "@mariozechner/pi-agent-core";
|
||||||
|
import type { TSchema } from "@sinclair/typebox";
|
||||||
|
import { Value } from "@sinclair/typebox/value";
|
||||||
|
|
||||||
import { createSubsystemLogger } from "../../logging/subsystem.js";
|
import { createSubsystemLogger } from "../../logging/subsystem.js";
|
||||||
import { normalizeToolName } from "../tool-policy.js";
|
import { normalizeToolName } from "../tool-policy.js";
|
||||||
@ -25,6 +27,7 @@ import type { AnyAgentTool } from "../tools/common.js";
|
|||||||
import type {
|
import type {
|
||||||
McpCallToolResult,
|
McpCallToolResult,
|
||||||
McpContentBlock,
|
McpContentBlock,
|
||||||
|
McpRequestHandlerExtra,
|
||||||
McpSdkServerConfig,
|
McpSdkServerConfig,
|
||||||
McpServerConstructor,
|
McpServerConstructor,
|
||||||
McpServerLike,
|
McpServerLike,
|
||||||
@ -33,9 +36,85 @@ import type {
|
|||||||
const log = createSubsystemLogger("agents/claude-agent-sdk/tool-bridge");
|
const log = createSubsystemLogger("agents/claude-agent-sdk/tool-bridge");
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Schema conversion: TypeBox → JSON Schema (passthrough)
|
// Schema conversion: TypeBox → Zod-compatible schema
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Interface for Zod-compatible schema that the MCP SDK expects.
|
||||||
|
* The SDK checks for `parse` and `safeParse` methods to identify valid schemas.
|
||||||
|
*/
|
||||||
|
export interface ZodCompatibleSchema {
|
||||||
|
/** Synchronous parse - throws on validation failure */
|
||||||
|
parse(data: unknown): unknown;
|
||||||
|
/** Safe synchronous parse - returns success/error result */
|
||||||
|
safeParse(data: unknown): { success: true; data: unknown } | { success: false; error: unknown };
|
||||||
|
/** Async version of safeParse (used by MCP SDK) */
|
||||||
|
safeParseAsync(
|
||||||
|
data: unknown,
|
||||||
|
): Promise<{ success: true; data: unknown } | { success: false; error: unknown }>;
|
||||||
|
/** Mark this as a Zod-like schema instance (SDK checks for _def or _zod) */
|
||||||
|
_def: { typeName: string; description?: string };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wrap a TypeBox schema to make it Zod-compatible for the MCP SDK.
|
||||||
|
*
|
||||||
|
* The MCP SDK's McpServer.tool() checks `isZodTypeLike()` which requires:
|
||||||
|
* - `parse` method (function)
|
||||||
|
* - `safeParse` method (function)
|
||||||
|
*
|
||||||
|
* This wrapper uses TypeBox's Value module to perform validation.
|
||||||
|
*/
|
||||||
|
export function createZodCompatibleSchema(typeboxSchema: TSchema): ZodCompatibleSchema {
|
||||||
|
return {
|
||||||
|
parse(data: unknown): unknown {
|
||||||
|
// Decode handles coercion (string→number, etc.) and throws on failure
|
||||||
|
return Value.Decode(typeboxSchema, data);
|
||||||
|
},
|
||||||
|
|
||||||
|
safeParse(
|
||||||
|
data: unknown,
|
||||||
|
): { success: true; data: unknown } | { success: false; error: unknown } {
|
||||||
|
try {
|
||||||
|
// First check if valid
|
||||||
|
if (!Value.Check(typeboxSchema, data)) {
|
||||||
|
// Get the first validation error for a useful message
|
||||||
|
const errors = [...Value.Errors(typeboxSchema, data)];
|
||||||
|
const firstError = errors[0];
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: {
|
||||||
|
message: firstError?.message ?? "Validation failed",
|
||||||
|
path: firstError?.path ?? "",
|
||||||
|
issues: errors.map((e) => ({ message: e.message, path: e.path })),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
// Decode to apply any transformations
|
||||||
|
const decoded = Value.Decode(typeboxSchema, data);
|
||||||
|
return { success: true, data: decoded };
|
||||||
|
} catch (err) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: err instanceof Error ? { message: err.message } : { message: String(err) },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async safeParseAsync(
|
||||||
|
data: unknown,
|
||||||
|
): Promise<{ success: true; data: unknown } | { success: false; error: unknown }> {
|
||||||
|
return this.safeParse(data);
|
||||||
|
},
|
||||||
|
|
||||||
|
// Mimic Zod v3's internal structure so isZodSchemaInstance returns true
|
||||||
|
_def: {
|
||||||
|
typeName: "ZodObject",
|
||||||
|
description: (typeboxSchema as { description?: string }).description,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Extract a clean JSON Schema object from a TypeBox schema.
|
* Extract a clean JSON Schema object from a TypeBox schema.
|
||||||
*
|
*
|
||||||
@ -57,6 +136,20 @@ export function extractJsonSchema(tool: AnyAgentTool): Record<string, unknown> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a Zod-compatible schema from a tool's TypeBox parameters.
|
||||||
|
* Falls back to a permissive schema if the tool has no parameters.
|
||||||
|
*/
|
||||||
|
export function extractZodCompatibleSchema(tool: AnyAgentTool): ZodCompatibleSchema | undefined {
|
||||||
|
const schema = tool.parameters as TSchema | undefined;
|
||||||
|
if (!schema || typeof schema !== "object") {
|
||||||
|
// Return undefined to indicate no input schema
|
||||||
|
// The MCP SDK will then call handler(extra) instead of handler(args, extra)
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return createZodCompatibleSchema(schema);
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Result conversion: AgentToolResult → McpCallToolResult
|
// Result conversion: AgentToolResult → McpCallToolResult
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@ -132,7 +225,12 @@ export function convertToolResult(result: AgentToolResult<unknown>): McpCallTool
|
|||||||
*
|
*
|
||||||
* Differences bridged:
|
* Differences bridged:
|
||||||
* - Moltbot: `execute(toolCallId, params, signal?, onUpdate?)`
|
* - Moltbot: `execute(toolCallId, params, signal?, onUpdate?)`
|
||||||
* - MCP: `handler(args) → Promise<CallToolResult>`
|
* - MCP: `handler(args, extra) → Promise<CallToolResult>`
|
||||||
|
*
|
||||||
|
* When using registerTool() with inputSchema, the MCP SDK calls
|
||||||
|
* the handler with (args, extra) where:
|
||||||
|
* - args: the validated tool input parameters
|
||||||
|
* - extra: RequestHandlerExtra with signal, sessionId, _meta, etc.
|
||||||
*
|
*
|
||||||
* Notable: MCP handlers have no native streaming update callback.
|
* Notable: MCP handlers have no native streaming update callback.
|
||||||
* We pass the shared `abortSignal` (if provided) and create an `onUpdate`
|
* We pass the shared `abortSignal` (if provided) and create an `onUpdate`
|
||||||
@ -142,10 +240,13 @@ export function wrapToolHandler(
|
|||||||
tool: AnyAgentTool,
|
tool: AnyAgentTool,
|
||||||
abortSignal?: AbortSignal,
|
abortSignal?: AbortSignal,
|
||||||
onToolUpdate?: OnToolUpdateCallback,
|
onToolUpdate?: OnToolUpdateCallback,
|
||||||
): (args: Record<string, unknown>) => Promise<McpCallToolResult> {
|
): (args: Record<string, unknown>, extra: McpRequestHandlerExtra) => Promise<McpCallToolResult> {
|
||||||
const normalizedName = normalizeToolName(tool.name);
|
const normalizedName = normalizeToolName(tool.name);
|
||||||
|
|
||||||
return async (args: Record<string, unknown>): Promise<McpCallToolResult> => {
|
return async (
|
||||||
|
args: Record<string, unknown>,
|
||||||
|
extra: McpRequestHandlerExtra,
|
||||||
|
): Promise<McpCallToolResult> => {
|
||||||
// Generate a synthetic toolCallId. The Claude Agent SDK doesn't expose its
|
// Generate a synthetic toolCallId. The Claude Agent SDK doesn't expose its
|
||||||
// internal tool call ID to MCP handlers, so we create a unique one for
|
// internal tool call ID to MCP handlers, so we create a unique one for
|
||||||
// internal tracking/logging. This is safe because Moltbot tools only use
|
// internal tracking/logging. This is safe because Moltbot tools only use
|
||||||
@ -160,6 +261,9 @@ export function wrapToolHandler(
|
|||||||
argsPreview: JSON.stringify(args).slice(0, 500),
|
argsPreview: JSON.stringify(args).slice(0, 500),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Use the signal from extra if available, fall back to the shared abortSignal
|
||||||
|
const signal = extra?.signal ?? abortSignal;
|
||||||
|
|
||||||
// Create an onUpdate callback that forwards to the bridge callback.
|
// Create an onUpdate callback that forwards to the bridge callback.
|
||||||
const onUpdate = onToolUpdate
|
const onUpdate = onToolUpdate
|
||||||
? (update: unknown) => {
|
? (update: unknown) => {
|
||||||
@ -172,7 +276,7 @@ export function wrapToolHandler(
|
|||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await tool.execute(toolCallId, args, abortSignal, onUpdate);
|
const result = await tool.execute(toolCallId, args, signal, onUpdate);
|
||||||
return convertToolResult(result);
|
return convertToolResult(result);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// Propagate AbortError so the SDK runner can handle cancellation.
|
// Propagate AbortError so the SDK runner can handle cancellation.
|
||||||
@ -309,10 +413,20 @@ export async function bridgeMoltbotToolsToMcpServer(options: BridgeOptions): Pro
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const jsonSchema = extractJsonSchema(tool);
|
const inputSchema = extractZodCompatibleSchema(tool);
|
||||||
const handler = wrapToolHandler(tool, options.abortSignal, options.onToolUpdate);
|
const handler = wrapToolHandler(tool, options.abortSignal, options.onToolUpdate);
|
||||||
|
|
||||||
server.tool(toolName, tool.description ?? `Moltbot tool: ${toolName}`, jsonSchema, handler);
|
// Use registerTool() which properly handles inputSchema.
|
||||||
|
// The MCP SDK will call handler(args, extra) when inputSchema is set,
|
||||||
|
// and validate the args against the schema before calling the handler.
|
||||||
|
server.registerTool(
|
||||||
|
toolName,
|
||||||
|
{
|
||||||
|
description: tool.description ?? `Moltbot tool: ${toolName}`,
|
||||||
|
inputSchema,
|
||||||
|
},
|
||||||
|
handler,
|
||||||
|
);
|
||||||
|
|
||||||
registered.push(toolName);
|
registered.push(toolName);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@ -364,9 +478,18 @@ export function bridgeMoltbotToolsSync(
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const jsonSchema = extractJsonSchema(tool);
|
const inputSchema = extractZodCompatibleSchema(tool);
|
||||||
const handler = wrapToolHandler(tool, options.abortSignal, options.onToolUpdate);
|
const handler = wrapToolHandler(tool, options.abortSignal, options.onToolUpdate);
|
||||||
server.tool(toolName, tool.description ?? `Moltbot tool: ${toolName}`, jsonSchema, handler);
|
|
||||||
|
server.registerTool(
|
||||||
|
toolName,
|
||||||
|
{
|
||||||
|
description: tool.description ?? `Moltbot tool: ${toolName}`,
|
||||||
|
inputSchema,
|
||||||
|
},
|
||||||
|
handler,
|
||||||
|
);
|
||||||
|
|
||||||
registered.push(toolName);
|
registered.push(toolName);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const message = err instanceof Error ? err.message : String(err);
|
const message = err instanceof Error ? err.message : String(err);
|
||||||
|
|||||||
@ -103,20 +103,59 @@ export type SdkResultEvent = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// MCP Server tool registration (shape of McpServer.tool() from the SDK)
|
// MCP Server tool registration (shape of McpServer from the SDK)
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extra context passed to tool handlers by the MCP SDK.
|
||||||
|
* Contains abort signal, session info, and other runtime context.
|
||||||
|
*/
|
||||||
|
export interface McpRequestHandlerExtra {
|
||||||
|
signal: AbortSignal;
|
||||||
|
sessionId?: string;
|
||||||
|
_meta?: Record<string, unknown>;
|
||||||
|
requestInfo?: { method: string; params?: unknown };
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tool handler signature when inputSchema is provided.
|
||||||
|
* The SDK calls handler(args, extra) where args is the validated input.
|
||||||
|
*/
|
||||||
|
export type McpToolHandler = (
|
||||||
|
args: Record<string, unknown>,
|
||||||
|
extra: McpRequestHandlerExtra,
|
||||||
|
) => Promise<McpCallToolResult>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Configuration object for registerTool().
|
||||||
|
*/
|
||||||
|
export interface McpToolConfig {
|
||||||
|
title?: string;
|
||||||
|
description?: string;
|
||||||
|
/** Zod-compatible schema with parse/safeParse methods */
|
||||||
|
inputSchema?: {
|
||||||
|
parse(data: unknown): unknown;
|
||||||
|
safeParse(data: unknown): { success: boolean; data?: unknown; error?: unknown };
|
||||||
|
safeParseAsync?(data: unknown): Promise<{ success: boolean; data?: unknown; error?: unknown }>;
|
||||||
|
_def?: Record<string, unknown>;
|
||||||
|
};
|
||||||
|
outputSchema?: unknown;
|
||||||
|
annotations?: Record<string, unknown>;
|
||||||
|
_meta?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Minimal interface for the McpServer class from `@modelcontextprotocol/sdk`.
|
* Minimal interface for the McpServer class from `@modelcontextprotocol/sdk`.
|
||||||
* We only use the `tool()` registration method and the constructor.
|
* We use registerTool() which properly handles Zod-compatible inputSchema.
|
||||||
*/
|
*/
|
||||||
export interface McpServerLike {
|
export interface McpServerLike {
|
||||||
tool(
|
/**
|
||||||
name: string,
|
* Register a tool with explicit configuration.
|
||||||
description: string,
|
* The inputSchema must be Zod-compatible (have parse/safeParse methods)
|
||||||
inputSchema: Record<string, unknown>,
|
* for the MCP SDK to properly validate args and pass them to the handler.
|
||||||
handler: (args: Record<string, unknown>) => Promise<McpCallToolResult>,
|
*/
|
||||||
): void;
|
registerTool(name: string, config: McpToolConfig, handler: McpToolHandler): void;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@ -148,8 +148,10 @@ export type SdkRunnerParams = {
|
|||||||
messageChannel?: string;
|
messageChannel?: string;
|
||||||
/** Channel-specific hints. */
|
/** Channel-specific hints. */
|
||||||
channelHints?: string;
|
channelHints?: string;
|
||||||
/** Available Moltbot skills. */
|
/** Available Moltbot skills (names only, for system prompt context). */
|
||||||
skills?: string[];
|
skills?: string[];
|
||||||
|
/** Full formatted skills prompt from skillsSnapshot (preferred over skills). */
|
||||||
|
skillsPrompt?: string;
|
||||||
/** Sender identifier. */
|
/** Sender identifier. */
|
||||||
senderId?: string | null;
|
senderId?: string | null;
|
||||||
/** Sender display name. */
|
/** Sender display name. */
|
||||||
|
|||||||
@ -12,18 +12,6 @@ import { resolveUserPath } from "../utils.js";
|
|||||||
const log = createSubsystemLogger("agents/auth-profiles");
|
const log = createSubsystemLogger("agents/auth-profiles");
|
||||||
|
|
||||||
const CLAUDE_CLI_CREDENTIALS_RELATIVE_PATH = ".claude/.credentials.json";
|
const CLAUDE_CLI_CREDENTIALS_RELATIVE_PATH = ".claude/.credentials.json";
|
||||||
|
|
||||||
/**
|
|
||||||
* Mask a token for logging - shows length and first/last 4 chars.
|
|
||||||
* Example: "sk-ant-api03-abc...xyz" (length: 108)
|
|
||||||
*/
|
|
||||||
function maskToken(token: string | undefined): string {
|
|
||||||
if (!token) return "(empty)";
|
|
||||||
if (token.length <= 12) return `${"*".repeat(token.length)} (length: ${token.length})`;
|
|
||||||
const first = token.slice(0, 4);
|
|
||||||
const last = token.slice(-4);
|
|
||||||
return `${first}...${"*".repeat(Math.min(8, token.length - 8))}...${last} (length: ${token.length})`;
|
|
||||||
}
|
|
||||||
const CODEX_CLI_AUTH_FILENAME = "auth.json";
|
const CODEX_CLI_AUTH_FILENAME = "auth.json";
|
||||||
const QWEN_CLI_CREDENTIALS_RELATIVE_PATH = ".qwen/oauth_creds.json";
|
const QWEN_CLI_CREDENTIALS_RELATIVE_PATH = ".qwen/oauth_creds.json";
|
||||||
|
|
||||||
@ -205,25 +193,15 @@ function readQwenCliCredentials(options?: { homeDir?: string }): QwenCliCredenti
|
|||||||
function readClaudeCliKeychainCredentials(
|
function readClaudeCliKeychainCredentials(
|
||||||
execSyncImpl: ExecSyncFn = execSync,
|
execSyncImpl: ExecSyncFn = execSync,
|
||||||
): ClaudeCliCredential | null {
|
): ClaudeCliCredential | null {
|
||||||
log.debug("[CCSDK-AUTH] Attempting to read credentials from macOS Keychain", {
|
|
||||||
service: CLAUDE_CLI_KEYCHAIN_SERVICE,
|
|
||||||
account: CLAUDE_CLI_KEYCHAIN_ACCOUNT,
|
|
||||||
});
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = execSyncImpl(
|
const result = execSyncImpl(
|
||||||
`security find-generic-password -s "${CLAUDE_CLI_KEYCHAIN_SERVICE}" -w`,
|
`security find-generic-password -s "${CLAUDE_CLI_KEYCHAIN_SERVICE}" -w`,
|
||||||
{ encoding: "utf8", timeout: 5000, stdio: ["pipe", "pipe", "pipe"] },
|
{ encoding: "utf8", timeout: 5000, stdio: ["pipe", "pipe", "pipe"] },
|
||||||
);
|
);
|
||||||
|
|
||||||
log.debug("[CCSDK-AUTH] Keychain returned data", {
|
|
||||||
rawLength: result.length,
|
|
||||||
});
|
|
||||||
|
|
||||||
const data = JSON.parse(result.trim());
|
const data = JSON.parse(result.trim());
|
||||||
const claudeOauth = data?.claudeAiOauth;
|
const claudeOauth = data?.claudeAiOauth;
|
||||||
if (!claudeOauth || typeof claudeOauth !== "object") {
|
if (!claudeOauth || typeof claudeOauth !== "object") {
|
||||||
log.debug("[CCSDK-AUTH] Keychain data missing claudeAiOauth object");
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -232,30 +210,12 @@ function readClaudeCliKeychainCredentials(
|
|||||||
const expiresAt = claudeOauth.expiresAt;
|
const expiresAt = claudeOauth.expiresAt;
|
||||||
|
|
||||||
if (typeof accessToken !== "string" || !accessToken) {
|
if (typeof accessToken !== "string" || !accessToken) {
|
||||||
log.debug("[CCSDK-AUTH] Keychain accessToken invalid or missing");
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
if (typeof expiresAt !== "number" || expiresAt <= 0) {
|
if (typeof expiresAt !== "number" || expiresAt <= 0) {
|
||||||
log.debug("[CCSDK-AUTH] Keychain expiresAt invalid", { expiresAt });
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const expiresDate = new Date(expiresAt);
|
|
||||||
const isExpired = expiresAt < Date.now();
|
|
||||||
const msUntilExpiry = expiresAt - Date.now();
|
|
||||||
|
|
||||||
log.debug("[CCSDK-AUTH] Keychain credentials parsed successfully", {
|
|
||||||
source: "keychain",
|
|
||||||
hasAccessToken: Boolean(accessToken),
|
|
||||||
accessTokenMasked: maskToken(accessToken),
|
|
||||||
hasRefreshToken: Boolean(refreshToken),
|
|
||||||
refreshTokenMasked: maskToken(refreshToken),
|
|
||||||
expiresAt: expiresDate.toISOString(),
|
|
||||||
isExpired,
|
|
||||||
msUntilExpiry,
|
|
||||||
type: refreshToken ? "oauth" : "token",
|
|
||||||
});
|
|
||||||
|
|
||||||
if (typeof refreshToken === "string" && refreshToken) {
|
if (typeof refreshToken === "string" && refreshToken) {
|
||||||
return {
|
return {
|
||||||
type: "oauth",
|
type: "oauth",
|
||||||
@ -272,10 +232,7 @@ function readClaudeCliKeychainCredentials(
|
|||||||
token: accessToken,
|
token: accessToken,
|
||||||
expires: expiresAt,
|
expires: expiresAt,
|
||||||
};
|
};
|
||||||
} catch (err) {
|
} catch {
|
||||||
log.debug("[CCSDK-AUTH] Keychain read failed", {
|
|
||||||
error: err instanceof Error ? err.message : String(err),
|
|
||||||
});
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -291,10 +248,6 @@ function readClaudeCliKeychainCredentials(
|
|||||||
function readClaudeCliLinuxSecretServiceCredentials(
|
function readClaudeCliLinuxSecretServiceCredentials(
|
||||||
execSyncImpl: ExecSyncFn = execSync,
|
execSyncImpl: ExecSyncFn = execSync,
|
||||||
): ClaudeCliCredential | null {
|
): ClaudeCliCredential | null {
|
||||||
log.debug("[CCSDK-AUTH] Attempting to read credentials from Linux Secret Service", {
|
|
||||||
service: CLAUDE_CLI_KEYCHAIN_SERVICE,
|
|
||||||
});
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// secret-tool lookup returns the secret value for matching attributes
|
// secret-tool lookup returns the secret value for matching attributes
|
||||||
const result = execSyncImpl(`secret-tool lookup service "${CLAUDE_CLI_KEYCHAIN_SERVICE}"`, {
|
const result = execSyncImpl(`secret-tool lookup service "${CLAUDE_CLI_KEYCHAIN_SERVICE}"`, {
|
||||||
@ -304,18 +257,12 @@ function readClaudeCliLinuxSecretServiceCredentials(
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!result || !result.trim()) {
|
if (!result || !result.trim()) {
|
||||||
log.debug("[CCSDK-AUTH] Linux Secret Service returned empty result");
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
log.debug("[CCSDK-AUTH] Linux Secret Service returned data", {
|
|
||||||
rawLength: result.length,
|
|
||||||
});
|
|
||||||
|
|
||||||
const data = JSON.parse(result.trim());
|
const data = JSON.parse(result.trim());
|
||||||
const claudeOauth = data?.claudeAiOauth;
|
const claudeOauth = data?.claudeAiOauth;
|
||||||
if (!claudeOauth || typeof claudeOauth !== "object") {
|
if (!claudeOauth || typeof claudeOauth !== "object") {
|
||||||
log.debug("[CCSDK-AUTH] Linux Secret Service data missing claudeAiOauth object");
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -324,30 +271,12 @@ function readClaudeCliLinuxSecretServiceCredentials(
|
|||||||
const expiresAt = claudeOauth.expiresAt;
|
const expiresAt = claudeOauth.expiresAt;
|
||||||
|
|
||||||
if (typeof accessToken !== "string" || !accessToken) {
|
if (typeof accessToken !== "string" || !accessToken) {
|
||||||
log.debug("[CCSDK-AUTH] Linux Secret Service accessToken invalid or missing");
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
if (typeof expiresAt !== "number" || expiresAt <= 0) {
|
if (typeof expiresAt !== "number" || expiresAt <= 0) {
|
||||||
log.debug("[CCSDK-AUTH] Linux Secret Service expiresAt invalid", { expiresAt });
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const expiresDate = new Date(expiresAt);
|
|
||||||
const isExpired = expiresAt < Date.now();
|
|
||||||
const msUntilExpiry = expiresAt - Date.now();
|
|
||||||
|
|
||||||
log.debug("[CCSDK-AUTH] Linux Secret Service credentials parsed successfully", {
|
|
||||||
source: "linux-secret-service",
|
|
||||||
hasAccessToken: Boolean(accessToken),
|
|
||||||
accessTokenMasked: maskToken(accessToken),
|
|
||||||
hasRefreshToken: Boolean(refreshToken),
|
|
||||||
refreshTokenMasked: maskToken(refreshToken),
|
|
||||||
expiresAt: expiresDate.toISOString(),
|
|
||||||
isExpired,
|
|
||||||
msUntilExpiry,
|
|
||||||
type: refreshToken ? "oauth" : "token",
|
|
||||||
});
|
|
||||||
|
|
||||||
if (typeof refreshToken === "string" && refreshToken) {
|
if (typeof refreshToken === "string" && refreshToken) {
|
||||||
return {
|
return {
|
||||||
type: "oauth",
|
type: "oauth",
|
||||||
@ -364,10 +293,7 @@ function readClaudeCliLinuxSecretServiceCredentials(
|
|||||||
token: accessToken,
|
token: accessToken,
|
||||||
expires: expiresAt,
|
expires: expiresAt,
|
||||||
};
|
};
|
||||||
} catch (err) {
|
} catch {
|
||||||
log.debug("[CCSDK-AUTH] Linux Secret Service read failed", {
|
|
||||||
error: err instanceof Error ? err.message : String(err),
|
|
||||||
});
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -381,10 +307,6 @@ function readClaudeCliLinuxSecretServiceCredentials(
|
|||||||
function readClaudeCliWindowsCredentialManagerCredentials(
|
function readClaudeCliWindowsCredentialManagerCredentials(
|
||||||
execSyncImpl: ExecSyncFn = execSync,
|
execSyncImpl: ExecSyncFn = execSync,
|
||||||
): ClaudeCliCredential | null {
|
): ClaudeCliCredential | null {
|
||||||
log.debug("[CCSDK-AUTH] Attempting to read credentials from Windows Credential Manager", {
|
|
||||||
target: CLAUDE_CLI_WINDOWS_TARGET,
|
|
||||||
});
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// PowerShell script to read from Windows Credential Manager
|
// PowerShell script to read from Windows Credential Manager
|
||||||
// Uses the .NET CredentialManager API via Add-Type
|
// Uses the .NET CredentialManager API via Add-Type
|
||||||
@ -442,18 +364,12 @@ function readClaudeCliWindowsCredentialManagerCredentials(
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (!result || !result.trim()) {
|
if (!result || !result.trim()) {
|
||||||
log.debug("[CCSDK-AUTH] Windows Credential Manager returned empty result");
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
log.debug("[CCSDK-AUTH] Windows Credential Manager returned data", {
|
|
||||||
rawLength: result.length,
|
|
||||||
});
|
|
||||||
|
|
||||||
const data = JSON.parse(result.trim());
|
const data = JSON.parse(result.trim());
|
||||||
const claudeOauth = data?.claudeAiOauth;
|
const claudeOauth = data?.claudeAiOauth;
|
||||||
if (!claudeOauth || typeof claudeOauth !== "object") {
|
if (!claudeOauth || typeof claudeOauth !== "object") {
|
||||||
log.debug("[CCSDK-AUTH] Windows Credential Manager data missing claudeAiOauth object");
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -462,30 +378,12 @@ function readClaudeCliWindowsCredentialManagerCredentials(
|
|||||||
const expiresAt = claudeOauth.expiresAt;
|
const expiresAt = claudeOauth.expiresAt;
|
||||||
|
|
||||||
if (typeof accessToken !== "string" || !accessToken) {
|
if (typeof accessToken !== "string" || !accessToken) {
|
||||||
log.debug("[CCSDK-AUTH] Windows Credential Manager accessToken invalid or missing");
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
if (typeof expiresAt !== "number" || expiresAt <= 0) {
|
if (typeof expiresAt !== "number" || expiresAt <= 0) {
|
||||||
log.debug("[CCSDK-AUTH] Windows Credential Manager expiresAt invalid", { expiresAt });
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const expiresDate = new Date(expiresAt);
|
|
||||||
const isExpired = expiresAt < Date.now();
|
|
||||||
const msUntilExpiry = expiresAt - Date.now();
|
|
||||||
|
|
||||||
log.debug("[CCSDK-AUTH] Windows Credential Manager credentials parsed successfully", {
|
|
||||||
source: "windows-credential-manager",
|
|
||||||
hasAccessToken: Boolean(accessToken),
|
|
||||||
accessTokenMasked: maskToken(accessToken),
|
|
||||||
hasRefreshToken: Boolean(refreshToken),
|
|
||||||
refreshTokenMasked: maskToken(refreshToken),
|
|
||||||
expiresAt: expiresDate.toISOString(),
|
|
||||||
isExpired,
|
|
||||||
msUntilExpiry,
|
|
||||||
type: refreshToken ? "oauth" : "token",
|
|
||||||
});
|
|
||||||
|
|
||||||
if (typeof refreshToken === "string" && refreshToken) {
|
if (typeof refreshToken === "string" && refreshToken) {
|
||||||
return {
|
return {
|
||||||
type: "oauth",
|
type: "oauth",
|
||||||
@ -502,10 +400,7 @@ function readClaudeCliWindowsCredentialManagerCredentials(
|
|||||||
token: accessToken,
|
token: accessToken,
|
||||||
expires: expiresAt,
|
expires: expiresAt,
|
||||||
};
|
};
|
||||||
} catch (err) {
|
} catch {
|
||||||
log.debug("[CCSDK-AUTH] Windows Credential Manager read failed", {
|
|
||||||
error: err instanceof Error ? err.message : String(err),
|
|
||||||
});
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -518,70 +413,38 @@ export function readClaudeCliCredentials(options?: {
|
|||||||
}): ClaudeCliCredential | null {
|
}): ClaudeCliCredential | null {
|
||||||
const platform = options?.platform ?? process.platform;
|
const platform = options?.platform ?? process.platform;
|
||||||
|
|
||||||
log.debug("[CCSDK-AUTH] readClaudeCliCredentials called", {
|
|
||||||
platform,
|
|
||||||
allowKeychainPrompt: options?.allowKeychainPrompt,
|
|
||||||
homeDir: options?.homeDir ?? "(default)",
|
|
||||||
});
|
|
||||||
|
|
||||||
// Try platform-specific secure credential storage first
|
// Try platform-specific secure credential storage first
|
||||||
if (options?.allowKeychainPrompt !== false) {
|
if (options?.allowKeychainPrompt !== false) {
|
||||||
let secureStoreCreds: ClaudeCliCredential | null = null;
|
let secureStoreCreds: ClaudeCliCredential | null = null;
|
||||||
let source = "";
|
let source = "";
|
||||||
|
|
||||||
if (platform === "darwin") {
|
if (platform === "darwin") {
|
||||||
log.debug("[CCSDK-AUTH] Attempting macOS Keychain read");
|
|
||||||
secureStoreCreds = readClaudeCliKeychainCredentials(options?.execSync);
|
secureStoreCreds = readClaudeCliKeychainCredentials(options?.execSync);
|
||||||
source = "macos-keychain";
|
source = "macos-keychain";
|
||||||
} else if (platform === "linux") {
|
} else if (platform === "linux") {
|
||||||
log.debug("[CCSDK-AUTH] Attempting Linux Secret Service read");
|
|
||||||
secureStoreCreds = readClaudeCliLinuxSecretServiceCredentials(options?.execSync);
|
secureStoreCreds = readClaudeCliLinuxSecretServiceCredentials(options?.execSync);
|
||||||
source = "linux-secret-service";
|
source = "linux-secret-service";
|
||||||
} else if (platform === "win32") {
|
} else if (platform === "win32") {
|
||||||
log.debug("[CCSDK-AUTH] Attempting Windows Credential Manager read");
|
|
||||||
secureStoreCreds = readClaudeCliWindowsCredentialManagerCredentials(options?.execSync);
|
secureStoreCreds = readClaudeCliWindowsCredentialManagerCredentials(options?.execSync);
|
||||||
source = "windows-credential-manager";
|
source = "windows-credential-manager";
|
||||||
}
|
}
|
||||||
|
|
||||||
if (secureStoreCreds) {
|
if (secureStoreCreds) {
|
||||||
log.info("[CCSDK-AUTH] Successfully read credentials from secure storage", {
|
log.debug("read claude cli credentials", { source, type: secureStoreCreds.type });
|
||||||
type: secureStoreCreds.type,
|
|
||||||
source,
|
|
||||||
accessTokenMasked:
|
|
||||||
secureStoreCreds.type === "oauth"
|
|
||||||
? maskToken(secureStoreCreds.access)
|
|
||||||
: maskToken(secureStoreCreds.token),
|
|
||||||
});
|
|
||||||
return secureStoreCreds;
|
return secureStoreCreds;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (source) {
|
|
||||||
log.debug("[CCSDK-AUTH] Secure storage read returned null, falling back to file", { source });
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fall back to file-based credentials
|
// Fall back to file-based credentials
|
||||||
const credPath = resolveClaudeCliCredentialsPath(options?.homeDir);
|
const credPath = resolveClaudeCliCredentialsPath(options?.homeDir);
|
||||||
log.debug("[CCSDK-AUTH] Attempting to read credentials from file", {
|
|
||||||
path: credPath,
|
|
||||||
});
|
|
||||||
|
|
||||||
const raw = loadJsonFile(credPath);
|
const raw = loadJsonFile(credPath);
|
||||||
if (!raw || typeof raw !== "object") {
|
if (!raw || typeof raw !== "object") {
|
||||||
log.debug("[CCSDK-AUTH] Credentials file not found or invalid", {
|
|
||||||
path: credPath,
|
|
||||||
rawType: typeof raw,
|
|
||||||
});
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = raw as Record<string, unknown>;
|
const data = raw as Record<string, unknown>;
|
||||||
const claudeOauth = data.claudeAiOauth as Record<string, unknown> | undefined;
|
const claudeOauth = data.claudeAiOauth as Record<string, unknown> | undefined;
|
||||||
if (!claudeOauth || typeof claudeOauth !== "object") {
|
if (!claudeOauth || typeof claudeOauth !== "object") {
|
||||||
log.debug("[CCSDK-AUTH] Credentials file missing claudeAiOauth", {
|
|
||||||
path: credPath,
|
|
||||||
topLevelKeys: Object.keys(data),
|
|
||||||
});
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -590,41 +453,21 @@ export function readClaudeCliCredentials(options?: {
|
|||||||
const expiresAt = claudeOauth.expiresAt;
|
const expiresAt = claudeOauth.expiresAt;
|
||||||
|
|
||||||
if (typeof accessToken !== "string" || !accessToken) {
|
if (typeof accessToken !== "string" || !accessToken) {
|
||||||
log.debug("[CCSDK-AUTH] Credentials file accessToken invalid", {
|
|
||||||
path: credPath,
|
|
||||||
});
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
if (typeof expiresAt !== "number" || expiresAt <= 0) {
|
if (typeof expiresAt !== "number" || expiresAt <= 0) {
|
||||||
log.debug("[CCSDK-AUTH] Credentials file expiresAt invalid", {
|
|
||||||
path: credPath,
|
|
||||||
expiresAt,
|
|
||||||
});
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const expiresDate = new Date(expiresAt);
|
const credType = typeof refreshToken === "string" && refreshToken ? "oauth" : "token";
|
||||||
const isExpired = expiresAt < Date.now();
|
log.debug("read claude cli credentials", { source: "file", type: credType });
|
||||||
const msUntilExpiry = expiresAt - Date.now();
|
|
||||||
|
|
||||||
log.info("[CCSDK-AUTH] Successfully read credentials from file", {
|
if (credType === "oauth") {
|
||||||
source: "file",
|
|
||||||
path: credPath,
|
|
||||||
accessTokenMasked: maskToken(accessToken as string),
|
|
||||||
hasRefreshToken: Boolean(refreshToken),
|
|
||||||
refreshTokenMasked: maskToken(refreshToken as string | undefined),
|
|
||||||
expiresAt: expiresDate.toISOString(),
|
|
||||||
isExpired,
|
|
||||||
msUntilExpiry,
|
|
||||||
type: refreshToken ? "oauth" : "token",
|
|
||||||
});
|
|
||||||
|
|
||||||
if (typeof refreshToken === "string" && refreshToken) {
|
|
||||||
return {
|
return {
|
||||||
type: "oauth",
|
type: "oauth",
|
||||||
provider: "anthropic",
|
provider: "anthropic",
|
||||||
access: accessToken,
|
access: accessToken,
|
||||||
refresh: refreshToken,
|
refresh: refreshToken as string,
|
||||||
expires: expiresAt,
|
expires: expiresAt,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@ -728,7 +571,6 @@ export function writeClaudeCliLinuxSecretServiceCredentials(
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (!existingResult || !existingResult.trim()) {
|
if (!existingResult || !existingResult.trim()) {
|
||||||
log.debug("[CCSDK-AUTH] No existing Linux Secret Service entry to update");
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -826,7 +668,6 @@ export function writeClaudeCliWindowsCredentialManagerCredentials(
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (!existingResult || !existingResult.trim()) {
|
if (!existingResult || !existingResult.trim()) {
|
||||||
log.debug("[CCSDK-AUTH] No existing Windows Credential Manager entry to update");
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -55,8 +55,6 @@ export async function createAgentRuntime(
|
|||||||
): Promise<AgentRuntime> {
|
): Promise<AgentRuntime> {
|
||||||
const runtimeKind = forceKind ?? resolveAgentRuntimeKind(config, agentId);
|
const runtimeKind = forceKind ?? resolveAgentRuntimeKind(config, agentId);
|
||||||
|
|
||||||
log.debug("Creating agent runtime", { agentId, runtime: runtimeKind, forced: !!forceKind });
|
|
||||||
|
|
||||||
if (runtimeKind === "ccsdk") {
|
if (runtimeKind === "ccsdk") {
|
||||||
// Dynamically import to avoid loading SDK when not needed
|
// Dynamically import to avoid loading SDK when not needed
|
||||||
const { createCcSdkAgentRuntime, isSdkAvailable } = await import("./claude-agent-sdk/index.js");
|
const { createCcSdkAgentRuntime, isSdkAvailable } = await import("./claude-agent-sdk/index.js");
|
||||||
|
|||||||
@ -6,11 +6,8 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import type { AgentRuntime, AgentRuntimeRunParams, AgentRuntimeResult } from "./agent-runtime.js";
|
import type { AgentRuntime, AgentRuntimeRunParams, AgentRuntimeResult } from "./agent-runtime.js";
|
||||||
import { createSubsystemLogger } from "../logging/subsystem.js";
|
|
||||||
import { runEmbeddedPiAgent } from "./pi-embedded.js";
|
import { runEmbeddedPiAgent } from "./pi-embedded.js";
|
||||||
|
|
||||||
const log = createSubsystemLogger("agents/pi-runtime");
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create a Pi Agent runtime instance.
|
* Create a Pi Agent runtime instance.
|
||||||
*
|
*
|
||||||
@ -23,13 +20,6 @@ export function createPiAgentRuntime(): AgentRuntime {
|
|||||||
displayName: "Pi Agent",
|
displayName: "Pi Agent",
|
||||||
|
|
||||||
async run(params: AgentRuntimeRunParams): Promise<AgentRuntimeResult> {
|
async run(params: AgentRuntimeRunParams): Promise<AgentRuntimeResult> {
|
||||||
log.info("Starting Pi Agent session", {
|
|
||||||
sessionId: params.sessionId,
|
|
||||||
runId: params.runId,
|
|
||||||
provider: params.provider ?? "default",
|
|
||||||
model: params.model ?? "default",
|
|
||||||
});
|
|
||||||
|
|
||||||
// Extract Pi-specific options from the options bag
|
// Extract Pi-specific options from the options bag
|
||||||
const piOpts = params.piOptions ?? {};
|
const piOpts = params.piOptions ?? {};
|
||||||
|
|
||||||
|
|||||||
@ -3,17 +3,21 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|||||||
import type { MoltbotConfig } from "../config/config.js";
|
import type { MoltbotConfig } from "../config/config.js";
|
||||||
import type { AgentRuntime, AgentRuntimeResult } from "./agent-runtime.js";
|
import type { AgentRuntime, AgentRuntimeResult } from "./agent-runtime.js";
|
||||||
|
|
||||||
|
// Mock run functions - defined separately to avoid unbound-method lint errors
|
||||||
|
const mockPiRun = vi.fn();
|
||||||
|
const mockCcsdkRun = vi.fn();
|
||||||
|
|
||||||
// Mock dependencies
|
// Mock dependencies
|
||||||
const mockPiRuntime: AgentRuntime = {
|
const mockPiRuntime: AgentRuntime = {
|
||||||
kind: "pi",
|
kind: "pi",
|
||||||
displayName: "Pi Agent",
|
displayName: "Pi Agent",
|
||||||
run: vi.fn(),
|
run: mockPiRun,
|
||||||
};
|
};
|
||||||
|
|
||||||
const mockCcsdkRuntime: AgentRuntime = {
|
const mockCcsdkRuntime: AgentRuntime = {
|
||||||
kind: "ccsdk",
|
kind: "ccsdk",
|
||||||
displayName: "Claude Code SDK",
|
displayName: "Claude Code SDK",
|
||||||
run: vi.fn(),
|
run: mockCcsdkRun,
|
||||||
};
|
};
|
||||||
|
|
||||||
vi.mock("./main-agent-runtime-factory.js", () => ({
|
vi.mock("./main-agent-runtime-factory.js", () => ({
|
||||||
@ -114,8 +118,8 @@ describe("unified-agent-runner", () => {
|
|||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
vi.mocked(createAgentRuntime).mockResolvedValue(mockPiRuntime);
|
vi.mocked(createAgentRuntime).mockResolvedValue(mockPiRuntime);
|
||||||
vi.mocked(mockPiRuntime.run).mockResolvedValue(successResult);
|
mockPiRun.mockResolvedValue(successResult);
|
||||||
vi.mocked(mockCcsdkRuntime.run).mockResolvedValue(successResult);
|
mockCcsdkRun.mockResolvedValue(successResult);
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
@ -130,7 +134,7 @@ describe("unified-agent-runner", () => {
|
|||||||
const result = await runAgentWithUnifiedFailover(baseParams);
|
const result = await runAgentWithUnifiedFailover(baseParams);
|
||||||
|
|
||||||
expect(result.runtime).toBe("pi");
|
expect(result.runtime).toBe("pi");
|
||||||
expect(mockPiRuntime.run).toHaveBeenCalled();
|
expect(mockPiRun).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("uses ccsdk as primary when configured", async () => {
|
it("uses ccsdk as primary when configured", async () => {
|
||||||
@ -143,7 +147,7 @@ describe("unified-agent-runner", () => {
|
|||||||
const result = await runAgentWithUnifiedFailover(baseParams);
|
const result = await runAgentWithUnifiedFailover(baseParams);
|
||||||
|
|
||||||
expect(result.runtime).toBe("ccsdk");
|
expect(result.runtime).toBe("ccsdk");
|
||||||
expect(mockCcsdkRuntime.run).toHaveBeenCalled();
|
expect(mockCcsdkRun).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -329,7 +333,7 @@ describe("unified-agent-runner", () => {
|
|||||||
const abortError = new Error("Aborted");
|
const abortError = new Error("Aborted");
|
||||||
abortError.name = "AbortError";
|
abortError.name = "AbortError";
|
||||||
|
|
||||||
vi.mocked(mockPiRuntime.run).mockRejectedValue(abortError);
|
mockPiRun.mockRejectedValue(abortError);
|
||||||
|
|
||||||
await expect(runAgentWithUnifiedFailover(baseParams)).rejects.toThrow("Aborted");
|
await expect(runAgentWithUnifiedFailover(baseParams)).rejects.toThrow("Aborted");
|
||||||
});
|
});
|
||||||
|
|||||||
@ -38,7 +38,7 @@ import {
|
|||||||
} from "./auth-profiles.js";
|
} from "./auth-profiles.js";
|
||||||
import { isReasoningTagProvider } from "../utils/provider-utils.js";
|
import { isReasoningTagProvider } from "../utils/provider-utils.js";
|
||||||
|
|
||||||
const log = createSubsystemLogger("agents/unified-runner");
|
const log = createSubsystemLogger("agent-runner");
|
||||||
|
|
||||||
/** Model candidate for fallback. */
|
/** Model candidate for fallback. */
|
||||||
type ModelCandidate = {
|
type ModelCandidate = {
|
||||||
@ -233,7 +233,7 @@ export async function runAgentWithUnifiedFailover(
|
|||||||
let attemptIndex = 0;
|
let attemptIndex = 0;
|
||||||
const totalAttempts = runtimeChain.length * modelCandidates.length;
|
const totalAttempts = runtimeChain.length * modelCandidates.length;
|
||||||
|
|
||||||
log.info("Starting unified agent run", {
|
log.info("Starting Agent run", {
|
||||||
sessionId: params.sessionId,
|
sessionId: params.sessionId,
|
||||||
runId: params.runId,
|
runId: params.runId,
|
||||||
agentId,
|
agentId,
|
||||||
@ -267,8 +267,6 @@ export async function runAgentWithUnifiedFailover(
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
log.debug("Trying runtime", { runtime: runtimeKind, displayName: runtime.displayName });
|
|
||||||
|
|
||||||
// Model-inner loop
|
// Model-inner loop
|
||||||
for (const candidate of modelCandidates) {
|
for (const candidate of modelCandidates) {
|
||||||
attemptIndex += 1;
|
attemptIndex += 1;
|
||||||
@ -295,14 +293,6 @@ export async function runAgentWithUnifiedFailover(
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
log.debug("Attempting model", {
|
|
||||||
runtime: runtimeKind,
|
|
||||||
provider: candidate.provider,
|
|
||||||
model: candidate.model,
|
|
||||||
attempt: attemptIndex,
|
|
||||||
total: totalAttempts,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Notify model selection before attempt (wrapped in try/catch to be resilient)
|
// Notify model selection before attempt (wrapped in try/catch to be resilient)
|
||||||
try {
|
try {
|
||||||
await params.onModelSelected?.({
|
await params.onModelSelected?.({
|
||||||
@ -346,7 +336,7 @@ export async function runAgentWithUnifiedFailover(
|
|||||||
: { enforceFinalTag: effectiveEnforceFinalTag },
|
: { enforceFinalTag: effectiveEnforceFinalTag },
|
||||||
});
|
});
|
||||||
|
|
||||||
log.info("Unified agent run completed", {
|
log.debug("Agent run completed", {
|
||||||
sessionId: params.sessionId,
|
sessionId: params.sessionId,
|
||||||
runId: params.runId,
|
runId: params.runId,
|
||||||
runtime: runtimeKind,
|
runtime: runtimeKind,
|
||||||
|
|||||||
@ -5,10 +5,7 @@ import { runCliAgent } from "../../agents/cli-runner.js";
|
|||||||
import { getCliSessionId } from "../../agents/cli-session.js";
|
import { getCliSessionId } from "../../agents/cli-session.js";
|
||||||
import { isCliProvider } from "../../agents/model-selection.js";
|
import { isCliProvider } from "../../agents/model-selection.js";
|
||||||
import { runEmbeddedPiAgent } from "../../agents/pi-embedded.js";
|
import { runEmbeddedPiAgent } from "../../agents/pi-embedded.js";
|
||||||
import {
|
import { runAgentWithUnifiedFailover } from "../../agents/unified-agent-runner.js";
|
||||||
runAgentWithUnifiedFailover,
|
|
||||||
type UnifiedAgentRunResult,
|
|
||||||
} from "../../agents/unified-agent-runner.js";
|
|
||||||
import {
|
import {
|
||||||
isCompactionFailureError,
|
isCompactionFailureError,
|
||||||
isContextOverflowError,
|
isContextOverflowError,
|
||||||
@ -240,11 +237,7 @@ export async function runAgentTurnWithFallback(params: {
|
|||||||
: ("plain" as const);
|
: ("plain" as const);
|
||||||
})();
|
})();
|
||||||
|
|
||||||
let unifiedResult: UnifiedAgentRunResult;
|
const unifiedResult = await runAgentWithUnifiedFailover({
|
||||||
logVerbose(
|
|
||||||
`[CCSDK-EXEC] Calling runAgentWithUnifiedFailover. hasOnBlockReply=${Boolean(params.opts?.onBlockReply)}, blockStreamingEnabled=${params.blockStreamingEnabled}, hasBlockReplyPipeline=${Boolean(params.blockReplyPipeline)}, hasOnReasoningStream=${Boolean(params.opts?.onReasoningStream)}, shouldStartOnReasoning=${params.typingSignals.shouldStartOnReasoning}`,
|
|
||||||
);
|
|
||||||
unifiedResult = await runAgentWithUnifiedFailover({
|
|
||||||
// Core params
|
// Core params
|
||||||
sessionId: params.followupRun.run.sessionId,
|
sessionId: params.followupRun.run.sessionId,
|
||||||
sessionKey: params.sessionKey,
|
sessionKey: params.sessionKey,
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user