feat: refactor Agent-agnostic types; update Tools and Unified Agent Runner to support latest CCSDK fixes
This commit is contained in:
parent
aa4418e496
commit
460395baea
@ -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/<agentId>/.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.
|
||||
|
||||
@ -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/<agentId>/sessions/sessions.json` (per agent).
|
||||
- Transcripts: `~/.clawdbot/agents/<agentId>/sessions/<SessionId>.jsonl` (Telegram topic sessions use `.../<SessionId>-topic-<threadId>.jsonl`).
|
||||
- Claude Agent SDK sessions (when using `ccsdk` runtime): `~/.clawdbot/agents/<agentId>/.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.
|
||||
|
||||
@ -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<ExecToolDefaults, "host" | "security" | "ask" | "node">;
|
||||
/** Bash elevated execution defaults. */
|
||||
bashElevated?: ExecElevatedDefaults;
|
||||
/** Client-provided tools (OpenResponses hosted tools). */
|
||||
clientTools?: ClientToolDefinition[];
|
||||
};
|
||||
|
||||
/**
|
||||
@ -79,8 +83,6 @@ export type CcSdkRuntimeOptions = {
|
||||
sdkOptions?: Record<string, unknown>;
|
||||
/** 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<ExecToolDefaults, "host" | "security" | "ask" | "node">;
|
||||
/** Client-provided tools (OpenResponses hosted tools). Applicable to both runtimes. */
|
||||
clientTools?: ClientToolDefinition[];
|
||||
|
||||
// ─── Runtime-specific option bags ───────────────────────────────────────────
|
||||
/** Pi runtime-specific options. */
|
||||
|
||||
76
src/agents/claude-agent-sdk/client-tool-bridge.ts
Normal file
76
src/agents/claude-agent-sdk/client-tool-bridge.ts
Normal file
@ -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<string, unknown>) => 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<string, unknown>,
|
||||
_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;
|
||||
});
|
||||
}
|
||||
493
src/agents/claude-agent-sdk/error-handling.test.ts
Normal file
493
src/agents/claude-agent-sdk/error-handling.test.ts
Normal file
@ -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"]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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<string, unknown> } | 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,
|
||||
|
||||
409
src/agents/claude-agent-sdk/sdk-hooks.test.ts
Normal file
409
src/agents/claude-agent-sdk/sdk-hooks.test.ts
Normal file
@ -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,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@ -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<SdkRunnerRes
|
||||
});
|
||||
}
|
||||
|
||||
// Standard agent event streams that should be emitted to the global system.
|
||||
// These are consumed by the Chat UI and other listeners (gateway, diagnostics).
|
||||
const GLOBAL_EVENT_STREAMS = new Set([
|
||||
"lifecycle",
|
||||
"tool",
|
||||
"assistant",
|
||||
"thinking",
|
||||
"compaction",
|
||||
]);
|
||||
|
||||
const emitEvent = (stream: string, data: Record<string, unknown>) => {
|
||||
// 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<SdkRunnerRes
|
||||
};
|
||||
|
||||
emitEvent("lifecycle", { phase: "start", startedAt, runtime: "sdk" });
|
||||
emitEvent("sdk", { type: "sdk_runner_start", runId: params.runId });
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Step 0: Run before_agent_start hooks
|
||||
@ -708,8 +729,6 @@ export async function runSdkAgent(params: SdkRunnerParams): Promise<SdkRunnerRes
|
||||
};
|
||||
}
|
||||
|
||||
emitEvent("sdk", { type: "sdk_loaded" });
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Step 2: Bridge Moltbot tools to MCP
|
||||
// -------------------------------------------------------------------------
|
||||
@ -767,11 +786,6 @@ export async function runSdkAgent(params: SdkRunnerParams): Promise<SdkRunnerRes
|
||||
? ` (skipped: ${bridgeResult.skippedTools.join(", ")})`
|
||||
: ""),
|
||||
);
|
||||
emitEvent("sdk", {
|
||||
type: "tools_bridged",
|
||||
toolCount: bridgeResult.toolCount,
|
||||
skipped: bridgeResult.skippedTools,
|
||||
});
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
@ -874,23 +888,15 @@ export async function runSdkAgent(params: SdkRunnerParams): Promise<SdkRunnerRes
|
||||
if (value !== undefined) sdkEnv[key] = value;
|
||||
}
|
||||
|
||||
// Add provider env vars (z.AI, custom endpoints, etc.).
|
||||
// Add provider env vars (z.AI, custom endpoints, explicit API key, etc.).
|
||||
// These override any inherited values from the parent process.
|
||||
if (params.providerConfig?.env) {
|
||||
log.debug("[CCSDK-ENV] Applying provider config env overrides", {
|
||||
providerName: params.providerConfig.name,
|
||||
providerEnvKeys: Object.keys(params.providerConfig.env),
|
||||
});
|
||||
for (const [key, value] of Object.entries(params.providerConfig.env)) {
|
||||
if (value !== undefined) {
|
||||
const wasOverwritten = sdkEnv[key] !== undefined && sdkEnv[key] !== value;
|
||||
if (wasOverwritten) {
|
||||
log.debug("[CCSDK-ENV] Overwriting inherited env var", {
|
||||
key,
|
||||
oldValueMasked:
|
||||
key.includes("KEY") || key.includes("TOKEN") ? maskToken(sdkEnv[key]) : sdkEnv[key],
|
||||
newValueMasked: key.includes("KEY") || key.includes("TOKEN") ? maskToken(value) : value,
|
||||
});
|
||||
}
|
||||
if (value !== undefined && value !== "") {
|
||||
sdkEnv[key] = value;
|
||||
}
|
||||
}
|
||||
@ -905,6 +911,21 @@ export async function runSdkAgent(params: SdkRunnerParams): Promise<SdkRunnerRes
|
||||
if (value !== undefined) sdkEnv[key] = value;
|
||||
}
|
||||
|
||||
// NOTE: We intentionally do NOT set CLAUDE_CONFIG_DIR.
|
||||
// Setting it to a custom directory breaks auth because the SDK scopes
|
||||
// credential resolution (including keychain lookups) to that directory.
|
||||
// The user's `claude login` credentials are associated with ~/.claude,
|
||||
// so we must let the SDK use the default config directory.
|
||||
//
|
||||
// TODO: If per-agent session isolation is needed, we may need to:
|
||||
// 1. Symlink auth state from ~/.claude to the agent's config dir, OR
|
||||
// 2. Use a different mechanism for session isolation that doesn't affect auth
|
||||
if (params.ccsdkConfigDir) {
|
||||
log.debug("[CCSDK-ENV] Ignoring ccsdkConfigDir to preserve auth - using default ~/.claude", {
|
||||
ignoredConfigDir: params.ccsdkConfigDir,
|
||||
});
|
||||
}
|
||||
|
||||
// Log final auth env state
|
||||
logAuthEnvVars(sdkEnv, "[CCSDK-ENV] Final SDK");
|
||||
|
||||
@ -984,8 +1005,6 @@ export async function runSdkAgent(params: SdkRunnerParams): Promise<SdkRunnerRes
|
||||
: timeoutController.signal;
|
||||
|
||||
try {
|
||||
emitEvent("sdk", { type: "query_start" });
|
||||
|
||||
log.debug("Starting SDK query", {
|
||||
promptFormat: hasMcpServers ? "AsyncIterable" : "string",
|
||||
promptLength: typeof prompt === "string" ? prompt.length : undefined,
|
||||
@ -1123,12 +1142,21 @@ export async function runSdkAgent(params: SdkRunnerParams): Promise<SdkRunnerRes
|
||||
pendingToolCalls.delete(toolCallId);
|
||||
}
|
||||
|
||||
// Include args for start events to match Pi runtime event shape.
|
||||
const argsForEvent =
|
||||
phase === "start" && record
|
||||
? ((record.input as Record<string, unknown>) ??
|
||||
(record.arguments as Record<string, unknown>) ??
|
||||
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<SdkRunnerRes
|
||||
}
|
||||
}
|
||||
|
||||
// Emit system/lifecycle events.
|
||||
if (kind === "system" && isRecord(event)) {
|
||||
emitEvent("sdk", event as Record<string, unknown>);
|
||||
}
|
||||
|
||||
// Handle terminal result event.
|
||||
if (kind === "result" && isRecord(event)) {
|
||||
const result = event.result;
|
||||
@ -1522,14 +1545,6 @@ export async function runSdkAgent(params: SdkRunnerParams): Promise<SdkRunnerRes
|
||||
log.debug("[CCSDK-FINAL] No onBlockReplyFlush callback - UI may stay on typing indicator");
|
||||
}
|
||||
|
||||
emitEvent("sdk", {
|
||||
type: "sdk_runner_end",
|
||||
eventCount,
|
||||
extractedChars,
|
||||
truncated,
|
||||
aborted,
|
||||
durationMs: Date.now() - startedAt,
|
||||
});
|
||||
emitEvent("lifecycle", {
|
||||
phase: "end",
|
||||
startedAt,
|
||||
|
||||
481
src/agents/claude-agent-sdk/tool-bridge.test.ts
Normal file
481
src/agents/claude-agent-sdk/tool-bridge.test.ts
Normal file
@ -0,0 +1,481 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
|
||||
import type { AgentToolResult } from "@mariozechner/pi-agent-core";
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock("../../logging/subsystem.js", () => ({
|
||||
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<unknown> = {
|
||||
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<unknown> = {
|
||||
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<unknown> = {
|
||||
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<unknown> = {
|
||||
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<unknown> = {
|
||||
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<unknown> = {
|
||||
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("<tool-details>"),
|
||||
});
|
||||
expect(mcpResult.content[1]).toMatchObject({
|
||||
text: expect.stringContaining('"key": "value"'),
|
||||
});
|
||||
});
|
||||
|
||||
it("handles null details gracefully", () => {
|
||||
const result: AgentToolResult<unknown> = {
|
||||
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<unknown> = {
|
||||
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<typeof vi.fn>) {
|
||||
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");
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -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) => {
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@ -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<T> = {
|
||||
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 });
|
||||
}
|
||||
|
||||
|
||||
@ -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<EmbeddedPiRunResult> {
|
||||
}): Promise<AgentRunResult> {
|
||||
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<EmbeddedPiRunResult> {
|
||||
}): Promise<AgentRunResult> {
|
||||
return runCliAgent({
|
||||
sessionId: params.sessionId,
|
||||
sessionKey: params.sessionKey,
|
||||
|
||||
226
src/agents/main-agent-runtime-factory.test.ts
Normal file
226
src/agents/main-agent-runtime-factory.test.ts
Normal file
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -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,
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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";
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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<EmbeddedPiRunResult> {
|
||||
): Promise<AgentRunResult> {
|
||||
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,
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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<string, unknown>;
|
||||
};
|
||||
};
|
||||
// Re-export for backwards compatibility
|
||||
export type { ClientToolDefinition } from "../../runtime-result-types.js";
|
||||
|
||||
export type RunEmbeddedPiAgentParams = {
|
||||
sessionId: string;
|
||||
|
||||
@ -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<ReturnType<typeof resolveSandboxContext>>,
|
||||
execElevated?: ExecElevatedDefaults,
|
||||
): EmbeddedSandboxInfo | undefined {
|
||||
): AgentSandboxInfo | undefined {
|
||||
if (!sandbox?.enabled) return undefined;
|
||||
const elevatedAllowed = Boolean(execElevated?.enabled && execElevated.allowed);
|
||||
return {
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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";
|
||||
|
||||
@ -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,
|
||||
|
||||
144
src/agents/runtime-result-types.ts
Normal file
144
src/agents/runtime-result-types.ts
Normal file
@ -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<string, unknown>;
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* 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";
|
||||
};
|
||||
};
|
||||
@ -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),
|
||||
});
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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,
|
||||
}),
|
||||
),
|
||||
});
|
||||
|
||||
|
||||
@ -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*.
|
||||
|
||||
@ -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<string, unknown>) : {};
|
||||
|
||||
@ -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: {
|
||||
|
||||
@ -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<string, unknown> = {
|
||||
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.",
|
||||
|
||||
@ -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?: {
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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<typeof loadConfig>) {
|
||||
|
||||
@ -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<typeof loadConfig>) {
|
||||
|
||||
@ -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?: {
|
||||
|
||||
@ -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) {
|
||||
|
||||
398
src/agents/unified-agent-runner.test.ts
Normal file
398
src/agents/unified-agent-runner.test.ts
Normal file
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -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;
|
||||
|
||||
511
src/agents/unified-runtime-adapter.test.ts
Normal file
511
src/agents/unified-runtime-adapter.test.ts
Normal file
@ -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>): 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -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,
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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<void> {
|
||||
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);
|
||||
|
||||
@ -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),
|
||||
|
||||
@ -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. */
|
||||
|
||||
@ -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");
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user