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.
|
The session ID is stable and chosen by Moltbot.
|
||||||
Legacy Pi/Tau session folders are **not** read.
|
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
|
## Steering while streaming
|
||||||
|
|
||||||
When queue mode is `steer`, inbound messages are injected into the current run.
|
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**:
|
- On the **gateway host**:
|
||||||
- Store file: `~/.clawdbot/agents/<agentId>/sessions/sessions.json` (per agent).
|
- 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`).
|
- 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.
|
- 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.
|
- 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.
|
- 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 { ReasoningLevel, ThinkLevel, VerboseLevel } from "../auto-reply/thinking.js";
|
||||||
import type { ExecElevatedDefaults, ExecToolDefaults } from "./bash-tools.js";
|
import type { ExecElevatedDefaults, ExecToolDefaults } from "./bash-tools.js";
|
||||||
import type { BlockReplyChunking, ToolResultFormat } from "./pi-embedded-subscribe.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 { 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. */
|
// Re-export shared result types for convenience
|
||||||
export type AgentRuntimeKind = "pi" | "ccsdk";
|
export type {
|
||||||
|
AgentRunResult,
|
||||||
|
AgentRunMeta,
|
||||||
|
AgentRunResultMeta,
|
||||||
|
AgentRuntimeKind,
|
||||||
|
ClientToolDefinition,
|
||||||
|
} from "./runtime-result-types.js";
|
||||||
|
|
||||||
/** Result type shared by all agent runtimes. */
|
/** Result type shared by all agent runtimes. */
|
||||||
export type AgentRuntimeResult = EmbeddedPiRunResult;
|
export type AgentRuntimeResult = AgentRunResult;
|
||||||
|
|
||||||
/** Streaming and event callbacks for agent runs. */
|
/** Streaming and event callbacks for agent runs. */
|
||||||
export type AgentRuntimeCallbacks = {
|
export type AgentRuntimeCallbacks = {
|
||||||
@ -59,12 +69,6 @@ export type AgentRuntimeCallbacks = {
|
|||||||
export type PiRuntimeOptions = {
|
export type PiRuntimeOptions = {
|
||||||
/** Whether to enforce final XML tag in responses. */
|
/** Whether to enforce final XML tag in responses. */
|
||||||
enforceFinalTag?: boolean;
|
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>;
|
sdkOptions?: Record<string, unknown>;
|
||||||
/** 3-tier model configuration for Claude Code SDK. */
|
/** 3-tier model configuration for Claude Code SDK. */
|
||||||
modelTiers?: CcSdkModelTiers;
|
modelTiers?: CcSdkModelTiers;
|
||||||
/** Existing Claude Code session ID for resumption. */
|
|
||||||
claudeSessionId?: string;
|
|
||||||
/** Fork the session instead of continuing it. */
|
/** Fork the session instead of continuing it. */
|
||||||
forkSession?: boolean;
|
forkSession?: boolean;
|
||||||
};
|
};
|
||||||
@ -174,6 +176,16 @@ export type AgentRuntimeRunParams = {
|
|||||||
shouldEmitToolOutput?: () => boolean;
|
shouldEmitToolOutput?: () => boolean;
|
||||||
/** Owner phone numbers for access control. */
|
/** Owner phone numbers for access control. */
|
||||||
ownerNumbers?: string[];
|
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 ───────────────────────────────────────────
|
// ─── Runtime-specific option bags ───────────────────────────────────────────
|
||||||
/** Pi runtime-specific options. */
|
/** 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.
|
* Provider configuration builders for the Claude Agent SDK.
|
||||||
*
|
*
|
||||||
* Supports multiple authentication methods:
|
* Supports multiple authentication methods:
|
||||||
* - Anthropic API key (ANTHROPIC_API_KEY)
|
* - Anthropic API key (ANTHROPIC_API_KEY) - when explicitly configured in moltbot.json
|
||||||
* - Claude Code CLI OAuth (reuses ~/.claude credentials)
|
* - Claude Code SDK native auth (default) - inherits parent env, SDK handles credential resolution
|
||||||
* - z.AI subscription (via ANTHROPIC_AUTH_TOKEN or CLI auth)
|
* - z.AI subscription (via ANTHROPIC_AUTH_TOKEN)
|
||||||
* - OpenRouter (Anthropic-compatible API)
|
* - OpenRouter (Anthropic-compatible API)
|
||||||
* - AWS Bedrock
|
* - AWS Bedrock
|
||||||
* - Google Vertex AI
|
* - 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 type { SdkProviderConfig, SdkProviderEnv } from "./types.js";
|
||||||
import { readClaudeCliCredentialsCached } from "../cli-credentials.js";
|
|
||||||
import { createSubsystemLogger } from "../../logging/subsystem.js";
|
import { createSubsystemLogger } from "../../logging/subsystem.js";
|
||||||
|
|
||||||
const log = createSubsystemLogger("agents/claude-agent-sdk");
|
const log = createSubsystemLogger("agents/claude-agent-sdk");
|
||||||
@ -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,
|
* This returns a minimal config with no env overrides, allowing the SDK to:
|
||||||
* enabling subscription-based access (Claude Max, z.AI, etc.) without an API key.
|
* - 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
|
* We don't try to be "smart" about unsetting env vars - if the user has auth
|
||||||
* environment or via the CLI's native auth mechanism.
|
* env vars set in their system environment, that's their configuration choice.
|
||||||
*/
|
*/
|
||||||
export function buildClaudeCliSdkProvider(): SdkProviderConfig | null {
|
export function buildClaudeCliSdkProvider(): SdkProviderConfig {
|
||||||
log.debug("[CCSDK-PROVIDER] buildClaudeCliSdkProvider called");
|
log.debug("[CCSDK-PROVIDER] buildClaudeCliSdkProvider called - using SDK native auth");
|
||||||
|
|
||||||
// Read cached credentials from Claude Code CLI
|
const config: SdkProviderConfig = {
|
||||||
const credentials = readClaudeCliCredentialsCached({
|
name: "Claude CLI (SDK native)",
|
||||||
allowKeychainPrompt: false,
|
env: {},
|
||||||
ttlMs: 60_000, // 1 minute cache
|
};
|
||||||
});
|
|
||||||
|
|
||||||
if (!credentials) {
|
logProviderConfig(config, "sdk-native-auth");
|
||||||
log.debug("[CCSDK-PROVIDER] No Claude CLI credentials found from cache");
|
return config;
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -278,9 +234,14 @@ export function buildVertexSdkProvider(): SdkProviderConfig {
|
|||||||
* Resolve provider configuration based on available credentials.
|
* Resolve provider configuration based on available credentials.
|
||||||
*
|
*
|
||||||
* Priority order:
|
* Priority order:
|
||||||
* 1. Explicit API key from config/env
|
* 1. Explicit API key from moltbot config (options.apiKey)
|
||||||
* 2. Claude Code CLI credentials (subscription auth)
|
* 2. Explicit auth token from moltbot config (options.authToken) - for z.AI, etc.
|
||||||
* 3. Environment variables (fallback)
|
* 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?: {
|
export function resolveProviderConfig(options?: {
|
||||||
apiKey?: string;
|
apiKey?: string;
|
||||||
@ -297,9 +258,9 @@ export function resolveProviderConfig(options?: {
|
|||||||
useCliCredentials: options?.useCliCredentials ?? true,
|
useCliCredentials: options?.useCliCredentials ?? true,
|
||||||
});
|
});
|
||||||
|
|
||||||
// 1. Explicit API key takes precedence
|
// 1. Explicit API key from moltbot config takes precedence
|
||||||
if (options?.apiKey) {
|
if (options?.apiKey) {
|
||||||
log.debug("[CCSDK-PROVIDER] Using explicit API key");
|
log.debug("[CCSDK-PROVIDER] Using explicit API key from moltbot config");
|
||||||
const config = buildAnthropicSdkProvider(options.apiKey);
|
const config = buildAnthropicSdkProvider(options.apiKey);
|
||||||
if (options.baseUrl && config.env) {
|
if (options.baseUrl && config.env) {
|
||||||
config.env.ANTHROPIC_BASE_URL = options.baseUrl;
|
config.env.ANTHROPIC_BASE_URL = options.baseUrl;
|
||||||
@ -308,9 +269,9 @@ export function resolveProviderConfig(options?: {
|
|||||||
return config;
|
return config;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Explicit auth token (OAuth/subscription)
|
// 2. Explicit auth token from moltbot config (for z.AI, custom endpoints, etc.)
|
||||||
if (options?.authToken) {
|
if (options?.authToken) {
|
||||||
log.debug("[CCSDK-PROVIDER] Using explicit auth token");
|
log.debug("[CCSDK-PROVIDER] Using explicit auth token from moltbot config");
|
||||||
const env: SdkProviderEnv = {
|
const env: SdkProviderEnv = {
|
||||||
ANTHROPIC_AUTH_TOKEN: options.authToken,
|
ANTHROPIC_AUTH_TOKEN: options.authToken,
|
||||||
};
|
};
|
||||||
@ -325,67 +286,14 @@ export function resolveProviderConfig(options?: {
|
|||||||
return config;
|
return config;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. Try Claude CLI credentials if enabled (default: true)
|
// 3. Default: SDK native auth
|
||||||
if (options?.useCliCredentials !== false) {
|
// Let the SDK use its internal credential resolution (keychain → OAuth flow).
|
||||||
log.debug("[CCSDK-PROVIDER] Attempting Claude CLI credentials");
|
// We inherit the full parent process env - if the user has ANTHROPIC_API_KEY or
|
||||||
const cliConfig = buildClaudeCliSdkProvider();
|
// ANTHROPIC_AUTH_TOKEN set, that's their configuration choice.
|
||||||
if (cliConfig) {
|
log.debug("[CCSDK-PROVIDER] Using SDK native auth (no explicit credentials configured)");
|
||||||
log.debug("[CCSDK-PROVIDER] Using Claude CLI credentials", {
|
const cliConfig = buildClaudeCliSdkProvider();
|
||||||
providerName: cliConfig.name,
|
if (options?.baseUrl && cliConfig.env) {
|
||||||
});
|
cliConfig.env.ANTHROPIC_BASE_URL = options.baseUrl;
|
||||||
if (options?.baseUrl && cliConfig.env) {
|
|
||||||
cliConfig.env.ANTHROPIC_BASE_URL = options.baseUrl;
|
|
||||||
}
|
|
||||||
return cliConfig;
|
|
||||||
}
|
|
||||||
log.debug("[CCSDK-PROVIDER] Claude CLI credentials not available");
|
|
||||||
}
|
}
|
||||||
|
return cliConfig;
|
||||||
// 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;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -24,6 +24,8 @@ import type { SdkConversationTurn, SdkRunnerResult } from "./types.js";
|
|||||||
import { createMoltbotCodingTools } from "../pi-tools.js";
|
import { createMoltbotCodingTools } from "../pi-tools.js";
|
||||||
import { resolveMoltbotAgentDir } from "../agent-paths.js";
|
import { resolveMoltbotAgentDir } from "../agent-paths.js";
|
||||||
import { resolveModelAuthMode } from "../model-auth.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");
|
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.
|
* Extract agent ID from session key.
|
||||||
*/
|
*/
|
||||||
@ -135,9 +162,11 @@ function adaptSdkResult(result: SdkRunnerResult, sessionId: string): AgentRuntim
|
|||||||
durationMs: result.meta.durationMs,
|
durationMs: result.meta.durationMs,
|
||||||
aborted: result.meta.aborted,
|
aborted: result.meta.aborted,
|
||||||
agentMeta: {
|
agentMeta: {
|
||||||
sessionId,
|
// Use CCSDK session ID if available (for resume), otherwise Moltbot session ID
|
||||||
provider: result.meta.provider ?? "sdk",
|
sessionId: result.claudeSessionId ?? sessionId,
|
||||||
|
provider: result.meta.provider ?? "anthropic",
|
||||||
model: result.meta.model ?? "default",
|
model: result.meta.model ?? "default",
|
||||||
|
runtime: "ccsdk",
|
||||||
usage,
|
usage,
|
||||||
},
|
},
|
||||||
error: undefined,
|
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)
|
// 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
|
// If pre-built tools are provided via context, use those instead
|
||||||
const tools: AnyAgentTool[] =
|
const builtInTools: AnyAgentTool[] =
|
||||||
context?.tools ??
|
context?.tools ??
|
||||||
createMoltbotCodingTools({
|
createMoltbotCodingTools({
|
||||||
|
// Exec tool configuration (same as Pi runtime)
|
||||||
|
exec: {
|
||||||
|
...params.execOverrides,
|
||||||
|
elevated: params.bashElevated,
|
||||||
|
},
|
||||||
messageProvider: params.messageChannel ?? params.messageProvider,
|
messageProvider: params.messageChannel ?? params.messageProvider,
|
||||||
agentAccountId: params.agentAccountId,
|
agentAccountId: params.agentAccountId,
|
||||||
messageTo: params.messageTo,
|
messageTo: params.messageTo,
|
||||||
@ -226,7 +260,31 @@ export function createCcSdkAgentRuntime(context?: CcSdkAgentRuntimeContext): Age
|
|||||||
groupId: params.groupId,
|
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({
|
const sdkResult = await runSdkAgent({
|
||||||
// ─── Core shared params (spread) ────────────────────────────────────────
|
// ─── Core shared params (spread) ────────────────────────────────────────
|
||||||
@ -242,6 +300,7 @@ export function createCcSdkAgentRuntime(context?: CcSdkAgentRuntimeContext): Age
|
|||||||
timeoutMs: params.timeoutMs,
|
timeoutMs: params.timeoutMs,
|
||||||
abortSignal: params.abortSignal,
|
abortSignal: params.abortSignal,
|
||||||
extraSystemPrompt: params.extraSystemPrompt,
|
extraSystemPrompt: params.extraSystemPrompt,
|
||||||
|
ccsdkConfigDir,
|
||||||
|
|
||||||
// ─── Messaging & sender context (shared) ────────────────────────────────
|
// ─── Messaging & sender context (shared) ────────────────────────────────
|
||||||
messageChannel: params.messageChannel,
|
messageChannel: params.messageChannel,
|
||||||
@ -262,8 +321,10 @@ export function createCcSdkAgentRuntime(context?: CcSdkAgentRuntimeContext): Age
|
|||||||
hooksEnabled: ccsdkOpts.hooksEnabled ?? context?.ccsdkConfig?.hooksEnabled,
|
hooksEnabled: ccsdkOpts.hooksEnabled ?? context?.ccsdkConfig?.hooksEnabled,
|
||||||
sdkOptions: ccsdkOpts.sdkOptions ?? context?.ccsdkConfig?.options,
|
sdkOptions: ccsdkOpts.sdkOptions ?? context?.ccsdkConfig?.options,
|
||||||
modelTiers: ccsdkOpts.modelTiers ?? context?.ccsdkConfig?.models,
|
modelTiers: ccsdkOpts.modelTiers ?? context?.ccsdkConfig?.models,
|
||||||
claudeSessionId: ccsdkOpts.claudeSessionId,
|
claudeSessionId: params.providerSessionId,
|
||||||
forkSession: ccsdkOpts.forkSession,
|
forkSession: ccsdkOpts.forkSession,
|
||||||
|
// Map sandbox elevated permission level to CCSDK permission mode
|
||||||
|
permissionMode: mapElevatedToPermissionMode(params.sandboxInfo?.elevated?.defaultLevel),
|
||||||
thinkingLevel: params.thinkLevel,
|
thinkingLevel: params.thinkLevel,
|
||||||
conversationHistory,
|
conversationHistory,
|
||||||
tools,
|
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);
|
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 { createSubsystemLogger } from "../../logging/subsystem.js";
|
||||||
|
import { emitAgentEvent } from "../../infra/agent-events.js";
|
||||||
import { bridgeMoltbotToolsToMcpServer } from "./tool-bridge.js";
|
import { bridgeMoltbotToolsToMcpServer } from "./tool-bridge.js";
|
||||||
import type { SdkRunnerQueryOptions } from "./tool-bridge.types.js";
|
import type { SdkRunnerQueryOptions } from "./tool-bridge.types.js";
|
||||||
import { extractFromClaudeAgentSdkEvent } from "./extract.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>) => {
|
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 {
|
try {
|
||||||
void Promise.resolve(params.onAgentEvent?.({ stream, data })).catch(() => {
|
void Promise.resolve(params.onAgentEvent?.({ stream, data })).catch(() => {
|
||||||
// Don't let async callback errors trigger unhandled rejections.
|
// 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("lifecycle", { phase: "start", startedAt, runtime: "sdk" });
|
||||||
emitEvent("sdk", { type: "sdk_runner_start", runId: params.runId });
|
|
||||||
|
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
// Step 0: Run before_agent_start hooks
|
// 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
|
// Step 2: Bridge Moltbot tools to MCP
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
@ -767,11 +786,6 @@ export async function runSdkAgent(params: SdkRunnerParams): Promise<SdkRunnerRes
|
|||||||
? ` (skipped: ${bridgeResult.skippedTools.join(", ")})`
|
? ` (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;
|
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) {
|
if (params.providerConfig?.env) {
|
||||||
log.debug("[CCSDK-ENV] Applying provider config env overrides", {
|
log.debug("[CCSDK-ENV] Applying provider config env overrides", {
|
||||||
providerName: params.providerConfig.name,
|
providerName: params.providerConfig.name,
|
||||||
providerEnvKeys: Object.keys(params.providerConfig.env),
|
providerEnvKeys: Object.keys(params.providerConfig.env),
|
||||||
});
|
});
|
||||||
for (const [key, value] of Object.entries(params.providerConfig.env)) {
|
for (const [key, value] of Object.entries(params.providerConfig.env)) {
|
||||||
if (value !== undefined) {
|
if (value !== undefined && value !== "") {
|
||||||
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,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
sdkEnv[key] = value;
|
sdkEnv[key] = value;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -905,6 +911,21 @@ export async function runSdkAgent(params: SdkRunnerParams): Promise<SdkRunnerRes
|
|||||||
if (value !== undefined) sdkEnv[key] = value;
|
if (value !== undefined) sdkEnv[key] = value;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// NOTE: We intentionally do NOT set CLAUDE_CONFIG_DIR.
|
||||||
|
// Setting it to a custom directory breaks auth because the SDK scopes
|
||||||
|
// credential resolution (including keychain lookups) to that directory.
|
||||||
|
// The user's `claude login` credentials are associated with ~/.claude,
|
||||||
|
// so we must let the SDK use the default config directory.
|
||||||
|
//
|
||||||
|
// TODO: If per-agent session isolation is needed, we may need to:
|
||||||
|
// 1. Symlink auth state from ~/.claude to the agent's config dir, OR
|
||||||
|
// 2. Use a different mechanism for session isolation that doesn't affect auth
|
||||||
|
if (params.ccsdkConfigDir) {
|
||||||
|
log.debug("[CCSDK-ENV] Ignoring ccsdkConfigDir to preserve auth - using default ~/.claude", {
|
||||||
|
ignoredConfigDir: params.ccsdkConfigDir,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Log final auth env state
|
// Log final auth env state
|
||||||
logAuthEnvVars(sdkEnv, "[CCSDK-ENV] Final SDK");
|
logAuthEnvVars(sdkEnv, "[CCSDK-ENV] Final SDK");
|
||||||
|
|
||||||
@ -984,8 +1005,6 @@ export async function runSdkAgent(params: SdkRunnerParams): Promise<SdkRunnerRes
|
|||||||
: timeoutController.signal;
|
: timeoutController.signal;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
emitEvent("sdk", { type: "query_start" });
|
|
||||||
|
|
||||||
log.debug("Starting SDK query", {
|
log.debug("Starting SDK query", {
|
||||||
promptFormat: hasMcpServers ? "AsyncIterable" : "string",
|
promptFormat: hasMcpServers ? "AsyncIterable" : "string",
|
||||||
promptLength: typeof prompt === "string" ? prompt.length : undefined,
|
promptLength: typeof prompt === "string" ? prompt.length : undefined,
|
||||||
@ -1123,12 +1142,21 @@ export async function runSdkAgent(params: SdkRunnerParams): Promise<SdkRunnerRes
|
|||||||
pendingToolCalls.delete(toolCallId);
|
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", {
|
emitEvent("tool", {
|
||||||
phase,
|
phase,
|
||||||
name: normalizedName.name,
|
name: normalizedName.name,
|
||||||
toolCallId,
|
toolCallId,
|
||||||
sdkType: type,
|
sdkType: type,
|
||||||
...(normalizedName.rawName ? { rawName: normalizedName.rawName } : {}),
|
...(normalizedName.rawName ? { rawName: normalizedName.rawName } : {}),
|
||||||
|
...(argsForEvent ? { args: argsForEvent } : {}),
|
||||||
isError,
|
isError,
|
||||||
...(toolText ? { resultText: toolText } : {}),
|
...(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.
|
// Handle terminal result event.
|
||||||
if (kind === "result" && isRecord(event)) {
|
if (kind === "result" && isRecord(event)) {
|
||||||
const result = event.result;
|
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");
|
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", {
|
emitEvent("lifecycle", {
|
||||||
phase: "end",
|
phase: "end",
|
||||||
startedAt,
|
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.
|
// model's response.
|
||||||
const toolCallId = `mcp-bridge-${normalizedName}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
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.
|
// Create an onUpdate callback that forwards to the bridge callback.
|
||||||
const onUpdate = onToolUpdate
|
const onUpdate = onToolUpdate
|
||||||
? (update: unknown) => {
|
? (update: unknown) => {
|
||||||
|
|||||||
@ -211,6 +211,14 @@ export type SdkRunnerParams = {
|
|||||||
*/
|
*/
|
||||||
mcpServerName?: string;
|
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.
|
* Prior conversation history to serialize into the SDK prompt.
|
||||||
* Since the SDK is stateless, prior turns are injected as context
|
* Since the SDK is stateless, prior turns are injected as context
|
||||||
|
|||||||
@ -239,4 +239,297 @@ describe("cli credentials", () => {
|
|||||||
provider: "openai-codex",
|
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_SERVICE = "Claude Code-credentials";
|
||||||
const CLAUDE_CLI_KEYCHAIN_ACCOUNT = "Claude Code";
|
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> = {
|
type CachedValue<T> = {
|
||||||
value: T | null;
|
value: T | null;
|
||||||
readAt: number;
|
readAt: number;
|
||||||
@ -84,6 +87,7 @@ type ClaudeCliFileOptions = {
|
|||||||
|
|
||||||
type ClaudeCliWriteOptions = ClaudeCliFileOptions & {
|
type ClaudeCliWriteOptions = ClaudeCliFileOptions & {
|
||||||
platform?: NodeJS.Platform;
|
platform?: NodeJS.Platform;
|
||||||
|
execSync?: ExecSyncFn;
|
||||||
writeKeychain?: (credentials: OAuthCredentials) => boolean;
|
writeKeychain?: (credentials: OAuthCredentials) => boolean;
|
||||||
writeFile?: (credentials: OAuthCredentials, options?: ClaudeCliFileOptions) => 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?: {
|
export function readClaudeCliCredentials(options?: {
|
||||||
allowKeychainPrompt?: boolean;
|
allowKeychainPrompt?: boolean;
|
||||||
platform?: NodeJS.Platform;
|
platform?: NodeJS.Platform;
|
||||||
@ -290,22 +524,40 @@ export function readClaudeCliCredentials(options?: {
|
|||||||
homeDir: options?.homeDir ?? "(default)",
|
homeDir: options?.homeDir ?? "(default)",
|
||||||
});
|
});
|
||||||
|
|
||||||
// Try macOS Keychain first
|
// Try platform-specific secure credential storage first
|
||||||
if (platform === "darwin" && options?.allowKeychainPrompt !== false) {
|
if (options?.allowKeychainPrompt !== false) {
|
||||||
log.debug("[CCSDK-AUTH] Attempting macOS Keychain read");
|
let secureStoreCreds: ClaudeCliCredential | null = null;
|
||||||
const keychainCreds = readClaudeCliKeychainCredentials(options?.execSync);
|
let source = "";
|
||||||
if (keychainCreds) {
|
|
||||||
log.info("[CCSDK-AUTH] Successfully read credentials from macOS Keychain", {
|
if (platform === "darwin") {
|
||||||
type: keychainCreds.type,
|
log.debug("[CCSDK-AUTH] Attempting macOS Keychain read");
|
||||||
source: "keychain",
|
secureStoreCreds = readClaudeCliKeychainCredentials(options?.execSync);
|
||||||
accessTokenMasked:
|
source = "macos-keychain";
|
||||||
keychainCreds.type === "oauth"
|
} else if (platform === "linux") {
|
||||||
? maskToken(keychainCreds.access)
|
log.debug("[CCSDK-AUTH] Attempting Linux Secret Service read");
|
||||||
: maskToken(keychainCreds.token),
|
secureStoreCreds = readClaudeCliLinuxSecretServiceCredentials(options?.execSync);
|
||||||
});
|
source = "linux-secret-service";
|
||||||
return keychainCreds;
|
} 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
|
// 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(
|
export function writeClaudeCliFileCredentials(
|
||||||
newCredentials: OAuthCredentials,
|
newCredentials: OAuthCredentials,
|
||||||
options?: ClaudeCliFileOptions,
|
options?: ClaudeCliFileOptions,
|
||||||
@ -506,13 +962,29 @@ export function writeClaudeCliCredentials(
|
|||||||
options?.writeFile ??
|
options?.writeFile ??
|
||||||
((credentials, fileOptions) => writeClaudeCliFileCredentials(credentials, fileOptions));
|
((credentials, fileOptions) => writeClaudeCliFileCredentials(credentials, fileOptions));
|
||||||
|
|
||||||
|
// Try platform-specific secure storage first
|
||||||
if (platform === "darwin") {
|
if (platform === "darwin") {
|
||||||
const didWriteKeychain = writeKeychain(newCredentials);
|
const didWrite = writeKeychain(newCredentials);
|
||||||
if (didWriteKeychain) {
|
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;
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Fall back to file-based storage
|
||||||
return writeFile(newCredentials, { homeDir: options?.homeDir });
|
return writeFile(newCredentials, { homeDir: options?.homeDir });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -28,7 +28,7 @@ import {
|
|||||||
} from "./cli-runner/helpers.js";
|
} from "./cli-runner/helpers.js";
|
||||||
import { FailoverError, resolveFailoverStatus } from "./failover-error.js";
|
import { FailoverError, resolveFailoverStatus } from "./failover-error.js";
|
||||||
import { classifyFailoverReason, isFailoverErrorMessage } from "./pi-embedded-helpers.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");
|
const log = createSubsystemLogger("agent/claude-cli");
|
||||||
|
|
||||||
@ -49,7 +49,7 @@ export async function runCliAgent(params: {
|
|||||||
ownerNumbers?: string[];
|
ownerNumbers?: string[];
|
||||||
cliSessionId?: string;
|
cliSessionId?: string;
|
||||||
images?: ImageContent[];
|
images?: ImageContent[];
|
||||||
}): Promise<EmbeddedPiRunResult> {
|
}): Promise<AgentRunResult> {
|
||||||
const started = Date.now();
|
const started = Date.now();
|
||||||
const resolvedWorkspace = resolveUserPath(params.workspaceDir);
|
const resolvedWorkspace = resolveUserPath(params.workspaceDir);
|
||||||
const workspaceDir = resolvedWorkspace;
|
const workspaceDir = resolvedWorkspace;
|
||||||
@ -314,7 +314,7 @@ export async function runClaudeCliAgent(params: {
|
|||||||
ownerNumbers?: string[];
|
ownerNumbers?: string[];
|
||||||
claudeSessionId?: string;
|
claudeSessionId?: string;
|
||||||
images?: ImageContent[];
|
images?: ImageContent[];
|
||||||
}): Promise<EmbeddedPiRunResult> {
|
}): Promise<AgentRunResult> {
|
||||||
return runCliAgent({
|
return runCliAgent({
|
||||||
sessionId: params.sessionId,
|
sessionId: params.sessionId,
|
||||||
sessionKey: params.sessionKey,
|
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
|
// Spread shared params directly, then add Pi-specific options from the bag
|
||||||
return runEmbeddedPiAgent({
|
return runEmbeddedPiAgent({
|
||||||
...params,
|
...params,
|
||||||
// Pi-specific options from piOptions bag override any shared fields
|
// Pi-specific options from piOptions bag
|
||||||
enforceFinalTag: piOpts.enforceFinalTag,
|
enforceFinalTag: piOpts.enforceFinalTag,
|
||||||
execOverrides: piOpts.execOverrides,
|
// Client tools (now at top level, applicable to both runtimes)
|
||||||
bashElevated: piOpts.bashElevated,
|
clientTools: params.clientTools,
|
||||||
clientTools: piOpts.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 { describe, expect, it, vi } from "vitest";
|
||||||
import type { MoltbotConfig } from "../config/config.js";
|
import type { MoltbotConfig } from "../config/config.js";
|
||||||
import { ensureMoltbotModelsJson } from "./models-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";
|
import type { SandboxContext } from "./sandbox.js";
|
||||||
|
|
||||||
vi.mock("@mariozechner/pi-ai", async () => {
|
vi.mock("@mariozechner/pi-ai", async () => {
|
||||||
@ -97,9 +97,9 @@ const _readSessionMessages = async (sessionFile: string) => {
|
|||||||
.map((entry) => entry.message as { role?: string; content?: unknown });
|
.map((entry) => entry.message as { role?: string; content?: unknown });
|
||||||
};
|
};
|
||||||
|
|
||||||
describe("buildEmbeddedSandboxInfo", () => {
|
describe("buildAgentSandboxInfo", () => {
|
||||||
it("returns undefined when sandbox is missing", () => {
|
it("returns undefined when sandbox is missing", () => {
|
||||||
expect(buildEmbeddedSandboxInfo()).toBeUndefined();
|
expect(buildAgentSandboxInfo()).toBeUndefined();
|
||||||
});
|
});
|
||||||
it("maps sandbox context into prompt info", () => {
|
it("maps sandbox context into prompt info", () => {
|
||||||
const sandbox = {
|
const sandbox = {
|
||||||
@ -133,7 +133,7 @@ describe("buildEmbeddedSandboxInfo", () => {
|
|||||||
},
|
},
|
||||||
} satisfies SandboxContext;
|
} satisfies SandboxContext;
|
||||||
|
|
||||||
expect(buildEmbeddedSandboxInfo(sandbox)).toEqual({
|
expect(buildAgentSandboxInfo(sandbox)).toEqual({
|
||||||
enabled: true,
|
enabled: true,
|
||||||
workspaceDir: "/tmp/moltbot-sandbox",
|
workspaceDir: "/tmp/moltbot-sandbox",
|
||||||
workspaceAccess: "none",
|
workspaceAccess: "none",
|
||||||
@ -171,7 +171,7 @@ describe("buildEmbeddedSandboxInfo", () => {
|
|||||||
} satisfies SandboxContext;
|
} satisfies SandboxContext;
|
||||||
|
|
||||||
expect(
|
expect(
|
||||||
buildEmbeddedSandboxInfo(sandbox, {
|
buildAgentSandboxInfo(sandbox, {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
allowed: true,
|
allowed: true,
|
||||||
defaultLevel: "on",
|
defaultLevel: "on",
|
||||||
|
|||||||
@ -16,12 +16,19 @@ export {
|
|||||||
queueEmbeddedPiMessage,
|
queueEmbeddedPiMessage,
|
||||||
waitForEmbeddedPiRunEnd,
|
waitForEmbeddedPiRunEnd,
|
||||||
} from "./pi-embedded-runner/runs.js";
|
} 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 { createSystemPromptOverride } from "./pi-embedded-runner/system-prompt.js";
|
||||||
export { splitSdkTools } from "./pi-embedded-runner/tool-split.js";
|
export { splitSdkTools } from "./pi-embedded-runner/tool-split.js";
|
||||||
|
// Re-export shared result types (generalized names)
|
||||||
export type {
|
export type {
|
||||||
EmbeddedPiAgentMeta,
|
AgentRunMeta,
|
||||||
EmbeddedPiCompactResult,
|
AgentRunResultMeta,
|
||||||
EmbeddedPiRunMeta,
|
AgentRunResult,
|
||||||
EmbeddedPiRunResult,
|
AgentRuntimeKind,
|
||||||
} from "./pi-embedded-runner/types.js";
|
} 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 { resolveGlobalLane, resolveSessionLane } from "./lanes.js";
|
||||||
import { log } from "./logger.js";
|
import { log } from "./logger.js";
|
||||||
import { buildModelAliasLines, resolveModel } from "./model.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 { prewarmSessionFile, trackSessionManagerAccess } from "./session-manager-cache.js";
|
||||||
import { buildEmbeddedSystemPrompt, createSystemPromptOverride } from "./system-prompt.js";
|
import { buildEmbeddedSystemPrompt, createSystemPromptOverride } from "./system-prompt.js";
|
||||||
import { splitSdkTools } from "./tool-split.js";
|
import { splitSdkTools } from "./tool-split.js";
|
||||||
@ -304,7 +304,7 @@ export async function compactEmbeddedPiSessionDirect(
|
|||||||
capabilities: runtimeCapabilities,
|
capabilities: runtimeCapabilities,
|
||||||
channelActions,
|
channelActions,
|
||||||
};
|
};
|
||||||
const sandboxInfo = buildEmbeddedSandboxInfo(sandbox, params.bashElevated);
|
const sandboxInfo = buildAgentSandboxInfo(sandbox, params.bashElevated);
|
||||||
const reasoningTagHint = isReasoningTagProvider(provider);
|
const reasoningTagHint = isReasoningTagProvider(provider);
|
||||||
const userTimezone = resolveUserTimezone(params.config?.agents?.defaults?.userTimezone);
|
const userTimezone = resolveUserTimezone(params.config?.agents?.defaults?.userTimezone);
|
||||||
const userTimeFormat = resolveUserTimeFormat(params.config?.agents?.defaults?.timeFormat);
|
const userTimeFormat = resolveUserTimeFormat(params.config?.agents?.defaults?.timeFormat);
|
||||||
|
|||||||
@ -50,7 +50,7 @@ import { resolveModel } from "./model.js";
|
|||||||
import { runEmbeddedAttempt } from "./run/attempt.js";
|
import { runEmbeddedAttempt } from "./run/attempt.js";
|
||||||
import type { RunEmbeddedPiAgentParams } from "./run/params.js";
|
import type { RunEmbeddedPiAgentParams } from "./run/params.js";
|
||||||
import { buildEmbeddedRunPayloads } from "./run/payloads.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";
|
import { describeUnknownError } from "./utils.js";
|
||||||
|
|
||||||
type ApiKeyInfo = ResolvedProviderAuth;
|
type ApiKeyInfo = ResolvedProviderAuth;
|
||||||
@ -69,7 +69,7 @@ function scrubAnthropicRefusalMagic(prompt: string): string {
|
|||||||
|
|
||||||
export async function runEmbeddedPiAgent(
|
export async function runEmbeddedPiAgent(
|
||||||
params: RunEmbeddedPiAgentParams,
|
params: RunEmbeddedPiAgentParams,
|
||||||
): Promise<EmbeddedPiRunResult> {
|
): Promise<AgentRunResult> {
|
||||||
const sessionLane = resolveSessionLane(params.sessionKey?.trim() || params.sessionId);
|
const sessionLane = resolveSessionLane(params.sessionKey?.trim() || params.sessionId);
|
||||||
const globalLane = resolveGlobalLane(params.lane);
|
const globalLane = resolveGlobalLane(params.lane);
|
||||||
const enqueueGlobal =
|
const enqueueGlobal =
|
||||||
@ -611,7 +611,7 @@ export async function runEmbeddedPiAgent(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const usage = normalizeUsage(lastAssistant?.usage as UsageLike);
|
const usage = normalizeUsage(lastAssistant?.usage as UsageLike);
|
||||||
const agentMeta: EmbeddedPiAgentMeta = {
|
const agentMeta: AgentRunMeta = {
|
||||||
sessionId: sessionIdUsed,
|
sessionId: sessionIdUsed,
|
||||||
provider: lastAssistant?.provider ?? provider,
|
provider: lastAssistant?.provider ?? provider,
|
||||||
model: lastAssistant?.model ?? model.id,
|
model: lastAssistant?.model ?? model.id,
|
||||||
|
|||||||
@ -70,7 +70,7 @@ import {
|
|||||||
type EmbeddedPiQueueHandle,
|
type EmbeddedPiQueueHandle,
|
||||||
setActiveEmbeddedRun,
|
setActiveEmbeddedRun,
|
||||||
} from "../runs.js";
|
} from "../runs.js";
|
||||||
import { buildEmbeddedSandboxInfo } from "../sandbox-info.js";
|
import { buildAgentSandboxInfo } from "../sandbox-info.js";
|
||||||
import { prewarmSessionFile, trackSessionManagerAccess } from "../session-manager-cache.js";
|
import { prewarmSessionFile, trackSessionManagerAccess } from "../session-manager-cache.js";
|
||||||
import { prepareSessionManagerForRun } from "../session-manager-init.js";
|
import { prepareSessionManagerForRun } from "../session-manager-init.js";
|
||||||
import { buildEmbeddedSystemPrompt, createSystemPromptOverride } from "../system-prompt.js";
|
import { buildEmbeddedSystemPrompt, createSystemPromptOverride } from "../system-prompt.js";
|
||||||
@ -285,7 +285,7 @@ export async function runEmbeddedAttempt(
|
|||||||
sessionKey: params.sessionKey,
|
sessionKey: params.sessionKey,
|
||||||
config: params.config,
|
config: params.config,
|
||||||
});
|
});
|
||||||
const sandboxInfo = buildEmbeddedSandboxInfo(sandbox, params.bashElevated);
|
const sandboxInfo = buildAgentSandboxInfo(sandbox, params.bashElevated);
|
||||||
const reasoningTagHint = isReasoningTagProvider(params.provider);
|
const reasoningTagHint = isReasoningTagProvider(params.provider);
|
||||||
// Resolve channel-specific message actions for system prompt
|
// Resolve channel-specific message actions for system prompt
|
||||||
const channelActions = runtimeChannel
|
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 { ExecElevatedDefaults, ExecToolDefaults } from "../../bash-tools.js";
|
||||||
import type { BlockReplyChunking, ToolResultFormat } from "../../pi-embedded-subscribe.js";
|
import type { BlockReplyChunking, ToolResultFormat } from "../../pi-embedded-subscribe.js";
|
||||||
import type { SkillSnapshot } from "../../skills.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)
|
// Re-export for backwards compatibility
|
||||||
export type ClientToolDefinition = {
|
export type { ClientToolDefinition } from "../../runtime-result-types.js";
|
||||||
type: "function";
|
|
||||||
function: {
|
|
||||||
name: string;
|
|
||||||
description?: string;
|
|
||||||
parameters?: Record<string, unknown>;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
export type RunEmbeddedPiAgentParams = {
|
export type RunEmbeddedPiAgentParams = {
|
||||||
sessionId: string;
|
sessionId: string;
|
||||||
|
|||||||
@ -1,11 +1,11 @@
|
|||||||
import type { ExecElevatedDefaults } from "../bash-tools.js";
|
import type { ExecElevatedDefaults } from "../bash-tools.js";
|
||||||
import type { resolveSandboxContext } from "../sandbox.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>>,
|
sandbox?: Awaited<ReturnType<typeof resolveSandboxContext>>,
|
||||||
execElevated?: ExecElevatedDefaults,
|
execElevated?: ExecElevatedDefaults,
|
||||||
): EmbeddedSandboxInfo | undefined {
|
): AgentSandboxInfo | undefined {
|
||||||
if (!sandbox?.enabled) return undefined;
|
if (!sandbox?.enabled) return undefined;
|
||||||
const elevatedAllowed = Boolean(execElevated?.enabled && execElevated.allowed);
|
const elevatedAllowed = Boolean(execElevated?.enabled && execElevated.allowed);
|
||||||
return {
|
return {
|
||||||
|
|||||||
@ -3,7 +3,7 @@ import type { ResolvedTimeFormat } from "../date-time.js";
|
|||||||
import type { EmbeddedContextFile } from "../pi-embedded-helpers.js";
|
import type { EmbeddedContextFile } from "../pi-embedded-helpers.js";
|
||||||
import { buildAgentSystemPrompt, type PromptMode } from "../system-prompt.js";
|
import { buildAgentSystemPrompt, type PromptMode } from "../system-prompt.js";
|
||||||
import { buildToolSummaryMap } from "../tool-summaries.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";
|
import type { ReasoningLevel, ThinkLevel } from "./utils.js";
|
||||||
|
|
||||||
export function buildEmbeddedSystemPrompt(params: {
|
export function buildEmbeddedSystemPrompt(params: {
|
||||||
@ -38,7 +38,7 @@ export function buildEmbeddedSystemPrompt(params: {
|
|||||||
channelActions?: string[];
|
channelActions?: string[];
|
||||||
};
|
};
|
||||||
messageToolHints?: string[];
|
messageToolHints?: string[];
|
||||||
sandboxInfo?: EmbeddedSandboxInfo;
|
sandboxInfo?: AgentSandboxInfo;
|
||||||
tools: AgentTool[];
|
tools: AgentTool[];
|
||||||
modelAliasLines: string[];
|
modelAliasLines: string[];
|
||||||
userTimezone: string;
|
userTimezone: string;
|
||||||
|
|||||||
@ -1,56 +1,12 @@
|
|||||||
import type { MessagingToolSend } from "../pi-embedded-messaging.js";
|
/**
|
||||||
import type { SessionSystemPromptReport } from "../../config/sessions/types.js";
|
* Pi runtime-specific types.
|
||||||
|
*
|
||||||
export type EmbeddedPiAgentMeta = {
|
* For shared agent result types, see ../runtime-result-types.ts
|
||||||
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[];
|
|
||||||
};
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Result from a Pi session compaction operation.
|
||||||
|
*/
|
||||||
export type EmbeddedPiCompactResult = {
|
export type EmbeddedPiCompactResult = {
|
||||||
ok: boolean;
|
ok: boolean;
|
||||||
compacted: boolean;
|
compacted: boolean;
|
||||||
@ -64,16 +20,12 @@ export type EmbeddedPiCompactResult = {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export type EmbeddedSandboxInfo = {
|
// Re-export shared types for convenience
|
||||||
enabled: boolean;
|
export {
|
||||||
workspaceDir?: string;
|
type AgentRunMeta,
|
||||||
workspaceAccess?: "none" | "ro" | "rw";
|
type AgentRunResultMeta,
|
||||||
agentWorkspaceMount?: string;
|
type AgentRunPayload,
|
||||||
browserBridgeUrl?: string;
|
type AgentRunResult,
|
||||||
browserNoVncUrl?: string;
|
type AgentRuntimeKind,
|
||||||
hostBrowserAllowed?: boolean;
|
type AgentSandboxInfo,
|
||||||
elevated?: {
|
} from "../runtime-result-types.js";
|
||||||
allowed: boolean;
|
|
||||||
defaultLevel: "on" | "off" | "ask" | "full";
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|||||||
@ -1,9 +1,8 @@
|
|||||||
export type {
|
// Re-export shared agent result types
|
||||||
EmbeddedPiAgentMeta,
|
export type { AgentRunMeta, AgentRunResultMeta, AgentRunResult } from "./runtime-result-types.js";
|
||||||
EmbeddedPiCompactResult,
|
|
||||||
EmbeddedPiRunMeta,
|
// Re-export Pi-specific types
|
||||||
EmbeddedPiRunResult,
|
export type { EmbeddedPiCompactResult } from "./pi-embedded-runner.js";
|
||||||
} from "./pi-embedded-runner.js";
|
|
||||||
export {
|
export {
|
||||||
abortEmbeddedPiRun,
|
abortEmbeddedPiRun,
|
||||||
compactEmbeddedPiSession,
|
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.
|
// because Claude API on Vertex AI rejects nested anyOf schemas as invalid JSON Schema.
|
||||||
// The discriminator (kind) determines which properties are relevant; runtime validates.
|
// The discriminator (kind) determines which properties are relevant; runtime validates.
|
||||||
const BrowserActSchema = Type.Object({
|
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
|
// Common fields
|
||||||
targetId: Type.Optional(Type.String()),
|
targetId: Type.Optional(Type.String({ description: "Tab ID to target (from tabs action)." })),
|
||||||
ref: Type.Optional(Type.String()),
|
ref: Type.Optional(
|
||||||
|
Type.String({ description: "Element reference from snapshot (e.g., 'E1', 'button[0]')." }),
|
||||||
|
),
|
||||||
// click
|
// click
|
||||||
doubleClick: Type.Optional(Type.Boolean()),
|
doubleClick: Type.Optional(
|
||||||
button: Type.Optional(Type.String()),
|
Type.Boolean({ description: "Perform a double-click instead of single-click." }),
|
||||||
modifiers: Type.Optional(Type.Array(Type.String())),
|
),
|
||||||
|
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
|
// type
|
||||||
text: Type.Optional(Type.String()),
|
text: Type.Optional(Type.String({ description: "Text to type into the focused element." })),
|
||||||
submit: Type.Optional(Type.Boolean()),
|
submit: Type.Optional(Type.Boolean({ description: "Press Enter after typing to submit." })),
|
||||||
slowly: Type.Optional(Type.Boolean()),
|
slowly: Type.Optional(
|
||||||
|
Type.Boolean({ description: "Type slowly with delays between keystrokes." }),
|
||||||
|
),
|
||||||
// press
|
// press
|
||||||
key: Type.Optional(Type.String()),
|
key: Type.Optional(
|
||||||
|
Type.String({ description: "Key to press (e.g., 'Enter', 'Escape', 'Tab')." }),
|
||||||
|
),
|
||||||
// drag
|
// drag
|
||||||
startRef: Type.Optional(Type.String()),
|
startRef: Type.Optional(Type.String({ description: "Starting element reference for drag." })),
|
||||||
endRef: Type.Optional(Type.String()),
|
endRef: Type.Optional(Type.String({ description: "Ending element reference for drag." })),
|
||||||
// select
|
// 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
|
// 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
|
// resize
|
||||||
width: Type.Optional(Type.Number()),
|
width: Type.Optional(Type.Number({ description: "New viewport width in pixels." })),
|
||||||
height: Type.Optional(Type.Number()),
|
height: Type.Optional(Type.Number({ description: "New viewport height in pixels." })),
|
||||||
// wait
|
// wait
|
||||||
timeMs: Type.Optional(Type.Number()),
|
timeMs: Type.Optional(Type.Number({ description: "Time to wait in milliseconds." })),
|
||||||
textGone: Type.Optional(Type.String()),
|
textGone: Type.Optional(
|
||||||
|
Type.String({ description: "Wait until this text disappears from the page." }),
|
||||||
|
),
|
||||||
// evaluate
|
// 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"`.
|
// IMPORTANT: OpenAI function tool schemas must have a top-level `type: "object"`.
|
||||||
// A root-level `Type.Union([...])` compiles to `{ anyOf: [...] }` (no `type`),
|
// A root-level `Type.Union([...])` compiles to `{ anyOf: [...] }` (no `type`),
|
||||||
// which OpenAI rejects ("Invalid schema ... type: None"). Keep this schema an object.
|
// which OpenAI rejects ("Invalid schema ... type: None"). Keep this schema an object.
|
||||||
export const BrowserToolSchema = Type.Object({
|
export const BrowserToolSchema = Type.Object({
|
||||||
action: stringEnum(BROWSER_TOOL_ACTIONS),
|
action: stringEnum(BROWSER_TOOL_ACTIONS, {
|
||||||
target: optionalStringEnum(BROWSER_TARGETS),
|
description:
|
||||||
node: Type.Optional(Type.String()),
|
"Browser action: status, start, stop, profiles, tabs, open, focus, close, snapshot, screenshot, navigate, console, pdf, upload, dialog, act.",
|
||||||
profile: Type.Optional(Type.String()),
|
}),
|
||||||
targetUrl: Type.Optional(Type.String()),
|
target: optionalStringEnum(BROWSER_TARGETS, {
|
||||||
targetId: Type.Optional(Type.String()),
|
description: "Browser target: 'sandbox' (isolated), 'host' (local), or 'node' (paired node).",
|
||||||
limit: Type.Optional(Type.Number()),
|
}),
|
||||||
maxChars: Type.Optional(Type.Number()),
|
node: Type.Optional(Type.String({ description: "Node ID or name when target='node'." })),
|
||||||
mode: optionalStringEnum(BROWSER_SNAPSHOT_MODES),
|
profile: Type.Optional(
|
||||||
snapshotFormat: optionalStringEnum(BROWSER_SNAPSHOT_FORMATS),
|
Type.String({ description: "Browser profile name (for persistent state/cookies)." }),
|
||||||
refs: optionalStringEnum(BROWSER_SNAPSHOT_REFS),
|
),
|
||||||
interactive: Type.Optional(Type.Boolean()),
|
targetUrl: Type.Optional(
|
||||||
compact: Type.Optional(Type.Boolean()),
|
Type.String({ description: "URL to navigate to when opening a new tab." }),
|
||||||
depth: Type.Optional(Type.Number()),
|
),
|
||||||
selector: Type.Optional(Type.String()),
|
targetId: Type.Optional(Type.String({ description: "Tab ID to target (from tabs action)." })),
|
||||||
frame: Type.Optional(Type.String()),
|
limit: Type.Optional(
|
||||||
labels: Type.Optional(Type.Boolean()),
|
Type.Number({ description: "Limit for console logs or other list results." }),
|
||||||
fullPage: Type.Optional(Type.Boolean()),
|
),
|
||||||
ref: Type.Optional(Type.String()),
|
maxChars: Type.Optional(
|
||||||
element: Type.Optional(Type.String()),
|
Type.Number({ description: "Maximum characters to return in snapshot text." }),
|
||||||
type: optionalStringEnum(BROWSER_IMAGE_TYPES),
|
),
|
||||||
level: Type.Optional(Type.String()),
|
mode: optionalStringEnum(BROWSER_SNAPSHOT_MODES, {
|
||||||
paths: Type.Optional(Type.Array(Type.String())),
|
description: "Snapshot mode: 'efficient' for optimized output.",
|
||||||
inputRef: Type.Optional(Type.String()),
|
}),
|
||||||
timeoutMs: Type.Optional(Type.Number()),
|
snapshotFormat: optionalStringEnum(BROWSER_SNAPSHOT_FORMATS, {
|
||||||
accept: Type.Optional(Type.Boolean()),
|
description: "Snapshot format: 'aria' (accessibility tree) or 'ai' (AI-optimized).",
|
||||||
promptText: Type.Optional(Type.String()),
|
}),
|
||||||
|
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),
|
request: Type.Optional(BrowserActSchema),
|
||||||
});
|
});
|
||||||
|
|||||||
@ -24,29 +24,43 @@ const CANVAS_SNAPSHOT_FORMATS = ["png", "jpg", "jpeg"] as const;
|
|||||||
|
|
||||||
// Flattened schema: runtime validates per-action requirements.
|
// Flattened schema: runtime validates per-action requirements.
|
||||||
const CanvasToolSchema = Type.Object({
|
const CanvasToolSchema = Type.Object({
|
||||||
action: stringEnum(CANVAS_ACTIONS),
|
action: stringEnum(CANVAS_ACTIONS, {
|
||||||
gatewayUrl: Type.Optional(Type.String()),
|
description: "Canvas action: present, hide, navigate, eval, snapshot, a2ui_push, a2ui_reset.",
|
||||||
gatewayToken: Type.Optional(Type.String()),
|
}),
|
||||||
timeoutMs: Type.Optional(Type.Number()),
|
gatewayUrl: Type.Optional(
|
||||||
node: Type.Optional(Type.String()),
|
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
|
// present
|
||||||
target: Type.Optional(Type.String()),
|
target: Type.Optional(Type.String({ description: "URL to load when presenting the canvas." })),
|
||||||
x: Type.Optional(Type.Number()),
|
x: Type.Optional(Type.Number({ description: "Canvas window X position in pixels." })),
|
||||||
y: Type.Optional(Type.Number()),
|
y: Type.Optional(Type.Number({ description: "Canvas window Y position in pixels." })),
|
||||||
width: Type.Optional(Type.Number()),
|
width: Type.Optional(Type.Number({ description: "Canvas window width in pixels." })),
|
||||||
height: Type.Optional(Type.Number()),
|
height: Type.Optional(Type.Number({ description: "Canvas window height in pixels." })),
|
||||||
// navigate
|
// navigate
|
||||||
url: Type.Optional(Type.String()),
|
url: Type.Optional(Type.String({ description: "URL to navigate the canvas to." })),
|
||||||
// eval
|
// eval
|
||||||
javaScript: Type.Optional(Type.String()),
|
javaScript: Type.Optional(
|
||||||
|
Type.String({ description: "JavaScript code to evaluate in the canvas context." }),
|
||||||
|
),
|
||||||
// snapshot
|
// snapshot
|
||||||
outputFormat: optionalStringEnum(CANVAS_SNAPSHOT_FORMATS),
|
outputFormat: optionalStringEnum(CANVAS_SNAPSHOT_FORMATS, {
|
||||||
maxWidth: Type.Optional(Type.Number()),
|
description: "Snapshot image format: 'png', 'jpg', or 'jpeg'.",
|
||||||
quality: Type.Optional(Type.Number()),
|
}),
|
||||||
delayMs: Type.Optional(Type.Number()),
|
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
|
// a2ui_push
|
||||||
jsonl: Type.Optional(Type.String()),
|
jsonl: Type.Optional(Type.String({ description: "A2UI JSONL content to push directly." })),
|
||||||
jsonlPath: Type.Optional(Type.String()),
|
jsonlPath: Type.Optional(
|
||||||
|
Type.String({ description: "Path to A2UI JSONL file to read and push." }),
|
||||||
|
),
|
||||||
});
|
});
|
||||||
|
|
||||||
export function createCanvasTool(): AnyAgentTool {
|
export function createCanvasTool(): AnyAgentTool {
|
||||||
|
|||||||
@ -24,19 +24,49 @@ const REMINDER_CONTEXT_MARKER = "\n\nRecent context:\n";
|
|||||||
|
|
||||||
// Flattened schema: runtime validates per-action requirements.
|
// Flattened schema: runtime validates per-action requirements.
|
||||||
const CronToolSchema = Type.Object({
|
const CronToolSchema = Type.Object({
|
||||||
action: stringEnum(CRON_ACTIONS),
|
action: stringEnum(CRON_ACTIONS, {
|
||||||
gatewayUrl: Type.Optional(Type.String()),
|
description: "Cron action: status, list, add, update, remove, run, runs, wake.",
|
||||||
gatewayToken: Type.Optional(Type.String()),
|
}),
|
||||||
timeoutMs: Type.Optional(Type.Number()),
|
gatewayUrl: Type.Optional(
|
||||||
includeDisabled: Type.Optional(Type.Boolean()),
|
Type.String({ description: "Gateway URL override (uses default if omitted)." }),
|
||||||
job: Type.Optional(Type.Object({}, { additionalProperties: true })),
|
),
|
||||||
jobId: Type.Optional(Type.String()),
|
gatewayToken: Type.Optional(Type.String({ description: "Gateway authentication token." })),
|
||||||
id: Type.Optional(Type.String()),
|
timeoutMs: Type.Optional(Type.Number({ description: "Request timeout in milliseconds." })),
|
||||||
patch: Type.Optional(Type.Object({}, { additionalProperties: true })),
|
includeDisabled: Type.Optional(
|
||||||
text: Type.Optional(Type.String()),
|
Type.Boolean({ description: "Include disabled jobs in list output." }),
|
||||||
mode: optionalStringEnum(CRON_WAKE_MODES),
|
),
|
||||||
|
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(
|
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.
|
// because Claude API on Vertex AI rejects nested anyOf schemas as invalid JSON Schema.
|
||||||
// The discriminator (action) determines which properties are relevant; runtime validates.
|
// The discriminator (action) determines which properties are relevant; runtime validates.
|
||||||
const GatewayToolSchema = Type.Object({
|
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
|
// restart
|
||||||
delayMs: Type.Optional(Type.Number()),
|
delayMs: Type.Optional(
|
||||||
reason: Type.Optional(Type.String()),
|
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
|
// config.get, config.schema, config.apply, update.run
|
||||||
gatewayUrl: Type.Optional(Type.String()),
|
gatewayUrl: Type.Optional(
|
||||||
gatewayToken: Type.Optional(Type.String()),
|
Type.String({ description: "Gateway URL override (uses default if omitted)." }),
|
||||||
timeoutMs: Type.Optional(Type.Number()),
|
),
|
||||||
|
gatewayToken: Type.Optional(Type.String({ description: "Gateway authentication token." })),
|
||||||
|
timeoutMs: Type.Optional(Type.Number({ description: "Request timeout in milliseconds." })),
|
||||||
// config.apply, config.patch
|
// config.apply, config.patch
|
||||||
raw: Type.Optional(Type.String()),
|
raw: Type.Optional(
|
||||||
baseHash: Type.Optional(Type.String()),
|
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
|
// config.apply, config.patch, update.run
|
||||||
sessionKey: Type.Optional(Type.String()),
|
sessionKey: Type.Optional(
|
||||||
note: Type.Optional(Type.String()),
|
Type.String({ description: "Session key to notify after restart completes." }),
|
||||||
restartDelayMs: Type.Optional(Type.Number()),
|
),
|
||||||
|
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:
|
// NOTE: We intentionally avoid top-level `allOf`/`anyOf`/`oneOf` conditionals here:
|
||||||
// - OpenAI rejects tool schemas that include these keywords at the *top-level*.
|
// - OpenAI rejects tool schemas that include these keywords at the *top-level*.
|
||||||
|
|||||||
@ -324,10 +324,27 @@ export function createImageTool(options?: {
|
|||||||
name: "image",
|
name: "image",
|
||||||
description,
|
description,
|
||||||
parameters: Type.Object({
|
parameters: Type.Object({
|
||||||
prompt: Type.Optional(Type.String()),
|
prompt: Type.Optional(
|
||||||
image: Type.String(),
|
Type.String({
|
||||||
model: Type.Optional(Type.String()),
|
description:
|
||||||
maxBytesMb: Type.Optional(Type.Number()),
|
"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) => {
|
execute: async (_toolCallId, args) => {
|
||||||
const record = args && typeof args === "object" ? (args as Record<string, unknown>) : {};
|
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";
|
import { jsonResult, readNumberParam, readStringParam } from "./common.js";
|
||||||
|
|
||||||
const MemorySearchSchema = Type.Object({
|
const MemorySearchSchema = Type.Object({
|
||||||
query: Type.String(),
|
query: Type.String({
|
||||||
maxResults: Type.Optional(Type.Number()),
|
description:
|
||||||
minScore: Type.Optional(Type.Number()),
|
"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({
|
const MemoryGetSchema = Type.Object({
|
||||||
path: Type.String(),
|
path: Type.String({
|
||||||
from: Type.Optional(Type.Number()),
|
description:
|
||||||
lines: Type.Optional(Type.Number()),
|
"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: {
|
export function createMemorySearchTool(options: {
|
||||||
|
|||||||
@ -25,17 +25,19 @@ import { jsonResult, readNumberParam, readStringParam } from "./common.js";
|
|||||||
const AllMessageActions = CHANNEL_MESSAGE_ACTION_NAMES;
|
const AllMessageActions = CHANNEL_MESSAGE_ACTION_NAMES;
|
||||||
function buildRoutingSchema() {
|
function buildRoutingSchema() {
|
||||||
return {
|
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." })),
|
target: Type.Optional(channelTargetSchema({ description: "Target channel/user id or name." })),
|
||||||
targets: Type.Optional(channelTargetsSchema()),
|
targets: Type.Optional(channelTargetsSchema()),
|
||||||
accountId: Type.Optional(Type.String()),
|
accountId: Type.Optional(Type.String({ description: "Account ID for multi-account setups." })),
|
||||||
dryRun: Type.Optional(Type.Boolean()),
|
dryRun: Type.Optional(Type.Boolean({ description: "Simulate the action without sending." })),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildSendSchema(options: { includeButtons: boolean; includeCards: boolean }) {
|
function buildSendSchema(options: { includeButtons: boolean; includeCards: boolean }) {
|
||||||
const props: Record<string, unknown> = {
|
const props: Record<string, unknown> = {
|
||||||
message: Type.Optional(Type.String()),
|
message: Type.Optional(Type.String({ description: "Text message content to send." })),
|
||||||
effectId: Type.Optional(
|
effectId: Type.Optional(
|
||||||
Type.String({
|
Type.String({
|
||||||
description: "Message effect name/id for sendWithEffect (e.g., invisible ink).",
|
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(
|
effect: Type.Optional(
|
||||||
Type.String({ description: "Alias for effectId (e.g., invisible-ink, balloons)." }),
|
Type.String({ description: "Alias for effectId (e.g., invisible-ink, balloons)." }),
|
||||||
),
|
),
|
||||||
media: Type.Optional(Type.String()),
|
media: Type.Optional(Type.String({ description: "URL or path to media file to attach." })),
|
||||||
filename: Type.Optional(Type.String()),
|
filename: Type.Optional(Type.String({ description: "Filename for the attachment." })),
|
||||||
buffer: Type.Optional(
|
buffer: Type.Optional(
|
||||||
Type.String({
|
Type.String({
|
||||||
description: "Base64 payload for attachments (optionally a data: URL).",
|
description: "Base64 payload for attachments (optionally a data: URL).",
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
contentType: Type.Optional(Type.String()),
|
contentType: Type.Optional(
|
||||||
mimeType: Type.Optional(Type.String()),
|
Type.String({ description: "MIME content type of the attachment." }),
|
||||||
caption: Type.Optional(Type.String()),
|
),
|
||||||
path: Type.Optional(Type.String()),
|
mimeType: Type.Optional(
|
||||||
filePath: Type.Optional(Type.String()),
|
Type.String({ description: "MIME type of the attachment (alias for contentType)." }),
|
||||||
replyTo: Type.Optional(Type.String()),
|
),
|
||||||
threadId: Type.Optional(Type.String()),
|
caption: Type.Optional(Type.String({ description: "Caption for media attachments." })),
|
||||||
asVoice: Type.Optional(Type.Boolean()),
|
path: Type.Optional(Type.String({ description: "Local file path to attach." })),
|
||||||
silent: Type.Optional(Type.Boolean()),
|
filePath: Type.Optional(
|
||||||
quoteText: Type.Optional(
|
Type.String({ description: "Local file path to attach (alias for path)." }),
|
||||||
Type.String({ description: "Quote text for Telegram reply_parameters" }),
|
),
|
||||||
|
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(
|
buttons: Type.Optional(
|
||||||
Type.Array(
|
Type.Array(
|
||||||
Type.Array(
|
Type.Array(
|
||||||
Type.Object({
|
Type.Object({
|
||||||
text: Type.String(),
|
text: Type.String({ description: "Button label text." }),
|
||||||
callback_data: Type.String(),
|
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,
|
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() {
|
function buildReactionSchema() {
|
||||||
return {
|
return {
|
||||||
messageId: Type.Optional(Type.String()),
|
messageId: Type.Optional(Type.String({ description: "Message ID to react to." })),
|
||||||
emoji: Type.Optional(Type.String()),
|
emoji: Type.Optional(Type.String({ description: "Emoji character or name for the reaction." })),
|
||||||
remove: Type.Optional(Type.Boolean()),
|
remove: Type.Optional(Type.Boolean({ description: "Remove the reaction instead of adding." })),
|
||||||
targetAuthor: Type.Optional(Type.String()),
|
targetAuthor: Type.Optional(
|
||||||
targetAuthorUuid: Type.Optional(Type.String()),
|
Type.String({ description: "Author of the message to react to (Signal)." }),
|
||||||
groupId: Type.Optional(Type.String()),
|
),
|
||||||
|
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() {
|
function buildFetchSchema() {
|
||||||
return {
|
return {
|
||||||
limit: Type.Optional(Type.Number()),
|
limit: Type.Optional(Type.Number({ description: "Maximum number of messages to fetch." })),
|
||||||
before: Type.Optional(Type.String()),
|
before: Type.Optional(Type.String({ description: "Fetch messages before this message ID." })),
|
||||||
after: Type.Optional(Type.String()),
|
after: Type.Optional(Type.String({ description: "Fetch messages after this message ID." })),
|
||||||
around: Type.Optional(Type.String()),
|
around: Type.Optional(Type.String({ description: "Fetch messages around this message ID." })),
|
||||||
fromMe: Type.Optional(Type.Boolean()),
|
fromMe: Type.Optional(Type.Boolean({ description: "Filter to only messages from the bot." })),
|
||||||
includeArchived: Type.Optional(Type.Boolean()),
|
includeArchived: Type.Optional(
|
||||||
|
Type.Boolean({ description: "Include archived messages in results." }),
|
||||||
|
),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildPollSchema() {
|
function buildPollSchema() {
|
||||||
return {
|
return {
|
||||||
pollQuestion: Type.Optional(Type.String()),
|
pollQuestion: Type.Optional(Type.String({ description: "Poll question text." })),
|
||||||
pollOption: Type.Optional(Type.Array(Type.String())),
|
pollOption: Type.Optional(
|
||||||
pollDurationHours: Type.Optional(Type.Number()),
|
Type.Array(Type.String(), { description: "Array of poll answer options." }),
|
||||||
pollMulti: Type.Optional(Type.Boolean()),
|
),
|
||||||
|
pollDurationHours: Type.Optional(
|
||||||
|
Type.Number({ description: "Poll duration in hours before closing." }),
|
||||||
|
),
|
||||||
|
pollMulti: Type.Optional(Type.Boolean({ description: "Allow multiple answer selections." })),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildChannelTargetSchema() {
|
function buildChannelTargetSchema() {
|
||||||
return {
|
return {
|
||||||
channelId: Type.Optional(
|
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(
|
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()),
|
guildId: Type.Optional(Type.String({ description: "Discord server/guild ID." })),
|
||||||
userId: Type.Optional(Type.String()),
|
userId: Type.Optional(Type.String({ description: "User ID to filter by." })),
|
||||||
authorId: Type.Optional(Type.String()),
|
authorId: Type.Optional(Type.String({ description: "Message author ID to filter by." })),
|
||||||
authorIds: Type.Optional(Type.Array(Type.String())),
|
authorIds: Type.Optional(
|
||||||
roleId: Type.Optional(Type.String()),
|
Type.Array(Type.String(), { description: "Multiple author IDs to filter by." }),
|
||||||
roleIds: Type.Optional(Type.Array(Type.String())),
|
),
|
||||||
participant: Type.Optional(Type.String()),
|
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() {
|
function buildStickerSchema() {
|
||||||
return {
|
return {
|
||||||
emojiName: Type.Optional(Type.String()),
|
emojiName: Type.Optional(Type.String({ description: "Emoji name to convert to sticker." })),
|
||||||
stickerId: Type.Optional(Type.Array(Type.String())),
|
stickerId: Type.Optional(Type.Array(Type.String(), { description: "Sticker IDs to send." })),
|
||||||
stickerName: Type.Optional(Type.String()),
|
stickerName: Type.Optional(Type.String({ description: "Name for creating a new sticker." })),
|
||||||
stickerDesc: Type.Optional(Type.String()),
|
stickerDesc: Type.Optional(Type.String({ description: "Description for the sticker." })),
|
||||||
stickerTags: Type.Optional(Type.String()),
|
stickerTags: Type.Optional(Type.String({ description: "Tags/keywords for the sticker." })),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildThreadSchema() {
|
function buildThreadSchema() {
|
||||||
return {
|
return {
|
||||||
threadName: Type.Optional(Type.String()),
|
threadName: Type.Optional(Type.String({ description: "Name for creating a new thread." })),
|
||||||
autoArchiveMin: Type.Optional(Type.Number()),
|
autoArchiveMin: Type.Optional(
|
||||||
|
Type.Number({ description: "Minutes of inactivity before auto-archiving the thread." }),
|
||||||
|
),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildEventSchema() {
|
function buildEventSchema() {
|
||||||
return {
|
return {
|
||||||
query: Type.Optional(Type.String()),
|
query: Type.Optional(Type.String({ description: "Search query for events." })),
|
||||||
eventName: Type.Optional(Type.String()),
|
eventName: Type.Optional(Type.String({ description: "Event name/title." })),
|
||||||
eventType: Type.Optional(Type.String()),
|
eventType: Type.Optional(Type.String({ description: "Event type (e.g., 'meeting', 'call')." })),
|
||||||
startTime: Type.Optional(Type.String()),
|
startTime: Type.Optional(
|
||||||
endTime: Type.Optional(Type.String()),
|
Type.String({ description: "Event start time (ISO 8601 or natural language)." }),
|
||||||
desc: Type.Optional(Type.String()),
|
),
|
||||||
location: Type.Optional(Type.String()),
|
endTime: Type.Optional(
|
||||||
durationMin: Type.Optional(Type.Number()),
|
Type.String({ description: "Event end time (ISO 8601 or natural language)." }),
|
||||||
until: Type.Optional(Type.String()),
|
),
|
||||||
|
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() {
|
function buildModerationSchema() {
|
||||||
return {
|
return {
|
||||||
reason: Type.Optional(Type.String()),
|
reason: Type.Optional(
|
||||||
deleteDays: Type.Optional(Type.Number()),
|
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() {
|
function buildGatewaySchema() {
|
||||||
return {
|
return {
|
||||||
gatewayUrl: Type.Optional(Type.String()),
|
gatewayUrl: Type.Optional(
|
||||||
gatewayToken: Type.Optional(Type.String()),
|
Type.String({ description: "Gateway URL override (uses default if omitted)." }),
|
||||||
timeoutMs: Type.Optional(Type.Number()),
|
),
|
||||||
|
gatewayToken: Type.Optional(Type.String({ description: "Gateway authentication token." })),
|
||||||
|
timeoutMs: Type.Optional(Type.Number({ description: "Request timeout in milliseconds." })),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildChannelManagementSchema() {
|
function buildChannelManagementSchema() {
|
||||||
return {
|
return {
|
||||||
name: Type.Optional(Type.String()),
|
name: Type.Optional(Type.String({ description: "Channel name." })),
|
||||||
type: Type.Optional(Type.Number()),
|
type: Type.Optional(
|
||||||
parentId: Type.Optional(Type.String()),
|
Type.Number({ description: "Channel type (Discord channel type number)." }),
|
||||||
topic: Type.Optional(Type.String()),
|
),
|
||||||
position: Type.Optional(Type.Number()),
|
parentId: Type.Optional(Type.String({ description: "Parent category ID for the channel." })),
|
||||||
nsfw: Type.Optional(Type.Boolean()),
|
topic: Type.Optional(Type.String({ description: "Channel topic/description." })),
|
||||||
rateLimitPerUser: Type.Optional(Type.Number()),
|
position: Type.Optional(Type.Number({ description: "Channel position in the list." })),
|
||||||
categoryId: Type.Optional(Type.String()),
|
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(
|
clearParent: Type.Optional(
|
||||||
Type.Boolean({
|
Type.Boolean({
|
||||||
description: "Clear the parent/category when supported by the provider.",
|
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.
|
// Flattened schema: runtime validates per-action requirements.
|
||||||
const NodesToolSchema = Type.Object({
|
const NodesToolSchema = Type.Object({
|
||||||
action: stringEnum(NODES_TOOL_ACTIONS),
|
action: stringEnum(NODES_TOOL_ACTIONS, {
|
||||||
gatewayUrl: Type.Optional(Type.String()),
|
description:
|
||||||
gatewayToken: Type.Optional(Type.String()),
|
"Node action: status, describe, pending, approve, reject, notify, camera_snap, camera_list, camera_clip, screen_record, location_get, run.",
|
||||||
timeoutMs: Type.Optional(Type.Number()),
|
}),
|
||||||
node: Type.Optional(Type.String()),
|
gatewayUrl: Type.Optional(
|
||||||
requestId: Type.Optional(Type.String()),
|
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
|
// notify
|
||||||
title: Type.Optional(Type.String()),
|
title: Type.Optional(Type.String({ description: "Notification title." })),
|
||||||
body: Type.Optional(Type.String()),
|
body: Type.Optional(Type.String({ description: "Notification body text." })),
|
||||||
sound: Type.Optional(Type.String()),
|
sound: Type.Optional(Type.String({ description: "Notification sound name." })),
|
||||||
priority: optionalStringEnum(NOTIFY_PRIORITIES),
|
priority: optionalStringEnum(NOTIFY_PRIORITIES, {
|
||||||
delivery: optionalStringEnum(NOTIFY_DELIVERIES),
|
description: "Notification priority: 'passive', 'active', or 'timeSensitive'.",
|
||||||
|
}),
|
||||||
|
delivery: optionalStringEnum(NOTIFY_DELIVERIES, {
|
||||||
|
description: "Notification delivery mode: 'system', 'overlay', or 'auto'.",
|
||||||
|
}),
|
||||||
// camera_snap / camera_clip
|
// camera_snap / camera_clip
|
||||||
facing: optionalStringEnum(CAMERA_FACING, {
|
facing: optionalStringEnum(CAMERA_FACING, {
|
||||||
description: "camera_snap: front/back/both; camera_clip: front/back only.",
|
description: "camera_snap: front/back/both; camera_clip: front/back only.",
|
||||||
}),
|
}),
|
||||||
maxWidth: Type.Optional(Type.Number()),
|
maxWidth: Type.Optional(
|
||||||
quality: Type.Optional(Type.Number()),
|
Type.Number({ description: "Maximum image width in pixels (camera_snap)." }),
|
||||||
delayMs: Type.Optional(Type.Number()),
|
),
|
||||||
deviceId: Type.Optional(Type.String()),
|
quality: Type.Optional(
|
||||||
duration: Type.Optional(Type.String()),
|
Type.Number({ description: "Image quality (0-100) for JPEG compression." }),
|
||||||
durationMs: Type.Optional(Type.Number()),
|
),
|
||||||
includeAudio: Type.Optional(Type.Boolean()),
|
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
|
// screen_record
|
||||||
fps: Type.Optional(Type.Number()),
|
fps: Type.Optional(Type.Number({ description: "Frames per second for screen recording." })),
|
||||||
screenIndex: Type.Optional(Type.Number()),
|
screenIndex: Type.Optional(
|
||||||
outPath: Type.Optional(Type.String()),
|
Type.Number({ description: "Screen/display index to record (0-based)." }),
|
||||||
|
),
|
||||||
|
outPath: Type.Optional(Type.String({ description: "Output file path for screen recording." })),
|
||||||
// location_get
|
// location_get
|
||||||
maxAgeMs: Type.Optional(Type.Number()),
|
maxAgeMs: Type.Optional(
|
||||||
locationTimeoutMs: Type.Optional(Type.Number()),
|
Type.Number({ description: "Maximum age of cached location in milliseconds." }),
|
||||||
desiredAccuracy: optionalStringEnum(LOCATION_ACCURACY),
|
),
|
||||||
|
locationTimeoutMs: Type.Optional(
|
||||||
|
Type.Number({ description: "Location request timeout in milliseconds." }),
|
||||||
|
),
|
||||||
|
desiredAccuracy: optionalStringEnum(LOCATION_ACCURACY, {
|
||||||
|
description: "Location accuracy: 'coarse', 'balanced', or 'precise'.",
|
||||||
|
}),
|
||||||
// run
|
// run
|
||||||
command: Type.Optional(Type.Array(Type.String())),
|
command: Type.Optional(
|
||||||
cwd: Type.Optional(Type.String()),
|
Type.Array(Type.String(), { description: "Command argv array (e.g., ['echo', 'Hello'])." }),
|
||||||
env: Type.Optional(Type.Array(Type.String())),
|
),
|
||||||
commandTimeoutMs: Type.Optional(Type.Number()),
|
cwd: Type.Optional(Type.String({ description: "Working directory for the command." })),
|
||||||
invokeTimeoutMs: Type.Optional(Type.Number()),
|
env: Type.Optional(
|
||||||
needsScreenRecording: Type.Optional(Type.Boolean()),
|
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?: {
|
export function createNodesTool(options?: {
|
||||||
|
|||||||
@ -49,8 +49,17 @@ import {
|
|||||||
import { loadCombinedSessionStoreForGateway } from "../../gateway/session-utils.js";
|
import { loadCombinedSessionStoreForGateway } from "../../gateway/session-utils.js";
|
||||||
|
|
||||||
const SessionStatusToolSchema = Type.Object({
|
const SessionStatusToolSchema = Type.Object({
|
||||||
sessionKey: Type.Optional(Type.String()),
|
sessionKey: Type.Optional(
|
||||||
model: Type.Optional(Type.String()),
|
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 {
|
function formatApiKeySnippet(apiKey: string): string {
|
||||||
|
|||||||
@ -14,9 +14,20 @@ import {
|
|||||||
} from "./sessions-helpers.js";
|
} from "./sessions-helpers.js";
|
||||||
|
|
||||||
const SessionsHistoryToolSchema = Type.Object({
|
const SessionsHistoryToolSchema = Type.Object({
|
||||||
sessionKey: Type.String(),
|
sessionKey: Type.String({
|
||||||
limit: Type.Optional(Type.Number({ minimum: 1 })),
|
description: "The session key or session ID to fetch history from.",
|
||||||
includeTools: Type.Optional(Type.Boolean()),
|
}),
|
||||||
|
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>) {
|
function resolveSandboxSessionToolsVisibility(cfg: ReturnType<typeof loadConfig>) {
|
||||||
|
|||||||
@ -19,10 +19,30 @@ import {
|
|||||||
} from "./sessions-helpers.js";
|
} from "./sessions-helpers.js";
|
||||||
|
|
||||||
const SessionsListToolSchema = Type.Object({
|
const SessionsListToolSchema = Type.Object({
|
||||||
kinds: Type.Optional(Type.Array(Type.String())),
|
kinds: Type.Optional(
|
||||||
limit: Type.Optional(Type.Number({ minimum: 1 })),
|
Type.Array(Type.String(), {
|
||||||
activeMinutes: Type.Optional(Type.Number({ minimum: 1 })),
|
description:
|
||||||
messageLimit: Type.Optional(Type.Number({ minimum: 0 })),
|
"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>) {
|
function resolveSandboxSessionToolsVisibility(cfg: ReturnType<typeof loadConfig>) {
|
||||||
|
|||||||
@ -29,11 +29,36 @@ import { buildAgentToAgentMessageContext, resolvePingPongTurns } from "./session
|
|||||||
import { runSessionsSendA2AFlow } from "./sessions-send-tool.a2a.js";
|
import { runSessionsSendA2AFlow } from "./sessions-send-tool.a2a.js";
|
||||||
|
|
||||||
const SessionsSendToolSchema = Type.Object({
|
const SessionsSendToolSchema = Type.Object({
|
||||||
sessionKey: Type.Optional(Type.String()),
|
sessionKey: Type.Optional(
|
||||||
label: Type.Optional(Type.String({ minLength: 1, maxLength: SESSION_LABEL_MAX_LENGTH })),
|
Type.String({
|
||||||
agentId: Type.Optional(Type.String({ minLength: 1, maxLength: 64 })),
|
description:
|
||||||
message: Type.String(),
|
"Target session key (e.g., 'agent:main:abc123'). Use either sessionKey OR label, not both.",
|
||||||
timeoutSeconds: Type.Optional(Type.Number({ minimum: 0 })),
|
}),
|
||||||
|
),
|
||||||
|
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?: {
|
export function createSessionsSendTool(opts?: {
|
||||||
|
|||||||
@ -26,15 +26,49 @@ import {
|
|||||||
} from "./sessions-helpers.js";
|
} from "./sessions-helpers.js";
|
||||||
|
|
||||||
const SessionsSpawnToolSchema = Type.Object({
|
const SessionsSpawnToolSchema = Type.Object({
|
||||||
task: Type.String(),
|
task: Type.String({
|
||||||
label: Type.Optional(Type.String()),
|
description: "The task or prompt to send to the spawned sub-agent session.",
|
||||||
agentId: Type.Optional(Type.String()),
|
}),
|
||||||
model: Type.Optional(Type.String()),
|
label: Type.Optional(
|
||||||
thinking: Type.Optional(Type.String()),
|
Type.String({
|
||||||
runTimeoutSeconds: Type.Optional(Type.Number({ minimum: 0 })),
|
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.
|
// Back-compat alias. Prefer runTimeoutSeconds.
|
||||||
timeoutSeconds: Type.Optional(Type.Number({ minimum: 0 })),
|
timeoutSeconds: Type.Optional(
|
||||||
cleanup: optionalStringEnum(["delete", "keep"] as const),
|
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) {
|
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,
|
total: totalAttempts,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Notify model selection before attempt
|
// Notify model selection before attempt (wrapped in try/catch to be resilient)
|
||||||
await params.onModelSelected?.({
|
try {
|
||||||
runtime: runtimeKind,
|
await params.onModelSelected?.({
|
||||||
provider: candidate.provider,
|
runtime: runtimeKind,
|
||||||
model: candidate.model,
|
provider: candidate.provider,
|
||||||
attempt: attemptIndex,
|
model: candidate.model,
|
||||||
total: totalAttempts,
|
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
|
// Determine if this is a different provider than originally requested
|
||||||
const originalProvider = params.provider ?? DEFAULT_PROVIDER;
|
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,
|
shouldEmitToolOutput: params.shouldEmitToolOutput,
|
||||||
ownerNumbers: params.followupRun.run.ownerNumbers,
|
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
|
// Pi-specific options
|
||||||
piOptions: {
|
piOptions: {
|
||||||
enforceFinalTag: resolveEnforceFinalTag(
|
enforceFinalTag: resolveEnforceFinalTag(
|
||||||
params.followupRun.run,
|
params.followupRun.run,
|
||||||
params.followupRun.run.provider,
|
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
|
// Fallback config
|
||||||
fallbacksOverride: resolveAgentModelFallbacksOverride(
|
fallbacksOverride: resolveAgentModelFallbacksOverride(
|
||||||
params.followupRun.run.config,
|
params.followupRun.run.config,
|
||||||
|
|||||||
@ -132,14 +132,30 @@ export async function runMemoryFlushIfNeeded(params: {
|
|||||||
reasoningLevel: params.followupRun.run.reasoningLevel,
|
reasoningLevel: params.followupRun.run.reasoningLevel,
|
||||||
ownerNumbers: params.followupRun.run.ownerNumbers,
|
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
|
// Pi-specific options
|
||||||
piOptions: {
|
piOptions: {
|
||||||
enforceFinalTag: resolveEnforceFinalTag(
|
enforceFinalTag: resolveEnforceFinalTag(
|
||||||
params.followupRun.run,
|
params.followupRun.run,
|
||||||
params.followupRun.run.provider,
|
params.followupRun.run.provider,
|
||||||
),
|
),
|
||||||
execOverrides: params.followupRun.run.execOverrides,
|
|
||||||
bashElevated: params.followupRun.run.bashElevated,
|
|
||||||
},
|
},
|
||||||
|
|
||||||
// Fallback config
|
// Fallback config
|
||||||
|
|||||||
@ -3,6 +3,7 @@ import fs from "node:fs";
|
|||||||
import { lookupContextTokens } from "../../agents/context.js";
|
import { lookupContextTokens } from "../../agents/context.js";
|
||||||
import { DEFAULT_CONTEXT_TOKENS } from "../../agents/defaults.js";
|
import { DEFAULT_CONTEXT_TOKENS } from "../../agents/defaults.js";
|
||||||
import { resolveModelAuthMode } from "../../agents/model-auth.js";
|
import { resolveModelAuthMode } from "../../agents/model-auth.js";
|
||||||
|
import { setCliSessionId } from "../../agents/cli-session.js";
|
||||||
import { isCliProvider } from "../../agents/model-selection.js";
|
import { isCliProvider } from "../../agents/model-selection.js";
|
||||||
import { queueEmbeddedPiMessage } from "../../agents/pi-embedded.js";
|
import { queueEmbeddedPiMessage } from "../../agents/pi-embedded.js";
|
||||||
import { hasNonzeroUsage } from "../../agents/usage.js";
|
import { hasNonzeroUsage } from "../../agents/usage.js";
|
||||||
@ -15,6 +16,7 @@ import {
|
|||||||
updateSessionStoreEntry,
|
updateSessionStoreEntry,
|
||||||
} from "../../config/sessions.js";
|
} from "../../config/sessions.js";
|
||||||
import type { TypingMode } from "../../config/types.js";
|
import type { TypingMode } from "../../config/types.js";
|
||||||
|
import { logVerbose } from "../../globals.js";
|
||||||
import { defaultRuntime } from "../../runtime.js";
|
import { defaultRuntime } from "../../runtime.js";
|
||||||
import { estimateUsageCost, resolveModelCostConfig } from "../../utils/usage-format.js";
|
import { estimateUsageCost, resolveModelCostConfig } from "../../utils/usage-format.js";
|
||||||
import type { OriginatingChannelType, TemplateContext } from "../templating.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 modelUsed = runResult.meta.agentMeta?.model ?? fallbackModel ?? defaultModel;
|
||||||
const providerUsed =
|
const providerUsed =
|
||||||
runResult.meta.agentMeta?.provider ?? fallbackProvider ?? followupRun.run.provider;
|
runResult.meta.agentMeta?.provider ?? fallbackProvider ?? followupRun.run.provider;
|
||||||
const cliSessionId = isCliProvider(providerUsed, cfg)
|
|
||||||
? runResult.meta.agentMeta?.sessionId?.trim()
|
|
||||||
: undefined;
|
|
||||||
const contextTokensUsed =
|
const contextTokensUsed =
|
||||||
agentCfgContextTokens ??
|
agentCfgContextTokens ??
|
||||||
lookupContextTokens(modelUsed) ??
|
lookupContextTokens(modelUsed) ??
|
||||||
activeSessionEntry?.contextTokens ??
|
activeSessionEntry?.contextTokens ??
|
||||||
DEFAULT_CONTEXT_TOKENS;
|
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({
|
await persistSessionUsageUpdate({
|
||||||
storePath,
|
storePath,
|
||||||
sessionKey,
|
sessionKey,
|
||||||
@ -385,9 +401,23 @@ export async function runReplyAgent(params: {
|
|||||||
providerUsed,
|
providerUsed,
|
||||||
contextTokensUsed,
|
contextTokensUsed,
|
||||||
systemPromptReport: runResult.meta.systemPromptReport,
|
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".
|
// 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
|
// Otherwise, a late typing trigger (e.g. from a tool callback) can outlive the run and
|
||||||
// keep the typing indicator stuck.
|
// keep the typing indicator stuck.
|
||||||
|
|||||||
@ -165,11 +165,27 @@ export function createFollowupRunner(params: {
|
|||||||
blockReplyBreak: queued.run.blockReplyBreak,
|
blockReplyBreak: queued.run.blockReplyBreak,
|
||||||
ownerNumbers: queued.run.ownerNumbers,
|
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
|
// Pi-specific options
|
||||||
piOptions: {
|
piOptions: {
|
||||||
enforceFinalTag: queued.run.enforceFinalTag,
|
enforceFinalTag: queued.run.enforceFinalTag,
|
||||||
execOverrides: queued.run.execOverrides,
|
|
||||||
bashElevated: queued.run.bashElevated,
|
|
||||||
},
|
},
|
||||||
|
|
||||||
// Fallback config
|
// Fallback config
|
||||||
|
|||||||
@ -16,6 +16,8 @@ export async function persistSessionUsageUpdate(params: {
|
|||||||
contextTokensUsed?: number;
|
contextTokensUsed?: number;
|
||||||
systemPromptReport?: SessionSystemPromptReport;
|
systemPromptReport?: SessionSystemPromptReport;
|
||||||
cliSessionId?: string;
|
cliSessionId?: string;
|
||||||
|
/** Provider key for storing cliSessionId (defaults to providerUsed). Use "ccsdk" for CCSDK sessions. */
|
||||||
|
cliSessionIdProvider?: string;
|
||||||
logLabel?: string;
|
logLabel?: string;
|
||||||
}): Promise<void> {
|
}): Promise<void> {
|
||||||
const { storePath, sessionKey } = params;
|
const { storePath, sessionKey } = params;
|
||||||
@ -42,7 +44,8 @@ export async function persistSessionUsageUpdate(params: {
|
|||||||
systemPromptReport: params.systemPromptReport ?? entry.systemPromptReport,
|
systemPromptReport: params.systemPromptReport ?? entry.systemPromptReport,
|
||||||
updatedAt: Date.now(),
|
updatedAt: Date.now(),
|
||||||
};
|
};
|
||||||
const cliProvider = params.providerUsed ?? entry.modelProvider;
|
const cliProvider =
|
||||||
|
params.cliSessionIdProvider ?? params.providerUsed ?? entry.modelProvider;
|
||||||
if (params.cliSessionId && cliProvider) {
|
if (params.cliSessionId && cliProvider) {
|
||||||
const nextEntry = { ...entry, ...patch };
|
const nextEntry = { ...entry, ...patch };
|
||||||
setCliSessionId(nextEntry, cliProvider, params.cliSessionId);
|
setCliSessionId(nextEntry, cliProvider, params.cliSessionId);
|
||||||
@ -74,7 +77,8 @@ export async function persistSessionUsageUpdate(params: {
|
|||||||
systemPromptReport: params.systemPromptReport ?? entry.systemPromptReport,
|
systemPromptReport: params.systemPromptReport ?? entry.systemPromptReport,
|
||||||
updatedAt: Date.now(),
|
updatedAt: Date.now(),
|
||||||
};
|
};
|
||||||
const cliProvider = params.providerUsed ?? entry.modelProvider;
|
const cliProvider =
|
||||||
|
params.cliSessionIdProvider ?? params.providerUsed ?? entry.modelProvider;
|
||||||
if (params.cliSessionId && cliProvider) {
|
if (params.cliSessionId && cliProvider) {
|
||||||
const nextEntry = { ...entry, ...patch };
|
const nextEntry = { ...entry, ...patch };
|
||||||
setCliSessionId(nextEntry, cliProvider, params.cliSessionId);
|
setCliSessionId(nextEntry, cliProvider, params.cliSessionId);
|
||||||
|
|||||||
@ -447,10 +447,9 @@ export async function agentCommand(
|
|||||||
replyToMode: runContext.replyToMode,
|
replyToMode: runContext.replyToMode,
|
||||||
hasRepliedRef: runContext.hasRepliedRef,
|
hasRepliedRef: runContext.hasRepliedRef,
|
||||||
|
|
||||||
// Pi-specific options
|
// Client-provided tools (OpenResponses hosted tools)
|
||||||
piOptions: {
|
// Now at top level so both Pi and CCSDK runtimes can use them
|
||||||
clientTools: opts.clientTools,
|
clientTools: opts.clientTools,
|
||||||
},
|
|
||||||
|
|
||||||
// Fallback config
|
// Fallback config
|
||||||
fallbacksOverride: resolveAgentModelFallbacksOverride(cfg, sessionAgentId),
|
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";
|
import type { ChannelOutboundTargetMode } from "../../channels/plugins/types.js";
|
||||||
|
|
||||||
/** Image content block for Claude API multimodal messages. */
|
/** 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()));
|
if (store.startsWith("~")) return path.resolve(store.replace(/^~(?=$|[\\/])/, os.homedir()));
|
||||||
return path.resolve(store);
|
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