diff --git a/docs/concepts/agent.md b/docs/concepts/agent.md index 15772e19f..4f0bd3bd4 100644 --- a/docs/concepts/agent.md +++ b/docs/concepts/agent.md @@ -74,6 +74,27 @@ Session transcripts are stored as JSONL at: The session ID is stable and chosen by Moltbot. Legacy Pi/Tau session folders are **not** read. +## Claude Agent SDK Runtime + +When using the Claude Agent SDK (`ccsdk`) runtime, Moltbot stores SDK-specific +configuration and sessions in a per-agent `.claude` directory: + +``` +~/.clawdbot/agents//.claude/ +├── sessions/ # SDK native session files +├── settings.json # Per-agent SDK settings (optional) +├── CLAUDE.md # Per-agent memory/instructions (optional) +├── skills/ # Per-agent skills (optional) +└── commands/ # Per-agent slash commands (optional) +``` + +This separation ensures Moltbot agent sessions are stored separately from +standard Claude Code CLI sessions (`~/.claude/`), and allows per-agent +customization of Claude Code features like settings, skills, and commands. + +The SDK config directory is set via the `CLAUDE_CONFIG_DIR` environment variable +when launching the SDK process. + ## Steering while streaming When queue mode is `steer`, inbound messages are injected into the current run. diff --git a/docs/concepts/session.md b/docs/concepts/session.md index b15b1a1ea..bf7467a52 100644 --- a/docs/concepts/session.md +++ b/docs/concepts/session.md @@ -24,6 +24,7 @@ All session state is **owned by the gateway** (the “master” Moltbot). UI cli - On the **gateway host**: - Store file: `~/.clawdbot/agents//sessions/sessions.json` (per agent). - Transcripts: `~/.clawdbot/agents//sessions/.jsonl` (Telegram topic sessions use `.../-topic-.jsonl`). +- Claude Agent SDK sessions (when using `ccsdk` runtime): `~/.clawdbot/agents//.claude/sessions/` (separate from CLI sessions at `~/.claude/`). - The store is a map `sessionKey -> { sessionId, updatedAt, ... }`. Deleting entries is safe; they are recreated on demand. - Group entries may include `displayName`, `channel`, `subject`, `room`, and `space` to label sessions in UIs. - Session entries include `origin` metadata (label + routing hints) so UIs can explain where a session came from. diff --git a/src/agents/agent-runtime.ts b/src/agents/agent-runtime.ts index 3cc412706..d960bc9ab 100644 --- a/src/agents/agent-runtime.ts +++ b/src/agents/agent-runtime.ts @@ -11,15 +11,25 @@ import type { AgentStreamParams } from "../commands/agent/types.js"; import type { ReasoningLevel, ThinkLevel, VerboseLevel } from "../auto-reply/thinking.js"; import type { ExecElevatedDefaults, ExecToolDefaults } from "./bash-tools.js"; import type { BlockReplyChunking, ToolResultFormat } from "./pi-embedded-subscribe.js"; -import type { ClientToolDefinition } from "./pi-embedded-runner/run/params.js"; import type { SkillSnapshot } from "./skills.js"; -import type { EmbeddedPiRunResult } from "./pi-embedded-runner/types.js"; +import type { + AgentRunResult, + AgentRuntimeKind, + AgentSandboxInfo, + ClientToolDefinition, +} from "./runtime-result-types.js"; -/** Agent runtime backend discriminant. */ -export type AgentRuntimeKind = "pi" | "ccsdk"; +// Re-export shared result types for convenience +export type { + AgentRunResult, + AgentRunMeta, + AgentRunResultMeta, + AgentRuntimeKind, + ClientToolDefinition, +} from "./runtime-result-types.js"; /** Result type shared by all agent runtimes. */ -export type AgentRuntimeResult = EmbeddedPiRunResult; +export type AgentRuntimeResult = AgentRunResult; /** Streaming and event callbacks for agent runs. */ export type AgentRuntimeCallbacks = { @@ -59,12 +69,6 @@ export type AgentRuntimeCallbacks = { export type PiRuntimeOptions = { /** Whether to enforce final XML tag in responses. */ enforceFinalTag?: boolean; - /** Execution tool overrides (host, security, ask, node). */ - execOverrides?: Pick; - /** Bash elevated execution defaults. */ - bashElevated?: ExecElevatedDefaults; - /** Client-provided tools (OpenResponses hosted tools). */ - clientTools?: ClientToolDefinition[]; }; /** @@ -79,8 +83,6 @@ export type CcSdkRuntimeOptions = { sdkOptions?: Record; /** 3-tier model configuration for Claude Code SDK. */ modelTiers?: CcSdkModelTiers; - /** Existing Claude Code session ID for resumption. */ - claudeSessionId?: string; /** Fork the session instead of continuing it. */ forkSession?: boolean; }; @@ -174,6 +176,16 @@ export type AgentRuntimeRunParams = { shouldEmitToolOutput?: () => boolean; /** Owner phone numbers for access control. */ ownerNumbers?: string[]; + /** Provider session ID for session resumption (used by CLI providers and CCSDK). */ + providerSessionId?: string; + /** Sandbox/workspace configuration for agent runs. */ + sandboxInfo?: AgentSandboxInfo; + /** Bash/exec elevated execution defaults. Controls where exec commands run (sandbox vs host). */ + bashElevated?: ExecElevatedDefaults; + /** Exec tool overrides (host, security, ask, node). Shared by both runtimes. */ + execOverrides?: Pick; + /** Client-provided tools (OpenResponses hosted tools). Applicable to both runtimes. */ + clientTools?: ClientToolDefinition[]; // ─── Runtime-specific option bags ─────────────────────────────────────────── /** Pi runtime-specific options. */ diff --git a/src/agents/claude-agent-sdk/client-tool-bridge.ts b/src/agents/claude-agent-sdk/client-tool-bridge.ts new file mode 100644 index 000000000..ed83e6227 --- /dev/null +++ b/src/agents/claude-agent-sdk/client-tool-bridge.ts @@ -0,0 +1,76 @@ +/** + * Bridge for converting client-provided tools (OpenResponses hosted tools) + * to the AnyAgentTool format used by the Claude Agent SDK runtime. + * + * These tools return "pending" results when called - execution is delegated + * back to the client that provided the tool definitions. + */ + +import type { ClientToolDefinition } from "../runtime-result-types.js"; +import type { AnyAgentTool } from "../tools/common.js"; +import { jsonResult } from "../tools/common.js"; +import { createSubsystemLogger } from "../../logging/subsystem.js"; + +const log = createSubsystemLogger("agents/claude-agent-sdk/client-tool-bridge"); + +/** + * Callback type for when a client tool is invoked. + * Allows callers to track which tools were called and with what parameters. + */ +export type OnClientToolCallCallback = (toolName: string, params: Record) => void; + +/** + * Convert ClientToolDefinition array to AnyAgentTool array for CCSDK. + * + * These tools return "pending" results indicating that execution should be + * delegated to the client. This matches the behavior in the Pi runtime's + * toClientToolDefinitions() function. + * + * @param clientTools - Array of client-provided tool definitions + * @param onClientToolCall - Optional callback invoked when a client tool is called + * @returns Array of AnyAgentTool compatible with the CCSDK tool bridge + */ +export function convertClientToolsForSdk( + clientTools: ClientToolDefinition[], + onClientToolCall?: OnClientToolCallCallback, +): AnyAgentTool[] { + return clientTools.map((tool) => { + const func = tool.function; + const toolName = func.name; + + log.debug(`Converting client tool for CCSDK: ${toolName}`); + + return { + name: toolName, + label: toolName, + description: func.description ?? "", + // Pass through the JSON Schema parameters directly + // The tool-bridge will serialize this for MCP + parameters: func.parameters ?? { type: "object", properties: {} }, + + execute: async ( + _toolCallId: string, + params: Record, + _signal?: AbortSignal, + _onUpdate?: unknown, + ) => { + log.debug(`Client tool "${toolName}" called with params`, { + paramKeys: Object.keys(params), + }); + + // Notify callback that a client tool was invoked + if (onClientToolCall) { + onClientToolCall(toolName, params); + } + + // Return a pending result - the client will handle actual execution + // This matches the behavior of toClientToolDefinitions in pi-tool-definition-adapter.ts + return jsonResult({ + status: "pending", + tool: toolName, + message: "Tool execution delegated to client", + }); + }, + } as AnyAgentTool; + }); +} diff --git a/src/agents/claude-agent-sdk/error-handling.test.ts b/src/agents/claude-agent-sdk/error-handling.test.ts new file mode 100644 index 000000000..a2209019e --- /dev/null +++ b/src/agents/claude-agent-sdk/error-handling.test.ts @@ -0,0 +1,493 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; + +// Mock dependencies +vi.mock("../../logging/subsystem.js", () => ({ + createSubsystemLogger: vi.fn(() => ({ + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + })), +})); + +import { + classifyError, + isRetryableError, + describeErrorKind, + withRetry, + CcsdkError, + isCcsdkError, + toCcsdkError, + DEFAULT_RETRY_OPTIONS, +} from "./error-handling.js"; +import type { RetryOptions } from "./error-handling.js"; + +describe("error-handling", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + vi.resetAllMocks(); + }); + + describe("classifyError", () => { + describe("rate limit errors", () => { + it("classifies HTTP 429 status as rate_limit", () => { + expect(classifyError({ status: 429 })).toBe("rate_limit"); + }); + + it("classifies rate limit message patterns", () => { + expect(classifyError({ message: "Rate limit exceeded" })).toBe("rate_limit"); + expect(classifyError({ message: "Too many requests" })).toBe("rate_limit"); + expect(classifyError({ message: "Resource has been exhausted" })).toBe("rate_limit"); + expect(classifyError({ message: "Server overloaded" })).toBe("rate_limit"); + expect(classifyError({ message: "Request throttled" })).toBe("rate_limit"); + }); + }); + + describe("context overflow errors", () => { + it("classifies context window errors", () => { + expect(classifyError({ message: "Context window exceeded" })).toBe("context_overflow"); + expect(classifyError({ message: "context length too long" })).toBe("context_overflow"); + expect(classifyError({ message: "Context overflow error" })).toBe("context_overflow"); + }); + + it("classifies token limit errors", () => { + expect(classifyError({ message: "Exceeds maximum token limit" })).toBe("context_overflow"); + expect(classifyError({ message: "Maximum context size reached" })).toBe("context_overflow"); + expect(classifyError({ message: "Prompt too long for model" })).toBe("context_overflow"); + }); + }); + + describe("auth failure errors", () => { + it("classifies HTTP 401 and 403 as auth_failure", () => { + expect(classifyError({ status: 401 })).toBe("auth_failure"); + expect(classifyError({ status: 403 })).toBe("auth_failure"); + }); + + it("classifies authentication message patterns", () => { + expect(classifyError({ message: "Authentication failed" })).toBe("auth_failure"); + expect(classifyError({ message: "Authorization error" })).toBe("auth_failure"); + expect(classifyError({ message: "Invalid API key" })).toBe("auth_failure"); + expect(classifyError({ message: "Invalid token provided" })).toBe("auth_failure"); + expect(classifyError({ message: "Permission denied" })).toBe("auth_failure"); + expect(classifyError({ message: "Unauthorized access" })).toBe("auth_failure"); + expect(classifyError({ message: "Forbidden" })).toBe("auth_failure"); + }); + }); + + describe("network errors", () => { + it("classifies network error codes", () => { + expect(classifyError({ code: "ECONNREFUSED" })).toBe("network"); + expect(classifyError({ code: "ECONNRESET" })).toBe("network"); + expect(classifyError({ code: "ENOTFOUND" })).toBe("network"); + }); + + it("classifies network message patterns", () => { + expect(classifyError({ message: "Network error" })).toBe("network"); + expect(classifyError({ message: "Connection refused" })).toBe("network"); + expect(classifyError({ message: "connect failed" })).toBe("network"); + expect(classifyError({ message: "Socket error" })).toBe("network"); + expect(classifyError({ message: "Fetch failed" })).toBe("network"); + }); + }); + + describe("timeout errors", () => { + it("classifies HTTP 408 as timeout", () => { + expect(classifyError({ status: 408 })).toBe("timeout"); + }); + + it("classifies timeout error codes", () => { + expect(classifyError({ code: "ETIMEDOUT" })).toBe("timeout"); + expect(classifyError({ code: "ESOCKETTIMEDOUT" })).toBe("timeout"); + }); + + it("classifies TimeoutError by name", () => { + const error = new Error("Request timed out"); + error.name = "TimeoutError"; + expect(classifyError(error)).toBe("timeout"); + }); + + it("classifies AbortError as timeout", () => { + const error = new Error("Request aborted"); + error.name = "AbortError"; + expect(classifyError(error)).toBe("timeout"); + }); + + it("classifies timeout message patterns", () => { + expect(classifyError({ message: "Request timed out" })).toBe("timeout"); + expect(classifyError({ message: "Timeout error" })).toBe("timeout"); + expect(classifyError({ message: "Deadline exceeded" })).toBe("timeout"); + expect(classifyError({ message: "Request was aborted" })).toBe("timeout"); + }); + }); + + describe("tool errors", () => { + it("classifies tool execution errors", () => { + expect(classifyError({ message: "Tool error occurred" })).toBe("tool_error"); + expect(classifyError({ message: "Tool execution failed" })).toBe("tool_error"); + expect(classifyError({ message: "MCP error" })).toBe("tool_error"); + expect(classifyError({ message: "MCP tool failed" })).toBe("tool_error"); + }); + }); + + describe("unknown errors", () => { + it("returns unknown for null/undefined", () => { + expect(classifyError(null)).toBe("unknown"); + expect(classifyError(undefined)).toBe("unknown"); + }); + + it("returns unknown for unrecognized errors", () => { + expect(classifyError({ message: "Something unexpected happened" })).toBe("unknown"); + expect(classifyError(new Error("Generic error"))).toBe("unknown"); + }); + + it("returns unknown for empty error objects", () => { + expect(classifyError({})).toBe("unknown"); + }); + }); + + describe("edge cases", () => { + it("handles errors with both status and message", () => { + // Status takes precedence + expect(classifyError({ status: 429, message: "Connection error" })).toBe("rate_limit"); + }); + + it("handles string errors", () => { + expect(classifyError("Rate limit exceeded")).toBe("rate_limit"); + }); + + it("handles numeric status as string", () => { + expect(classifyError({ status: "429" })).toBe("rate_limit"); + }); + + it("handles errors with code field", () => { + expect(classifyError({ code: "ETIMEDOUT", message: "unknown" })).toBe("timeout"); + }); + }); + }); + + describe("isRetryableError", () => { + it("returns true for rate_limit errors by default", () => { + expect(isRetryableError({ status: 429 })).toBe(true); + }); + + it("returns true for network errors by default", () => { + expect(isRetryableError({ code: "ECONNRESET" })).toBe(true); + }); + + it("returns true for timeout errors by default", () => { + expect(isRetryableError({ status: 408 })).toBe(true); + }); + + it("returns false for auth errors by default", () => { + expect(isRetryableError({ status: 401 })).toBe(false); + }); + + it("returns false for context overflow by default", () => { + expect(isRetryableError({ message: "Context window exceeded" })).toBe(false); + }); + + it("respects custom retryable kinds list", () => { + expect(isRetryableError({ status: 401 }, ["auth_failure"])).toBe(true); + expect(isRetryableError({ status: 429 }, ["auth_failure"])).toBe(false); + }); + }); + + describe("describeErrorKind", () => { + it("returns human-readable descriptions", () => { + expect(describeErrorKind("rate_limit")).toBe("Rate limit exceeded"); + expect(describeErrorKind("context_overflow")).toBe("Context window overflow"); + expect(describeErrorKind("auth_failure")).toBe("Authentication failed"); + expect(describeErrorKind("network")).toBe("Network error"); + expect(describeErrorKind("timeout")).toBe("Request timed out"); + expect(describeErrorKind("tool_error")).toBe("Tool execution error"); + expect(describeErrorKind("unknown")).toBe("Unknown error"); + }); + }); + + describe("withRetry", () => { + it("returns result on first successful attempt", async () => { + const fn = vi.fn().mockResolvedValue("success"); + + const options: RetryOptions = { + maxRetries: 3, + backoffMs: 100, + retryOn: ["rate_limit"], + }; + + const result = await withRetry(fn, options); + + expect(result).toBe("success"); + expect(fn).toHaveBeenCalledTimes(1); + }); + + it("retries on retryable errors", async () => { + const rateLimitError = { status: 429, message: "Rate limited" }; + const fn = vi + .fn() + .mockRejectedValueOnce(rateLimitError) + .mockRejectedValueOnce(rateLimitError) + .mockResolvedValue("success"); + + const options: RetryOptions = { + maxRetries: 3, + backoffMs: 100, + retryOn: ["rate_limit"], + }; + + const promise = withRetry(fn, options); + + // Advance timers for retries + await vi.advanceTimersByTimeAsync(100); + await vi.advanceTimersByTimeAsync(200); + + const result = await promise; + + expect(result).toBe("success"); + expect(fn).toHaveBeenCalledTimes(3); + }); + + it("throws immediately on non-retryable errors", async () => { + const authError = { status: 401, message: "Unauthorized" }; + const fn = vi.fn().mockRejectedValue(authError); + + const options: RetryOptions = { + maxRetries: 3, + backoffMs: 100, + retryOn: ["rate_limit"], + }; + + await expect(withRetry(fn, options)).rejects.toMatchObject({ status: 401 }); + expect(fn).toHaveBeenCalledTimes(1); + }); + + it("throws after exhausting retries", async () => { + const rateLimitError = { status: 429, message: "Rate limited" }; + const fn = vi.fn().mockRejectedValue(rateLimitError); + + const options: RetryOptions = { + maxRetries: 2, + backoffMs: 100, + retryOn: ["rate_limit"], + }; + + const promise = withRetry(fn, options); + + // Advance timers for all retries + await vi.advanceTimersByTimeAsync(100); + await vi.advanceTimersByTimeAsync(200); + + await expect(promise).rejects.toMatchObject({ status: 429 }); + expect(fn).toHaveBeenCalledTimes(3); // Initial + 2 retries + }); + + it("respects abort signal", async () => { + const fn = vi.fn().mockRejectedValue({ status: 429 }); + const controller = new AbortController(); + + const options: RetryOptions = { + maxRetries: 3, + backoffMs: 100, + retryOn: ["rate_limit"], + abortSignal: controller.signal, + }; + + controller.abort(); + + await expect(withRetry(fn, options)).rejects.toThrow("Aborted"); + }); + + it("calls onRetry callback for each retry", async () => { + const rateLimitError = { status: 429, message: "Rate limited" }; + const fn = vi.fn().mockRejectedValueOnce(rateLimitError).mockResolvedValue("success"); + + const onRetry = vi.fn(); + + const options: RetryOptions = { + maxRetries: 3, + backoffMs: 100, + retryOn: ["rate_limit"], + onRetry, + }; + + const promise = withRetry(fn, options); + await vi.advanceTimersByTimeAsync(200); + await promise; + + expect(onRetry).toHaveBeenCalledWith( + 1, // attempt number + rateLimitError, + expect.any(Number), // delay + ); + }); + + it("applies exponential backoff", async () => { + const rateLimitError = { status: 429, message: "Rate limited" }; + const fn = vi + .fn() + .mockRejectedValueOnce(rateLimitError) + .mockRejectedValueOnce(rateLimitError) + .mockResolvedValue("success"); + + const delays: number[] = []; + + const options: RetryOptions = { + maxRetries: 3, + backoffMs: 100, + backoffMultiplier: 2, + retryOn: ["rate_limit"], + onRetry: (_attempt, _error, delay) => { + delays.push(delay); + }, + }; + + const promise = withRetry(fn, options); + + // Need to advance through both retry waits + await vi.advanceTimersByTimeAsync(500); + await promise; + + // Delays should increase exponentially (with some jitter) + expect(delays[0]).toBeGreaterThanOrEqual(100); + expect(delays[0]).toBeLessThanOrEqual(120); // 100 + 20% jitter + expect(delays[1]).toBeGreaterThanOrEqual(200); + expect(delays[1]).toBeLessThanOrEqual(240); // 200 + 20% jitter + }); + + it("respects maxBackoffMs", async () => { + const rateLimitError = { status: 429, message: "Rate limited" }; + const fn = vi + .fn() + .mockRejectedValueOnce(rateLimitError) + .mockRejectedValueOnce(rateLimitError) + .mockRejectedValueOnce(rateLimitError) + .mockResolvedValue("success"); + + const delays: number[] = []; + + const options: RetryOptions = { + maxRetries: 4, + backoffMs: 1000, + maxBackoffMs: 1500, + backoffMultiplier: 10, + retryOn: ["rate_limit"], + onRetry: (_attempt, _error, delay) => { + delays.push(delay); + }, + }; + + const promise = withRetry(fn, options); + await vi.advanceTimersByTimeAsync(10000); + await promise; + + // All delays should be capped at maxBackoffMs (+ jitter) + for (const delay of delays) { + expect(delay).toBeLessThanOrEqual(1500 * 1.2); + } + }); + }); + + describe("CcsdkError", () => { + it("creates error with kind and message", () => { + const error = new CcsdkError("Rate limited", "rate_limit"); + + expect(error.message).toBe("Rate limited"); + expect(error.kind).toBe("rate_limit"); + expect(error.name).toBe("CcsdkError"); + }); + + it("includes optional status and code", () => { + const error = new CcsdkError("Auth failed", "auth_failure", { + status: 401, + code: "AUTH_ERROR", + }); + + expect(error.status).toBe(401); + expect(error.code).toBe("AUTH_ERROR"); + }); + + it("chains cause from original error", () => { + const original = new Error("Original error"); + const error = new CcsdkError("Wrapped error", "unknown", { + cause: original, + }); + + // Access cause via ES2022 Error.cause property (cast to any for TypeScript) + expect((error as unknown as { cause: unknown }).cause).toBe(original); + }); + }); + + describe("isCcsdkError", () => { + it("returns true for CcsdkError instances", () => { + const error = new CcsdkError("Test", "unknown"); + expect(isCcsdkError(error)).toBe(true); + }); + + it("returns false for regular errors", () => { + const error = new Error("Test"); + expect(isCcsdkError(error)).toBe(false); + }); + + it("returns false for non-errors", () => { + expect(isCcsdkError(null)).toBe(false); + expect(isCcsdkError({ message: "not an error" })).toBe(false); + }); + }); + + describe("toCcsdkError", () => { + it("returns same error if already CcsdkError", () => { + const error = new CcsdkError("Already wrapped", "rate_limit"); + expect(toCcsdkError(error)).toBe(error); + }); + + it("converts regular error to CcsdkError", () => { + const error = new Error("Rate limit exceeded"); + const converted = toCcsdkError(error); + + expect(converted).toBeInstanceOf(CcsdkError); + expect(converted.message).toBe("Rate limit exceeded"); + expect(converted.kind).toBe("rate_limit"); + }); + + it("extracts status and code from error", () => { + const error = Object.assign(new Error("Not found"), { + status: 404, + code: "NOT_FOUND", + }); + + const converted = toCcsdkError(error); + + expect(converted.status).toBe(404); + expect(converted.code).toBe("NOT_FOUND"); + }); + + it("uses kind description for empty message", () => { + const converted = toCcsdkError({ status: 429 }); + + expect(converted.message).toBe("Rate limit exceeded"); + expect(converted.kind).toBe("rate_limit"); + }); + + it("chains original error as cause", () => { + const original = new Error("Original"); + const converted = toCcsdkError(original); + + // Access cause via ES2022 Error.cause property (cast to any for TypeScript) + expect((converted as unknown as { cause: unknown }).cause).toBe(original); + }); + }); + + describe("DEFAULT_RETRY_OPTIONS", () => { + it("has sensible defaults", () => { + expect(DEFAULT_RETRY_OPTIONS.maxRetries).toBe(3); + expect(DEFAULT_RETRY_OPTIONS.backoffMs).toBe(1000); + expect(DEFAULT_RETRY_OPTIONS.maxBackoffMs).toBe(30_000); + expect(DEFAULT_RETRY_OPTIONS.backoffMultiplier).toBe(2); + expect(DEFAULT_RETRY_OPTIONS.retryOn).toEqual(["rate_limit", "network", "timeout"]); + }); + }); +}); diff --git a/src/agents/claude-agent-sdk/provider-config.ts b/src/agents/claude-agent-sdk/provider-config.ts index 2f382e65b..a8b1b2a2a 100644 --- a/src/agents/claude-agent-sdk/provider-config.ts +++ b/src/agents/claude-agent-sdk/provider-config.ts @@ -2,16 +2,20 @@ * Provider configuration builders for the Claude Agent SDK. * * Supports multiple authentication methods: - * - Anthropic API key (ANTHROPIC_API_KEY) - * - Claude Code CLI OAuth (reuses ~/.claude credentials) - * - z.AI subscription (via ANTHROPIC_AUTH_TOKEN or CLI auth) + * - Anthropic API key (ANTHROPIC_API_KEY) - when explicitly configured in moltbot.json + * - Claude Code SDK native auth (default) - inherits parent env, SDK handles credential resolution + * - z.AI subscription (via ANTHROPIC_AUTH_TOKEN) * - OpenRouter (Anthropic-compatible API) * - AWS Bedrock * - Google Vertex AI + * + * For SDK native auth, we inherit the full parent process environment and let the SDK + * handle credential resolution. If the user has ANTHROPIC_API_KEY or ANTHROPIC_AUTH_TOKEN + * set in their environment, the SDK will use them. Otherwise, it uses its native + * keychain-based OAuth flow. */ import type { SdkProviderConfig, SdkProviderEnv } from "./types.js"; -import { readClaudeCliCredentialsCached } from "../cli-credentials.js"; import { createSubsystemLogger } from "../../logging/subsystem.js"; const log = createSubsystemLogger("agents/claude-agent-sdk"); @@ -67,73 +71,25 @@ export function buildAnthropicSdkProvider(apiKey: string): SdkProviderConfig { } /** - * Build provider config for Claude Code CLI/subscription auth. + * Build provider config for Claude Code SDK native auth. * - * This method reads credentials from the Claude Code CLI's keychain/file storage, - * enabling subscription-based access (Claude Max, z.AI, etc.) without an API key. + * This returns a minimal config with no env overrides, allowing the SDK to: + * - Inherit any ANTHROPIC_API_KEY or ANTHROPIC_AUTH_TOKEN from parent process + * - Use its internal credential resolution (keychain → OAuth) if no env vars are set * - * The SDK will automatically use these credentials when they're available in the - * environment or via the CLI's native auth mechanism. + * We don't try to be "smart" about unsetting env vars - if the user has auth + * env vars set in their system environment, that's their configuration choice. */ -export function buildClaudeCliSdkProvider(): SdkProviderConfig | null { - log.debug("[CCSDK-PROVIDER] buildClaudeCliSdkProvider called"); +export function buildClaudeCliSdkProvider(): SdkProviderConfig { + log.debug("[CCSDK-PROVIDER] buildClaudeCliSdkProvider called - using SDK native auth"); - // Read cached credentials from Claude Code CLI - const credentials = readClaudeCliCredentialsCached({ - allowKeychainPrompt: false, - ttlMs: 60_000, // 1 minute cache - }); + const config: SdkProviderConfig = { + name: "Claude CLI (SDK native)", + env: {}, + }; - if (!credentials) { - log.debug("[CCSDK-PROVIDER] No Claude CLI credentials found from cache"); - return null; - } - - log.debug("[CCSDK-PROVIDER] Claude CLI credentials loaded", { - type: credentials.type, - provider: credentials.provider, - expiresAt: new Date(credentials.expires).toISOString(), - isExpired: credentials.expires < Date.now(), - msUntilExpiry: credentials.expires - Date.now(), - }); - - // For OAuth credentials, use the access token as ANTHROPIC_AUTH_TOKEN - if (credentials.type === "oauth") { - // Check if token is expired - if (credentials.expires < Date.now()) { - log.warn("[CCSDK-PROVIDER] Claude CLI credentials EXPIRED", { - expiresAt: new Date(credentials.expires).toISOString(), - expiredAgo: Date.now() - credentials.expires, - }); - // Still return the config - the SDK may handle refresh internally - } - - const config: SdkProviderConfig = { - name: "Claude CLI (subscription)", - env: { - ANTHROPIC_AUTH_TOKEN: credentials.access, - }, - }; - logProviderConfig(config, "claude-cli-oauth"); - return config; - } - - // For token-type credentials (less common) - if (credentials.type === "token") { - const config: SdkProviderConfig = { - name: "Claude CLI (token)", - env: { - ANTHROPIC_AUTH_TOKEN: credentials.token, - }, - }; - logProviderConfig(config, "claude-cli-token"); - return config; - } - - log.debug("[CCSDK-PROVIDER] Unknown credential type", { - type: (credentials as { type: string }).type, - }); - return null; + logProviderConfig(config, "sdk-native-auth"); + return config; } /** @@ -278,9 +234,14 @@ export function buildVertexSdkProvider(): SdkProviderConfig { * Resolve provider configuration based on available credentials. * * Priority order: - * 1. Explicit API key from config/env - * 2. Claude Code CLI credentials (subscription auth) - * 3. Environment variables (fallback) + * 1. Explicit API key from moltbot config (options.apiKey) + * 2. Explicit auth token from moltbot config (options.authToken) - for z.AI, etc. + * 3. SDK native auth (default) - inherits parent env, SDK handles credential resolution + * + * The SDK will inherit the full parent process environment. If auth env vars + * (ANTHROPIC_API_KEY, ANTHROPIC_AUTH_TOKEN) are set in the parent process, + * the SDK will use them. Otherwise, it falls back to its native credential + * resolution (keychain → OAuth). */ export function resolveProviderConfig(options?: { apiKey?: string; @@ -297,9 +258,9 @@ export function resolveProviderConfig(options?: { useCliCredentials: options?.useCliCredentials ?? true, }); - // 1. Explicit API key takes precedence + // 1. Explicit API key from moltbot config takes precedence if (options?.apiKey) { - log.debug("[CCSDK-PROVIDER] Using explicit API key"); + log.debug("[CCSDK-PROVIDER] Using explicit API key from moltbot config"); const config = buildAnthropicSdkProvider(options.apiKey); if (options.baseUrl && config.env) { config.env.ANTHROPIC_BASE_URL = options.baseUrl; @@ -308,9 +269,9 @@ export function resolveProviderConfig(options?: { return config; } - // 2. Explicit auth token (OAuth/subscription) + // 2. Explicit auth token from moltbot config (for z.AI, custom endpoints, etc.) if (options?.authToken) { - log.debug("[CCSDK-PROVIDER] Using explicit auth token"); + log.debug("[CCSDK-PROVIDER] Using explicit auth token from moltbot config"); const env: SdkProviderEnv = { ANTHROPIC_AUTH_TOKEN: options.authToken, }; @@ -325,67 +286,14 @@ export function resolveProviderConfig(options?: { return config; } - // 3. Try Claude CLI credentials if enabled (default: true) - if (options?.useCliCredentials !== false) { - log.debug("[CCSDK-PROVIDER] Attempting Claude CLI credentials"); - const cliConfig = buildClaudeCliSdkProvider(); - if (cliConfig) { - log.debug("[CCSDK-PROVIDER] Using Claude CLI credentials", { - providerName: cliConfig.name, - }); - if (options?.baseUrl && cliConfig.env) { - cliConfig.env.ANTHROPIC_BASE_URL = options.baseUrl; - } - return cliConfig; - } - log.debug("[CCSDK-PROVIDER] Claude CLI credentials not available"); + // 3. Default: SDK native auth + // 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 + // ANTHROPIC_AUTH_TOKEN set, that's their configuration choice. + log.debug("[CCSDK-PROVIDER] Using SDK native auth (no explicit credentials configured)"); + const cliConfig = buildClaudeCliSdkProvider(); + if (options?.baseUrl && cliConfig.env) { + cliConfig.env.ANTHROPIC_BASE_URL = options.baseUrl; } - - // 4. Check environment variables - const envApiKey = process.env.ANTHROPIC_API_KEY; - const envAuthToken = process.env.ANTHROPIC_AUTH_TOKEN; - - log.debug("[CCSDK-PROVIDER] Checking environment variables", { - hasEnvApiKey: Boolean(envApiKey), - envApiKeyMasked: maskToken(envApiKey), - hasEnvAuthToken: Boolean(envAuthToken), - envAuthTokenMasked: maskToken(envAuthToken), - hasEnvBaseUrl: Boolean(process.env.ANTHROPIC_BASE_URL), - }); - - if (envApiKey) { - const config: SdkProviderConfig = { - name: "Anthropic (env)", - env: { - ANTHROPIC_API_KEY: envApiKey, - ANTHROPIC_BASE_URL: options?.baseUrl ?? process.env.ANTHROPIC_BASE_URL, - }, - }; - logProviderConfig(config, "env-api-key"); - return config; - } - - if (envAuthToken) { - const config: SdkProviderConfig = { - name: "Anthropic (env auth token)", - env: { - ANTHROPIC_AUTH_TOKEN: envAuthToken, - ANTHROPIC_BASE_URL: options?.baseUrl ?? process.env.ANTHROPIC_BASE_URL, - }, - }; - logProviderConfig(config, "env-auth-token"); - return config; - } - - // 5. Return empty config - SDK will use its own credential resolution - log.warn("[CCSDK-PROVIDER] No credentials found - SDK will use native auth (may fail)", { - triedCliCredentials: options?.useCliCredentials !== false, - triedEnvVars: true, - }); - const config: SdkProviderConfig = { - name: "Anthropic (SDK native)", - env: {}, - }; - logProviderConfig(config, "sdk-native-fallback"); - return config; + return cliConfig; } diff --git a/src/agents/claude-agent-sdk/sdk-agent-runtime.ts b/src/agents/claude-agent-sdk/sdk-agent-runtime.ts index ed635d9ae..b1d666380 100644 --- a/src/agents/claude-agent-sdk/sdk-agent-runtime.ts +++ b/src/agents/claude-agent-sdk/sdk-agent-runtime.ts @@ -24,6 +24,8 @@ import type { SdkConversationTurn, SdkRunnerResult } from "./types.js"; import { createMoltbotCodingTools } from "../pi-tools.js"; import { resolveMoltbotAgentDir } from "../agent-paths.js"; import { resolveModelAuthMode } from "../model-auth.js"; +import { resolveCcSdkConfigDirForAgent } from "../../config/sessions/paths.js"; +import { convertClientToolsForSdk } from "./client-tool-bridge.js"; const log = createSubsystemLogger("agents/ccsdk-runtime"); @@ -81,6 +83,31 @@ function mapVerboseLevel(verboseLevel?: VerboseLevel): SdkVerboseLevel { } } +/** + * Map elevated permission level to CCSDK permission mode. + * + * Translates the platform's elevated permission level (from sandboxInfo) + * to the Claude Code SDK's permission mode for file edits and other operations. + * + * Note: This is distinct from bashElevated which controls where exec commands run. + * The CCSDK permissionMode controls Claude Code's built-in permission system. + */ +function mapElevatedToPermissionMode( + elevatedLevel?: "on" | "off" | "ask" | "full", +): string | undefined { + switch (elevatedLevel) { + case "off": + case "ask": + return "default"; + case "on": + return "acceptEdits"; + case "full": + return "bypassPermissions"; + default: + return undefined; + } +} + /** * Extract agent ID from session key. */ @@ -135,9 +162,11 @@ function adaptSdkResult(result: SdkRunnerResult, sessionId: string): AgentRuntim durationMs: result.meta.durationMs, aborted: result.meta.aborted, agentMeta: { - sessionId, - provider: result.meta.provider ?? "sdk", + // Use CCSDK session ID if available (for resume), otherwise Moltbot session ID + sessionId: result.claudeSessionId ?? sessionId, + provider: result.meta.provider ?? "anthropic", model: result.meta.model ?? "default", + runtime: "ccsdk", usage, }, error: undefined, @@ -206,9 +235,14 @@ export function createCcSdkAgentRuntime(context?: CcSdkAgentRuntimeContext): Age // Build tools for this run (similar to how Pi runtime builds tools in attempt.ts) // If pre-built tools are provided via context, use those instead - const tools: AnyAgentTool[] = + const builtInTools: AnyAgentTool[] = context?.tools ?? createMoltbotCodingTools({ + // Exec tool configuration (same as Pi runtime) + exec: { + ...params.execOverrides, + elevated: params.bashElevated, + }, messageProvider: params.messageChannel ?? params.messageProvider, agentAccountId: params.agentAccountId, messageTo: params.messageTo, @@ -226,7 +260,31 @@ export function createCcSdkAgentRuntime(context?: CcSdkAgentRuntimeContext): Age groupId: params.groupId, }); - log.debug("Built tools for CCSDK run", { toolCount: tools.length }); + // Track client tool calls for OpenResponses integration + // Note: clientToolCallDetected is set when a client tool is invoked; future work may + // expose this in SdkRunnerResult similar to how Pi runtime exposes it in AttemptResult. + let clientToolCallDetected: { name: string; params: Record } | null = null; + void clientToolCallDetected; // Suppress unused warning (callback mutates this) + + // Convert client tools (OpenResponses hosted tools) for CCSDK + const clientToolsConverted = params.clientTools + ? convertClientToolsForSdk(params.clientTools, (toolName, toolParams) => { + clientToolCallDetected = { name: toolName, params: toolParams }; + }) + : []; + + // Combine built-in tools with client tools + const tools: AnyAgentTool[] = [...builtInTools, ...clientToolsConverted]; + + log.debug("Built tools for CCSDK run", { + builtInCount: builtInTools.length, + clientToolCount: clientToolsConverted.length, + totalCount: tools.length, + }); + + // Resolve CCSDK config directory for this agent's session storage. + // This ensures Moltbot sessions are stored separately from CLI sessions. + const ccsdkConfigDir = resolveCcSdkConfigDirForAgent(agentId); const sdkResult = await runSdkAgent({ // ─── Core shared params (spread) ──────────────────────────────────────── @@ -242,6 +300,7 @@ export function createCcSdkAgentRuntime(context?: CcSdkAgentRuntimeContext): Age timeoutMs: params.timeoutMs, abortSignal: params.abortSignal, extraSystemPrompt: params.extraSystemPrompt, + ccsdkConfigDir, // ─── Messaging & sender context (shared) ──────────────────────────────── messageChannel: params.messageChannel, @@ -262,8 +321,10 @@ export function createCcSdkAgentRuntime(context?: CcSdkAgentRuntimeContext): Age hooksEnabled: ccsdkOpts.hooksEnabled ?? context?.ccsdkConfig?.hooksEnabled, sdkOptions: ccsdkOpts.sdkOptions ?? context?.ccsdkConfig?.options, modelTiers: ccsdkOpts.modelTiers ?? context?.ccsdkConfig?.models, - claudeSessionId: ccsdkOpts.claudeSessionId, + claudeSessionId: params.providerSessionId, forkSession: ccsdkOpts.forkSession, + // Map sandbox elevated permission level to CCSDK permission mode + permissionMode: mapElevatedToPermissionMode(params.sandboxInfo?.elevated?.defaultLevel), thinkingLevel: params.thinkLevel, conversationHistory, tools, diff --git a/src/agents/claude-agent-sdk/sdk-hooks.test.ts b/src/agents/claude-agent-sdk/sdk-hooks.test.ts new file mode 100644 index 000000000..9f74b9fcc --- /dev/null +++ b/src/agents/claude-agent-sdk/sdk-hooks.test.ts @@ -0,0 +1,409 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; + +import { buildMoltbotSdkHooks } from "./sdk-hooks.js"; + +describe("sdk-hooks", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(() => { + vi.resetAllMocks(); + }); + + describe("buildMoltbotSdkHooks", () => { + it("returns hooks config with all expected hook types", () => { + const emitEvent = vi.fn(); + const hooks = buildMoltbotSdkHooks({ + mcpServerName: "moltbot", + emitEvent, + }); + + expect(hooks.PreToolUse).toBeDefined(); + expect(hooks.PostToolUse).toBeDefined(); + expect(hooks.PostToolUseFailure).toBeDefined(); + expect(hooks.Notification).toBeDefined(); + expect(hooks.SessionStart).toBeDefined(); + expect(hooks.SessionEnd).toBeDefined(); + expect(hooks.UserPromptSubmit).toBeDefined(); + expect(hooks.Stop).toBeDefined(); + expect(hooks.SubagentStart).toBeDefined(); + expect(hooks.SubagentStop).toBeDefined(); + expect(hooks.PreCompact).toBeDefined(); + }); + + it("returns hooks with correct structure (array of matchers with hooks)", () => { + const emitEvent = vi.fn(); + const hooks = buildMoltbotSdkHooks({ + mcpServerName: "moltbot", + emitEvent, + }); + + // Each hook type should be an array of callback matchers + expect(Array.isArray(hooks.PreToolUse)).toBe(true); + expect(hooks.PreToolUse![0]).toHaveProperty("hooks"); + expect(Array.isArray(hooks.PreToolUse![0].hooks)).toBe(true); + expect(typeof hooks.PreToolUse![0].hooks[0]).toBe("function"); + }); + + describe("PreToolUse hook (toolStartHook)", () => { + it("emits hook event with correct data", async () => { + const emitEvent = vi.fn(); + const hooks = buildMoltbotSdkHooks({ + mcpServerName: "moltbot", + emitEvent, + }); + + const toolStartHook = hooks.PreToolUse![0].hooks[0]; + await toolStartHook( + { tool_name: "mcp__moltbot__bash", tool_input: { command: "ls" } }, + "tool-use-123", + {}, + ); + + expect(emitEvent).toHaveBeenCalledWith( + "hook", + expect.objectContaining({ + hookEventName: "PreToolUse", + toolUseId: "tool-use-123", + }), + ); + }); + + it("emits tool event with normalized name", async () => { + const emitEvent = vi.fn(); + const hooks = buildMoltbotSdkHooks({ + mcpServerName: "moltbot", + emitEvent, + }); + + const toolStartHook = hooks.PreToolUse![0].hooks[0]; + await toolStartHook( + { tool_name: "mcp__moltbot__bash_exec", tool_input: { cmd: "pwd" } }, + "use-456", + {}, + ); + + // Should emit a tool event with phase: start + const toolEvent = emitEvent.mock.calls.find((c) => c[0] === "tool"); + expect(toolEvent).toBeDefined(); + expect(toolEvent![1]).toMatchObject({ + phase: "start", + name: expect.any(String), + toolCallId: "use-456", + }); + }); + + it("strips MCP server prefix from tool name", async () => { + const emitEvent = vi.fn(); + const hooks = buildMoltbotSdkHooks({ + mcpServerName: "moltbot", + emitEvent, + }); + + const toolStartHook = hooks.PreToolUse![0].hooks[0]; + await toolStartHook({ tool_name: "mcp__moltbot__some_tool" }, "id-1", {}); + + const toolEvent = emitEvent.mock.calls.find((c) => c[0] === "tool"); + expect(toolEvent![1].name).not.toContain("mcp__"); + }); + + it("returns empty object (non-blocking)", async () => { + const emitEvent = vi.fn(); + const hooks = buildMoltbotSdkHooks({ + mcpServerName: "moltbot", + emitEvent, + }); + + const toolStartHook = hooks.PreToolUse![0].hooks[0]; + const result = await toolStartHook({ tool_name: "test" }, "id", {}); + + expect(result).toEqual({}); + }); + + it("handles missing tool_name gracefully", async () => { + const emitEvent = vi.fn(); + const hooks = buildMoltbotSdkHooks({ + mcpServerName: "moltbot", + emitEvent, + }); + + const toolStartHook = hooks.PreToolUse![0].hooks[0]; + // Should not throw + await expect(toolStartHook({}, "id", {})).resolves.toEqual({}); + }); + }); + + describe("PostToolUse hook (toolResultHook)", () => { + it("emits tool event with result", async () => { + const emitEvent = vi.fn(); + const hooks = buildMoltbotSdkHooks({ + mcpServerName: "moltbot", + emitEvent, + }); + + const toolResultHook = hooks.PostToolUse![0].hooks[0]; + await toolResultHook( + { + tool_name: "mcp__moltbot__bash", + tool_response: "command output here", + }, + "tool-use-789", + {}, + ); + + const toolEvent = emitEvent.mock.calls.find((c) => c[0] === "tool"); + expect(toolEvent).toBeDefined(); + expect(toolEvent![1]).toMatchObject({ + phase: "result", + isError: false, + toolCallId: "tool-use-789", + }); + }); + + it("calls onToolResult callback with result text", async () => { + const emitEvent = vi.fn(); + const onToolResult = vi.fn(); + const hooks = buildMoltbotSdkHooks({ + mcpServerName: "moltbot", + emitEvent, + onToolResult, + }); + + const toolResultHook = hooks.PostToolUse![0].hooks[0]; + await toolResultHook( + { + tool_name: "test_tool", + tool_response: "Tool completed successfully", + }, + "id", + {}, + ); + + expect(onToolResult).toHaveBeenCalledWith({ + text: expect.stringContaining("Tool completed"), + }); + }); + + it("does not call onToolResult when result is empty", async () => { + const emitEvent = vi.fn(); + const onToolResult = vi.fn(); + const hooks = buildMoltbotSdkHooks({ + mcpServerName: "moltbot", + emitEvent, + onToolResult, + }); + + const toolResultHook = hooks.PostToolUse![0].hooks[0]; + await toolResultHook({ tool_name: "test_tool", tool_response: "" }, "id", {}); + + expect(onToolResult).not.toHaveBeenCalled(); + }); + + it("catches and ignores onToolResult callback errors", async () => { + const emitEvent = vi.fn(); + const onToolResult = vi.fn().mockRejectedValue(new Error("Callback failed")); + const hooks = buildMoltbotSdkHooks({ + mcpServerName: "moltbot", + emitEvent, + onToolResult, + }); + + const toolResultHook = hooks.PostToolUse![0].hooks[0]; + // Should not throw + await expect( + toolResultHook({ tool_name: "test", tool_response: "result" }, "id", {}), + ).resolves.toEqual({}); + }); + }); + + describe("PostToolUseFailure hook (toolFailureHook)", () => { + it("emits tool event with isError true", async () => { + const emitEvent = vi.fn(); + const hooks = buildMoltbotSdkHooks({ + mcpServerName: "moltbot", + emitEvent, + }); + + const toolFailureHook = hooks.PostToolUseFailure![0].hooks[0]; + await toolFailureHook( + { + tool_name: "mcp__moltbot__bash", + error: "Command failed with exit code 1", + }, + "tool-use-error", + {}, + ); + + const toolEvent = emitEvent.mock.calls.find((c) => c[0] === "tool"); + expect(toolEvent).toBeDefined(); + expect(toolEvent![1]).toMatchObject({ + phase: "result", + isError: true, + toolCallId: "tool-use-error", + }); + }); + + it("calls onToolResult with error text", async () => { + const emitEvent = vi.fn(); + const onToolResult = vi.fn(); + const hooks = buildMoltbotSdkHooks({ + mcpServerName: "moltbot", + emitEvent, + onToolResult, + }); + + const toolFailureHook = hooks.PostToolUseFailure![0].hooks[0]; + await toolFailureHook({ tool_name: "test_tool", error: "Something went wrong" }, "id", {}); + + expect(onToolResult).toHaveBeenCalledWith({ + text: expect.stringContaining("Something went wrong"), + }); + }); + + it("catches and ignores onToolResult callback errors", async () => { + const emitEvent = vi.fn(); + const onToolResult = vi.fn().mockRejectedValue(new Error("Callback error")); + const hooks = buildMoltbotSdkHooks({ + mcpServerName: "moltbot", + emitEvent, + onToolResult, + }); + + const toolFailureHook = hooks.PostToolUseFailure![0].hooks[0]; + await expect( + toolFailureHook({ tool_name: "test", error: "fail" }, "id", {}), + ).resolves.toEqual({}); + }); + }); + + describe("passthrough hooks", () => { + const passthroughHookNames = [ + "Notification", + "SessionStart", + "SessionEnd", + "UserPromptSubmit", + "Stop", + "SubagentStart", + "SubagentStop", + "PreCompact", + ] as const; + + for (const hookName of passthroughHookNames) { + it(`${hookName} emits hook event`, async () => { + const emitEvent = vi.fn(); + const hooks = buildMoltbotSdkHooks({ + mcpServerName: "moltbot", + emitEvent, + }); + + const hook = hooks[hookName]![0].hooks[0]; + await hook({ data: "test" }, "use-id", { session_id: "session" }); + + expect(emitEvent).toHaveBeenCalledWith( + "hook", + expect.objectContaining({ + hookEventName: hookName, + }), + ); + }); + + it(`${hookName} returns empty object`, async () => { + const emitEvent = vi.fn(); + const hooks = buildMoltbotSdkHooks({ + mcpServerName: "moltbot", + emitEvent, + }); + + const hook = hooks[hookName]![0].hooks[0]; + const result = await hook({}, "id", {}); + + expect(result).toEqual({}); + }); + } + }); + + describe("tool name normalization", () => { + it("removes mcp__serverName__ prefix", async () => { + const emitEvent = vi.fn(); + const hooks = buildMoltbotSdkHooks({ + mcpServerName: "custom-server", + emitEvent, + }); + + const toolStartHook = hooks.PreToolUse![0].hooks[0]; + await toolStartHook({ tool_name: "mcp__custom-server__my_tool" }, "id", {}); + + const toolEvent = emitEvent.mock.calls.find((c) => c[0] === "tool"); + expect(toolEvent![1].name).toBe("my_tool"); + }); + + it("handles tools without MCP prefix", async () => { + const emitEvent = vi.fn(); + const hooks = buildMoltbotSdkHooks({ + mcpServerName: "moltbot", + emitEvent, + }); + + const toolStartHook = hooks.PreToolUse![0].hooks[0]; + await toolStartHook({ tool_name: "plain_tool" }, "id", {}); + + const toolEvent = emitEvent.mock.calls.find((c) => c[0] === "tool"); + expect(toolEvent![1].name).toBe("plain_tool"); + }); + + it("handles empty tool name", async () => { + const emitEvent = vi.fn(); + const hooks = buildMoltbotSdkHooks({ + mcpServerName: "moltbot", + emitEvent, + }); + + const toolStartHook = hooks.PreToolUse![0].hooks[0]; + await toolStartHook({ tool_name: "" }, "id", {}); + + const toolEvent = emitEvent.mock.calls.find((c) => c[0] === "tool"); + expect(toolEvent![1].name).toBe("tool"); // fallback + }); + }); + + describe("input sanitization", () => { + it("wraps non-record input in object", async () => { + const emitEvent = vi.fn(); + const hooks = buildMoltbotSdkHooks({ + mcpServerName: "moltbot", + emitEvent, + }); + + const hook = hooks.Notification![0].hooks[0]; + await hook("string input", "id", {}); + + expect(emitEvent).toHaveBeenCalledWith( + "hook", + expect.objectContaining({ + input: "string input", + }), + ); + }); + + it("spreads record input into event", async () => { + const emitEvent = vi.fn(); + const hooks = buildMoltbotSdkHooks({ + mcpServerName: "moltbot", + emitEvent, + }); + + const hook = hooks.Notification![0].hooks[0]; + await hook({ key: "value", count: 42 }, "id", {}); + + expect(emitEvent).toHaveBeenCalledWith( + "hook", + expect.objectContaining({ + key: "value", + count: 42, + }), + ); + }); + }); + }); +}); diff --git a/src/agents/claude-agent-sdk/sdk-runner.test.ts b/src/agents/claude-agent-sdk/sdk-runner.test.ts index 309cff052..90710fb3e 100644 --- a/src/agents/claude-agent-sdk/sdk-runner.test.ts +++ b/src/agents/claude-agent-sdk/sdk-runner.test.ts @@ -426,4 +426,263 @@ describe("runSdkAgent", () => { expect(firstPartial).toBeLessThanOrEqual(firstBlock); }); }); + + describe("session resumption", () => { + it("extracts session_id from SDK init event", async () => { + mockQuery.mockImplementationOnce(async function* () { + yield { type: "system", subtype: "init", session_id: "sdk-session-abc123" }; + yield { type: "assistant", text: "Hello" }; + yield { type: "result", result: "Hello" }; + }); + + const result = await runSdkAgent(baseParams); + + expect(result.claudeSessionId).toBe("sdk-session-abc123"); + expect(result.payloads[0]?.text).toBe("Hello"); + }); + + it("extracts session_id from any event with session_id field", async () => { + mockQuery.mockImplementationOnce(async function* () { + yield { type: "assistant", text: "Response", session_id: "session-from-assistant" }; + yield { type: "result", result: "Response" }; + }); + + const result = await runSdkAgent(baseParams); + + expect(result.claudeSessionId).toBe("session-from-assistant"); + }); + + it("passes resume option to SDK when claudeSessionId is provided", async () => { + mockQuery.mockImplementationOnce(async function* () { + yield { type: "result", result: "Resumed session response" }; + }); + + await runSdkAgent({ + ...baseParams, + claudeSessionId: "existing-session-xyz", + }); + + expect(mockQuery).toHaveBeenCalledTimes(1); + const callArgs = mockQuery.mock.calls[0]; + const options = callArgs?.[0]?.options; + expect(options?.resume).toBe("existing-session-xyz"); + }); + + it("passes forkSession option when both claudeSessionId and forkSession are provided", async () => { + mockQuery.mockImplementationOnce(async function* () { + yield { type: "result", result: "Forked session response" }; + }); + + await runSdkAgent({ + ...baseParams, + claudeSessionId: "session-to-fork", + forkSession: true, + }); + + expect(mockQuery).toHaveBeenCalledTimes(1); + const callArgs = mockQuery.mock.calls[0]; + const options = callArgs?.[0]?.options; + expect(options?.resume).toBe("session-to-fork"); + expect(options?.forkSession).toBe(true); + }); + + it("does not set resume option when claudeSessionId is not provided", async () => { + mockQuery.mockImplementationOnce(async function* () { + yield { type: "result", result: "New session response" }; + }); + + await runSdkAgent(baseParams); + + expect(mockQuery).toHaveBeenCalledTimes(1); + const callArgs = mockQuery.mock.calls[0]; + const options = callArgs?.[0]?.options; + expect(options?.resume).toBeUndefined(); + }); + }); + + describe("callback error handling", () => { + it("continues streaming if onPartialReply throws synchronously", async () => { + const onPartialReply = vi.fn().mockImplementation(() => { + throw new Error("onPartialReply sync error"); + }); + + mockQuery.mockImplementationOnce(async function* () { + yield { type: "text", text: "First chunk" }; + yield { type: "text", text: "Second chunk" }; + yield { type: "result", result: "Final output" }; + }); + + const result = await runSdkAgent({ + ...baseParams, + onPartialReply, + }); + + // Should complete despite callback error + expect(result.payloads[0]?.text).toBe("Final output"); + expect(result.payloads[0]?.isError).toBeUndefined(); + expect(onPartialReply).toHaveBeenCalled(); + }); + + it("continues streaming if onPartialReply returns rejected promise", async () => { + const onPartialReply = vi.fn().mockRejectedValue(new Error("onPartialReply async error")); + + mockQuery.mockImplementationOnce(async function* () { + yield { type: "text", text: "Streaming" }; + yield { type: "result", result: "Done" }; + }); + + const result = await runSdkAgent({ + ...baseParams, + onPartialReply, + }); + + expect(result.payloads[0]?.text).toBe("Done"); + expect(result.payloads[0]?.isError).toBeUndefined(); + }); + + it("continues streaming if onBlockReply throws", async () => { + const onBlockReply = vi.fn().mockImplementation(() => { + throw new Error("onBlockReply error"); + }); + + mockQuery.mockImplementationOnce(async function* () { + yield { type: "assistant", text: "Block 1" }; + yield { type: "assistant", text: "Block 2" }; + yield { type: "result", result: "Block 2" }; + }); + + const result = await runSdkAgent({ + ...baseParams, + onBlockReply, + }); + + expect(result.payloads[0]?.text).toBe("Block 2"); + expect(onBlockReply).toHaveBeenCalled(); + }); + + it("continues streaming if onReasoningStream throws", async () => { + const onReasoningStream = vi.fn().mockImplementation(() => { + throw new Error("onReasoningStream error"); + }); + + mockQuery.mockImplementationOnce(async function* () { + yield { type: "thinking", thinking: "Thinking content" }; + yield { type: "assistant", text: "Response" }; + yield { type: "result", result: "Response" }; + }); + + const result = await runSdkAgent({ + ...baseParams, + thinkingLevel: "high", + onReasoningStream, + }); + + expect(result.payloads[0]?.text).toBe("Response"); + expect(onReasoningStream).toHaveBeenCalled(); + }); + + it("continues streaming if onBlockReplyFlush throws", async () => { + const onBlockReplyFlush = vi.fn().mockImplementation(() => { + throw new Error("onBlockReplyFlush error"); + }); + + mockQuery.mockImplementationOnce(async function* () { + yield { type: "assistant", text: "Content" }; + yield { type: "result", result: "Content" }; + }); + + const result = await runSdkAgent({ + ...baseParams, + onBlockReply: vi.fn(), + onBlockReplyFlush, + }); + + expect(result.payloads[0]?.text).toBe("Content"); + expect(onBlockReplyFlush).toHaveBeenCalled(); + }); + + it("handles multiple callbacks throwing simultaneously", async () => { + const onPartialReply = vi.fn().mockRejectedValue(new Error("partial error")); + const onBlockReply = vi.fn().mockImplementation(() => { + throw new Error("block error"); + }); + const onReasoningStream = vi.fn().mockRejectedValue(new Error("reasoning error")); + const onAgentEvent = vi.fn().mockImplementation(() => { + throw new Error("event error"); + }); + + mockQuery.mockImplementationOnce(async function* () { + yield { type: "thinking", thinking: "Thinking..." }; + yield { type: "assistant", text: "Response" }; + yield { type: "result", result: "Response" }; + }); + + const result = await runSdkAgent({ + ...baseParams, + thinkingLevel: "high", + onPartialReply, + onBlockReply, + onReasoningStream, + onAgentEvent, + }); + + // All callbacks should have been called, and result should still be correct + expect(result.payloads[0]?.text).toBe("Response"); + expect(result.payloads[0]?.isError).toBeUndefined(); + expect(onPartialReply).toHaveBeenCalled(); + expect(onBlockReply).toHaveBeenCalled(); + expect(onReasoningStream).toHaveBeenCalled(); + expect(onAgentEvent).toHaveBeenCalled(); + }); + + it("continues if onToolResult callback throws", async () => { + const onToolResult = vi.fn().mockImplementation(() => { + throw new Error("onToolResult error"); + }); + + mockQuery.mockImplementationOnce(async function* () { + yield { + type: "tool_use", + tool_use_id: "tool-123", + name: "test_tool", + input: {}, + }; + yield { + type: "tool_result", + tool_use_id: "tool-123", + content: [{ type: "text", text: "Tool output" }], + }; + yield { type: "assistant", text: "Here is the result" }; + yield { type: "result", result: "Here is the result" }; + }); + + const result = await runSdkAgent({ + ...baseParams, + onToolResult, + }); + + // Should complete despite callback error + expect(result.payloads[0]?.text).toBe("Here is the result"); + }); + + it("completes run even if onAssistantMessageStart throws", async () => { + const onAssistantMessageStart = vi.fn().mockImplementation(() => { + throw new Error("onAssistantMessageStart error"); + }); + + mockQuery.mockImplementationOnce(async function* () { + yield { type: "assistant_message_start" }; + yield { type: "assistant", text: "Hello" }; + yield { type: "result", result: "Hello" }; + }); + + const result = await runSdkAgent({ + ...baseParams, + onAssistantMessageStart, + }); + + expect(result.payloads[0]?.text).toBe("Hello"); + expect(onAssistantMessageStart).toHaveBeenCalled(); + }); + }); }); diff --git a/src/agents/claude-agent-sdk/sdk-runner.ts b/src/agents/claude-agent-sdk/sdk-runner.ts index 87dae2872..d511d4543 100644 --- a/src/agents/claude-agent-sdk/sdk-runner.ts +++ b/src/agents/claude-agent-sdk/sdk-runner.ts @@ -15,6 +15,7 @@ */ import { createSubsystemLogger } from "../../logging/subsystem.js"; +import { emitAgentEvent } from "../../infra/agent-events.js"; import { bridgeMoltbotToolsToMcpServer } from "./tool-bridge.js"; import type { SdkRunnerQueryOptions } from "./tool-bridge.types.js"; import { extractFromClaudeAgentSdkEvent } from "./extract.js"; @@ -632,7 +633,28 @@ export async function runSdkAgent(params: SdkRunnerParams): Promise) => { + // Emit to global agent event system for standard streams. + // This allows the Chat UI to receive tool events, assistant text, etc. + if (GLOBAL_EVENT_STREAMS.has(stream) && params.runId) { + emitAgentEvent({ + runId: params.runId, + stream, + data, + }); + } + + // Also emit via callback for typing signals, etc. try { void Promise.resolve(params.onAgentEvent?.({ stream, data })).catch(() => { // Don't let async callback errors trigger unhandled rejections. @@ -643,7 +665,6 @@ export async function runSdkAgent(params: SdkRunnerParams): Promise) ?? + (record.arguments as Record) ?? + undefined) + : undefined; + emitEvent("tool", { phase, name: normalizedName.name, toolCallId, sdkType: type, ...(normalizedName.rawName ? { rawName: normalizedName.rawName } : {}), + ...(argsForEvent ? { args: argsForEvent } : {}), isError, ...(toolText ? { resultText: toolText } : {}), }); @@ -1144,11 +1172,6 @@ export async function runSdkAgent(params: SdkRunnerParams): Promise); - } - // Handle terminal result event. if (kind === "result" && isRecord(event)) { const result = event.result; @@ -1522,14 +1545,6 @@ export async function runSdkAgent(params: SdkRunnerParams): Promise ({ + createSubsystemLogger: vi.fn(() => ({ + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + })), +})); + +vi.mock("../tool-policy.js", () => ({ + normalizeToolName: vi.fn((name: string) => name.toLowerCase().replace(/-/g, "_")), +})); + +import { + extractJsonSchema, + convertToolResult, + wrapToolHandler, + mcpToolName, + buildMcpAllowedTools, + bridgeMoltbotToolsSync, + resetMcpServerCache, +} from "./tool-bridge.js"; +import type { AnyAgentTool } from "../tools/common.js"; + +describe("tool-bridge", () => { + beforeEach(() => { + vi.clearAllMocks(); + resetMcpServerCache(); + }); + + afterEach(() => { + vi.resetAllMocks(); + }); + + describe("extractJsonSchema", () => { + it("extracts JSON Schema from TypeBox schema", () => { + const tool = { + name: "test_tool", + parameters: { + type: "object", + properties: { + input: { type: "string" }, + }, + required: ["input"], + }, + } as unknown as AnyAgentTool; + + const schema = extractJsonSchema(tool); + + expect(schema).toEqual({ + type: "object", + properties: { + input: { type: "string" }, + }, + required: ["input"], + }); + }); + + it("returns empty object schema when no parameters defined", () => { + const tool = { + name: "no_params_tool", + parameters: undefined, + } as unknown as AnyAgentTool; + + const schema = extractJsonSchema(tool); + + expect(schema).toEqual({ type: "object", properties: {} }); + }); + + it("returns empty object schema for null parameters", () => { + const tool = { + name: "null_params_tool", + parameters: null, + } as unknown as AnyAgentTool; + + const schema = extractJsonSchema(tool); + + expect(schema).toEqual({ type: "object", properties: {} }); + }); + + it("strips non-serializable properties from schema", () => { + const symbolKey = Symbol("internal"); + const tool = { + name: "symbol_tool", + parameters: { + type: "object", + [symbolKey]: "should be stripped", + properties: {}, + }, + } as unknown as AnyAgentTool; + + const schema = extractJsonSchema(tool); + + expect(schema).toEqual({ type: "object", properties: {} }); + expect(Object.getOwnPropertySymbols(schema)).toHaveLength(0); + }); + }); + + describe("convertToolResult", () => { + it("converts text content blocks", () => { + const result: AgentToolResult = { + content: [{ type: "text", text: "Hello world" }], + }; + + const mcpResult = convertToolResult(result); + + expect(mcpResult.content).toEqual([{ type: "text", text: "Hello world" }]); + expect(mcpResult.isError).toBeUndefined(); + }); + + it("converts image content blocks", () => { + const result: AgentToolResult = { + content: [{ type: "image", data: "base64data", mimeType: "image/png" }], + }; + + const mcpResult = convertToolResult(result); + + expect(mcpResult.content).toEqual([ + { type: "image", data: "base64data", mimeType: "image/png" }, + ]); + }); + + it("converts tool_error blocks with isError flag", () => { + const result: AgentToolResult = { + content: [{ type: "tool_error", error: "Something went wrong" }], + }; + + const mcpResult = convertToolResult(result); + + expect(mcpResult.content).toEqual([{ type: "text", text: "Something went wrong" }]); + expect(mcpResult.isError).toBe(true); + }); + + it("converts blocks with error field to isError", () => { + const result: AgentToolResult = { + content: [{ type: "custom", error: "Custom error message" }], + }; + + const mcpResult = convertToolResult(result); + + expect(mcpResult.isError).toBe(true); + expect(mcpResult.content[0]).toEqual({ type: "text", text: "Custom error message" }); + }); + + it("returns (no output) for empty content", () => { + const result: AgentToolResult = { + content: [], + }; + + const mcpResult = convertToolResult(result); + + expect(mcpResult.content).toEqual([{ type: "text", text: "(no output)" }]); + }); + + it("serializes details as tool-details text block", () => { + const result: AgentToolResult = { + content: [{ type: "text", text: "Output" }], + details: { key: "value", count: 42 }, + }; + + const mcpResult = convertToolResult(result); + + expect(mcpResult.content.length).toBe(2); + expect(mcpResult.content[1]).toMatchObject({ + type: "text", + text: expect.stringContaining(""), + }); + expect(mcpResult.content[1]).toMatchObject({ + text: expect.stringContaining('"key": "value"'), + }); + }); + + it("handles null details gracefully", () => { + const result: AgentToolResult = { + content: [{ type: "text", text: "Output" }], + details: null, + }; + + const mcpResult = convertToolResult(result); + + // Should not add a details block for null + expect(mcpResult.content.length).toBe(1); + }); + + it("handles multiple content blocks", () => { + const result: AgentToolResult = { + content: [ + { type: "text", text: "Line 1" }, + { type: "text", text: "Line 2" }, + { type: "image", data: "abc", mimeType: "image/jpeg" }, + ], + }; + + const mcpResult = convertToolResult(result); + + expect(mcpResult.content.length).toBe(3); + }); + }); + + describe("wrapToolHandler", () => { + it("executes tool and converts result", async () => { + const mockExecute = vi.fn().mockResolvedValue({ + content: [{ type: "text", text: "Tool output" }], + }); + + const tool = { + name: "test-tool", + execute: mockExecute, + } as unknown as AnyAgentTool; + + const handler = wrapToolHandler(tool); + const result = await handler({ input: "test" }); + + expect(mockExecute).toHaveBeenCalledWith( + expect.stringContaining("mcp-bridge-test_tool"), + { input: "test" }, + undefined, + undefined, + ); + expect(result.content).toEqual([{ type: "text", text: "Tool output" }]); + }); + + it("passes abort signal to tool execute", async () => { + const mockExecute = vi.fn().mockResolvedValue({ + content: [{ type: "text", text: "Done" }], + }); + + const tool = { + name: "abort-aware-tool", + execute: mockExecute, + } as unknown as AnyAgentTool; + + const controller = new AbortController(); + const handler = wrapToolHandler(tool, controller.signal); + await handler({}); + + expect(mockExecute).toHaveBeenCalledWith( + expect.any(String), + {}, + controller.signal, + undefined, + ); + }); + + it("handles tool execution errors gracefully", async () => { + const mockExecute = vi.fn().mockRejectedValue(new Error("Tool crashed")); + + const tool = { + name: "crashing-tool", + execute: mockExecute, + } as unknown as AnyAgentTool; + + const handler = wrapToolHandler(tool); + const result = await handler({}); + + expect(result.isError).toBe(true); + expect(result.content[0]).toMatchObject({ + type: "text", + text: expect.stringContaining("Tool error"), + }); + }); + + it("returns abort message for AbortError", async () => { + const abortError = new Error("Aborted"); + abortError.name = "AbortError"; + const mockExecute = vi.fn().mockRejectedValue(abortError); + + const tool = { + name: "abortable-tool", + execute: mockExecute, + } as unknown as AnyAgentTool; + + const handler = wrapToolHandler(tool); + const result = await handler({}); + + expect(result.isError).toBe(true); + expect(result.content[0]).toMatchObject({ + type: "text", + text: expect.stringContaining("was aborted"), + }); + }); + + it("calls onToolUpdate callback with update data", async () => { + const mockExecute = vi.fn().mockImplementation(async (_id, _args, _signal, onUpdate) => { + onUpdate?.({ progress: 50 }); + return { content: [{ type: "text", text: "Done" }] }; + }); + + const tool = { + name: "updating-tool", + execute: mockExecute, + } as unknown as AnyAgentTool; + + const onToolUpdate = vi.fn(); + const handler = wrapToolHandler(tool, undefined, onToolUpdate); + await handler({}); + + expect(onToolUpdate).toHaveBeenCalledWith( + expect.objectContaining({ + toolName: "updating_tool", + update: { progress: 50 }, + }), + ); + }); + + it("does not break execution when onToolUpdate callback throws", async () => { + const mockExecute = vi.fn().mockImplementation(async (_id, _args, _signal, onUpdate) => { + onUpdate?.({ progress: 100 }); + return { content: [{ type: "text", text: "Success" }] }; + }); + + const tool = { + name: "callback-error-tool", + execute: mockExecute, + } as unknown as AnyAgentTool; + + const onToolUpdate = vi.fn().mockRejectedValue(new Error("Callback failed")); + const handler = wrapToolHandler(tool, undefined, onToolUpdate); + + // Should not throw even if callback fails + const result = await handler({}); + + expect(result.content[0]).toMatchObject({ + type: "text", + text: "Success", + }); + }); + + it("generates unique tool call IDs for each invocation", async () => { + const toolCallIds: string[] = []; + const mockExecute = vi.fn().mockImplementation(async (id) => { + toolCallIds.push(id); + return { content: [{ type: "text", text: "Done" }] }; + }); + + const tool = { + name: "unique-id-tool", + execute: mockExecute, + } as unknown as AnyAgentTool; + + const handler = wrapToolHandler(tool); + await handler({}); + await handler({}); + + expect(toolCallIds[0]).not.toBe(toolCallIds[1]); + expect(toolCallIds[0]).toContain("mcp-bridge-unique_id_tool"); + expect(toolCallIds[1]).toContain("mcp-bridge-unique_id_tool"); + }); + }); + + describe("mcpToolName", () => { + it("formats tool name with server prefix", () => { + expect(mcpToolName("moltbot", "bash")).toBe("mcp__moltbot__bash"); + }); + + it("handles multi-part tool names", () => { + expect(mcpToolName("my-server", "my_tool_name")).toBe("mcp__my-server__my_tool_name"); + }); + }); + + describe("buildMcpAllowedTools", () => { + it("builds allowed tools list from tool array", () => { + const tools = [{ name: "tool_a" }, { name: "tool_b" }] as AnyAgentTool[]; + + const allowed = buildMcpAllowedTools("moltbot", tools); + + expect(allowed).toEqual(["mcp__moltbot__tool_a", "mcp__moltbot__tool_b"]); + }); + + it("returns empty array for empty tools list", () => { + const allowed = buildMcpAllowedTools("moltbot", []); + + expect(allowed).toEqual([]); + }); + }); + + describe("bridgeMoltbotToolsSync", () => { + // Helper to create a class-like McpServer mock (required because implementation uses `new`) + function createMockMcpServerClass(toolFn: ReturnType) { + return class MockMcpServer { + tool = toolFn; + constructor(_opts: { name: string; version: string }) { + // Constructor receives options + } + }; + } + + it("registers tools with MCP server", () => { + const registeredTools: string[] = []; + const toolFn = vi.fn((name: string) => { + registeredTools.push(name); + }); + const MockMcpServer = createMockMcpServerClass(toolFn); + + const tools = [ + { + name: "tool_one", + description: "First tool", + parameters: { type: "object", properties: {} }, + execute: vi.fn(), + }, + { + name: "tool_two", + description: "Second tool", + parameters: { type: "object", properties: {} }, + execute: vi.fn(), + }, + ] as unknown as AnyAgentTool[]; + + const result = bridgeMoltbotToolsSync({ + name: "test-server", + tools, + McpServer: MockMcpServer as any, + }); + + expect(result.toolCount).toBe(2); + expect(result.registeredTools).toEqual(["tool_one", "tool_two"]); + expect(result.skippedTools).toEqual([]); + }); + + it("skips tools with empty names", () => { + const MockMcpServer = createMockMcpServerClass(vi.fn()); + + const tools = [ + { name: "", execute: vi.fn() }, + { name: " ", execute: vi.fn() }, + { name: "valid_tool", execute: vi.fn() }, + ] as unknown as AnyAgentTool[]; + + const result = bridgeMoltbotToolsSync({ + name: "test-server", + tools, + McpServer: MockMcpServer as any, + }); + + expect(result.toolCount).toBe(1); + expect(result.registeredTools).toEqual(["valid_tool"]); + expect(result.skippedTools).toContain("(unnamed)"); + }); + + it("returns correct server config structure", () => { + const MockMcpServer = createMockMcpServerClass(vi.fn()); + + const result = bridgeMoltbotToolsSync({ + name: "my-server", + tools: [], + McpServer: MockMcpServer as any, + }); + + expect(result.serverConfig.type).toBe("sdk"); + expect(result.serverConfig.name).toBe("my-server"); + expect(result.serverConfig.instance).toBeInstanceOf(MockMcpServer); + }); + + it("handles tool registration errors gracefully", () => { + const toolFn = vi.fn((name: string) => { + if (name === "bad_tool") throw new Error("Registration failed"); + }); + const MockMcpServer = createMockMcpServerClass(toolFn); + + const tools = [ + { name: "good_tool", execute: vi.fn() }, + { name: "bad_tool", execute: vi.fn() }, + ] as unknown as AnyAgentTool[]; + + const result = bridgeMoltbotToolsSync({ + name: "test-server", + tools, + McpServer: MockMcpServer as any, + }); + + expect(result.registeredTools).toEqual(["good_tool"]); + expect(result.skippedTools).toContain("bad_tool"); + }); + }); +}); diff --git a/src/agents/claude-agent-sdk/tool-bridge.ts b/src/agents/claude-agent-sdk/tool-bridge.ts index 031ca37d1..93ed4d81e 100644 --- a/src/agents/claude-agent-sdk/tool-bridge.ts +++ b/src/agents/claude-agent-sdk/tool-bridge.ts @@ -153,6 +153,13 @@ export function wrapToolHandler( // model's response. const toolCallId = `mcp-bridge-${normalizedName}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; + // Debug: log received arguments to diagnose parameter passing issues + log.debug(`Tool ${normalizedName} received args`, { + argsKeys: Object.keys(args), + argsType: typeof args, + argsPreview: JSON.stringify(args).slice(0, 500), + }); + // Create an onUpdate callback that forwards to the bridge callback. const onUpdate = onToolUpdate ? (update: unknown) => { diff --git a/src/agents/claude-agent-sdk/types.ts b/src/agents/claude-agent-sdk/types.ts index 14e7164a4..53554cc05 100644 --- a/src/agents/claude-agent-sdk/types.ts +++ b/src/agents/claude-agent-sdk/types.ts @@ -211,6 +211,14 @@ export type SdkRunnerParams = { */ mcpServerName?: string; + /** + * Custom Claude Code config directory for session storage. + * When provided, sets CLAUDE_CONFIG_DIR environment variable to ensure + * Moltbot agent sessions are stored separately from standard Claude Code + * CLI sessions. Typically: ~/.clawdbot/agents/{agentId}/ccsdk/ + */ + ccsdkConfigDir?: string; + /** * Prior conversation history to serialize into the SDK prompt. * Since the SDK is stateless, prior turns are injected as context diff --git a/src/agents/cli-credentials.test.ts b/src/agents/cli-credentials.test.ts index 9dbedf6be..560ba6fc9 100644 --- a/src/agents/cli-credentials.test.ts +++ b/src/agents/cli-credentials.test.ts @@ -239,4 +239,297 @@ describe("cli credentials", () => { provider: "openai-codex", }); }); + + describe("Linux Secret Service", () => { + it("reads credentials from Linux Secret Service using secret-tool", async () => { + execSyncMock.mockImplementation((command: unknown) => { + const cmd = String(command); + if (cmd.includes("secret-tool lookup")) { + return JSON.stringify({ + claudeAiOauth: { + accessToken: "linux-access", + refreshToken: "linux-refresh", + expiresAt: Date.now() + 60_000, + }, + }); + } + return ""; + }); + + const { readClaudeCliCredentials } = await import("./cli-credentials.js"); + const creds = readClaudeCliCredentials({ + allowKeychainPrompt: true, + platform: "linux", + execSync: execSyncMock, + }); + + expect(creds).toMatchObject({ + type: "oauth", + provider: "anthropic", + access: "linux-access", + refresh: "linux-refresh", + }); + expect(execSyncMock).toHaveBeenCalledWith( + expect.stringContaining("secret-tool lookup"), + expect.any(Object), + ); + }); + + it("falls back to file when Linux Secret Service is unavailable", async () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "moltbot-linux-")); + const credPath = path.join(tempDir, ".claude", ".credentials.json"); + + fs.mkdirSync(path.dirname(credPath), { recursive: true, mode: 0o700 }); + fs.writeFileSync( + credPath, + JSON.stringify({ + claudeAiOauth: { + accessToken: "file-access", + refreshToken: "file-refresh", + expiresAt: Date.now() + 60_000, + }, + }), + "utf8", + ); + + execSyncMock.mockImplementation(() => { + throw new Error("secret-tool not found"); + }); + + const { readClaudeCliCredentials } = await import("./cli-credentials.js"); + const creds = readClaudeCliCredentials({ + allowKeychainPrompt: true, + platform: "linux", + homeDir: tempDir, + execSync: execSyncMock, + }); + + expect(creds).toMatchObject({ + access: "file-access", + refresh: "file-refresh", + }); + }); + + it("writes credentials to Linux Secret Service", async () => { + const commands: string[] = []; + + execSyncMock.mockImplementation((command: unknown) => { + const cmd = String(command); + commands.push(cmd); + + if (cmd.includes("secret-tool lookup")) { + return JSON.stringify({ + claudeAiOauth: { + accessToken: "old-access", + refreshToken: "old-refresh", + expiresAt: Date.now() + 60_000, + }, + }); + } + return ""; + }); + + const { writeClaudeCliLinuxSecretServiceCredentials } = await import("./cli-credentials.js"); + + const ok = writeClaudeCliLinuxSecretServiceCredentials( + { + access: "new-access", + refresh: "new-refresh", + expires: Date.now() + 60_000, + }, + { execSync: execSyncMock }, + ); + + expect(ok).toBe(true); + expect(commands.some((cmd) => cmd.includes("secret-tool store"))).toBe(true); + }); + }); + + describe("Windows Credential Manager", () => { + it("reads credentials from Windows Credential Manager using PowerShell", async () => { + execSyncMock.mockImplementation((command: unknown) => { + const cmd = String(command); + if (cmd.includes("powershell") && cmd.includes("CredRead")) { + return JSON.stringify({ + claudeAiOauth: { + accessToken: "windows-access", + refreshToken: "windows-refresh", + expiresAt: Date.now() + 60_000, + }, + }); + } + return ""; + }); + + const { readClaudeCliCredentials } = await import("./cli-credentials.js"); + const creds = readClaudeCliCredentials({ + allowKeychainPrompt: true, + platform: "win32", + execSync: execSyncMock, + }); + + expect(creds).toMatchObject({ + type: "oauth", + provider: "anthropic", + access: "windows-access", + refresh: "windows-refresh", + }); + expect(execSyncMock).toHaveBeenCalledWith( + expect.stringContaining("powershell"), + expect.any(Object), + ); + }); + + it("falls back to file when Windows Credential Manager is unavailable", async () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "moltbot-win-")); + const credPath = path.join(tempDir, ".claude", ".credentials.json"); + + fs.mkdirSync(path.dirname(credPath), { recursive: true, mode: 0o700 }); + fs.writeFileSync( + credPath, + JSON.stringify({ + claudeAiOauth: { + accessToken: "file-access", + refreshToken: "file-refresh", + expiresAt: Date.now() + 60_000, + }, + }), + "utf8", + ); + + execSyncMock.mockImplementation(() => { + throw new Error("powershell not found"); + }); + + const { readClaudeCliCredentials } = await import("./cli-credentials.js"); + const creds = readClaudeCliCredentials({ + allowKeychainPrompt: true, + platform: "win32", + homeDir: tempDir, + execSync: execSyncMock, + }); + + expect(creds).toMatchObject({ + access: "file-access", + refresh: "file-refresh", + }); + }); + + it("writes credentials to Windows Credential Manager", async () => { + const commands: string[] = []; + + execSyncMock.mockImplementation((command: unknown) => { + const cmd = String(command); + commands.push(cmd); + + if (cmd.includes("CredRead")) { + return JSON.stringify({ + claudeAiOauth: { + accessToken: "old-access", + refreshToken: "old-refresh", + expiresAt: Date.now() + 60_000, + }, + }); + } + if (cmd.includes("CredWrite")) { + return "True"; + } + return ""; + }); + + const { writeClaudeCliWindowsCredentialManagerCredentials } = + await import("./cli-credentials.js"); + + const ok = writeClaudeCliWindowsCredentialManagerCredentials( + { + access: "new-access", + refresh: "new-refresh", + expires: Date.now() + 60_000, + }, + { execSync: execSyncMock }, + ); + + expect(ok).toBe(true); + expect(commands.some((cmd) => cmd.includes("CredWrite"))).toBe(true); + }); + }); + + describe("writeClaudeCliCredentials cross-platform", () => { + it("uses Linux Secret Service on linux platform", async () => { + const commands: string[] = []; + + execSyncMock.mockImplementation((command: unknown) => { + const cmd = String(command); + commands.push(cmd); + + if (cmd.includes("secret-tool lookup")) { + return JSON.stringify({ + claudeAiOauth: { + accessToken: "old-access", + refreshToken: "old-refresh", + expiresAt: Date.now() + 60_000, + }, + }); + } + return ""; + }); + + const { writeClaudeCliCredentials } = await import("./cli-credentials.js"); + + const ok = writeClaudeCliCredentials( + { + access: "new-access", + refresh: "new-refresh", + expires: Date.now() + 60_000, + }, + { + platform: "linux", + execSync: execSyncMock, + }, + ); + + expect(ok).toBe(true); + expect(commands.some((cmd) => cmd.includes("secret-tool"))).toBe(true); + }); + + it("uses Windows Credential Manager on win32 platform", async () => { + const commands: string[] = []; + + execSyncMock.mockImplementation((command: unknown) => { + const cmd = String(command); + commands.push(cmd); + + if (cmd.includes("CredRead")) { + return JSON.stringify({ + claudeAiOauth: { + accessToken: "old-access", + refreshToken: "old-refresh", + expiresAt: Date.now() + 60_000, + }, + }); + } + if (cmd.includes("CredWrite")) { + return "True"; + } + return ""; + }); + + const { writeClaudeCliCredentials } = await import("./cli-credentials.js"); + + const ok = writeClaudeCliCredentials( + { + access: "new-access", + refresh: "new-refresh", + expires: Date.now() + 60_000, + }, + { + platform: "win32", + execSync: execSyncMock, + }, + ); + + expect(ok).toBe(true); + expect(commands.some((cmd) => cmd.includes("powershell"))).toBe(true); + }); + }); }); diff --git a/src/agents/cli-credentials.ts b/src/agents/cli-credentials.ts index a0f2fb2c5..d1e08f764 100644 --- a/src/agents/cli-credentials.ts +++ b/src/agents/cli-credentials.ts @@ -30,6 +30,9 @@ const QWEN_CLI_CREDENTIALS_RELATIVE_PATH = ".qwen/oauth_creds.json"; const CLAUDE_CLI_KEYCHAIN_SERVICE = "Claude Code-credentials"; const CLAUDE_CLI_KEYCHAIN_ACCOUNT = "Claude Code"; +// Windows Credential Manager target name (matches Claude Code CLI) +const CLAUDE_CLI_WINDOWS_TARGET = "Claude Code-credentials"; + type CachedValue = { value: T | null; readAt: number; @@ -84,6 +87,7 @@ type ClaudeCliFileOptions = { type ClaudeCliWriteOptions = ClaudeCliFileOptions & { platform?: NodeJS.Platform; + execSync?: ExecSyncFn; writeKeychain?: (credentials: OAuthCredentials) => boolean; writeFile?: (credentials: OAuthCredentials, options?: ClaudeCliFileOptions) => boolean; }; @@ -276,6 +280,236 @@ function readClaudeCliKeychainCredentials( } } +/** + * Read Claude CLI credentials from Linux Secret Service (libsecret). + * + * This uses the `secret-tool` CLI which interfaces with GNOME Keyring, + * KWallet, or any freedesktop.org Secret Service D-Bus compatible keyring. + * + * Requires: libsecret-tools package (apt install libsecret-tools on Debian/Ubuntu) + */ +function readClaudeCliLinuxSecretServiceCredentials( + execSyncImpl: ExecSyncFn = execSync, +): ClaudeCliCredential | null { + log.debug("[CCSDK-AUTH] Attempting to read credentials from Linux Secret Service", { + service: CLAUDE_CLI_KEYCHAIN_SERVICE, + }); + + try { + // secret-tool lookup returns the secret value for matching attributes + const result = execSyncImpl(`secret-tool lookup service "${CLAUDE_CLI_KEYCHAIN_SERVICE}"`, { + encoding: "utf8", + timeout: 5000, + stdio: ["pipe", "pipe", "pipe"], + }); + + if (!result || !result.trim()) { + log.debug("[CCSDK-AUTH] Linux Secret Service returned empty result"); + return null; + } + + log.debug("[CCSDK-AUTH] Linux Secret Service returned data", { + rawLength: result.length, + }); + + const data = JSON.parse(result.trim()); + const claudeOauth = data?.claudeAiOauth; + if (!claudeOauth || typeof claudeOauth !== "object") { + log.debug("[CCSDK-AUTH] Linux Secret Service data missing claudeAiOauth object"); + return null; + } + + const accessToken = claudeOauth.accessToken; + const refreshToken = claudeOauth.refreshToken; + const expiresAt = claudeOauth.expiresAt; + + if (typeof accessToken !== "string" || !accessToken) { + log.debug("[CCSDK-AUTH] Linux Secret Service accessToken invalid or missing"); + return null; + } + if (typeof expiresAt !== "number" || expiresAt <= 0) { + log.debug("[CCSDK-AUTH] Linux Secret Service expiresAt invalid", { expiresAt }); + 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) { + return { + type: "oauth", + provider: "anthropic", + access: accessToken, + refresh: refreshToken, + expires: expiresAt, + }; + } + + return { + type: "token", + provider: "anthropic", + token: accessToken, + expires: expiresAt, + }; + } catch (err) { + log.debug("[CCSDK-AUTH] Linux Secret Service read failed", { + error: err instanceof Error ? err.message : String(err), + }); + return null; + } +} + +/** + * Read Claude CLI credentials from Windows Credential Manager. + * + * This uses PowerShell to retrieve credentials from Windows Credential Manager + * using the CredRead API. The credential is stored as a "Generic" credential. + */ +function readClaudeCliWindowsCredentialManagerCredentials( + execSyncImpl: ExecSyncFn = execSync, +): ClaudeCliCredential | null { + log.debug("[CCSDK-AUTH] Attempting to read credentials from Windows Credential Manager", { + target: CLAUDE_CLI_WINDOWS_TARGET, + }); + + try { + // PowerShell script to read from Windows Credential Manager + // Uses the .NET CredentialManager API via Add-Type + const psScript = ` + Add-Type -TypeDefinition @" + using System; + using System.Runtime.InteropServices; + using System.Text; + public class CredManager { + [DllImport("advapi32.dll", SetLastError = true, CharSet = CharSet.Unicode)] + public static extern bool CredRead(string target, int type, int flags, out IntPtr credential); + [DllImport("advapi32.dll", SetLastError = true)] + public static extern bool CredFree(IntPtr credential); + [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)] + public struct CREDENTIAL { + public int Flags; + public int Type; + public string TargetName; + public string Comment; + public System.Runtime.InteropServices.ComTypes.FILETIME LastWritten; + public int CredentialBlobSize; + public IntPtr CredentialBlob; + public int Persist; + public int AttributeCount; + public IntPtr Attributes; + public string TargetAlias; + public string UserName; + } + public static string GetCredential(string target) { + IntPtr credPtr; + if (!CredRead(target, 1, 0, out credPtr)) return null; + try { + CREDENTIAL cred = (CREDENTIAL)Marshal.PtrToStructure(credPtr, typeof(CREDENTIAL)); + if (cred.CredentialBlobSize > 0) { + return Marshal.PtrToStringUni(cred.CredentialBlob, cred.CredentialBlobSize / 2); + } + return null; + } finally { + CredFree(credPtr); + } + } + } +"@ + $result = [CredManager]::GetCredential("${CLAUDE_CLI_WINDOWS_TARGET}") + if ($result) { Write-Output $result } + `.replace(/\n/g, " "); + + const result = execSyncImpl( + `powershell -NoProfile -Command "${psScript.replace(/"/g, '\\"')}"`, + { + encoding: "utf8", + timeout: 10000, + stdio: ["pipe", "pipe", "pipe"], + }, + ); + + if (!result || !result.trim()) { + log.debug("[CCSDK-AUTH] Windows Credential Manager returned empty result"); + return null; + } + + log.debug("[CCSDK-AUTH] Windows Credential Manager returned data", { + rawLength: result.length, + }); + + const data = JSON.parse(result.trim()); + const claudeOauth = data?.claudeAiOauth; + if (!claudeOauth || typeof claudeOauth !== "object") { + log.debug("[CCSDK-AUTH] Windows Credential Manager data missing claudeAiOauth object"); + return null; + } + + const accessToken = claudeOauth.accessToken; + const refreshToken = claudeOauth.refreshToken; + const expiresAt = claudeOauth.expiresAt; + + if (typeof accessToken !== "string" || !accessToken) { + log.debug("[CCSDK-AUTH] Windows Credential Manager accessToken invalid or missing"); + return null; + } + if (typeof expiresAt !== "number" || expiresAt <= 0) { + log.debug("[CCSDK-AUTH] Windows Credential Manager expiresAt invalid", { expiresAt }); + 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) { + return { + type: "oauth", + provider: "anthropic", + access: accessToken, + refresh: refreshToken, + expires: expiresAt, + }; + } + + return { + type: "token", + provider: "anthropic", + token: accessToken, + expires: expiresAt, + }; + } catch (err) { + log.debug("[CCSDK-AUTH] Windows Credential Manager read failed", { + error: err instanceof Error ? err.message : String(err), + }); + return null; + } +} + export function readClaudeCliCredentials(options?: { allowKeychainPrompt?: boolean; platform?: NodeJS.Platform; @@ -290,22 +524,40 @@ export function readClaudeCliCredentials(options?: { homeDir: options?.homeDir ?? "(default)", }); - // Try macOS Keychain first - if (platform === "darwin" && options?.allowKeychainPrompt !== false) { - log.debug("[CCSDK-AUTH] Attempting macOS Keychain read"); - const keychainCreds = readClaudeCliKeychainCredentials(options?.execSync); - if (keychainCreds) { - log.info("[CCSDK-AUTH] Successfully read credentials from macOS Keychain", { - type: keychainCreds.type, - source: "keychain", - accessTokenMasked: - keychainCreds.type === "oauth" - ? maskToken(keychainCreds.access) - : maskToken(keychainCreds.token), - }); - return keychainCreds; + // Try platform-specific secure credential storage first + if (options?.allowKeychainPrompt !== false) { + let secureStoreCreds: ClaudeCliCredential | null = null; + let source = ""; + + if (platform === "darwin") { + log.debug("[CCSDK-AUTH] Attempting macOS Keychain read"); + secureStoreCreds = readClaudeCliKeychainCredentials(options?.execSync); + source = "macos-keychain"; + } else if (platform === "linux") { + log.debug("[CCSDK-AUTH] Attempting Linux Secret Service read"); + secureStoreCreds = readClaudeCliLinuxSecretServiceCredentials(options?.execSync); + source = "linux-secret-service"; + } else if (platform === "win32") { + log.debug("[CCSDK-AUTH] Attempting Windows Credential Manager read"); + secureStoreCreds = readClaudeCliWindowsCredentialManagerCredentials(options?.execSync); + source = "windows-credential-manager"; + } + + if (secureStoreCreds) { + log.info("[CCSDK-AUTH] Successfully read credentials from secure storage", { + type: secureStoreCreds.type, + source, + accessTokenMasked: + secureStoreCreds.type === "oauth" + ? maskToken(secureStoreCreds.access) + : maskToken(secureStoreCreds.token), + }); + return secureStoreCreds; + } + + if (source) { + log.debug("[CCSDK-AUTH] Secure storage read returned null, falling back to file", { source }); } - log.debug("[CCSDK-AUTH] Keychain read returned null, falling back to file"); } // Fall back to file-based credentials @@ -458,6 +710,210 @@ export function writeClaudeCliKeychainCredentials( } } +/** + * Write Claude CLI credentials to Linux Secret Service (libsecret). + * + * Updates existing credentials in GNOME Keyring, KWallet, or compatible keyring. + */ +export function writeClaudeCliLinuxSecretServiceCredentials( + newCredentials: OAuthCredentials, + options?: { execSync?: ExecSyncFn }, +): boolean { + const execSyncImpl = options?.execSync ?? execSync; + try { + // First read existing data to preserve other fields + const existingResult = execSyncImpl( + `secret-tool lookup service "${CLAUDE_CLI_KEYCHAIN_SERVICE}" 2>/dev/null`, + { encoding: "utf8", timeout: 5000, stdio: ["pipe", "pipe", "pipe"] }, + ); + + if (!existingResult || !existingResult.trim()) { + log.debug("[CCSDK-AUTH] No existing Linux Secret Service entry to update"); + return false; + } + + const existingData = JSON.parse(existingResult.trim()); + const existingOauth = existingData?.claudeAiOauth; + if (!existingOauth || typeof existingOauth !== "object") { + return false; + } + + existingData.claudeAiOauth = { + ...existingOauth, + accessToken: newCredentials.access, + refreshToken: newCredentials.refresh, + expiresAt: newCredentials.expires, + }; + + const newValue = JSON.stringify(existingData); + + // Write back using secret-tool store (overwrites existing entry with same attributes) + execSyncImpl( + `echo -n '${newValue.replace(/'/g, "'\\''")}' | secret-tool store --label="Claude Code" service "${CLAUDE_CLI_KEYCHAIN_SERVICE}"`, + { encoding: "utf8", timeout: 5000, stdio: ["pipe", "pipe", "pipe"], shell: "/bin/bash" }, + ); + + log.info("wrote refreshed credentials to linux secret service", { + expires: new Date(newCredentials.expires).toISOString(), + }); + return true; + } catch (error) { + log.warn("failed to write credentials to linux secret service", { + error: error instanceof Error ? error.message : String(error), + }); + return false; + } +} + +/** + * Write Claude CLI credentials to Windows Credential Manager. + * + * Updates existing credentials in Windows Credential Manager. + */ +export function writeClaudeCliWindowsCredentialManagerCredentials( + newCredentials: OAuthCredentials, + options?: { execSync?: ExecSyncFn }, +): boolean { + const execSyncImpl = options?.execSync ?? execSync; + try { + // First read existing data to preserve other fields + const psReadScript = ` + Add-Type -TypeDefinition @" + using System; + using System.Runtime.InteropServices; + public class CredManager { + [DllImport("advapi32.dll", SetLastError = true, CharSet = CharSet.Unicode)] + public static extern bool CredRead(string target, int type, int flags, out IntPtr credential); + [DllImport("advapi32.dll", SetLastError = true)] + public static extern bool CredFree(IntPtr credential); + [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)] + public struct CREDENTIAL { + public int Flags; + public int Type; + public string TargetName; + public string Comment; + public System.Runtime.InteropServices.ComTypes.FILETIME LastWritten; + public int CredentialBlobSize; + public IntPtr CredentialBlob; + public int Persist; + public int AttributeCount; + public IntPtr Attributes; + public string TargetAlias; + public string UserName; + } + public static string GetCredential(string target) { + IntPtr credPtr; + if (!CredRead(target, 1, 0, out credPtr)) return null; + try { + CREDENTIAL cred = (CREDENTIAL)Marshal.PtrToStructure(credPtr, typeof(CREDENTIAL)); + if (cred.CredentialBlobSize > 0) { + return Marshal.PtrToStringUni(cred.CredentialBlob, cred.CredentialBlobSize / 2); + } + return null; + } finally { + CredFree(credPtr); + } + } + } +"@ + $result = [CredManager]::GetCredential("${CLAUDE_CLI_WINDOWS_TARGET}") + if ($result) { Write-Output $result } + `.replace(/\n/g, " "); + + const existingResult = execSyncImpl( + `powershell -NoProfile -Command "${psReadScript.replace(/"/g, '\\"')}"`, + { encoding: "utf8", timeout: 10000, stdio: ["pipe", "pipe", "pipe"] }, + ); + + if (!existingResult || !existingResult.trim()) { + log.debug("[CCSDK-AUTH] No existing Windows Credential Manager entry to update"); + return false; + } + + const existingData = JSON.parse(existingResult.trim()); + const existingOauth = existingData?.claudeAiOauth; + if (!existingOauth || typeof existingOauth !== "object") { + return false; + } + + existingData.claudeAiOauth = { + ...existingOauth, + accessToken: newCredentials.access, + refreshToken: newCredentials.refresh, + expiresAt: newCredentials.expires, + }; + + const newValue = JSON.stringify(existingData); + + // Write back using PowerShell CredWrite + const psWriteScript = ` + Add-Type -TypeDefinition @" + using System; + using System.Runtime.InteropServices; + using System.Text; + public class CredWriter { + [DllImport("advapi32.dll", SetLastError = true, CharSet = CharSet.Unicode)] + public static extern bool CredWrite(ref CREDENTIAL credential, int flags); + [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)] + public struct CREDENTIAL { + public int Flags; + public int Type; + public string TargetName; + public string Comment; + public System.Runtime.InteropServices.ComTypes.FILETIME LastWritten; + public int CredentialBlobSize; + public IntPtr CredentialBlob; + public int Persist; + public int AttributeCount; + public IntPtr Attributes; + public string TargetAlias; + public string UserName; + } + public static bool WriteCredential(string target, string secret) { + byte[] byteArray = Encoding.Unicode.GetBytes(secret); + CREDENTIAL cred = new CREDENTIAL(); + cred.Type = 1; + cred.TargetName = target; + cred.CredentialBlobSize = byteArray.Length; + cred.CredentialBlob = Marshal.AllocHGlobal(byteArray.Length); + Marshal.Copy(byteArray, 0, cred.CredentialBlob, byteArray.Length); + cred.Persist = 2; + cred.UserName = "Claude Code"; + try { + return CredWrite(ref cred, 0); + } finally { + Marshal.FreeHGlobal(cred.CredentialBlob); + } + } + } +"@ + [CredWriter]::WriteCredential("${CLAUDE_CLI_WINDOWS_TARGET}", '${newValue.replace(/'/g, "''")}') + `.replace(/\n/g, " "); + + const writeResult = execSyncImpl( + `powershell -NoProfile -Command "${psWriteScript.replace(/"/g, '\\"')}"`, + { encoding: "utf8", timeout: 10000, stdio: ["pipe", "pipe", "pipe"] }, + ); + + if (writeResult.trim() === "True") { + log.info("wrote refreshed credentials to windows credential manager", { + expires: new Date(newCredentials.expires).toISOString(), + }); + return true; + } + + log.warn("failed to write credentials to windows credential manager", { + result: writeResult.trim(), + }); + return false; + } catch (error) { + log.warn("failed to write credentials to windows credential manager", { + error: error instanceof Error ? error.message : String(error), + }); + return false; + } +} + export function writeClaudeCliFileCredentials( newCredentials: OAuthCredentials, options?: ClaudeCliFileOptions, @@ -506,13 +962,29 @@ export function writeClaudeCliCredentials( options?.writeFile ?? ((credentials, fileOptions) => writeClaudeCliFileCredentials(credentials, fileOptions)); + // Try platform-specific secure storage first if (platform === "darwin") { - const didWriteKeychain = writeKeychain(newCredentials); - if (didWriteKeychain) { + const didWrite = writeKeychain(newCredentials); + if (didWrite) { + return true; + } + } else if (platform === "linux") { + const didWrite = writeClaudeCliLinuxSecretServiceCredentials(newCredentials, { + execSync: options?.execSync, + }); + if (didWrite) { + return true; + } + } else if (platform === "win32") { + const didWrite = writeClaudeCliWindowsCredentialManagerCredentials(newCredentials, { + execSync: options?.execSync, + }); + if (didWrite) { return true; } } + // Fall back to file-based storage return writeFile(newCredentials, { homeDir: options?.homeDir }); } diff --git a/src/agents/cli-runner.ts b/src/agents/cli-runner.ts index c998a9e6f..2e06fa84b 100644 --- a/src/agents/cli-runner.ts +++ b/src/agents/cli-runner.ts @@ -28,7 +28,7 @@ import { } from "./cli-runner/helpers.js"; import { FailoverError, resolveFailoverStatus } from "./failover-error.js"; import { classifyFailoverReason, isFailoverErrorMessage } from "./pi-embedded-helpers.js"; -import type { EmbeddedPiRunResult } from "./pi-embedded-runner.js"; +import type { AgentRunResult } from "./runtime-result-types.js"; const log = createSubsystemLogger("agent/claude-cli"); @@ -49,7 +49,7 @@ export async function runCliAgent(params: { ownerNumbers?: string[]; cliSessionId?: string; images?: ImageContent[]; -}): Promise { +}): Promise { const started = Date.now(); const resolvedWorkspace = resolveUserPath(params.workspaceDir); const workspaceDir = resolvedWorkspace; @@ -314,7 +314,7 @@ export async function runClaudeCliAgent(params: { ownerNumbers?: string[]; claudeSessionId?: string; images?: ImageContent[]; -}): Promise { +}): Promise { return runCliAgent({ sessionId: params.sessionId, sessionKey: params.sessionKey, diff --git a/src/agents/main-agent-runtime-factory.test.ts b/src/agents/main-agent-runtime-factory.test.ts new file mode 100644 index 000000000..be6785440 --- /dev/null +++ b/src/agents/main-agent-runtime-factory.test.ts @@ -0,0 +1,226 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; + +import type { MoltbotConfig } from "../config/config.js"; + +// Mock the dependent modules +vi.mock("./pi-agent-runtime.js", () => ({ + createPiAgentRuntime: vi.fn(() => ({ + kind: "pi", + displayName: "Pi Agent", + run: vi.fn(), + })), +})); + +vi.mock("./agent-scope.js", () => ({ + resolveAgentConfig: vi.fn(), +})); + +vi.mock("../logging/subsystem.js", () => ({ + createSubsystemLogger: vi.fn(() => ({ + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + })), +})); + +// Dynamic import mock for CCSDK +const mockCcSdkRuntime = { + kind: "ccsdk" as const, + displayName: "Claude Code SDK", + run: vi.fn(), +}; +const mockIsSdkAvailable = vi.fn(() => true); +const mockCreateCcSdkAgentRuntime = vi.fn(() => mockCcSdkRuntime); + +vi.mock("./claude-agent-sdk/index.js", () => ({ + createCcSdkAgentRuntime: mockCreateCcSdkAgentRuntime, + isSdkAvailable: mockIsSdkAvailable, +})); + +import { + resolveAgentRuntimeKind, + createAgentRuntime, + isCcSdkRuntimeAvailable, +} from "./main-agent-runtime-factory.js"; +import { createPiAgentRuntime } from "./pi-agent-runtime.js"; +import { resolveAgentConfig } from "./agent-scope.js"; + +describe("main-agent-runtime-factory", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockIsSdkAvailable.mockReturnValue(true); + }); + + afterEach(() => { + vi.resetAllMocks(); + }); + + describe("resolveAgentRuntimeKind", () => { + it("returns per-agent runtime when configured as 'pi'", () => { + vi.mocked(resolveAgentConfig).mockReturnValue({ runtime: "pi" }); + + const result = resolveAgentRuntimeKind({} as MoltbotConfig, "test-agent"); + + expect(result).toBe("pi"); + }); + + it("returns per-agent runtime when configured as 'ccsdk'", () => { + vi.mocked(resolveAgentConfig).mockReturnValue({ runtime: "ccsdk" }); + + const result = resolveAgentRuntimeKind({} as MoltbotConfig, "test-agent"); + + expect(result).toBe("ccsdk"); + }); + + it("falls back to defaults.runtime when per-agent not configured", () => { + vi.mocked(resolveAgentConfig).mockReturnValue(undefined); + + const config: MoltbotConfig = { + agents: { + defaults: { + runtime: "ccsdk", + }, + }, + }; + + const result = resolveAgentRuntimeKind(config, "test-agent"); + + expect(result).toBe("ccsdk"); + }); + + it("falls back to 'pi' when no runtime is configured", () => { + vi.mocked(resolveAgentConfig).mockReturnValue(undefined); + + const config: MoltbotConfig = {}; + + const result = resolveAgentRuntimeKind(config, "test-agent"); + + expect(result).toBe("pi"); + }); + + it("ignores invalid per-agent runtime values", () => { + vi.mocked(resolveAgentConfig).mockReturnValue({ runtime: "invalid" as any }); + + const config: MoltbotConfig = { + agents: { + defaults: { + runtime: "ccsdk", + }, + }, + }; + + const result = resolveAgentRuntimeKind(config, "test-agent"); + + expect(result).toBe("ccsdk"); + }); + + it("ignores invalid defaults.runtime values", () => { + vi.mocked(resolveAgentConfig).mockReturnValue(undefined); + + const config: MoltbotConfig = { + agents: { + defaults: { + runtime: "bogus" as any, + }, + }, + }; + + const result = resolveAgentRuntimeKind(config, "test-agent"); + + expect(result).toBe("pi"); + }); + + it("prefers per-agent runtime over defaults", () => { + vi.mocked(resolveAgentConfig).mockReturnValue({ runtime: "pi" }); + + const config: MoltbotConfig = { + agents: { + defaults: { + runtime: "ccsdk", + }, + }, + }; + + const result = resolveAgentRuntimeKind(config, "test-agent"); + + expect(result).toBe("pi"); + }); + }); + + describe("createAgentRuntime", () => { + it("creates Pi runtime when resolved kind is 'pi'", async () => { + vi.mocked(resolveAgentConfig).mockReturnValue({ runtime: "pi" }); + + const runtime = await createAgentRuntime({} as MoltbotConfig, "test-agent"); + + expect(createPiAgentRuntime).toHaveBeenCalled(); + expect(runtime.kind).toBe("pi"); + }); + + it("creates CCSDK runtime when resolved kind is 'ccsdk' and SDK available", async () => { + vi.mocked(resolveAgentConfig).mockReturnValue({ runtime: "ccsdk" }); + mockIsSdkAvailable.mockReturnValue(true); + + const runtime = await createAgentRuntime({} as MoltbotConfig, "test-agent"); + + expect(mockCreateCcSdkAgentRuntime).toHaveBeenCalled(); + expect(runtime.kind).toBe("ccsdk"); + }); + + it("falls back to Pi runtime when CCSDK requested but SDK unavailable", async () => { + vi.mocked(resolveAgentConfig).mockReturnValue({ runtime: "ccsdk" }); + mockIsSdkAvailable.mockReturnValue(false); + + const runtime = await createAgentRuntime({} as MoltbotConfig, "test-agent"); + + expect(createPiAgentRuntime).toHaveBeenCalled(); + expect(runtime.kind).toBe("pi"); + }); + + it("respects forceKind parameter to override config resolution", async () => { + vi.mocked(resolveAgentConfig).mockReturnValue({ runtime: "pi" }); + mockIsSdkAvailable.mockReturnValue(true); + + const runtime = await createAgentRuntime({} as MoltbotConfig, "test-agent", "ccsdk"); + + expect(mockCreateCcSdkAgentRuntime).toHaveBeenCalled(); + expect(runtime.kind).toBe("ccsdk"); + }); + + it("passes CCSDK config to createCcSdkAgentRuntime", async () => { + const ccsdkConfig = { modelTiers: { primary: "claude-sonnet-4-20250514" } }; + vi.mocked(resolveAgentConfig).mockReturnValue({ + runtime: "ccsdk", + ccsdk: ccsdkConfig, + }); + mockIsSdkAvailable.mockReturnValue(true); + + const config: MoltbotConfig = {}; + await createAgentRuntime(config, "test-agent"); + + expect(mockCreateCcSdkAgentRuntime).toHaveBeenCalledWith({ + config, + ccsdkConfig, + }); + }); + }); + + describe("isCcSdkRuntimeAvailable", () => { + it("returns true when SDK is available", async () => { + mockIsSdkAvailable.mockReturnValue(true); + + const result = await isCcSdkRuntimeAvailable(); + + expect(result).toBe(true); + }); + + it("returns false when SDK is unavailable", async () => { + mockIsSdkAvailable.mockReturnValue(false); + + const result = await isCcSdkRuntimeAvailable(); + + expect(result).toBe(false); + }); + }); +}); diff --git a/src/agents/pi-agent-runtime.ts b/src/agents/pi-agent-runtime.ts index b0acecca0..91a0c1d66 100644 --- a/src/agents/pi-agent-runtime.ts +++ b/src/agents/pi-agent-runtime.ts @@ -36,11 +36,13 @@ export function createPiAgentRuntime(): AgentRuntime { // Spread shared params directly, then add Pi-specific options from the bag return runEmbeddedPiAgent({ ...params, - // Pi-specific options from piOptions bag override any shared fields + // Pi-specific options from piOptions bag enforceFinalTag: piOpts.enforceFinalTag, - execOverrides: piOpts.execOverrides, - bashElevated: piOpts.bashElevated, - clientTools: piOpts.clientTools, + // Client tools (now at top level, applicable to both runtimes) + clientTools: params.clientTools, + // Shared params (execOverrides and bashElevated are now at top level) + execOverrides: params.execOverrides, + bashElevated: params.bashElevated, }); }, }; diff --git a/src/agents/pi-embedded-runner.buildembeddedsandboxinfo.test.ts b/src/agents/pi-embedded-runner.buildembeddedsandboxinfo.test.ts index 5cc37fc03..c098c3fb0 100644 --- a/src/agents/pi-embedded-runner.buildembeddedsandboxinfo.test.ts +++ b/src/agents/pi-embedded-runner.buildembeddedsandboxinfo.test.ts @@ -2,7 +2,7 @@ import fs from "node:fs/promises"; import { describe, expect, it, vi } from "vitest"; import type { MoltbotConfig } from "../config/config.js"; import { ensureMoltbotModelsJson } from "./models-config.js"; -import { buildEmbeddedSandboxInfo } from "./pi-embedded-runner.js"; +import { buildAgentSandboxInfo } from "./pi-embedded-runner.js"; import type { SandboxContext } from "./sandbox.js"; vi.mock("@mariozechner/pi-ai", async () => { @@ -97,9 +97,9 @@ const _readSessionMessages = async (sessionFile: string) => { .map((entry) => entry.message as { role?: string; content?: unknown }); }; -describe("buildEmbeddedSandboxInfo", () => { +describe("buildAgentSandboxInfo", () => { it("returns undefined when sandbox is missing", () => { - expect(buildEmbeddedSandboxInfo()).toBeUndefined(); + expect(buildAgentSandboxInfo()).toBeUndefined(); }); it("maps sandbox context into prompt info", () => { const sandbox = { @@ -133,7 +133,7 @@ describe("buildEmbeddedSandboxInfo", () => { }, } satisfies SandboxContext; - expect(buildEmbeddedSandboxInfo(sandbox)).toEqual({ + expect(buildAgentSandboxInfo(sandbox)).toEqual({ enabled: true, workspaceDir: "/tmp/moltbot-sandbox", workspaceAccess: "none", @@ -171,7 +171,7 @@ describe("buildEmbeddedSandboxInfo", () => { } satisfies SandboxContext; expect( - buildEmbeddedSandboxInfo(sandbox, { + buildAgentSandboxInfo(sandbox, { enabled: true, allowed: true, defaultLevel: "on", diff --git a/src/agents/pi-embedded-runner.ts b/src/agents/pi-embedded-runner.ts index bdebd0005..8e18333ae 100644 --- a/src/agents/pi-embedded-runner.ts +++ b/src/agents/pi-embedded-runner.ts @@ -16,12 +16,19 @@ export { queueEmbeddedPiMessage, waitForEmbeddedPiRunEnd, } from "./pi-embedded-runner/runs.js"; -export { buildEmbeddedSandboxInfo } from "./pi-embedded-runner/sandbox-info.js"; +export { buildAgentSandboxInfo } from "./pi-embedded-runner/sandbox-info.js"; export { createSystemPromptOverride } from "./pi-embedded-runner/system-prompt.js"; export { splitSdkTools } from "./pi-embedded-runner/tool-split.js"; +// Re-export shared result types (generalized names) export type { - EmbeddedPiAgentMeta, - EmbeddedPiCompactResult, - EmbeddedPiRunMeta, - EmbeddedPiRunResult, -} from "./pi-embedded-runner/types.js"; + AgentRunMeta, + AgentRunResultMeta, + AgentRunResult, + AgentRuntimeKind, +} from "./runtime-result-types.js"; + +// Re-export Pi-specific types +export type { EmbeddedPiCompactResult } from "./pi-embedded-runner/types.js"; + +// Re-export shared sandbox info type +export type { AgentSandboxInfo } from "./runtime-result-types.js"; diff --git a/src/agents/pi-embedded-runner/compact.ts b/src/agents/pi-embedded-runner/compact.ts index dc68561c2..3d556cc54 100644 --- a/src/agents/pi-embedded-runner/compact.ts +++ b/src/agents/pi-embedded-runner/compact.ts @@ -61,7 +61,7 @@ import { getDmHistoryLimitFromSessionKey, limitHistoryTurns } from "./history.js import { resolveGlobalLane, resolveSessionLane } from "./lanes.js"; import { log } from "./logger.js"; import { buildModelAliasLines, resolveModel } from "./model.js"; -import { buildEmbeddedSandboxInfo } from "./sandbox-info.js"; +import { buildAgentSandboxInfo } from "./sandbox-info.js"; import { prewarmSessionFile, trackSessionManagerAccess } from "./session-manager-cache.js"; import { buildEmbeddedSystemPrompt, createSystemPromptOverride } from "./system-prompt.js"; import { splitSdkTools } from "./tool-split.js"; @@ -304,7 +304,7 @@ export async function compactEmbeddedPiSessionDirect( capabilities: runtimeCapabilities, channelActions, }; - const sandboxInfo = buildEmbeddedSandboxInfo(sandbox, params.bashElevated); + const sandboxInfo = buildAgentSandboxInfo(sandbox, params.bashElevated); const reasoningTagHint = isReasoningTagProvider(provider); const userTimezone = resolveUserTimezone(params.config?.agents?.defaults?.userTimezone); const userTimeFormat = resolveUserTimeFormat(params.config?.agents?.defaults?.timeFormat); diff --git a/src/agents/pi-embedded-runner/run.ts b/src/agents/pi-embedded-runner/run.ts index 870453f38..ff5272b82 100644 --- a/src/agents/pi-embedded-runner/run.ts +++ b/src/agents/pi-embedded-runner/run.ts @@ -50,7 +50,7 @@ import { resolveModel } from "./model.js"; import { runEmbeddedAttempt } from "./run/attempt.js"; import type { RunEmbeddedPiAgentParams } from "./run/params.js"; import { buildEmbeddedRunPayloads } from "./run/payloads.js"; -import type { EmbeddedPiAgentMeta, EmbeddedPiRunResult } from "./types.js"; +import type { AgentRunMeta, AgentRunResult } from "../runtime-result-types.js"; import { describeUnknownError } from "./utils.js"; type ApiKeyInfo = ResolvedProviderAuth; @@ -69,7 +69,7 @@ function scrubAnthropicRefusalMagic(prompt: string): string { export async function runEmbeddedPiAgent( params: RunEmbeddedPiAgentParams, -): Promise { +): Promise { const sessionLane = resolveSessionLane(params.sessionKey?.trim() || params.sessionId); const globalLane = resolveGlobalLane(params.lane); const enqueueGlobal = @@ -611,7 +611,7 @@ export async function runEmbeddedPiAgent( } const usage = normalizeUsage(lastAssistant?.usage as UsageLike); - const agentMeta: EmbeddedPiAgentMeta = { + const agentMeta: AgentRunMeta = { sessionId: sessionIdUsed, provider: lastAssistant?.provider ?? provider, model: lastAssistant?.model ?? model.id, diff --git a/src/agents/pi-embedded-runner/run/attempt.ts b/src/agents/pi-embedded-runner/run/attempt.ts index 46a53bd8f..32dcec347 100644 --- a/src/agents/pi-embedded-runner/run/attempt.ts +++ b/src/agents/pi-embedded-runner/run/attempt.ts @@ -70,7 +70,7 @@ import { type EmbeddedPiQueueHandle, setActiveEmbeddedRun, } from "../runs.js"; -import { buildEmbeddedSandboxInfo } from "../sandbox-info.js"; +import { buildAgentSandboxInfo } from "../sandbox-info.js"; import { prewarmSessionFile, trackSessionManagerAccess } from "../session-manager-cache.js"; import { prepareSessionManagerForRun } from "../session-manager-init.js"; import { buildEmbeddedSystemPrompt, createSystemPromptOverride } from "../system-prompt.js"; @@ -285,7 +285,7 @@ export async function runEmbeddedAttempt( sessionKey: params.sessionKey, config: params.config, }); - const sandboxInfo = buildEmbeddedSandboxInfo(sandbox, params.bashElevated); + const sandboxInfo = buildAgentSandboxInfo(sandbox, params.bashElevated); const reasoningTagHint = isReasoningTagProvider(params.provider); // Resolve channel-specific message actions for system prompt const channelActions = runtimeChannel diff --git a/src/agents/pi-embedded-runner/run/params.ts b/src/agents/pi-embedded-runner/run/params.ts index b21a5e3fc..7990064f0 100644 --- a/src/agents/pi-embedded-runner/run/params.ts +++ b/src/agents/pi-embedded-runner/run/params.ts @@ -6,16 +6,10 @@ import type { enqueueCommand } from "../../../process/command-queue.js"; import type { ExecElevatedDefaults, ExecToolDefaults } from "../../bash-tools.js"; import type { BlockReplyChunking, ToolResultFormat } from "../../pi-embedded-subscribe.js"; import type { SkillSnapshot } from "../../skills.js"; +import type { ClientToolDefinition } from "../../runtime-result-types.js"; -// Simplified tool definition for client-provided tools (OpenResponses hosted tools) -export type ClientToolDefinition = { - type: "function"; - function: { - name: string; - description?: string; - parameters?: Record; - }; -}; +// Re-export for backwards compatibility +export type { ClientToolDefinition } from "../../runtime-result-types.js"; export type RunEmbeddedPiAgentParams = { sessionId: string; diff --git a/src/agents/pi-embedded-runner/sandbox-info.ts b/src/agents/pi-embedded-runner/sandbox-info.ts index a72797c9c..c9b2e407e 100644 --- a/src/agents/pi-embedded-runner/sandbox-info.ts +++ b/src/agents/pi-embedded-runner/sandbox-info.ts @@ -1,11 +1,11 @@ import type { ExecElevatedDefaults } from "../bash-tools.js"; import type { resolveSandboxContext } from "../sandbox.js"; -import type { EmbeddedSandboxInfo } from "./types.js"; +import type { AgentSandboxInfo } from "../runtime-result-types.js"; -export function buildEmbeddedSandboxInfo( +export function buildAgentSandboxInfo( sandbox?: Awaited>, execElevated?: ExecElevatedDefaults, -): EmbeddedSandboxInfo | undefined { +): AgentSandboxInfo | undefined { if (!sandbox?.enabled) return undefined; const elevatedAllowed = Boolean(execElevated?.enabled && execElevated.allowed); return { diff --git a/src/agents/pi-embedded-runner/system-prompt.ts b/src/agents/pi-embedded-runner/system-prompt.ts index 33c4bfc8b..afd8435e3 100644 --- a/src/agents/pi-embedded-runner/system-prompt.ts +++ b/src/agents/pi-embedded-runner/system-prompt.ts @@ -3,7 +3,7 @@ import type { ResolvedTimeFormat } from "../date-time.js"; import type { EmbeddedContextFile } from "../pi-embedded-helpers.js"; import { buildAgentSystemPrompt, type PromptMode } from "../system-prompt.js"; import { buildToolSummaryMap } from "../tool-summaries.js"; -import type { EmbeddedSandboxInfo } from "./types.js"; +import type { AgentSandboxInfo } from "../runtime-result-types.js"; import type { ReasoningLevel, ThinkLevel } from "./utils.js"; export function buildEmbeddedSystemPrompt(params: { @@ -38,7 +38,7 @@ export function buildEmbeddedSystemPrompt(params: { channelActions?: string[]; }; messageToolHints?: string[]; - sandboxInfo?: EmbeddedSandboxInfo; + sandboxInfo?: AgentSandboxInfo; tools: AgentTool[]; modelAliasLines: string[]; userTimezone: string; diff --git a/src/agents/pi-embedded-runner/types.ts b/src/agents/pi-embedded-runner/types.ts index 27ccfa64e..64bd17664 100644 --- a/src/agents/pi-embedded-runner/types.ts +++ b/src/agents/pi-embedded-runner/types.ts @@ -1,56 +1,12 @@ -import type { MessagingToolSend } from "../pi-embedded-messaging.js"; -import type { SessionSystemPromptReport } from "../../config/sessions/types.js"; - -export type EmbeddedPiAgentMeta = { - sessionId: string; - provider: string; - model: string; - usage?: { - input?: number; - output?: number; - cacheRead?: number; - cacheWrite?: number; - total?: number; - }; -}; - -export type EmbeddedPiRunMeta = { - durationMs: number; - agentMeta?: EmbeddedPiAgentMeta; - aborted?: boolean; - systemPromptReport?: SessionSystemPromptReport; - error?: { - kind: "context_overflow" | "compaction_failure" | "role_ordering" | "image_size"; - message: string; - }; - /** Stop reason for the agent run (e.g., "completed", "tool_calls"). */ - stopReason?: string; - /** Pending tool calls when stopReason is "tool_calls". */ - pendingToolCalls?: Array<{ - id: string; - name: string; - arguments: string; - }>; -}; - -export type EmbeddedPiRunResult = { - payloads?: Array<{ - text?: string; - mediaUrl?: string; - mediaUrls?: string[]; - replyToId?: string; - isError?: boolean; - }>; - meta: EmbeddedPiRunMeta; - // True if a messaging tool (telegram, whatsapp, discord, slack, sessions_send) - // successfully sent a message. Used to suppress agent's confirmation text. - didSendViaMessagingTool?: boolean; - // Texts successfully sent via messaging tools during the run. - messagingToolSentTexts?: string[]; - // Messaging tool targets that successfully sent a message during the run. - messagingToolSentTargets?: MessagingToolSend[]; -}; +/** + * Pi runtime-specific types. + * + * For shared agent result types, see ../runtime-result-types.ts + */ +/** + * Result from a Pi session compaction operation. + */ export type EmbeddedPiCompactResult = { ok: boolean; compacted: boolean; @@ -64,16 +20,12 @@ export type EmbeddedPiCompactResult = { }; }; -export type EmbeddedSandboxInfo = { - enabled: boolean; - workspaceDir?: string; - workspaceAccess?: "none" | "ro" | "rw"; - agentWorkspaceMount?: string; - browserBridgeUrl?: string; - browserNoVncUrl?: string; - hostBrowserAllowed?: boolean; - elevated?: { - allowed: boolean; - defaultLevel: "on" | "off" | "ask" | "full"; - }; -}; +// Re-export shared types for convenience +export { + type AgentRunMeta, + type AgentRunResultMeta, + type AgentRunPayload, + type AgentRunResult, + type AgentRuntimeKind, + type AgentSandboxInfo, +} from "../runtime-result-types.js"; diff --git a/src/agents/pi-embedded.ts b/src/agents/pi-embedded.ts index 81e99feec..19cd3f2c0 100644 --- a/src/agents/pi-embedded.ts +++ b/src/agents/pi-embedded.ts @@ -1,9 +1,8 @@ -export type { - EmbeddedPiAgentMeta, - EmbeddedPiCompactResult, - EmbeddedPiRunMeta, - EmbeddedPiRunResult, -} from "./pi-embedded-runner.js"; +// Re-export shared agent result types +export type { AgentRunMeta, AgentRunResultMeta, AgentRunResult } from "./runtime-result-types.js"; + +// Re-export Pi-specific types +export type { EmbeddedPiCompactResult } from "./pi-embedded-runner.js"; export { abortEmbeddedPiRun, compactEmbeddedPiSession, diff --git a/src/agents/runtime-result-types.ts b/src/agents/runtime-result-types.ts new file mode 100644 index 000000000..37eab1a97 --- /dev/null +++ b/src/agents/runtime-result-types.ts @@ -0,0 +1,144 @@ +/** + * Shared result types for all agent runtimes. + * + * These types define the common contract for agent run results across + * different execution backends (Pi Agent, Claude Agent SDK, etc.). + */ + +import type { MessagingToolSend } from "./pi-embedded-messaging.js"; +import type { SessionSystemPromptReport } from "../config/sessions/types.js"; + +/** Runtime backend discriminant for agent runs. */ +export type AgentRuntimeKind = "pi" | "ccsdk"; + +/** + * Simplified tool definition for client-provided tools (OpenResponses hosted tools). + * + * These tools are defined by external clients (e.g., via OpenResponses API) and + * return "pending" results when called - execution is delegated back to the client. + */ +export type ClientToolDefinition = { + type: "function"; + function: { + name: string; + description?: string; + parameters?: Record; + }; +}; + +/** + * Agent metadata returned from a run. + * + * Contains information about the session, provider, model, and token usage. + */ +export type AgentRunMeta = { + /** Session ID for this run (provider-specific, used for session resumption). */ + sessionId: string; + /** Provider that executed this run (e.g., "anthropic", "openai"). */ + provider: string; + /** Model used for this run. */ + model: string; + /** Runtime backend that executed this run. */ + runtime?: AgentRuntimeKind; + /** Token usage statistics. */ + usage?: { + input?: number; + output?: number; + cacheRead?: number; + cacheWrite?: number; + total?: number; + }; +}; + +/** + * Metadata about an agent run execution. + * + * Contains timing, status, and error information. + */ +export type AgentRunResultMeta = { + /** Duration of the run in milliseconds. */ + durationMs: number; + /** Agent-specific metadata (session, provider, model, usage). */ + agentMeta?: AgentRunMeta; + /** Whether the run was aborted. */ + aborted?: boolean; + /** System prompt report for diagnostics. */ + systemPromptReport?: SessionSystemPromptReport; + /** Error information if the run failed. */ + error?: { + kind: "context_overflow" | "compaction_failure" | "role_ordering" | "image_size"; + message: string; + }; + /** Stop reason for the agent run (e.g., "completed", "tool_calls"). */ + stopReason?: string; + /** Pending tool calls when stopReason is "tool_calls". */ + pendingToolCalls?: Array<{ + id: string; + name: string; + arguments: string; + }>; +}; + +/** + * Result payload from an agent run. + */ +export type AgentRunPayload = { + /** Text content of the payload. */ + text?: string; + /** Single media URL (legacy, prefer mediaUrls). */ + mediaUrl?: string; + /** Media URLs attached to the payload. */ + mediaUrls?: string[]; + /** Message ID to reply to. */ + replyToId?: string; + /** Whether this payload represents an error. */ + isError?: boolean; +}; + +/** + * Complete result from an agent run. + * + * This is the shared result type returned by all agent runtimes. + */ +export type AgentRunResult = { + /** Response payloads from the agent. */ + payloads?: AgentRunPayload[]; + /** Execution metadata. */ + meta: AgentRunResultMeta; + /** + * True if a messaging tool (telegram, whatsapp, discord, slack, sessions_send) + * successfully sent a message. Used to suppress agent's confirmation text. + */ + didSendViaMessagingTool?: boolean; + /** Texts successfully sent via messaging tools during the run. */ + messagingToolSentTexts?: string[]; + /** Messaging tool targets that successfully sent a message during the run. */ + messagingToolSentTargets?: MessagingToolSend[]; +}; + +/** + * Sandbox/workspace configuration for agent runs. + * + * Describes the execution environment constraints and capabilities. + */ +export type AgentSandboxInfo = { + /** Whether sandboxing is enabled. */ + enabled: boolean; + /** Workspace directory path. */ + workspaceDir?: string; + /** Workspace access level. */ + workspaceAccess?: "none" | "ro" | "rw"; + /** Mount point for agent workspace (when read-only). */ + agentWorkspaceMount?: string; + /** Browser bridge URL for browser automation. */ + browserBridgeUrl?: string; + /** NoVNC URL for browser viewing. */ + browserNoVncUrl?: string; + /** Whether host browser control is allowed. */ + hostBrowserAllowed?: boolean; + /** Elevated execution permissions. */ + elevated?: { + allowed: boolean; + defaultLevel: "on" | "off" | "ask" | "full"; + }; +}; diff --git a/src/agents/tools/browser-tool.schema.ts b/src/agents/tools/browser-tool.schema.ts index 30cf3cc0f..dd11ddd76 100644 --- a/src/agents/tools/browser-tool.schema.ts +++ b/src/agents/tools/browser-tool.schema.ts @@ -47,67 +47,123 @@ const BROWSER_IMAGE_TYPES = ["png", "jpeg"] as const; // because Claude API on Vertex AI rejects nested anyOf schemas as invalid JSON Schema. // The discriminator (kind) determines which properties are relevant; runtime validates. const BrowserActSchema = Type.Object({ - kind: stringEnum(BROWSER_ACT_KINDS), + kind: stringEnum(BROWSER_ACT_KINDS, { + description: + "Action type: click, type, press, hover, drag, select, fill, resize, wait, evaluate, close.", + }), // Common fields - targetId: Type.Optional(Type.String()), - ref: Type.Optional(Type.String()), + targetId: Type.Optional(Type.String({ description: "Tab ID to target (from tabs action)." })), + ref: Type.Optional( + Type.String({ description: "Element reference from snapshot (e.g., 'E1', 'button[0]')." }), + ), // click - doubleClick: Type.Optional(Type.Boolean()), - button: Type.Optional(Type.String()), - modifiers: Type.Optional(Type.Array(Type.String())), + doubleClick: Type.Optional( + Type.Boolean({ description: "Perform a double-click instead of single-click." }), + ), + button: Type.Optional( + Type.String({ description: "Mouse button: 'left', 'right', or 'middle'." }), + ), + modifiers: Type.Optional( + Type.Array(Type.String(), { + description: "Keyboard modifiers to hold: 'Alt', 'Control', 'Meta', 'Shift'.", + }), + ), // type - text: Type.Optional(Type.String()), - submit: Type.Optional(Type.Boolean()), - slowly: Type.Optional(Type.Boolean()), + text: Type.Optional(Type.String({ description: "Text to type into the focused element." })), + submit: Type.Optional(Type.Boolean({ description: "Press Enter after typing to submit." })), + slowly: Type.Optional( + Type.Boolean({ description: "Type slowly with delays between keystrokes." }), + ), // press - key: Type.Optional(Type.String()), + key: Type.Optional( + Type.String({ description: "Key to press (e.g., 'Enter', 'Escape', 'Tab')." }), + ), // drag - startRef: Type.Optional(Type.String()), - endRef: Type.Optional(Type.String()), + startRef: Type.Optional(Type.String({ description: "Starting element reference for drag." })), + endRef: Type.Optional(Type.String({ description: "Ending element reference for drag." })), // select - values: Type.Optional(Type.Array(Type.String())), + values: Type.Optional( + Type.Array(Type.String(), { description: "Values to select in a dropdown/select element." }), + ), // fill - use permissive array of objects - fields: Type.Optional(Type.Array(Type.Object({}, { additionalProperties: true }))), + fields: Type.Optional( + Type.Array(Type.Object({}, { additionalProperties: true }), { + description: "Array of field objects to fill in a form.", + }), + ), // resize - width: Type.Optional(Type.Number()), - height: Type.Optional(Type.Number()), + width: Type.Optional(Type.Number({ description: "New viewport width in pixels." })), + height: Type.Optional(Type.Number({ description: "New viewport height in pixels." })), // wait - timeMs: Type.Optional(Type.Number()), - textGone: Type.Optional(Type.String()), + timeMs: Type.Optional(Type.Number({ description: "Time to wait in milliseconds." })), + textGone: Type.Optional( + Type.String({ description: "Wait until this text disappears from the page." }), + ), // evaluate - fn: Type.Optional(Type.String()), + fn: Type.Optional( + Type.String({ description: "JavaScript function body to evaluate in the page context." }), + ), }); // IMPORTANT: OpenAI function tool schemas must have a top-level `type: "object"`. // A root-level `Type.Union([...])` compiles to `{ anyOf: [...] }` (no `type`), // which OpenAI rejects ("Invalid schema ... type: None"). Keep this schema an object. export const BrowserToolSchema = Type.Object({ - action: stringEnum(BROWSER_TOOL_ACTIONS), - target: optionalStringEnum(BROWSER_TARGETS), - node: Type.Optional(Type.String()), - profile: Type.Optional(Type.String()), - targetUrl: Type.Optional(Type.String()), - targetId: Type.Optional(Type.String()), - limit: Type.Optional(Type.Number()), - maxChars: Type.Optional(Type.Number()), - mode: optionalStringEnum(BROWSER_SNAPSHOT_MODES), - snapshotFormat: optionalStringEnum(BROWSER_SNAPSHOT_FORMATS), - refs: optionalStringEnum(BROWSER_SNAPSHOT_REFS), - interactive: Type.Optional(Type.Boolean()), - compact: Type.Optional(Type.Boolean()), - depth: Type.Optional(Type.Number()), - selector: Type.Optional(Type.String()), - frame: Type.Optional(Type.String()), - labels: Type.Optional(Type.Boolean()), - fullPage: Type.Optional(Type.Boolean()), - ref: Type.Optional(Type.String()), - element: Type.Optional(Type.String()), - type: optionalStringEnum(BROWSER_IMAGE_TYPES), - level: Type.Optional(Type.String()), - paths: Type.Optional(Type.Array(Type.String())), - inputRef: Type.Optional(Type.String()), - timeoutMs: Type.Optional(Type.Number()), - accept: Type.Optional(Type.Boolean()), - promptText: Type.Optional(Type.String()), + action: stringEnum(BROWSER_TOOL_ACTIONS, { + description: + "Browser action: status, start, stop, profiles, tabs, open, focus, close, snapshot, screenshot, navigate, console, pdf, upload, dialog, act.", + }), + target: optionalStringEnum(BROWSER_TARGETS, { + description: "Browser target: 'sandbox' (isolated), 'host' (local), or 'node' (paired node).", + }), + node: Type.Optional(Type.String({ description: "Node ID or name when target='node'." })), + profile: Type.Optional( + Type.String({ description: "Browser profile name (for persistent state/cookies)." }), + ), + targetUrl: Type.Optional( + Type.String({ description: "URL to navigate to when opening a new tab." }), + ), + targetId: Type.Optional(Type.String({ description: "Tab ID to target (from tabs action)." })), + limit: Type.Optional( + Type.Number({ description: "Limit for console logs or other list results." }), + ), + maxChars: Type.Optional( + Type.Number({ description: "Maximum characters to return in snapshot text." }), + ), + mode: optionalStringEnum(BROWSER_SNAPSHOT_MODES, { + description: "Snapshot mode: 'efficient' for optimized output.", + }), + snapshotFormat: optionalStringEnum(BROWSER_SNAPSHOT_FORMATS, { + description: "Snapshot format: 'aria' (accessibility tree) or 'ai' (AI-optimized).", + }), + refs: optionalStringEnum(BROWSER_SNAPSHOT_REFS, { + description: "Reference style for elements: 'role' or 'aria'.", + }), + interactive: Type.Optional( + Type.Boolean({ description: "Include only interactive elements in snapshot." }), + ), + compact: Type.Optional(Type.Boolean({ description: "Use compact snapshot format." })), + depth: Type.Optional(Type.Number({ description: "Maximum DOM depth to traverse." })), + selector: Type.Optional(Type.String({ description: "CSS selector to scope the snapshot to." })), + frame: Type.Optional(Type.String({ description: "Frame name or index to target." })), + labels: Type.Optional(Type.Boolean({ description: "Include element labels in snapshot." })), + fullPage: Type.Optional( + Type.Boolean({ description: "Capture full page screenshot (not just viewport)." }), + ), + ref: Type.Optional(Type.String({ description: "Element reference for act or screenshot." })), + element: Type.Optional(Type.String({ description: "Element selector for screenshot." })), + type: optionalStringEnum(BROWSER_IMAGE_TYPES, { + description: "Screenshot image format: 'png' or 'jpeg'.", + }), + level: Type.Optional( + Type.String({ description: "Console log level filter (e.g., 'error', 'warn')." }), + ), + paths: Type.Optional(Type.Array(Type.String(), { description: "File paths for upload action." })), + inputRef: Type.Optional(Type.String({ description: "File input element reference for upload." })), + timeoutMs: Type.Optional(Type.Number({ description: "Timeout in milliseconds for the action." })), + accept: Type.Optional( + Type.Boolean({ description: "Accept or dismiss the dialog (for dialog action)." }), + ), + promptText: Type.Optional(Type.String({ description: "Text to enter in a prompt dialog." })), request: Type.Optional(BrowserActSchema), }); diff --git a/src/agents/tools/canvas-tool.ts b/src/agents/tools/canvas-tool.ts index 5914b4546..d381ed1aa 100644 --- a/src/agents/tools/canvas-tool.ts +++ b/src/agents/tools/canvas-tool.ts @@ -24,29 +24,43 @@ const CANVAS_SNAPSHOT_FORMATS = ["png", "jpg", "jpeg"] as const; // Flattened schema: runtime validates per-action requirements. const CanvasToolSchema = Type.Object({ - action: stringEnum(CANVAS_ACTIONS), - gatewayUrl: Type.Optional(Type.String()), - gatewayToken: Type.Optional(Type.String()), - timeoutMs: Type.Optional(Type.Number()), - node: Type.Optional(Type.String()), + action: stringEnum(CANVAS_ACTIONS, { + description: "Canvas action: present, hide, navigate, eval, snapshot, a2ui_push, a2ui_reset.", + }), + gatewayUrl: Type.Optional( + Type.String({ description: "Gateway URL override (uses default if omitted)." }), + ), + gatewayToken: Type.Optional(Type.String({ description: "Gateway authentication token." })), + timeoutMs: Type.Optional(Type.Number({ description: "Request timeout in milliseconds." })), + node: Type.Optional(Type.String({ description: "Node ID or name to target." })), // present - target: Type.Optional(Type.String()), - x: Type.Optional(Type.Number()), - y: Type.Optional(Type.Number()), - width: Type.Optional(Type.Number()), - height: Type.Optional(Type.Number()), + target: Type.Optional(Type.String({ description: "URL to load when presenting the canvas." })), + x: Type.Optional(Type.Number({ description: "Canvas window X position in pixels." })), + y: Type.Optional(Type.Number({ description: "Canvas window Y position in pixels." })), + width: Type.Optional(Type.Number({ description: "Canvas window width in pixels." })), + height: Type.Optional(Type.Number({ description: "Canvas window height in pixels." })), // navigate - url: Type.Optional(Type.String()), + url: Type.Optional(Type.String({ description: "URL to navigate the canvas to." })), // eval - javaScript: Type.Optional(Type.String()), + javaScript: Type.Optional( + Type.String({ description: "JavaScript code to evaluate in the canvas context." }), + ), // snapshot - outputFormat: optionalStringEnum(CANVAS_SNAPSHOT_FORMATS), - maxWidth: Type.Optional(Type.Number()), - quality: Type.Optional(Type.Number()), - delayMs: Type.Optional(Type.Number()), + outputFormat: optionalStringEnum(CANVAS_SNAPSHOT_FORMATS, { + description: "Snapshot image format: 'png', 'jpg', or 'jpeg'.", + }), + maxWidth: Type.Optional(Type.Number({ description: "Maximum snapshot width in pixels." })), + quality: Type.Optional( + Type.Number({ description: "Image quality (0-100) for JPEG compression." }), + ), + delayMs: Type.Optional( + Type.Number({ description: "Delay in milliseconds before capturing snapshot." }), + ), // a2ui_push - jsonl: Type.Optional(Type.String()), - jsonlPath: Type.Optional(Type.String()), + jsonl: Type.Optional(Type.String({ description: "A2UI JSONL content to push directly." })), + jsonlPath: Type.Optional( + Type.String({ description: "Path to A2UI JSONL file to read and push." }), + ), }); export function createCanvasTool(): AnyAgentTool { diff --git a/src/agents/tools/cron-tool.ts b/src/agents/tools/cron-tool.ts index 739b3ada3..7f5d33226 100644 --- a/src/agents/tools/cron-tool.ts +++ b/src/agents/tools/cron-tool.ts @@ -24,19 +24,49 @@ const REMINDER_CONTEXT_MARKER = "\n\nRecent context:\n"; // Flattened schema: runtime validates per-action requirements. const CronToolSchema = Type.Object({ - action: stringEnum(CRON_ACTIONS), - gatewayUrl: Type.Optional(Type.String()), - gatewayToken: Type.Optional(Type.String()), - timeoutMs: Type.Optional(Type.Number()), - includeDisabled: Type.Optional(Type.Boolean()), - job: Type.Optional(Type.Object({}, { additionalProperties: true })), - jobId: Type.Optional(Type.String()), - id: Type.Optional(Type.String()), - patch: Type.Optional(Type.Object({}, { additionalProperties: true })), - text: Type.Optional(Type.String()), - mode: optionalStringEnum(CRON_WAKE_MODES), + action: stringEnum(CRON_ACTIONS, { + description: "Cron action: status, list, add, update, remove, run, runs, wake.", + }), + gatewayUrl: Type.Optional( + Type.String({ description: "Gateway URL override (uses default if omitted)." }), + ), + gatewayToken: Type.Optional(Type.String({ description: "Gateway authentication token." })), + timeoutMs: Type.Optional(Type.Number({ description: "Request timeout in milliseconds." })), + includeDisabled: Type.Optional( + Type.Boolean({ description: "Include disabled jobs in list output." }), + ), + job: Type.Optional( + Type.Object( + {}, + { + additionalProperties: true, + description: + "Job definition object for add action. See tool description for schema details.", + }, + ), + ), + jobId: Type.Optional(Type.String({ description: "Job ID for update/remove/run/runs actions." })), + id: Type.Optional(Type.String({ description: "Deprecated: use jobId instead." })), + patch: Type.Optional( + Type.Object( + {}, + { + additionalProperties: true, + description: "Partial job update object for update action.", + }, + ), + ), + text: Type.Optional(Type.String({ description: "Wake event text message (for wake action)." })), + mode: optionalStringEnum(CRON_WAKE_MODES, { + description: "Wake mode: 'now' (immediate) or 'next-heartbeat' (default).", + }), contextMessages: Type.Optional( - Type.Number({ minimum: 0, maximum: REMINDER_CONTEXT_MESSAGES_MAX }), + Type.Number({ + description: + "Number of recent conversation messages (0-10) to include as context in the job text.", + minimum: 0, + maximum: REMINDER_CONTEXT_MESSAGES_MAX, + }), ), }); diff --git a/src/agents/tools/gateway-tool.ts b/src/agents/tools/gateway-tool.ts index d6c2ec7ae..900d56b76 100644 --- a/src/agents/tools/gateway-tool.ts +++ b/src/agents/tools/gateway-tool.ts @@ -37,21 +37,43 @@ const GATEWAY_ACTIONS = [ // because Claude API on Vertex AI rejects nested anyOf schemas as invalid JSON Schema. // The discriminator (action) determines which properties are relevant; runtime validates. const GatewayToolSchema = Type.Object({ - action: stringEnum(GATEWAY_ACTIONS), + action: stringEnum(GATEWAY_ACTIONS, { + description: + "Gateway action: restart, config.get, config.schema, config.apply, config.patch, update.run.", + }), // restart - delayMs: Type.Optional(Type.Number()), - reason: Type.Optional(Type.String()), + delayMs: Type.Optional( + Type.Number({ description: "Delay in milliseconds before restarting (for restart action)." }), + ), + reason: Type.Optional( + Type.String({ description: "Human-readable reason for the restart (logged)." }), + ), // config.get, config.schema, config.apply, update.run - gatewayUrl: Type.Optional(Type.String()), - gatewayToken: Type.Optional(Type.String()), - timeoutMs: Type.Optional(Type.Number()), + gatewayUrl: Type.Optional( + Type.String({ description: "Gateway URL override (uses default if omitted)." }), + ), + gatewayToken: Type.Optional(Type.String({ description: "Gateway authentication token." })), + timeoutMs: Type.Optional(Type.Number({ description: "Request timeout in milliseconds." })), // config.apply, config.patch - raw: Type.Optional(Type.String()), - baseHash: Type.Optional(Type.String()), + raw: Type.Optional( + Type.String({ + description: + "Raw YAML/JSON config content. Required for config.apply (full replace) and config.patch (partial merge).", + }), + ), + baseHash: Type.Optional( + Type.String({ + description: "Base config hash for optimistic concurrency. Auto-fetched if omitted.", + }), + ), // config.apply, config.patch, update.run - sessionKey: Type.Optional(Type.String()), - note: Type.Optional(Type.String()), - restartDelayMs: Type.Optional(Type.Number()), + sessionKey: Type.Optional( + Type.String({ description: "Session key to notify after restart completes." }), + ), + note: Type.Optional(Type.String({ description: "Note to include in the restart notification." })), + restartDelayMs: Type.Optional( + Type.Number({ description: "Delay in milliseconds before restarting after config change." }), + ), }); // NOTE: We intentionally avoid top-level `allOf`/`anyOf`/`oneOf` conditionals here: // - OpenAI rejects tool schemas that include these keywords at the *top-level*. diff --git a/src/agents/tools/image-tool.ts b/src/agents/tools/image-tool.ts index ff3ca1b2c..4b1f48dbc 100644 --- a/src/agents/tools/image-tool.ts +++ b/src/agents/tools/image-tool.ts @@ -324,10 +324,27 @@ export function createImageTool(options?: { name: "image", description, parameters: Type.Object({ - prompt: Type.Optional(Type.String()), - image: Type.String(), - model: Type.Optional(Type.String()), - maxBytesMb: Type.Optional(Type.Number()), + prompt: Type.Optional( + Type.String({ + description: + "The prompt or question to ask about the image. Defaults to 'Describe the image.'", + }), + ), + image: Type.String({ + description: + "Path to the image file, a file:// URL, a data: URL, or an http(s) URL to analyze.", + }), + model: Type.Optional( + Type.String({ + description: + "Vision model override (e.g., 'openai/gpt-5-mini'). Uses configured imageModel if omitted.", + }), + ), + maxBytesMb: Type.Optional( + Type.Number({ + description: "Maximum image size in megabytes. Larger images are rejected.", + }), + ), }), execute: async (_toolCallId, args) => { const record = args && typeof args === "object" ? (args as Record) : {}; diff --git a/src/agents/tools/memory-tool.ts b/src/agents/tools/memory-tool.ts index b7b619af3..03b2e0b44 100644 --- a/src/agents/tools/memory-tool.ts +++ b/src/agents/tools/memory-tool.ts @@ -8,15 +8,38 @@ import type { AnyAgentTool } from "./common.js"; import { jsonResult, readNumberParam, readStringParam } from "./common.js"; const MemorySearchSchema = Type.Object({ - query: Type.String(), - maxResults: Type.Optional(Type.Number()), - minScore: Type.Optional(Type.Number()), + query: Type.String({ + description: + "The semantic search query to find relevant memories. Be specific and descriptive for best results.", + }), + maxResults: Type.Optional( + Type.Number({ + description: "Maximum number of results to return. Defaults to 10.", + }), + ), + minScore: Type.Optional( + Type.Number({ + description: + "Minimum similarity score (0-1) for results. Higher values return more relevant but fewer results.", + }), + ), }); const MemoryGetSchema = Type.Object({ - path: Type.String(), - from: Type.Optional(Type.Number()), - lines: Type.Optional(Type.Number()), + path: Type.String({ + description: + "Relative path to the memory file (e.g., 'MEMORY.md' or 'memory/projects.md'). Use paths from memory_search results.", + }), + from: Type.Optional( + Type.Number({ + description: "Starting line number (1-indexed). Omit to start from the beginning.", + }), + ), + lines: Type.Optional( + Type.Number({ + description: "Number of lines to read. Omit to read to the end of file.", + }), + ), }); export function createMemorySearchTool(options: { diff --git a/src/agents/tools/message-tool.ts b/src/agents/tools/message-tool.ts index 4ea178a54..08b4c0aaf 100644 --- a/src/agents/tools/message-tool.ts +++ b/src/agents/tools/message-tool.ts @@ -25,17 +25,19 @@ import { jsonResult, readNumberParam, readStringParam } from "./common.js"; const AllMessageActions = CHANNEL_MESSAGE_ACTION_NAMES; function buildRoutingSchema() { return { - channel: Type.Optional(Type.String()), + channel: Type.Optional( + Type.String({ description: "Channel provider (e.g., 'telegram', 'discord', 'slack')." }), + ), target: Type.Optional(channelTargetSchema({ description: "Target channel/user id or name." })), targets: Type.Optional(channelTargetsSchema()), - accountId: Type.Optional(Type.String()), - dryRun: Type.Optional(Type.Boolean()), + accountId: Type.Optional(Type.String({ description: "Account ID for multi-account setups." })), + dryRun: Type.Optional(Type.Boolean({ description: "Simulate the action without sending." })), }; } function buildSendSchema(options: { includeButtons: boolean; includeCards: boolean }) { const props: Record = { - message: Type.Optional(Type.String()), + message: Type.Optional(Type.String({ description: "Text message content to send." })), effectId: Type.Optional( Type.String({ description: "Message effect name/id for sendWithEffect (e.g., invisible ink).", @@ -44,37 +46,49 @@ function buildSendSchema(options: { includeButtons: boolean; includeCards: boole effect: Type.Optional( Type.String({ description: "Alias for effectId (e.g., invisible-ink, balloons)." }), ), - media: Type.Optional(Type.String()), - filename: Type.Optional(Type.String()), + media: Type.Optional(Type.String({ description: "URL or path to media file to attach." })), + filename: Type.Optional(Type.String({ description: "Filename for the attachment." })), buffer: Type.Optional( Type.String({ description: "Base64 payload for attachments (optionally a data: URL).", }), ), - contentType: Type.Optional(Type.String()), - mimeType: Type.Optional(Type.String()), - caption: Type.Optional(Type.String()), - path: Type.Optional(Type.String()), - filePath: Type.Optional(Type.String()), - replyTo: Type.Optional(Type.String()), - threadId: Type.Optional(Type.String()), - asVoice: Type.Optional(Type.Boolean()), - silent: Type.Optional(Type.Boolean()), - quoteText: Type.Optional( - Type.String({ description: "Quote text for Telegram reply_parameters" }), + contentType: Type.Optional( + Type.String({ description: "MIME content type of the attachment." }), + ), + mimeType: Type.Optional( + Type.String({ description: "MIME type of the attachment (alias for contentType)." }), + ), + caption: Type.Optional(Type.String({ description: "Caption for media attachments." })), + path: Type.Optional(Type.String({ description: "Local file path to attach." })), + filePath: Type.Optional( + Type.String({ description: "Local file path to attach (alias for path)." }), + ), + replyTo: Type.Optional(Type.String({ description: "Message ID to reply to." })), + threadId: Type.Optional(Type.String({ description: "Thread/topic ID to send to." })), + asVoice: Type.Optional(Type.Boolean({ description: "Send audio as a voice message bubble." })), + silent: Type.Optional(Type.Boolean({ description: "Send without notification sound." })), + quoteText: Type.Optional( + Type.String({ description: "Quote text for Telegram reply_parameters." }), + ), + bestEffort: Type.Optional( + Type.Boolean({ description: "Continue on partial failures (e.g., some targets failed)." }), + ), + gifPlayback: Type.Optional( + Type.Boolean({ description: "Enable GIF autoplay for the attachment." }), ), - bestEffort: Type.Optional(Type.Boolean()), - gifPlayback: Type.Optional(Type.Boolean()), buttons: Type.Optional( Type.Array( Type.Array( Type.Object({ - text: Type.String(), - callback_data: Type.String(), + text: Type.String({ description: "Button label text." }), + callback_data: Type.String({ + description: "Callback data sent when button is pressed.", + }), }), ), { - description: "Telegram inline keyboard buttons (array of button rows)", + description: "Telegram inline keyboard buttons (array of button rows).", }, ), ), @@ -83,7 +97,7 @@ function buildSendSchema(options: { includeButtons: boolean; includeCards: boole {}, { additionalProperties: true, - description: "Adaptive Card JSON object (when supported by the channel)", + description: "Adaptive Card JSON object (when supported by the channel).", }, ), ), @@ -95,109 +109,141 @@ function buildSendSchema(options: { includeButtons: boolean; includeCards: boole function buildReactionSchema() { return { - messageId: Type.Optional(Type.String()), - emoji: Type.Optional(Type.String()), - remove: Type.Optional(Type.Boolean()), - targetAuthor: Type.Optional(Type.String()), - targetAuthorUuid: Type.Optional(Type.String()), - groupId: Type.Optional(Type.String()), + messageId: Type.Optional(Type.String({ description: "Message ID to react to." })), + emoji: Type.Optional(Type.String({ description: "Emoji character or name for the reaction." })), + remove: Type.Optional(Type.Boolean({ description: "Remove the reaction instead of adding." })), + targetAuthor: Type.Optional( + Type.String({ description: "Author of the message to react to (Signal)." }), + ), + targetAuthorUuid: Type.Optional( + Type.String({ description: "UUID of the message author (Signal)." }), + ), + groupId: Type.Optional(Type.String({ description: "Group ID for group reactions (Signal)." })), }; } function buildFetchSchema() { return { - limit: Type.Optional(Type.Number()), - before: Type.Optional(Type.String()), - after: Type.Optional(Type.String()), - around: Type.Optional(Type.String()), - fromMe: Type.Optional(Type.Boolean()), - includeArchived: Type.Optional(Type.Boolean()), + limit: Type.Optional(Type.Number({ description: "Maximum number of messages to fetch." })), + before: Type.Optional(Type.String({ description: "Fetch messages before this message ID." })), + after: Type.Optional(Type.String({ description: "Fetch messages after this message ID." })), + around: Type.Optional(Type.String({ description: "Fetch messages around this message ID." })), + fromMe: Type.Optional(Type.Boolean({ description: "Filter to only messages from the bot." })), + includeArchived: Type.Optional( + Type.Boolean({ description: "Include archived messages in results." }), + ), }; } function buildPollSchema() { return { - pollQuestion: Type.Optional(Type.String()), - pollOption: Type.Optional(Type.Array(Type.String())), - pollDurationHours: Type.Optional(Type.Number()), - pollMulti: Type.Optional(Type.Boolean()), + pollQuestion: Type.Optional(Type.String({ description: "Poll question text." })), + pollOption: Type.Optional( + Type.Array(Type.String(), { description: "Array of poll answer options." }), + ), + pollDurationHours: Type.Optional( + Type.Number({ description: "Poll duration in hours before closing." }), + ), + pollMulti: Type.Optional(Type.Boolean({ description: "Allow multiple answer selections." })), }; } function buildChannelTargetSchema() { return { channelId: Type.Optional( - Type.String({ description: "Channel id filter (search/thread list/event create)." }), + Type.String({ description: "Channel ID filter (search/thread list/event create)." }), ), channelIds: Type.Optional( - Type.Array(Type.String({ description: "Channel id filter (repeatable)." })), + Type.Array(Type.String(), { description: "Multiple channel IDs to filter by." }), ), - guildId: Type.Optional(Type.String()), - userId: Type.Optional(Type.String()), - authorId: Type.Optional(Type.String()), - authorIds: Type.Optional(Type.Array(Type.String())), - roleId: Type.Optional(Type.String()), - roleIds: Type.Optional(Type.Array(Type.String())), - participant: Type.Optional(Type.String()), + guildId: Type.Optional(Type.String({ description: "Discord server/guild ID." })), + userId: Type.Optional(Type.String({ description: "User ID to filter by." })), + authorId: Type.Optional(Type.String({ description: "Message author ID to filter by." })), + authorIds: Type.Optional( + Type.Array(Type.String(), { description: "Multiple author IDs to filter by." }), + ), + roleId: Type.Optional(Type.String({ description: "Discord role ID to filter by." })), + roleIds: Type.Optional( + Type.Array(Type.String(), { description: "Multiple role IDs to filter by." }), + ), + participant: Type.Optional(Type.String({ description: "Participant ID/name to filter by." })), }; } function buildStickerSchema() { return { - emojiName: Type.Optional(Type.String()), - stickerId: Type.Optional(Type.Array(Type.String())), - stickerName: Type.Optional(Type.String()), - stickerDesc: Type.Optional(Type.String()), - stickerTags: Type.Optional(Type.String()), + emojiName: Type.Optional(Type.String({ description: "Emoji name to convert to sticker." })), + stickerId: Type.Optional(Type.Array(Type.String(), { description: "Sticker IDs to send." })), + stickerName: Type.Optional(Type.String({ description: "Name for creating a new sticker." })), + stickerDesc: Type.Optional(Type.String({ description: "Description for the sticker." })), + stickerTags: Type.Optional(Type.String({ description: "Tags/keywords for the sticker." })), }; } function buildThreadSchema() { return { - threadName: Type.Optional(Type.String()), - autoArchiveMin: Type.Optional(Type.Number()), + threadName: Type.Optional(Type.String({ description: "Name for creating a new thread." })), + autoArchiveMin: Type.Optional( + Type.Number({ description: "Minutes of inactivity before auto-archiving the thread." }), + ), }; } function buildEventSchema() { return { - query: Type.Optional(Type.String()), - eventName: Type.Optional(Type.String()), - eventType: Type.Optional(Type.String()), - startTime: Type.Optional(Type.String()), - endTime: Type.Optional(Type.String()), - desc: Type.Optional(Type.String()), - location: Type.Optional(Type.String()), - durationMin: Type.Optional(Type.Number()), - until: Type.Optional(Type.String()), + query: Type.Optional(Type.String({ description: "Search query for events." })), + eventName: Type.Optional(Type.String({ description: "Event name/title." })), + eventType: Type.Optional(Type.String({ description: "Event type (e.g., 'meeting', 'call')." })), + startTime: Type.Optional( + Type.String({ description: "Event start time (ISO 8601 or natural language)." }), + ), + endTime: Type.Optional( + Type.String({ description: "Event end time (ISO 8601 or natural language)." }), + ), + desc: Type.Optional(Type.String({ description: "Event description." })), + location: Type.Optional( + Type.String({ description: "Event location (physical address or URL)." }), + ), + durationMin: Type.Optional(Type.Number({ description: "Event duration in minutes." })), + until: Type.Optional(Type.String({ description: "End date for recurring events." })), }; } function buildModerationSchema() { return { - reason: Type.Optional(Type.String()), - deleteDays: Type.Optional(Type.Number()), + reason: Type.Optional( + Type.String({ description: "Reason for moderation action (logged/displayed)." }), + ), + deleteDays: Type.Optional( + Type.Number({ description: "Number of days of messages to delete (ban action)." }), + ), }; } function buildGatewaySchema() { return { - gatewayUrl: Type.Optional(Type.String()), - gatewayToken: Type.Optional(Type.String()), - timeoutMs: Type.Optional(Type.Number()), + gatewayUrl: Type.Optional( + Type.String({ description: "Gateway URL override (uses default if omitted)." }), + ), + gatewayToken: Type.Optional(Type.String({ description: "Gateway authentication token." })), + timeoutMs: Type.Optional(Type.Number({ description: "Request timeout in milliseconds." })), }; } function buildChannelManagementSchema() { return { - name: Type.Optional(Type.String()), - type: Type.Optional(Type.Number()), - parentId: Type.Optional(Type.String()), - topic: Type.Optional(Type.String()), - position: Type.Optional(Type.Number()), - nsfw: Type.Optional(Type.Boolean()), - rateLimitPerUser: Type.Optional(Type.Number()), - categoryId: Type.Optional(Type.String()), + name: Type.Optional(Type.String({ description: "Channel name." })), + type: Type.Optional( + Type.Number({ description: "Channel type (Discord channel type number)." }), + ), + parentId: Type.Optional(Type.String({ description: "Parent category ID for the channel." })), + topic: Type.Optional(Type.String({ description: "Channel topic/description." })), + position: Type.Optional(Type.Number({ description: "Channel position in the list." })), + nsfw: Type.Optional(Type.Boolean({ description: "Mark channel as NSFW (age-restricted)." })), + rateLimitPerUser: Type.Optional( + Type.Number({ description: "Slowmode delay in seconds between messages." }), + ), + categoryId: Type.Optional(Type.String({ description: "Category ID to move the channel to." })), clearParent: Type.Optional( Type.Boolean({ description: "Clear the parent/category when supported by the provider.", diff --git a/src/agents/tools/nodes-tool.ts b/src/agents/tools/nodes-tool.ts index 4c090c60c..6c41a4f36 100644 --- a/src/agents/tools/nodes-tool.ts +++ b/src/agents/tools/nodes-tool.ts @@ -48,44 +48,81 @@ const LOCATION_ACCURACY = ["coarse", "balanced", "precise"] as const; // Flattened schema: runtime validates per-action requirements. const NodesToolSchema = Type.Object({ - action: stringEnum(NODES_TOOL_ACTIONS), - gatewayUrl: Type.Optional(Type.String()), - gatewayToken: Type.Optional(Type.String()), - timeoutMs: Type.Optional(Type.Number()), - node: Type.Optional(Type.String()), - requestId: Type.Optional(Type.String()), + action: stringEnum(NODES_TOOL_ACTIONS, { + description: + "Node action: status, describe, pending, approve, reject, notify, camera_snap, camera_list, camera_clip, screen_record, location_get, run.", + }), + gatewayUrl: Type.Optional( + Type.String({ description: "Gateway URL override (uses default if omitted)." }), + ), + gatewayToken: Type.Optional(Type.String({ description: "Gateway authentication token." })), + timeoutMs: Type.Optional(Type.Number({ description: "Request timeout in milliseconds." })), + node: Type.Optional(Type.String({ description: "Node ID or name to target." })), + requestId: Type.Optional( + Type.String({ description: "Pairing request ID (for approve/reject actions)." }), + ), // notify - title: Type.Optional(Type.String()), - body: Type.Optional(Type.String()), - sound: Type.Optional(Type.String()), - priority: optionalStringEnum(NOTIFY_PRIORITIES), - delivery: optionalStringEnum(NOTIFY_DELIVERIES), + title: Type.Optional(Type.String({ description: "Notification title." })), + body: Type.Optional(Type.String({ description: "Notification body text." })), + sound: Type.Optional(Type.String({ description: "Notification sound name." })), + priority: optionalStringEnum(NOTIFY_PRIORITIES, { + description: "Notification priority: 'passive', 'active', or 'timeSensitive'.", + }), + delivery: optionalStringEnum(NOTIFY_DELIVERIES, { + description: "Notification delivery mode: 'system', 'overlay', or 'auto'.", + }), // camera_snap / camera_clip facing: optionalStringEnum(CAMERA_FACING, { description: "camera_snap: front/back/both; camera_clip: front/back only.", }), - maxWidth: Type.Optional(Type.Number()), - quality: Type.Optional(Type.Number()), - delayMs: Type.Optional(Type.Number()), - deviceId: Type.Optional(Type.String()), - duration: Type.Optional(Type.String()), - durationMs: Type.Optional(Type.Number()), - includeAudio: Type.Optional(Type.Boolean()), + maxWidth: Type.Optional( + Type.Number({ description: "Maximum image width in pixels (camera_snap)." }), + ), + quality: Type.Optional( + Type.Number({ description: "Image quality (0-100) for JPEG compression." }), + ), + delayMs: Type.Optional(Type.Number({ description: "Delay in milliseconds before capturing." })), + deviceId: Type.Optional(Type.String({ description: "Specific camera device ID to use." })), + duration: Type.Optional( + Type.String({ description: "Recording duration as a string (e.g., '5s', '1m')." }), + ), + durationMs: Type.Optional(Type.Number({ description: "Recording duration in milliseconds." })), + includeAudio: Type.Optional( + Type.Boolean({ description: "Include audio in camera_clip or screen_record." }), + ), // screen_record - fps: Type.Optional(Type.Number()), - screenIndex: Type.Optional(Type.Number()), - outPath: Type.Optional(Type.String()), + fps: Type.Optional(Type.Number({ description: "Frames per second for screen recording." })), + screenIndex: Type.Optional( + Type.Number({ description: "Screen/display index to record (0-based)." }), + ), + outPath: Type.Optional(Type.String({ description: "Output file path for screen recording." })), // location_get - maxAgeMs: Type.Optional(Type.Number()), - locationTimeoutMs: Type.Optional(Type.Number()), - desiredAccuracy: optionalStringEnum(LOCATION_ACCURACY), + maxAgeMs: Type.Optional( + Type.Number({ description: "Maximum age of cached location in milliseconds." }), + ), + locationTimeoutMs: Type.Optional( + Type.Number({ description: "Location request timeout in milliseconds." }), + ), + desiredAccuracy: optionalStringEnum(LOCATION_ACCURACY, { + description: "Location accuracy: 'coarse', 'balanced', or 'precise'.", + }), // run - command: Type.Optional(Type.Array(Type.String())), - cwd: Type.Optional(Type.String()), - env: Type.Optional(Type.Array(Type.String())), - commandTimeoutMs: Type.Optional(Type.Number()), - invokeTimeoutMs: Type.Optional(Type.Number()), - needsScreenRecording: Type.Optional(Type.Boolean()), + command: Type.Optional( + Type.Array(Type.String(), { description: "Command argv array (e.g., ['echo', 'Hello'])." }), + ), + cwd: Type.Optional(Type.String({ description: "Working directory for the command." })), + env: Type.Optional( + Type.Array(Type.String(), { description: "Environment variables as KEY=VALUE strings." }), + ), + commandTimeoutMs: Type.Optional( + Type.Number({ description: "Timeout for the command execution in milliseconds." }), + ), + invokeTimeoutMs: Type.Optional( + Type.Number({ description: "Timeout for the node invoke RPC in milliseconds." }), + ), + needsScreenRecording: Type.Optional( + Type.Boolean({ description: "Hint that the command needs screen recording permission." }), + ), }); export function createNodesTool(options?: { diff --git a/src/agents/tools/session-status-tool.ts b/src/agents/tools/session-status-tool.ts index 4d47da4d7..d3659c6e0 100644 --- a/src/agents/tools/session-status-tool.ts +++ b/src/agents/tools/session-status-tool.ts @@ -49,8 +49,17 @@ import { import { loadCombinedSessionStoreForGateway } from "../../gateway/session-utils.js"; const SessionStatusToolSchema = Type.Object({ - sessionKey: Type.Optional(Type.String()), - model: Type.Optional(Type.String()), + sessionKey: Type.Optional( + Type.String({ + description: "Session key or session ID to get status for. Defaults to the current session.", + }), + ), + model: Type.Optional( + Type.String({ + description: + "Set a per-session model override (e.g., 'anthropic/claude-sonnet-4-20250514'). Use 'default' to reset overrides.", + }), + ), }); function formatApiKeySnippet(apiKey: string): string { diff --git a/src/agents/tools/sessions-history-tool.ts b/src/agents/tools/sessions-history-tool.ts index 5351b70c5..126bbf4f0 100644 --- a/src/agents/tools/sessions-history-tool.ts +++ b/src/agents/tools/sessions-history-tool.ts @@ -14,9 +14,20 @@ import { } from "./sessions-helpers.js"; const SessionsHistoryToolSchema = Type.Object({ - sessionKey: Type.String(), - limit: Type.Optional(Type.Number({ minimum: 1 })), - includeTools: Type.Optional(Type.Boolean()), + sessionKey: Type.String({ + description: "The session key or session ID to fetch history from.", + }), + limit: Type.Optional( + Type.Number({ + description: "Maximum number of messages to return.", + minimum: 1, + }), + ), + includeTools: Type.Optional( + Type.Boolean({ + description: "Include tool call/result messages in the output. Default: false (strips them).", + }), + ), }); function resolveSandboxSessionToolsVisibility(cfg: ReturnType) { diff --git a/src/agents/tools/sessions-list-tool.ts b/src/agents/tools/sessions-list-tool.ts index 148a33d30..3bc8a8280 100644 --- a/src/agents/tools/sessions-list-tool.ts +++ b/src/agents/tools/sessions-list-tool.ts @@ -19,10 +19,30 @@ import { } from "./sessions-helpers.js"; const SessionsListToolSchema = Type.Object({ - kinds: Type.Optional(Type.Array(Type.String())), - limit: Type.Optional(Type.Number({ minimum: 1 })), - activeMinutes: Type.Optional(Type.Number({ minimum: 1 })), - messageLimit: Type.Optional(Type.Number({ minimum: 0 })), + kinds: Type.Optional( + Type.Array(Type.String(), { + description: + "Filter by session kinds: 'main', 'subagent', 'spawned', 'internal'. Omit for all kinds.", + }), + ), + limit: Type.Optional( + Type.Number({ + description: "Maximum number of sessions to return.", + minimum: 1, + }), + ), + activeMinutes: Type.Optional( + Type.Number({ + description: "Only include sessions active within the last N minutes.", + minimum: 1, + }), + ), + messageLimit: Type.Optional( + Type.Number({ + description: "Number of recent messages to include per session. 0 = no messages.", + minimum: 0, + }), + ), }); function resolveSandboxSessionToolsVisibility(cfg: ReturnType) { diff --git a/src/agents/tools/sessions-send-tool.ts b/src/agents/tools/sessions-send-tool.ts index ff2d7e1f4..990d274a0 100644 --- a/src/agents/tools/sessions-send-tool.ts +++ b/src/agents/tools/sessions-send-tool.ts @@ -29,11 +29,36 @@ import { buildAgentToAgentMessageContext, resolvePingPongTurns } from "./session import { runSessionsSendA2AFlow } from "./sessions-send-tool.a2a.js"; const SessionsSendToolSchema = Type.Object({ - sessionKey: Type.Optional(Type.String()), - label: Type.Optional(Type.String({ minLength: 1, maxLength: SESSION_LABEL_MAX_LENGTH })), - agentId: Type.Optional(Type.String({ minLength: 1, maxLength: 64 })), - message: Type.String(), - timeoutSeconds: Type.Optional(Type.Number({ minimum: 0 })), + sessionKey: Type.Optional( + Type.String({ + description: + "Target session key (e.g., 'agent:main:abc123'). Use either sessionKey OR label, not both.", + }), + ), + label: Type.Optional( + Type.String({ + description: + "Human-readable session label to target. Use either sessionKey OR label, not both.", + minLength: 1, + maxLength: SESSION_LABEL_MAX_LENGTH, + }), + ), + agentId: Type.Optional( + Type.String({ + description: "Agent ID to scope the label lookup to (used with label parameter).", + minLength: 1, + maxLength: 64, + }), + ), + message: Type.String({ + description: "The message to send to the target session.", + }), + timeoutSeconds: Type.Optional( + Type.Number({ + description: "Timeout in seconds for waiting for response. 0 = fire-and-forget.", + minimum: 0, + }), + ), }); export function createSessionsSendTool(opts?: { diff --git a/src/agents/tools/sessions-spawn-tool.ts b/src/agents/tools/sessions-spawn-tool.ts index e5e1391d1..35f3ef0cf 100644 --- a/src/agents/tools/sessions-spawn-tool.ts +++ b/src/agents/tools/sessions-spawn-tool.ts @@ -26,15 +26,49 @@ import { } from "./sessions-helpers.js"; const SessionsSpawnToolSchema = Type.Object({ - task: Type.String(), - label: Type.Optional(Type.String()), - agentId: Type.Optional(Type.String()), - model: Type.Optional(Type.String()), - thinking: Type.Optional(Type.String()), - runTimeoutSeconds: Type.Optional(Type.Number({ minimum: 0 })), + task: Type.String({ + description: "The task or prompt to send to the spawned sub-agent session.", + }), + label: Type.Optional( + Type.String({ + description: "Human-readable label for the spawned session (for tracking/display).", + }), + ), + agentId: Type.Optional( + Type.String({ + description: + "Target agent ID to spawn. Must be in the allowlist (agents.X.subagents.allowAgents). Defaults to the requester's agent.", + }), + ), + model: Type.Optional( + Type.String({ + description: + "Model override for the sub-agent (e.g., 'anthropic/claude-sonnet-4-20250514'). Uses configured subagent model if omitted.", + }), + ), + thinking: Type.Optional( + Type.String({ + description: + "Thinking level override (e.g., 'none', 'low', 'medium', 'high'). Model-dependent.", + }), + ), + runTimeoutSeconds: Type.Optional( + Type.Number({ + description: "Maximum time in seconds for the sub-agent run. 0 = no timeout.", + minimum: 0, + }), + ), // Back-compat alias. Prefer runTimeoutSeconds. - timeoutSeconds: Type.Optional(Type.Number({ minimum: 0 })), - cleanup: optionalStringEnum(["delete", "keep"] as const), + timeoutSeconds: Type.Optional( + Type.Number({ + description: "Deprecated: use runTimeoutSeconds instead.", + minimum: 0, + }), + ), + cleanup: optionalStringEnum(["delete", "keep"] as const, { + description: + "Session cleanup policy after completion. 'delete' removes the session, 'keep' preserves it. Default: 'keep'.", + }), }); function splitModelRef(ref?: string) { diff --git a/src/agents/unified-agent-runner.test.ts b/src/agents/unified-agent-runner.test.ts new file mode 100644 index 000000000..b40a52b5c --- /dev/null +++ b/src/agents/unified-agent-runner.test.ts @@ -0,0 +1,398 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; + +import type { MoltbotConfig } from "../config/config.js"; +import type { AgentRuntime, AgentRuntimeResult } from "./agent-runtime.js"; + +// Mock dependencies +const mockPiRuntime: AgentRuntime = { + kind: "pi", + displayName: "Pi Agent", + run: vi.fn(), +}; + +const mockCcsdkRuntime: AgentRuntime = { + kind: "ccsdk", + displayName: "Claude Code SDK", + run: vi.fn(), +}; + +vi.mock("./main-agent-runtime-factory.js", () => ({ + createAgentRuntime: vi.fn(), + resolveAgentRuntimeKind: vi.fn(() => "pi"), +})); + +vi.mock("./agent-scope.js", () => ({ + resolveAgentModelFallbacksOverride: vi.fn(), +})); + +vi.mock("./model-selection.js", () => ({ + buildModelAliasIndex: vi.fn(() => new Map()), + modelKey: vi.fn((provider: string, model: string) => `${provider}/${model}`), + parseModelRef: vi.fn((raw: string, defaultProvider: string) => { + const [provider, model] = raw.includes("/") ? raw.split("/") : [defaultProvider, raw]; + return { provider, model }; + }), + resolveConfiguredModelRef: vi.fn(() => ({ + provider: "anthropic", + model: "claude-sonnet-4-20250514", + })), + resolveModelRefFromString: vi.fn( + ({ raw, defaultProvider }: { raw: string; defaultProvider: string }) => { + const [provider, model] = raw.includes("/") ? raw.split("/") : [defaultProvider, raw]; + return { ref: { provider, model } }; + }, + ), +})); + +vi.mock("./auth-profiles.js", () => ({ + ensureAuthProfileStore: vi.fn(() => ({ + profiles: {}, + cooldowns: {}, + })), + isProfileInCooldown: vi.fn(() => false), + resolveAuthProfileOrder: vi.fn(() => []), +})); + +vi.mock("./failover-error.js", () => ({ + coerceToFailoverError: vi.fn((err) => err), + describeFailoverError: vi.fn((err) => ({ + message: err instanceof Error ? err.message : String(err), + reason: "rate_limit", + status: 429, + })), + isFailoverError: vi.fn((err) => err?.name === "FailoverError"), + isTimeoutError: vi.fn(() => false), +})); + +vi.mock("../config/sessions.js", () => ({ + resolveAgentIdFromSessionKey: vi.fn(() => "test-agent"), +})); + +vi.mock("../utils/provider-utils.js", () => ({ + isReasoningTagProvider: vi.fn(() => false), +})); + +vi.mock("../logging/subsystem.js", () => ({ + createSubsystemLogger: vi.fn(() => ({ + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + })), +})); + +vi.mock("./defaults.js", () => ({ + DEFAULT_MODEL: "claude-sonnet-4-20250514", + DEFAULT_PROVIDER: "anthropic", +})); + +import { runAgentWithUnifiedFailover } from "./unified-agent-runner.js"; +import type { UnifiedAgentRunParams } from "./unified-agent-runner.js"; +import { createAgentRuntime, resolveAgentRuntimeKind } from "./main-agent-runtime-factory.js"; +import { isProfileInCooldown, resolveAuthProfileOrder } from "./auth-profiles.js"; +import { coerceToFailoverError, isFailoverError } from "./failover-error.js"; + +describe("unified-agent-runner", () => { + const baseParams: UnifiedAgentRunParams = { + sessionId: "test-session-123", + sessionKey: "agent:test-agent:session", + sessionFile: "/tmp/test-session.jsonl", + workspaceDir: "/tmp/workspace", + prompt: "Hello, world!", + timeoutMs: 5000, + runId: "run-123", + provider: "anthropic", + model: "claude-sonnet-4-20250514", + config: {} as MoltbotConfig, + }; + + const successResult: AgentRuntimeResult = { + payloads: [{ text: "Hello back!" }], + meta: {}, + }; + + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(createAgentRuntime).mockResolvedValue(mockPiRuntime); + vi.mocked(mockPiRuntime.run).mockResolvedValue(successResult); + vi.mocked(mockCcsdkRuntime.run).mockResolvedValue(successResult); + }); + + afterEach(() => { + vi.resetAllMocks(); + }); + + describe("runtime selection", () => { + it("attempts primary runtime first", async () => { + vi.mocked(resolveAgentRuntimeKind).mockReturnValue("pi"); + vi.mocked(createAgentRuntime).mockResolvedValue(mockPiRuntime); + + const result = await runAgentWithUnifiedFailover(baseParams); + + expect(result.runtime).toBe("pi"); + expect(mockPiRuntime.run).toHaveBeenCalled(); + }); + + it("uses ccsdk as primary when configured", async () => { + vi.mocked(resolveAgentRuntimeKind).mockReturnValue("ccsdk"); + vi.mocked(createAgentRuntime).mockImplementation(async (_config, _agentId, forceKind) => { + if (forceKind === "ccsdk") return mockCcsdkRuntime; + return mockPiRuntime; + }); + + const result = await runAgentWithUnifiedFailover(baseParams); + + expect(result.runtime).toBe("ccsdk"); + expect(mockCcsdkRuntime.run).toHaveBeenCalled(); + }); + }); + + describe("failover behavior", () => { + it("fails over to secondary runtime when primary fails", async () => { + const failoverError = Object.assign(new Error("Rate limit exceeded"), { + name: "FailoverError", + }); + vi.mocked(isFailoverError).mockReturnValue(true); + vi.mocked(coerceToFailoverError).mockReturnValue(failoverError as any); + + vi.mocked(resolveAgentRuntimeKind).mockReturnValue("ccsdk"); + vi.mocked(createAgentRuntime).mockImplementation(async (_config, _agentId, forceKind) => { + if (forceKind === "ccsdk") { + return { + ...mockCcsdkRuntime, + run: vi.fn().mockRejectedValue(failoverError), + }; + } + return mockPiRuntime; + }); + + const result = await runAgentWithUnifiedFailover(baseParams); + + // Should have failed over to Pi after CCSDK failed + expect(result.runtime).toBe("pi"); + expect(result.attempts.length).toBeGreaterThan(0); + expect(result.attempts[0].runtime).toBe("ccsdk"); + }); + + it("tries all model candidates before switching runtime", async () => { + const failoverError = Object.assign(new Error("Model unavailable"), { + name: "FailoverError", + }); + vi.mocked(isFailoverError).mockReturnValue(true); + vi.mocked(coerceToFailoverError).mockReturnValue(failoverError as any); + + // First runtime always fails + const failingRuntime: AgentRuntime = { + kind: "ccsdk", + displayName: "Claude Code SDK", + run: vi.fn().mockRejectedValue(failoverError), + }; + + vi.mocked(resolveAgentRuntimeKind).mockReturnValue("ccsdk"); + vi.mocked(createAgentRuntime).mockImplementation(async (_config, _agentId, forceKind) => { + if (forceKind === "ccsdk") return failingRuntime; + return mockPiRuntime; + }); + + const paramsWithFallbacks: UnifiedAgentRunParams = { + ...baseParams, + fallbacksOverride: ["anthropic/claude-haiku-3-5"], + }; + + const result = await runAgentWithUnifiedFailover(paramsWithFallbacks); + + // Should eventually succeed with Pi runtime + expect(result.runtime).toBe("pi"); + // Should have attempted multiple models on CCSDK first + const ccsdkAttempts = result.attempts.filter((a) => a.runtime === "ccsdk"); + expect(ccsdkAttempts.length).toBeGreaterThanOrEqual(1); + }); + + it("throws when all runtimes and models fail", async () => { + const failoverError = Object.assign(new Error("All failed"), { name: "FailoverError" }); + vi.mocked(isFailoverError).mockReturnValue(true); + vi.mocked(coerceToFailoverError).mockReturnValue(failoverError as any); + + vi.mocked(createAgentRuntime).mockResolvedValue({ + ...mockPiRuntime, + run: vi.fn().mockRejectedValue(failoverError), + }); + + await expect(runAgentWithUnifiedFailover(baseParams)).rejects.toThrow(); + }); + }); + + describe("callbacks", () => { + it("calls onModelSelected before each attempt", async () => { + const onModelSelected = vi.fn(); + + await runAgentWithUnifiedFailover({ + ...baseParams, + onModelSelected, + }); + + expect(onModelSelected).toHaveBeenCalledWith( + expect.objectContaining({ + runtime: "pi", + provider: "anthropic", + model: "claude-sonnet-4-20250514", + attempt: 1, + }), + ); + }); + + it("calls onError on failure with error details", async () => { + const failoverError = Object.assign(new Error("Rate limit"), { name: "FailoverError" }); + vi.mocked(isFailoverError).mockReturnValue(true); + vi.mocked(coerceToFailoverError).mockReturnValue(failoverError as any); + + const onError = vi.fn(); + let attemptCount = 0; + + vi.mocked(createAgentRuntime).mockResolvedValue({ + ...mockPiRuntime, + run: vi.fn().mockImplementation(async () => { + attemptCount++; + if (attemptCount === 1) throw failoverError; + return successResult; + }), + }); + + // Need a fallback to try again + await runAgentWithUnifiedFailover({ + ...baseParams, + fallbacksOverride: ["anthropic/claude-haiku-3-5"], + onError, + }); + + expect(onError).toHaveBeenCalledWith( + expect.objectContaining({ + runtime: "pi", + provider: "anthropic", + error: failoverError, + attempt: 1, + }), + ); + }); + + it("does not break when onModelSelected throws", async () => { + const onModelSelected = vi.fn().mockRejectedValue(new Error("Callback error")); + + // Should not throw even if callback fails + const result = await runAgentWithUnifiedFailover({ + ...baseParams, + onModelSelected, + }); + + expect(result.result).toBeDefined(); + }); + }); + + describe("auth profile handling", () => { + it("skips providers when all profiles are in cooldown", async () => { + // anthropic has profiles but all are in cooldown; openai has no profiles (no cooldown check) + vi.mocked(resolveAuthProfileOrder).mockImplementation(({ provider }) => + provider === "anthropic" ? ["profile-1", "profile-2"] : [], + ); + vi.mocked(isProfileInCooldown).mockReturnValue(true); // All anthropic profiles in cooldown + + vi.mocked(createAgentRuntime).mockResolvedValue(mockPiRuntime); + + // First candidate (anthropic) will be skipped due to cooldown, fallback (openai) will succeed + const result = await runAgentWithUnifiedFailover({ + ...baseParams, + fallbacksOverride: ["openai/gpt-4o"], + }); + + // Should record attempt as skipped due to cooldown + const cooldownAttempt = result.attempts.find((a) => a.reason === "rate_limit"); + expect(cooldownAttempt).toBeDefined(); + expect(cooldownAttempt?.provider).toBe("anthropic"); + // Final result should be from the fallback provider + expect(result.provider).toBe("openai"); + }); + + it("proceeds when at least one profile is available", async () => { + vi.mocked(resolveAuthProfileOrder).mockReturnValue(["profile-1", "profile-2"]); + vi.mocked(isProfileInCooldown).mockImplementation( + (_, profileId) => profileId === "profile-1", + ); + + const result = await runAgentWithUnifiedFailover(baseParams); + + expect(result.result).toBeDefined(); + }); + }); + + describe("abort handling", () => { + it("rethrows AbortError immediately", async () => { + const abortError = new Error("Aborted"); + abortError.name = "AbortError"; + + vi.mocked(mockPiRuntime.run).mockRejectedValue(abortError); + + await expect(runAgentWithUnifiedFailover(baseParams)).rejects.toThrow("Aborted"); + }); + + it("does not rethrow timeout errors as abort", async () => { + const timeoutError = Object.assign(new Error("Timeout"), { name: "FailoverError" }); + vi.mocked(isFailoverError).mockReturnValue(true); + vi.mocked(coerceToFailoverError).mockReturnValue(timeoutError as any); + + let attemptCount = 0; + vi.mocked(createAgentRuntime).mockResolvedValue({ + ...mockPiRuntime, + run: vi.fn().mockImplementation(async () => { + attemptCount++; + if (attemptCount === 1) throw timeoutError; + return successResult; + }), + }); + + // Should not throw, should failover + const result = await runAgentWithUnifiedFailover({ + ...baseParams, + fallbacksOverride: ["anthropic/claude-haiku-3-5"], + }); + + expect(result.result).toBeDefined(); + }); + }); + + describe("result structure", () => { + it("returns complete result with runtime, provider, model, and attempts", async () => { + const result = await runAgentWithUnifiedFailover(baseParams); + + expect(result.result).toBeDefined(); + expect(result.runtime).toBe("pi"); + expect(result.provider).toBe("anthropic"); + expect(result.model).toBe("claude-sonnet-4-20250514"); + expect(Array.isArray(result.attempts)).toBe(true); + }); + + it("records all failed attempts in attempts array", async () => { + const failoverError = Object.assign(new Error("Failed"), { name: "FailoverError" }); + vi.mocked(isFailoverError).mockReturnValue(true); + vi.mocked(coerceToFailoverError).mockReturnValue(failoverError as any); + + let attemptCount = 0; + vi.mocked(createAgentRuntime).mockResolvedValue({ + ...mockPiRuntime, + run: vi.fn().mockImplementation(async () => { + attemptCount++; + if (attemptCount < 2) throw failoverError; + return successResult; + }), + }); + + const result = await runAgentWithUnifiedFailover({ + ...baseParams, + fallbacksOverride: ["anthropic/claude-haiku-3-5"], + }); + + // First attempt should be recorded as failed + expect(result.attempts.length).toBeGreaterThan(0); + expect(result.attempts[0].error).toBeDefined(); + }); + }); +}); diff --git a/src/agents/unified-agent-runner.ts b/src/agents/unified-agent-runner.ts index 9f8bc37aa..52730a19d 100644 --- a/src/agents/unified-agent-runner.ts +++ b/src/agents/unified-agent-runner.ts @@ -303,14 +303,20 @@ export async function runAgentWithUnifiedFailover( total: totalAttempts, }); - // Notify model selection before attempt - await params.onModelSelected?.({ - runtime: runtimeKind, - provider: candidate.provider, - model: candidate.model, - attempt: attemptIndex, - total: totalAttempts, - }); + // Notify model selection before attempt (wrapped in try/catch to be resilient) + try { + await params.onModelSelected?.({ + runtime: runtimeKind, + provider: candidate.provider, + model: candidate.model, + attempt: attemptIndex, + total: totalAttempts, + }); + } catch (err) { + log.warn("onModelSelected callback failed", { + error: err instanceof Error ? err.message : String(err), + }); + } // Determine if this is a different provider than originally requested const originalProvider = params.provider ?? DEFAULT_PROVIDER; diff --git a/src/agents/unified-runtime-adapter.test.ts b/src/agents/unified-runtime-adapter.test.ts new file mode 100644 index 000000000..e236c2316 --- /dev/null +++ b/src/agents/unified-runtime-adapter.test.ts @@ -0,0 +1,511 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; + +import type { AgentRuntimeKind, AgentRuntimeResult } from "./agent-runtime.js"; +import type { RuntimeSlot, UnifiedRuntimeConfig } from "./unified-runtime-adapter.js"; + +// Mock dependencies +vi.mock("./failover-error.js", () => ({ + coerceToFailoverError: vi.fn((err) => { + if (err?.name === "FailoverError") return err; + return null; + }), + describeFailoverError: vi.fn((err) => ({ + message: err instanceof Error ? err.message : String(err), + reason: err?.reason ?? "rate_limit", + status: err?.status ?? 429, + code: err?.code, + })), + isFailoverError: vi.fn((err) => err?.name === "FailoverError"), + isTimeoutError: vi.fn(() => false), +})); + +vi.mock("./auth-profiles.js", () => ({ + ensureAuthProfileStore: vi.fn(() => ({ + profiles: {}, + cooldowns: new Map(), + })), + isProfileInCooldown: vi.fn(() => false), + markAuthProfileCooldown: vi.fn(), + resolveAuthProfileOrder: vi.fn(() => []), +})); + +vi.mock("../logging/subsystem.js", () => ({ + createSubsystemLogger: vi.fn(() => ({ + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + })), +})); + +import { runWithUnifiedFallback, isRuntimeSlotAvailable } from "./unified-runtime-adapter.js"; +import { + isProfileInCooldown, + markAuthProfileCooldown, + resolveAuthProfileOrder, +} from "./auth-profiles.js"; +import { coerceToFailoverError, isFailoverError } from "./failover-error.js"; + +describe("unified-runtime-adapter", () => { + const successResult: AgentRuntimeResult = { + payloads: [{ text: "Success" }], + meta: { durationMs: 100 }, + }; + + const createConfig = (overrides?: Partial): UnifiedRuntimeConfig => ({ + primaryRuntime: "pi", + provider: "anthropic", + model: "claude-sonnet-4-20250514", + ...overrides, + }); + + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(isProfileInCooldown).mockReturnValue(false); + vi.mocked(resolveAuthProfileOrder).mockReturnValue([]); + }); + + afterEach(() => { + vi.resetAllMocks(); + }); + + describe("runWithUnifiedFallback", () => { + describe("basic execution", () => { + it("executes run function with primary runtime", async () => { + const runFn = vi.fn().mockResolvedValue(successResult); + + const result = await runWithUnifiedFallback({ + config: createConfig(), + run: runFn, + }); + + expect(runFn).toHaveBeenCalledWith( + expect.objectContaining({ + runtime: "pi", + provider: "anthropic", + model: "claude-sonnet-4-20250514", + }), + ); + expect(result.runtime).toBe("pi"); + expect(result.result).toBe(successResult); + }); + + it("returns result with correct structure", async () => { + const runFn = vi.fn().mockResolvedValue(successResult); + + const result = await runWithUnifiedFallback({ + config: createConfig(), + run: runFn, + }); + + expect(result).toMatchObject({ + result: successResult, + runtime: "pi", + provider: "anthropic", + model: "claude-sonnet-4-20250514", + attempts: [], + }); + }); + }); + + describe("runtime failover", () => { + it("fails over to fallback runtime when primary fails", async () => { + const failoverError = Object.assign(new Error("Rate limit"), { + name: "FailoverError", + reason: "rate_limit", + }); + vi.mocked(isFailoverError).mockReturnValue(true); + vi.mocked(coerceToFailoverError).mockReturnValue(failoverError as any); + + const runFn = vi + .fn() + .mockImplementation(async ({ runtime }: { runtime: AgentRuntimeKind }) => { + if (runtime === "ccsdk") throw failoverError; + return successResult; + }); + + const result = await runWithUnifiedFallback({ + config: createConfig({ + primaryRuntime: "ccsdk", + fallbackRuntimes: [{ runtime: "pi" }], + }), + run: runFn, + }); + + expect(result.runtime).toBe("pi"); + expect(result.attempts.length).toBe(1); + expect(result.attempts[0].runtime).toBe("ccsdk"); + }); + + it("tries all fallback runtimes in order", async () => { + const failoverError = Object.assign(new Error("Failed"), { + name: "FailoverError", + reason: "rate_limit", + }); + vi.mocked(isFailoverError).mockReturnValue(true); + vi.mocked(coerceToFailoverError).mockReturnValue(failoverError as any); + + const runtimeOrder: AgentRuntimeKind[] = []; + let successOnThird = false; + + const runFn = vi + .fn() + .mockImplementation(async ({ runtime }: { runtime: AgentRuntimeKind }) => { + runtimeOrder.push(runtime); + if (runtimeOrder.length < 3) throw failoverError; + successOnThird = true; + return successResult; + }); + + await runWithUnifiedFallback({ + config: createConfig({ + primaryRuntime: "ccsdk", + fallbackRuntimes: [ + { runtime: "pi", provider: "openai" }, + { runtime: "pi", provider: "google" }, + ], + }), + run: runFn, + }); + + expect(runtimeOrder).toEqual(["ccsdk", "pi", "pi"]); + expect(successOnThird).toBe(true); + }); + + it("throws when all runtimes fail", async () => { + const failoverError = Object.assign(new Error("All failed"), { + name: "FailoverError", + reason: "rate_limit", + }); + vi.mocked(isFailoverError).mockReturnValue(true); + vi.mocked(coerceToFailoverError).mockReturnValue(failoverError as any); + + const runFn = vi.fn().mockRejectedValue(failoverError); + + await expect( + runWithUnifiedFallback({ + config: createConfig({ + primaryRuntime: "ccsdk", + fallbackRuntimes: [{ runtime: "pi" }], + }), + run: runFn, + }), + ).rejects.toThrow(); + }); + + it("does not deduplicate distinct slot configurations", async () => { + const attempts: string[] = []; + const failoverError = Object.assign(new Error("Failed"), { + name: "FailoverError", + reason: "rate_limit", + }); + vi.mocked(isFailoverError).mockReturnValue(true); + vi.mocked(coerceToFailoverError).mockReturnValue(failoverError as any); + + const runFn = vi + .fn() + .mockImplementation( + async ({ runtime, provider }: { runtime: AgentRuntimeKind; provider: string }) => { + const key = `${runtime}:${provider}`; + attempts.push(key); + // Fail first attempt, succeed on second distinct slot + if (attempts.length < 2) throw failoverError; + return successResult; + }, + ); + + // Two different providers for Pi - these should NOT be deduplicated + const result = await runWithUnifiedFallback({ + config: createConfig({ + primaryRuntime: "pi", + provider: "anthropic", + fallbackRuntimes: [{ runtime: "pi", provider: "openai" }], + }), + run: runFn, + }); + + // Both distinct slots should have been attempted + expect(attempts).toContain("pi:anthropic"); + expect(attempts).toContain("pi:openai"); + expect(attempts.length).toBe(2); + // Should have succeeded on second slot + expect(result.provider).toBe("openai"); + }); + }); + + describe("profile rotation", () => { + it("rotates through profiles on rate limit errors", async () => { + vi.mocked(resolveAuthProfileOrder).mockReturnValue(["profile-1", "profile-2", "profile-3"]); + + const failoverError = Object.assign(new Error("Rate limit"), { + name: "FailoverError", + reason: "rate_limit", + }); + vi.mocked(isFailoverError).mockReturnValue(true); + vi.mocked(coerceToFailoverError).mockReturnValue(failoverError as any); + + const profilesAttempted: (string | undefined)[] = []; + let attemptCount = 0; + + const runFn = vi.fn().mockImplementation(async ({ profileId }: { profileId?: string }) => { + profilesAttempted.push(profileId); + attemptCount++; + if (attemptCount < 3) throw failoverError; + return successResult; + }); + + await runWithUnifiedFallback({ + config: createConfig({ authProfiles: ["profile-1", "profile-2", "profile-3"] }), + run: runFn, + }); + + expect(profilesAttempted).toEqual(["profile-1", "profile-2", "profile-3"]); + }); + + it("marks profile as cooled down on auth error", async () => { + vi.mocked(resolveAuthProfileOrder).mockReturnValue(["profile-1"]); + + const authError = Object.assign(new Error("Auth failed"), { + name: "FailoverError", + reason: "auth", + }); + vi.mocked(isFailoverError).mockReturnValue(true); + vi.mocked(coerceToFailoverError).mockReturnValue(authError as any); + + let attemptCount = 0; + const runFn = vi.fn().mockImplementation(async () => { + attemptCount++; + if (attemptCount === 1) throw authError; + return successResult; + }); + + // Use CCSDK as fallback (different runtime) to avoid deduplication + await runWithUnifiedFallback({ + config: createConfig({ + primaryRuntime: "pi", + authProfiles: ["profile-1"], + fallbackRuntimes: [{ runtime: "ccsdk" }], + }), + cfg: {} as any, + run: runFn, + }); + + expect(markAuthProfileCooldown).toHaveBeenCalledWith( + expect.objectContaining({ + profileId: "profile-1", + }), + ); + }); + + it("marks profile as cooled down on billing error", async () => { + vi.mocked(resolveAuthProfileOrder).mockReturnValue(["profile-1"]); + + const billingError = Object.assign(new Error("Billing error"), { + name: "FailoverError", + reason: "billing", + }); + vi.mocked(isFailoverError).mockReturnValue(true); + vi.mocked(coerceToFailoverError).mockReturnValue(billingError as any); + + let attemptCount = 0; + const runFn = vi.fn().mockImplementation(async () => { + attemptCount++; + if (attemptCount === 1) throw billingError; + return successResult; + }); + + // Use CCSDK as fallback (different runtime) to avoid deduplication + await runWithUnifiedFallback({ + config: createConfig({ + primaryRuntime: "pi", + authProfiles: ["profile-1"], + fallbackRuntimes: [{ runtime: "ccsdk" }], + }), + cfg: {} as any, + run: runFn, + }); + + expect(markAuthProfileCooldown).toHaveBeenCalled(); + }); + + it("skips slots when all profiles are in cooldown", async () => { + // Return profiles only for CCSDK/anthropic, empty for Pi/openai + vi.mocked(resolveAuthProfileOrder).mockImplementation(({ provider }) => { + return provider === "anthropic" ? ["profile-1", "profile-2"] : []; + }); + // All anthropic profiles are in cooldown + vi.mocked(isProfileInCooldown).mockReturnValue(true); + + const runFn = vi.fn().mockResolvedValue(successResult); + + const result = await runWithUnifiedFallback({ + config: createConfig({ + primaryRuntime: "ccsdk", + provider: "anthropic", + // Pi with openai has no profiles, so no cooldown check + fallbackRuntimes: [{ runtime: "pi", provider: "openai" }], + }), + cfg: {} as any, + run: runFn, + }); + + // First slot (ccsdk/anthropic) skipped due to cooldown, second (pi/openai) succeeded + expect(result.attempts.some((a) => a.reason === "rate_limit")).toBe(true); + expect(result.runtime).toBe("pi"); + expect(result.provider).toBe("openai"); + }); + }); + + describe("error handling", () => { + it("rethrows AbortError immediately", async () => { + const abortError = new Error("Aborted"); + abortError.name = "AbortError"; + + const runFn = vi.fn().mockRejectedValue(abortError); + + await expect( + runWithUnifiedFallback({ + config: createConfig(), + run: runFn, + }), + ).rejects.toThrow("Aborted"); + }); + + it("rethrows non-failover errors", async () => { + const genericError = new Error("Unexpected error"); + vi.mocked(isFailoverError).mockReturnValue(false); + vi.mocked(coerceToFailoverError).mockReturnValue(null); + + const runFn = vi.fn().mockRejectedValue(genericError); + + await expect( + runWithUnifiedFallback({ + config: createConfig(), + run: runFn, + }), + ).rejects.toThrow("Unexpected error"); + }); + + it("calls onError callback for each failed attempt", async () => { + const failoverError = Object.assign(new Error("Failed"), { + name: "FailoverError", + reason: "rate_limit", + }); + vi.mocked(isFailoverError).mockReturnValue(true); + vi.mocked(coerceToFailoverError).mockReturnValue(failoverError as any); + + const onError = vi.fn(); + let attemptCount = 0; + + const runFn = vi.fn().mockImplementation(async () => { + attemptCount++; + if (attemptCount < 2) throw failoverError; + return successResult; + }); + + // Use CCSDK as fallback (different runtime) to have 2 distinct slots + await runWithUnifiedFallback({ + config: createConfig({ + primaryRuntime: "pi", + fallbackRuntimes: [{ runtime: "ccsdk" }], + }), + run: runFn, + onError, + }); + + expect(onError).toHaveBeenCalledWith( + expect.objectContaining({ + runtime: "pi", + error: failoverError, + attempt: 1, + }), + ); + }); + + it("includes error summary when all attempts fail", async () => { + const failoverError = Object.assign(new Error("Failed"), { + name: "FailoverError", + reason: "rate_limit", + }); + vi.mocked(isFailoverError).mockReturnValue(true); + vi.mocked(coerceToFailoverError).mockReturnValue(failoverError as any); + + const runFn = vi.fn().mockRejectedValue(failoverError); + + // Use CCSDK as fallback (different runtime) to have 2 distinct slots + // With 2+ attempts, the error summary format is used instead of rethrowing original + await expect( + runWithUnifiedFallback({ + config: createConfig({ + primaryRuntime: "pi", + fallbackRuntimes: [{ runtime: "ccsdk" }], + }), + run: runFn, + }), + ).rejects.toThrow(/All runtimes failed/); + }); + }); + + describe("single attempt behavior", () => { + it("throws original error when only one attempt fails", async () => { + const failoverError = Object.assign(new Error("Single failure"), { + name: "FailoverError", + reason: "rate_limit", + }); + vi.mocked(isFailoverError).mockReturnValue(true); + vi.mocked(coerceToFailoverError).mockReturnValue(failoverError as any); + + const runFn = vi.fn().mockRejectedValue(failoverError); + + await expect( + runWithUnifiedFallback({ + config: createConfig(), // No fallbacks + run: runFn, + }), + ).rejects.toThrow("Single failure"); + }); + }); + }); + + describe("isRuntimeSlotAvailable", () => { + it("returns true when no profiles are configured", () => { + vi.mocked(resolveAuthProfileOrder).mockReturnValue([]); + + const slot: RuntimeSlot = { runtime: "pi" }; + const result = isRuntimeSlotAvailable({ + slot, + config: createConfig(), + }); + + expect(result).toBe(true); + }); + + it("returns true when at least one profile is not in cooldown", () => { + vi.mocked(resolveAuthProfileOrder).mockReturnValue(["profile-1", "profile-2"]); + vi.mocked(isProfileInCooldown).mockImplementation((_, id) => id === "profile-1"); + + const slot: RuntimeSlot = { runtime: "pi" }; + const result = isRuntimeSlotAvailable({ + slot, + config: createConfig({ authProfiles: ["profile-1", "profile-2"] }), + cfg: {} as any, + }); + + expect(result).toBe(true); + }); + + it("returns false when all profiles are in cooldown", () => { + vi.mocked(resolveAuthProfileOrder).mockReturnValue(["profile-1", "profile-2"]); + vi.mocked(isProfileInCooldown).mockReturnValue(true); + + const slot: RuntimeSlot = { runtime: "pi" }; + const result = isRuntimeSlotAvailable({ + slot, + config: createConfig({ authProfiles: ["profile-1", "profile-2"] }), + cfg: {} as any, + }); + + expect(result).toBe(false); + }); + }); +}); diff --git a/src/auto-reply/reply/agent-runner-execution.ts b/src/auto-reply/reply/agent-runner-execution.ts index 2acb065a2..a94db7310 100644 --- a/src/auto-reply/reply/agent-runner-execution.ts +++ b/src/auto-reply/reply/agent-runner-execution.ts @@ -292,16 +292,43 @@ export async function runAgentTurnWithFallback(params: { shouldEmitToolOutput: params.shouldEmitToolOutput, ownerNumbers: params.followupRun.run.ownerNumbers, + // Sandbox info (minimal version for CCSDK permission mode) + sandboxInfo: (() => { + const bashElevated = params.followupRun.run.bashElevated; + const elevatedAllowed = Boolean(bashElevated?.enabled && bashElevated?.allowed); + if (!elevatedAllowed) return undefined; + return { + enabled: true, + elevated: { + allowed: true, + defaultLevel: bashElevated?.defaultLevel ?? "off", + }, + }; + })(), + + // Exec tool configuration (shared by both runtimes) + bashElevated: params.followupRun.run.bashElevated, + execOverrides: params.followupRun.run.execOverrides, + // Pi-specific options piOptions: { enforceFinalTag: resolveEnforceFinalTag( params.followupRun.run, params.followupRun.run.provider, ), - execOverrides: params.followupRun.run.execOverrides, - bashElevated: params.followupRun.run.bashElevated, }, + // Provider session ID for session resumption (CCSDK and CLI providers) + providerSessionId: (() => { + const entry = params.getActiveSessionEntry(); + const sessionId = getCliSessionId(entry, "ccsdk"); + logVerbose( + `[PROVIDER-SESSION] Retrieving provider session ID: ${sessionId ?? "(none)"}, ` + + `cliSessionIds keys: ${entry?.cliSessionIds ? Object.keys(entry.cliSessionIds).join(", ") : "(none)"}`, + ); + return sessionId; + })(), + // Fallback config fallbacksOverride: resolveAgentModelFallbacksOverride( params.followupRun.run.config, diff --git a/src/auto-reply/reply/agent-runner-memory.ts b/src/auto-reply/reply/agent-runner-memory.ts index fc8f1947a..90fb7b6ee 100644 --- a/src/auto-reply/reply/agent-runner-memory.ts +++ b/src/auto-reply/reply/agent-runner-memory.ts @@ -132,14 +132,30 @@ export async function runMemoryFlushIfNeeded(params: { reasoningLevel: params.followupRun.run.reasoningLevel, ownerNumbers: params.followupRun.run.ownerNumbers, + // Sandbox info (minimal version for CCSDK permission mode) + sandboxInfo: (() => { + const bashElevated = params.followupRun.run.bashElevated; + const elevatedAllowed = Boolean(bashElevated?.enabled && bashElevated?.allowed); + if (!elevatedAllowed) return undefined; + return { + enabled: true, + elevated: { + allowed: true, + defaultLevel: bashElevated?.defaultLevel ?? "off", + }, + }; + })(), + + // Exec tool configuration (shared by both runtimes) + bashElevated: params.followupRun.run.bashElevated, + execOverrides: params.followupRun.run.execOverrides, + // Pi-specific options piOptions: { enforceFinalTag: resolveEnforceFinalTag( params.followupRun.run, params.followupRun.run.provider, ), - execOverrides: params.followupRun.run.execOverrides, - bashElevated: params.followupRun.run.bashElevated, }, // Fallback config diff --git a/src/auto-reply/reply/agent-runner.ts b/src/auto-reply/reply/agent-runner.ts index 227e6f17e..7fe46f81f 100644 --- a/src/auto-reply/reply/agent-runner.ts +++ b/src/auto-reply/reply/agent-runner.ts @@ -3,6 +3,7 @@ import fs from "node:fs"; import { lookupContextTokens } from "../../agents/context.js"; import { DEFAULT_CONTEXT_TOKENS } from "../../agents/defaults.js"; import { resolveModelAuthMode } from "../../agents/model-auth.js"; +import { setCliSessionId } from "../../agents/cli-session.js"; import { isCliProvider } from "../../agents/model-selection.js"; import { queueEmbeddedPiMessage } from "../../agents/pi-embedded.js"; import { hasNonzeroUsage } from "../../agents/usage.js"; @@ -15,6 +16,7 @@ import { updateSessionStoreEntry, } from "../../config/sessions.js"; import type { TypingMode } from "../../config/types.js"; +import { logVerbose } from "../../globals.js"; import { defaultRuntime } from "../../runtime.js"; import { estimateUsageCost, resolveModelCostConfig } from "../../utils/usage-format.js"; import type { OriginatingChannelType, TemplateContext } from "../templating.js"; @@ -368,15 +370,29 @@ export async function runReplyAgent(params: { const modelUsed = runResult.meta.agentMeta?.model ?? fallbackModel ?? defaultModel; const providerUsed = runResult.meta.agentMeta?.provider ?? fallbackProvider ?? followupRun.run.provider; - const cliSessionId = isCliProvider(providerUsed, cfg) - ? runResult.meta.agentMeta?.sessionId?.trim() - : undefined; const contextTokensUsed = agentCfgContextTokens ?? lookupContextTokens(modelUsed) ?? activeSessionEntry?.contextTokens ?? DEFAULT_CONTEXT_TOKENS; + // Extract session ID for CLI providers or CCSDK runtime + // CLI providers use their provider name as key; CCSDK uses "ccsdk" + const isCcsdkRun = runResult.meta.agentMeta?.runtime === "ccsdk"; + const cliSessionId = runResult.meta.agentMeta?.sessionId?.trim(); + const cliSessionIdProvider = isCcsdkRun + ? "ccsdk" + : isCliProvider(providerUsed, cfg) + ? undefined + : undefined; + const shouldStoreSessionId = isCcsdkRun || isCliProvider(providerUsed, cfg); + + logVerbose( + `[PROVIDER-SESSION] Session ID detection: providerUsed=${providerUsed}, ` + + `isCcsdkRun=${isCcsdkRun}, shouldStore=${shouldStoreSessionId}, ` + + `sessionId=${cliSessionId ?? "(none)"}, providerKey=${cliSessionIdProvider ?? "(none)"}`, + ); + await persistSessionUsageUpdate({ storePath, sessionKey, @@ -385,9 +401,23 @@ export async function runReplyAgent(params: { providerUsed, contextTokensUsed, systemPromptReport: runResult.meta.systemPromptReport, - cliSessionId, + cliSessionId: shouldStoreSessionId ? cliSessionId : undefined, + cliSessionIdProvider, }); + // Update local activeSessionEntry with the provider session ID so subsequent turns + // within the same conversation can access it via getActiveSessionEntry() + if (shouldStoreSessionId && cliSessionId && activeSessionEntry) { + const providerKey = cliSessionIdProvider ?? providerUsed ?? activeSessionEntry.modelProvider; + if (providerKey) { + setCliSessionId(activeSessionEntry, providerKey, cliSessionId); + logVerbose( + `[PROVIDER-SESSION] Stored provider session ID in activeSessionEntry: ${cliSessionId}, ` + + `provider key: ${providerKey}`, + ); + } + } + // Drain any late tool/block deliveries before deciding there's "nothing to send". // Otherwise, a late typing trigger (e.g. from a tool callback) can outlive the run and // keep the typing indicator stuck. diff --git a/src/auto-reply/reply/followup-runner.ts b/src/auto-reply/reply/followup-runner.ts index 490bf332f..67dcf09f9 100644 --- a/src/auto-reply/reply/followup-runner.ts +++ b/src/auto-reply/reply/followup-runner.ts @@ -165,11 +165,27 @@ export function createFollowupRunner(params: { blockReplyBreak: queued.run.blockReplyBreak, ownerNumbers: queued.run.ownerNumbers, + // Sandbox info (minimal version for CCSDK permission mode) + sandboxInfo: (() => { + const bashElevated = queued.run.bashElevated; + const elevatedAllowed = Boolean(bashElevated?.enabled && bashElevated?.allowed); + if (!elevatedAllowed) return undefined; + return { + enabled: true, + elevated: { + allowed: true, + defaultLevel: bashElevated?.defaultLevel ?? "off", + }, + }; + })(), + + // Exec tool configuration (shared by both runtimes) + bashElevated: queued.run.bashElevated, + execOverrides: queued.run.execOverrides, + // Pi-specific options piOptions: { enforceFinalTag: queued.run.enforceFinalTag, - execOverrides: queued.run.execOverrides, - bashElevated: queued.run.bashElevated, }, // Fallback config diff --git a/src/auto-reply/reply/session-usage.ts b/src/auto-reply/reply/session-usage.ts index 3d5226258..0398b0c8f 100644 --- a/src/auto-reply/reply/session-usage.ts +++ b/src/auto-reply/reply/session-usage.ts @@ -16,6 +16,8 @@ export async function persistSessionUsageUpdate(params: { contextTokensUsed?: number; systemPromptReport?: SessionSystemPromptReport; cliSessionId?: string; + /** Provider key for storing cliSessionId (defaults to providerUsed). Use "ccsdk" for CCSDK sessions. */ + cliSessionIdProvider?: string; logLabel?: string; }): Promise { const { storePath, sessionKey } = params; @@ -42,7 +44,8 @@ export async function persistSessionUsageUpdate(params: { systemPromptReport: params.systemPromptReport ?? entry.systemPromptReport, updatedAt: Date.now(), }; - const cliProvider = params.providerUsed ?? entry.modelProvider; + const cliProvider = + params.cliSessionIdProvider ?? params.providerUsed ?? entry.modelProvider; if (params.cliSessionId && cliProvider) { const nextEntry = { ...entry, ...patch }; setCliSessionId(nextEntry, cliProvider, params.cliSessionId); @@ -74,7 +77,8 @@ export async function persistSessionUsageUpdate(params: { systemPromptReport: params.systemPromptReport ?? entry.systemPromptReport, updatedAt: Date.now(), }; - const cliProvider = params.providerUsed ?? entry.modelProvider; + const cliProvider = + params.cliSessionIdProvider ?? params.providerUsed ?? entry.modelProvider; if (params.cliSessionId && cliProvider) { const nextEntry = { ...entry, ...patch }; setCliSessionId(nextEntry, cliProvider, params.cliSessionId); diff --git a/src/commands/agent.ts b/src/commands/agent.ts index dafb0d8af..fc0969f13 100644 --- a/src/commands/agent.ts +++ b/src/commands/agent.ts @@ -447,10 +447,9 @@ export async function agentCommand( replyToMode: runContext.replyToMode, hasRepliedRef: runContext.hasRepliedRef, - // Pi-specific options - piOptions: { - clientTools: opts.clientTools, - }, + // Client-provided tools (OpenResponses hosted tools) + // Now at top level so both Pi and CCSDK runtimes can use them + clientTools: opts.clientTools, // Fallback config fallbacksOverride: resolveAgentModelFallbacksOverride(cfg, sessionAgentId), diff --git a/src/commands/agent/types.ts b/src/commands/agent/types.ts index e59c88725..815206041 100644 --- a/src/commands/agent/types.ts +++ b/src/commands/agent/types.ts @@ -1,4 +1,4 @@ -import type { ClientToolDefinition } from "../../agents/pi-embedded-runner/run/params.js"; +import type { ClientToolDefinition } from "../../agents/agent-runtime.js"; import type { ChannelOutboundTargetMode } from "../../channels/plugins/types.js"; /** Image content block for Claude API multimodal messages. */ diff --git a/src/config/sessions/paths.ts b/src/config/sessions/paths.ts index 63d2f6834..9537de56d 100644 --- a/src/config/sessions/paths.ts +++ b/src/config/sessions/paths.ts @@ -71,3 +71,25 @@ export function resolveStorePath(store?: string, opts?: { agentId?: string }) { if (store.startsWith("~")) return path.resolve(store.replace(/^~(?=$|[\\/])/, os.homedir())); return path.resolve(store); } + +/** + * Resolve the Claude Code SDK config directory for a given agent. + * + * This directory is passed to the SDK via the CLAUDE_CONFIG_DIR environment + * variable to ensure Moltbot agent sessions are stored separately from + * standard Claude Code CLI sessions. + * + * Using `.claude/` allows per-agent customization of other Claude Code + * features like settings.json, CLAUDE.md, skills, commands, etc. + * + * Structure: ~/.clawdbot/agents/{agentId}/.claude/ + */ +export function resolveCcSdkConfigDirForAgent( + agentId?: string, + env: NodeJS.ProcessEnv = process.env, + homedir: () => string = os.homedir, +): string { + const root = resolveStateDir(env, homedir); + const id = normalizeAgentId(agentId ?? DEFAULT_AGENT_ID); + return path.join(root, "agents", id, ".claude"); +}