mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 22:20:42 +00:00
Merge remote-tracking branch 'origin/main' into cs/codex-native-web-search-spec
# Conflicts: # src/agents/pi-embedded-runner/extra-params.ts # src/agents/pi-embedded-runner/openai-stream-wrappers.ts
This commit is contained in:
@@ -51,4 +51,40 @@ describe("syncExternalCliCredentials", () => {
|
||||
});
|
||||
expect(store.profiles[CODEX_CLI_PROFILE_ID]).toBeUndefined();
|
||||
});
|
||||
|
||||
it("refreshes stored Codex expiry from external CLI even when the cached profile looks fresh", () => {
|
||||
const staleExpiry = Date.now() + 30 * 60_000;
|
||||
const freshExpiry = Date.now() + 5 * 24 * 60 * 60_000;
|
||||
mocks.readCodexCliCredentialsCached.mockReturnValue({
|
||||
type: "oauth",
|
||||
provider: "openai-codex",
|
||||
access: "new-access-token",
|
||||
refresh: "new-refresh-token",
|
||||
expires: freshExpiry,
|
||||
accountId: "acct_456",
|
||||
});
|
||||
|
||||
const store: AuthProfileStore = {
|
||||
version: 1,
|
||||
profiles: {
|
||||
[OPENAI_CODEX_DEFAULT_PROFILE_ID]: {
|
||||
type: "oauth",
|
||||
provider: "openai-codex",
|
||||
access: "old-access-token",
|
||||
refresh: "old-refresh-token",
|
||||
expires: staleExpiry,
|
||||
accountId: "acct_456",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const mutated = syncExternalCliCredentials(store);
|
||||
|
||||
expect(mutated).toBe(true);
|
||||
expect(store.profiles[OPENAI_CODEX_DEFAULT_PROFILE_ID]).toMatchObject({
|
||||
access: "new-access-token",
|
||||
refresh: "new-refresh-token",
|
||||
expires: freshExpiry,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,13 +4,12 @@ import {
|
||||
readMiniMaxCliCredentialsCached,
|
||||
} from "../cli-credentials.js";
|
||||
import {
|
||||
EXTERNAL_CLI_NEAR_EXPIRY_MS,
|
||||
EXTERNAL_CLI_SYNC_TTL_MS,
|
||||
QWEN_CLI_PROFILE_ID,
|
||||
MINIMAX_CLI_PROFILE_ID,
|
||||
log,
|
||||
} from "./constants.js";
|
||||
import type { AuthProfileCredential, AuthProfileStore, OAuthCredential } from "./types.js";
|
||||
import type { AuthProfileStore, OAuthCredential } from "./types.js";
|
||||
|
||||
const OPENAI_CODEX_DEFAULT_PROFILE_ID = "openai-codex:default";
|
||||
|
||||
@@ -37,62 +36,33 @@ function shallowEqualOAuthCredentials(a: OAuthCredential | undefined, b: OAuthCr
|
||||
);
|
||||
}
|
||||
|
||||
function isExternalProfileFresh(cred: AuthProfileCredential | undefined, now: number): boolean {
|
||||
if (!cred) {
|
||||
return false;
|
||||
}
|
||||
if (cred.type !== "oauth" && cred.type !== "token") {
|
||||
return false;
|
||||
}
|
||||
if (
|
||||
cred.provider !== "qwen-portal" &&
|
||||
cred.provider !== "minimax-portal" &&
|
||||
cred.provider !== "openai-codex"
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
if (typeof cred.expires !== "number") {
|
||||
return true;
|
||||
}
|
||||
return cred.expires > now + EXTERNAL_CLI_NEAR_EXPIRY_MS;
|
||||
}
|
||||
|
||||
/** Sync external CLI credentials into the store for a given provider. */
|
||||
function syncExternalCliCredentialsForProvider(
|
||||
store: AuthProfileStore,
|
||||
profileId: string,
|
||||
provider: string,
|
||||
readCredentials: () => OAuthCredential | null,
|
||||
now: number,
|
||||
options: ExternalCliSyncOptions,
|
||||
): boolean {
|
||||
const existing = store.profiles[profileId];
|
||||
const shouldSync =
|
||||
!existing || existing.provider !== provider || !isExternalProfileFresh(existing, now);
|
||||
const creds = shouldSync ? readCredentials() : null;
|
||||
const creds = readCredentials();
|
||||
if (!creds) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const existingOAuth = existing?.type === "oauth" ? existing : undefined;
|
||||
const shouldUpdate =
|
||||
!existingOAuth ||
|
||||
existingOAuth.provider !== provider ||
|
||||
existingOAuth.expires <= now ||
|
||||
creds.expires > existingOAuth.expires;
|
||||
|
||||
if (shouldUpdate && !shallowEqualOAuthCredentials(existingOAuth, creds)) {
|
||||
store.profiles[profileId] = creds;
|
||||
if (options.log !== false) {
|
||||
log.info(`synced ${provider} credentials from external cli`, {
|
||||
profileId,
|
||||
expires: new Date(creds.expires).toISOString(),
|
||||
});
|
||||
}
|
||||
return true;
|
||||
if (shallowEqualOAuthCredentials(existingOAuth, creds)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return false;
|
||||
store.profiles[profileId] = creds;
|
||||
if (options.log !== false) {
|
||||
log.info(`synced ${provider} credentials from external cli`, {
|
||||
profileId,
|
||||
expires: new Date(creds.expires).toISOString(),
|
||||
});
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -106,46 +76,24 @@ export function syncExternalCliCredentials(
|
||||
options: ExternalCliSyncOptions = {},
|
||||
): boolean {
|
||||
let mutated = false;
|
||||
const now = Date.now();
|
||||
|
||||
// Sync from Qwen Code CLI
|
||||
const existingQwen = store.profiles[QWEN_CLI_PROFILE_ID];
|
||||
const shouldSyncQwen =
|
||||
!existingQwen ||
|
||||
existingQwen.provider !== "qwen-portal" ||
|
||||
!isExternalProfileFresh(existingQwen, now);
|
||||
const qwenCreds = shouldSyncQwen
|
||||
? readQwenCliCredentialsCached({ ttlMs: EXTERNAL_CLI_SYNC_TTL_MS })
|
||||
: null;
|
||||
if (qwenCreds) {
|
||||
const existing = store.profiles[QWEN_CLI_PROFILE_ID];
|
||||
const existingOAuth = existing?.type === "oauth" ? existing : undefined;
|
||||
const shouldUpdate =
|
||||
!existingOAuth ||
|
||||
existingOAuth.provider !== "qwen-portal" ||
|
||||
existingOAuth.expires <= now ||
|
||||
qwenCreds.expires > existingOAuth.expires;
|
||||
|
||||
if (shouldUpdate && !shallowEqualOAuthCredentials(existingOAuth, qwenCreds)) {
|
||||
store.profiles[QWEN_CLI_PROFILE_ID] = qwenCreds;
|
||||
mutated = true;
|
||||
if (options.log !== false) {
|
||||
log.info("synced qwen credentials from qwen cli", {
|
||||
profileId: QWEN_CLI_PROFILE_ID,
|
||||
expires: new Date(qwenCreds.expires).toISOString(),
|
||||
});
|
||||
}
|
||||
}
|
||||
if (
|
||||
syncExternalCliCredentialsForProvider(
|
||||
store,
|
||||
QWEN_CLI_PROFILE_ID,
|
||||
"qwen-portal",
|
||||
() => readQwenCliCredentialsCached({ ttlMs: EXTERNAL_CLI_SYNC_TTL_MS }),
|
||||
options,
|
||||
)
|
||||
) {
|
||||
mutated = true;
|
||||
}
|
||||
|
||||
// Sync from MiniMax Portal CLI
|
||||
if (
|
||||
syncExternalCliCredentialsForProvider(
|
||||
store,
|
||||
MINIMAX_CLI_PROFILE_ID,
|
||||
"minimax-portal",
|
||||
() => readMiniMaxCliCredentialsCached({ ttlMs: EXTERNAL_CLI_SYNC_TTL_MS }),
|
||||
now,
|
||||
options,
|
||||
)
|
||||
) {
|
||||
@@ -157,7 +105,6 @@ export function syncExternalCliCredentials(
|
||||
OPENAI_CODEX_DEFAULT_PROFILE_ID,
|
||||
"openai-codex",
|
||||
() => readCodexCliCredentialsCached({ ttlMs: EXTERNAL_CLI_SYNC_TTL_MS }),
|
||||
now,
|
||||
options,
|
||||
)
|
||||
) {
|
||||
|
||||
@@ -46,6 +46,12 @@ async function readCachedClaudeCliCredentials(allowKeychainPrompt: boolean) {
|
||||
});
|
||||
}
|
||||
|
||||
function createJwtWithExp(expSeconds: number): string {
|
||||
const encode = (value: Record<string, unknown>) =>
|
||||
Buffer.from(JSON.stringify(value)).toString("base64url");
|
||||
return `${encode({ alg: "RS256", typ: "JWT" })}.${encode({ exp: expSeconds })}.signature`;
|
||||
}
|
||||
|
||||
describe("cli credentials", () => {
|
||||
beforeAll(async () => {
|
||||
({
|
||||
@@ -229,6 +235,7 @@ describe("cli credentials", () => {
|
||||
it("reads Codex credentials from keychain when available", async () => {
|
||||
const tempHome = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-codex-"));
|
||||
process.env.CODEX_HOME = tempHome;
|
||||
const expSeconds = Math.floor(Date.parse("2026-03-23T00:48:49Z") / 1000);
|
||||
|
||||
const accountHash = "cli|";
|
||||
|
||||
@@ -238,7 +245,7 @@ describe("cli credentials", () => {
|
||||
expect(cmd).toContain(accountHash);
|
||||
return JSON.stringify({
|
||||
tokens: {
|
||||
access_token: "keychain-access",
|
||||
access_token: createJwtWithExp(expSeconds),
|
||||
refresh_token: "keychain-refresh",
|
||||
},
|
||||
last_refresh: "2026-01-01T00:00:00Z",
|
||||
@@ -248,15 +255,17 @@ describe("cli credentials", () => {
|
||||
const creds = readCodexCliCredentials({ platform: "darwin", execSync: execSyncMock });
|
||||
|
||||
expect(creds).toMatchObject({
|
||||
access: "keychain-access",
|
||||
access: createJwtWithExp(expSeconds),
|
||||
refresh: "keychain-refresh",
|
||||
provider: "openai-codex",
|
||||
expires: expSeconds * 1000,
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back to Codex auth.json when keychain is unavailable", async () => {
|
||||
const tempHome = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-codex-"));
|
||||
process.env.CODEX_HOME = tempHome;
|
||||
const expSeconds = Math.floor(Date.parse("2026-03-24T12:34:56Z") / 1000);
|
||||
execSyncMock.mockImplementation(() => {
|
||||
throw new Error("not found");
|
||||
});
|
||||
@@ -267,7 +276,7 @@ describe("cli credentials", () => {
|
||||
authPath,
|
||||
JSON.stringify({
|
||||
tokens: {
|
||||
access_token: "file-access",
|
||||
access_token: createJwtWithExp(expSeconds),
|
||||
refresh_token: "file-refresh",
|
||||
},
|
||||
}),
|
||||
@@ -277,9 +286,10 @@ describe("cli credentials", () => {
|
||||
const creds = readCodexCliCredentials({ execSync: execSyncMock });
|
||||
|
||||
expect(creds).toMatchObject({
|
||||
access: "file-access",
|
||||
access: createJwtWithExp(expSeconds),
|
||||
refresh: "file-refresh",
|
||||
provider: "openai-codex",
|
||||
expires: expSeconds * 1000,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -153,6 +153,22 @@ function computeCodexKeychainAccount(codexHome: string) {
|
||||
return `cli|${hash.slice(0, 16)}`;
|
||||
}
|
||||
|
||||
function decodeJwtExpiryMs(token: string): number | null {
|
||||
const parts = token.split(".");
|
||||
if (parts.length < 2) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const payloadRaw = Buffer.from(parts[1], "base64url").toString("utf8");
|
||||
const payload = JSON.parse(payloadRaw) as { exp?: unknown };
|
||||
return typeof payload.exp === "number" && Number.isFinite(payload.exp) && payload.exp > 0
|
||||
? payload.exp * 1000
|
||||
: null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function readCodexKeychainCredentials(options?: {
|
||||
platform?: NodeJS.Platform;
|
||||
execSync?: ExecSyncFn;
|
||||
@@ -193,9 +209,10 @@ function readCodexKeychainCredentials(options?: {
|
||||
typeof lastRefreshRaw === "string" || typeof lastRefreshRaw === "number"
|
||||
? new Date(lastRefreshRaw).getTime()
|
||||
: Date.now();
|
||||
const expires = Number.isFinite(lastRefresh)
|
||||
const fallbackExpiry = Number.isFinite(lastRefresh)
|
||||
? lastRefresh + 60 * 60 * 1000
|
||||
: Date.now() + 60 * 60 * 1000;
|
||||
const expires = decodeJwtExpiryMs(accessToken) ?? fallbackExpiry;
|
||||
const accountId = typeof tokens?.account_id === "string" ? tokens.account_id : undefined;
|
||||
|
||||
log.info("read codex credentials from keychain", {
|
||||
@@ -483,13 +500,14 @@ export function readCodexCliCredentials(options?: {
|
||||
return null;
|
||||
}
|
||||
|
||||
let expires: number;
|
||||
let fallbackExpiry: number;
|
||||
try {
|
||||
const stat = fs.statSync(authPath);
|
||||
expires = stat.mtimeMs + 60 * 60 * 1000;
|
||||
fallbackExpiry = stat.mtimeMs + 60 * 60 * 1000;
|
||||
} catch {
|
||||
expires = Date.now() + 60 * 60 * 1000;
|
||||
fallbackExpiry = Date.now() + 60 * 60 * 1000;
|
||||
}
|
||||
const expires = decodeJwtExpiryMs(accessToken) ?? fallbackExpiry;
|
||||
|
||||
return {
|
||||
type: "oauth",
|
||||
|
||||
@@ -5,43 +5,20 @@ import type { OpenClawConfig } from "../../config/config.js";
|
||||
import { applyMergePatch } from "../../config/merge-patch.js";
|
||||
import type { CliBackendConfig } from "../../config/types.js";
|
||||
import {
|
||||
extractMcpServerMap,
|
||||
loadEnabledBundleMcpConfig,
|
||||
type BundleMcpConfig,
|
||||
type BundleMcpServerConfig,
|
||||
} from "../../plugins/bundle-mcp.js";
|
||||
import { isRecord } from "../../utils.js";
|
||||
|
||||
type PreparedCliBundleMcpConfig = {
|
||||
backend: CliBackendConfig;
|
||||
cleanup?: () => Promise<void>;
|
||||
};
|
||||
|
||||
function extractServerMap(raw: unknown): Record<string, BundleMcpServerConfig> {
|
||||
if (!isRecord(raw)) {
|
||||
return {};
|
||||
}
|
||||
const nested = isRecord(raw.mcpServers)
|
||||
? raw.mcpServers
|
||||
: isRecord(raw.servers)
|
||||
? raw.servers
|
||||
: raw;
|
||||
if (!isRecord(nested)) {
|
||||
return {};
|
||||
}
|
||||
const result: Record<string, BundleMcpServerConfig> = {};
|
||||
for (const [serverName, serverRaw] of Object.entries(nested)) {
|
||||
if (!isRecord(serverRaw)) {
|
||||
continue;
|
||||
}
|
||||
result[serverName] = { ...serverRaw };
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
async function readExternalMcpConfig(configPath: string): Promise<BundleMcpConfig> {
|
||||
try {
|
||||
const raw = JSON.parse(await fs.readFile(configPath, "utf-8")) as unknown;
|
||||
return { mcpServers: extractServerMap(raw) };
|
||||
return { mcpServers: extractMcpServerMap(raw) };
|
||||
} catch {
|
||||
return { mcpServers: {} };
|
||||
}
|
||||
|
||||
29
src/agents/embedded-pi-mcp.ts
Normal file
29
src/agents/embedded-pi-mcp.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import { normalizeConfiguredMcpServers } from "../config/mcp-config.js";
|
||||
import type { BundleMcpDiagnostic, BundleMcpServerConfig } from "../plugins/bundle-mcp.js";
|
||||
import { loadEnabledBundleMcpConfig } from "../plugins/bundle-mcp.js";
|
||||
|
||||
export type EmbeddedPiMcpConfig = {
|
||||
mcpServers: Record<string, BundleMcpServerConfig>;
|
||||
diagnostics: BundleMcpDiagnostic[];
|
||||
};
|
||||
|
||||
export function loadEmbeddedPiMcpConfig(params: {
|
||||
workspaceDir: string;
|
||||
cfg?: OpenClawConfig;
|
||||
}): EmbeddedPiMcpConfig {
|
||||
const bundleMcp = loadEnabledBundleMcpConfig({
|
||||
workspaceDir: params.workspaceDir,
|
||||
cfg: params.cfg,
|
||||
});
|
||||
const configuredMcp = normalizeConfiguredMcpServers(params.cfg?.mcp?.servers);
|
||||
|
||||
return {
|
||||
// OpenClaw config is the owner-managed layer, so it overrides bundle defaults.
|
||||
mcpServers: {
|
||||
...bundleMcp.config.mcpServers,
|
||||
...configuredMcp,
|
||||
},
|
||||
diagnostics: bundleMcp.diagnostics,
|
||||
};
|
||||
}
|
||||
79
src/agents/mcp-stdio.ts
Normal file
79
src/agents/mcp-stdio.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
type StdioMcpServerLaunchConfig = {
|
||||
command: string;
|
||||
args?: string[];
|
||||
env?: Record<string, string>;
|
||||
cwd?: string;
|
||||
};
|
||||
|
||||
type StdioMcpServerLaunchResult =
|
||||
| { ok: true; config: StdioMcpServerLaunchConfig }
|
||||
| { ok: false; reason: string };
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return value !== null && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function toStringRecord(value: unknown): Record<string, string> | undefined {
|
||||
if (!isRecord(value)) {
|
||||
return undefined;
|
||||
}
|
||||
const entries = Object.entries(value)
|
||||
.map(([key, entry]) => {
|
||||
if (typeof entry === "string") {
|
||||
return [key, entry] as const;
|
||||
}
|
||||
if (typeof entry === "number" || typeof entry === "boolean") {
|
||||
return [key, String(entry)] as const;
|
||||
}
|
||||
return null;
|
||||
})
|
||||
.filter((entry): entry is readonly [string, string] => entry !== null);
|
||||
return entries.length > 0 ? Object.fromEntries(entries) : undefined;
|
||||
}
|
||||
|
||||
function toStringArray(value: unknown): string[] | undefined {
|
||||
if (!Array.isArray(value)) {
|
||||
return undefined;
|
||||
}
|
||||
const entries = value.filter((entry): entry is string => typeof entry === "string");
|
||||
return entries.length > 0 ? entries : [];
|
||||
}
|
||||
|
||||
export function resolveStdioMcpServerLaunchConfig(raw: unknown): StdioMcpServerLaunchResult {
|
||||
if (!isRecord(raw)) {
|
||||
return { ok: false, reason: "server config must be an object" };
|
||||
}
|
||||
if (typeof raw.command !== "string" || raw.command.trim().length === 0) {
|
||||
if (typeof raw.url === "string" && raw.url.trim().length > 0) {
|
||||
return {
|
||||
ok: false,
|
||||
reason: "only stdio MCP servers are supported right now",
|
||||
};
|
||||
}
|
||||
return { ok: false, reason: "its command is missing" };
|
||||
}
|
||||
const cwd =
|
||||
typeof raw.cwd === "string" && raw.cwd.trim().length > 0
|
||||
? raw.cwd
|
||||
: typeof raw.workingDirectory === "string" && raw.workingDirectory.trim().length > 0
|
||||
? raw.workingDirectory
|
||||
: undefined;
|
||||
return {
|
||||
ok: true,
|
||||
config: {
|
||||
command: raw.command,
|
||||
args: toStringArray(raw.args),
|
||||
env: toStringRecord(raw.env),
|
||||
cwd,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function describeStdioMcpServerLaunchConfig(config: StdioMcpServerLaunchConfig): string {
|
||||
const args =
|
||||
Array.isArray(config.args) && config.args.length > 0 ? ` ${config.args.join(" ")}` : "";
|
||||
const cwd = config.cwd ? ` (cwd=${config.cwd})` : "";
|
||||
return `${config.command}${args}${cwd}`;
|
||||
}
|
||||
|
||||
export type { StdioMcpServerLaunchConfig, StdioMcpServerLaunchResult };
|
||||
@@ -112,7 +112,8 @@ describe("model-selection", () => {
|
||||
expect(normalizeProviderId("z-ai")).toBe("zai");
|
||||
expect(normalizeProviderId("OpenCode-Zen")).toBe("opencode");
|
||||
expect(normalizeProviderId("qwen")).toBe("qwen-portal");
|
||||
expect(normalizeProviderId("kimi-code")).toBe("kimi-coding");
|
||||
expect(normalizeProviderId("kimi-code")).toBe("kimi");
|
||||
expect(normalizeProviderId("kimi-coding")).toBe("kimi");
|
||||
expect(normalizeProviderId("bedrock")).toBe("amazon-bedrock");
|
||||
expect(normalizeProviderId("aws-bedrock")).toBe("amazon-bedrock");
|
||||
expect(normalizeProviderId("amazon-bedrock")).toBe("amazon-bedrock");
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { resolveThinkingDefaultForModel } from "../auto-reply/thinking.js";
|
||||
import { resolveThinkingDefaultForModel } from "../auto-reply/thinking.shared.js";
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import {
|
||||
resolveAgentModelFallbackValues,
|
||||
|
||||
@@ -74,8 +74,8 @@ describe("models-config merge helpers", () => {
|
||||
headers: { "User-Agent": "claude-code/0.1.0" },
|
||||
models: [
|
||||
{
|
||||
id: "k2p5",
|
||||
name: "Kimi for Coding",
|
||||
id: "kimi-code",
|
||||
name: "Kimi Code",
|
||||
input: ["text", "image"],
|
||||
reasoning: true,
|
||||
},
|
||||
@@ -87,8 +87,8 @@ describe("models-config merge helpers", () => {
|
||||
headers: { "X-Kimi-Tenant": "tenant-a" },
|
||||
models: [
|
||||
{
|
||||
id: "k2p5",
|
||||
name: "Kimi for Coding",
|
||||
id: "kimi-code",
|
||||
name: "Kimi Code",
|
||||
input: ["text", "image"],
|
||||
reasoning: true,
|
||||
},
|
||||
|
||||
@@ -1,14 +1,6 @@
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import type { ModelDefinitionConfig } from "../config/types.models.js";
|
||||
import { createSubsystemLogger } from "../logging/subsystem.js";
|
||||
import { KILOCODE_BASE_URL } from "../providers/kilocode-shared.js";
|
||||
import {
|
||||
discoverHuggingfaceModels,
|
||||
HUGGINGFACE_BASE_URL,
|
||||
HUGGINGFACE_MODEL_CATALOG,
|
||||
buildHuggingfaceModelDefinition,
|
||||
} from "./huggingface-models.js";
|
||||
import { discoverKilocodeModels } from "./kilocode-models.js";
|
||||
import {
|
||||
enrichOllamaModelsWithContext,
|
||||
OLLAMA_DEFAULT_CONTEXT_WINDOW,
|
||||
@@ -24,9 +16,11 @@ import {
|
||||
SELF_HOSTED_DEFAULT_MAX_TOKENS,
|
||||
} from "./self-hosted-provider-defaults.js";
|
||||
import { SGLANG_DEFAULT_BASE_URL, SGLANG_PROVIDER_LABEL } from "./sglang-defaults.js";
|
||||
import { discoverVeniceModels, VENICE_BASE_URL } from "./venice-models.js";
|
||||
import { discoverVercelAiGatewayModels, VERCEL_AI_GATEWAY_BASE_URL } from "./vercel-ai-gateway.js";
|
||||
import { VLLM_DEFAULT_BASE_URL, VLLM_PROVIDER_LABEL } from "./vllm-defaults.js";
|
||||
export { buildHuggingfaceProvider } from "../../extensions/huggingface/provider-catalog.js";
|
||||
export { buildKilocodeProviderWithDiscovery } from "../../extensions/kilocode/provider-catalog.js";
|
||||
export { buildVeniceProvider } from "../../extensions/venice/provider-catalog.js";
|
||||
export { buildVercelAiGatewayProvider } from "../../extensions/vercel-ai-gateway/provider-catalog.js";
|
||||
|
||||
export { resolveOllamaApiBase } from "./ollama-models.js";
|
||||
|
||||
@@ -145,15 +139,6 @@ async function discoverOpenAICompatibleLocalModels(params: {
|
||||
}
|
||||
}
|
||||
|
||||
export async function buildVeniceProvider(): Promise<ProviderConfig> {
|
||||
const models = await discoverVeniceModels();
|
||||
return {
|
||||
baseUrl: VENICE_BASE_URL,
|
||||
api: "openai-completions",
|
||||
models,
|
||||
};
|
||||
}
|
||||
|
||||
export async function buildOllamaProvider(
|
||||
configuredBaseUrl?: string,
|
||||
opts?: { quiet?: boolean },
|
||||
@@ -166,27 +151,6 @@ export async function buildOllamaProvider(
|
||||
};
|
||||
}
|
||||
|
||||
export async function buildHuggingfaceProvider(discoveryApiKey?: string): Promise<ProviderConfig> {
|
||||
const resolvedSecret = discoveryApiKey?.trim() ?? "";
|
||||
const models =
|
||||
resolvedSecret !== ""
|
||||
? await discoverHuggingfaceModels(resolvedSecret)
|
||||
: HUGGINGFACE_MODEL_CATALOG.map(buildHuggingfaceModelDefinition);
|
||||
return {
|
||||
baseUrl: HUGGINGFACE_BASE_URL,
|
||||
api: "openai-completions",
|
||||
models,
|
||||
};
|
||||
}
|
||||
|
||||
export async function buildVercelAiGatewayProvider(): Promise<ProviderConfig> {
|
||||
return {
|
||||
baseUrl: VERCEL_AI_GATEWAY_BASE_URL,
|
||||
api: "anthropic-messages",
|
||||
models: await discoverVercelAiGatewayModels(),
|
||||
};
|
||||
}
|
||||
|
||||
export async function buildVllmProvider(params?: {
|
||||
baseUrl?: string;
|
||||
apiKey?: string;
|
||||
@@ -220,16 +184,3 @@ export async function buildSglangProvider(params?: {
|
||||
models,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the Kilocode provider with dynamic model discovery from the gateway
|
||||
* API. Falls back to the static catalog on failure.
|
||||
*/
|
||||
export async function buildKilocodeProviderWithDiscovery(): Promise<ProviderConfig> {
|
||||
const models = await discoverKilocodeModels();
|
||||
return {
|
||||
baseUrl: KILOCODE_BASE_URL,
|
||||
api: "openai-completions",
|
||||
models,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -6,46 +6,47 @@ import { captureEnv } from "../test-utils/env.js";
|
||||
import { resolveImplicitProvidersForTest } from "./models-config.e2e-harness.js";
|
||||
import { buildKimiCodingProvider } from "./models-config.providers.js";
|
||||
|
||||
describe("kimi-coding implicit provider (#22409)", () => {
|
||||
it("should include kimi-coding when KIMI_API_KEY is configured", async () => {
|
||||
describe("Kimi implicit provider (#22409)", () => {
|
||||
it("should include Kimi when KIMI_API_KEY is configured", async () => {
|
||||
const agentDir = mkdtempSync(join(tmpdir(), "openclaw-test-"));
|
||||
const envSnapshot = captureEnv(["KIMI_API_KEY"]);
|
||||
process.env.KIMI_API_KEY = "test-key"; // pragma: allowlist secret
|
||||
|
||||
try {
|
||||
const providers = await resolveImplicitProvidersForTest({ agentDir });
|
||||
expect(providers?.["kimi-coding"]).toBeDefined();
|
||||
expect(providers?.["kimi-coding"]?.api).toBe("anthropic-messages");
|
||||
expect(providers?.["kimi-coding"]?.baseUrl).toBe("https://api.kimi.com/coding/");
|
||||
expect(providers?.kimi).toBeDefined();
|
||||
expect(providers?.kimi?.api).toBe("anthropic-messages");
|
||||
expect(providers?.kimi?.baseUrl).toBe("https://api.kimi.com/coding/");
|
||||
} finally {
|
||||
envSnapshot.restore();
|
||||
}
|
||||
});
|
||||
|
||||
it("should build kimi-coding provider with anthropic-messages API", () => {
|
||||
it("should build Kimi provider with anthropic-messages API", () => {
|
||||
const provider = buildKimiCodingProvider();
|
||||
expect(provider.api).toBe("anthropic-messages");
|
||||
expect(provider.baseUrl).toBe("https://api.kimi.com/coding/");
|
||||
expect(provider.headers).toEqual({ "User-Agent": "claude-code/0.1.0" });
|
||||
expect(provider.models).toBeDefined();
|
||||
expect(provider.models.length).toBeGreaterThan(0);
|
||||
expect(provider.models[0].id).toBe("k2p5");
|
||||
expect(provider.models[0].id).toBe("kimi-code");
|
||||
expect(provider.models.some((model) => model.id === "k2p5")).toBe(true);
|
||||
});
|
||||
|
||||
it("should not include kimi-coding when no API key is configured", async () => {
|
||||
it("should not include Kimi when no API key is configured", async () => {
|
||||
const agentDir = mkdtempSync(join(tmpdir(), "openclaw-test-"));
|
||||
const envSnapshot = captureEnv(["KIMI_API_KEY"]);
|
||||
delete process.env.KIMI_API_KEY;
|
||||
|
||||
try {
|
||||
const providers = await resolveImplicitProvidersForTest({ agentDir });
|
||||
expect(providers?.["kimi-coding"]).toBeUndefined();
|
||||
expect(providers?.kimi).toBeUndefined();
|
||||
} finally {
|
||||
envSnapshot.restore();
|
||||
}
|
||||
});
|
||||
|
||||
it("uses explicit kimi-coding baseUrl when provided", async () => {
|
||||
it("uses explicit legacy kimi-coding baseUrl when provided", async () => {
|
||||
const agentDir = mkdtempSync(join(tmpdir(), "openclaw-test-"));
|
||||
const envSnapshot = captureEnv(["KIMI_API_KEY"]);
|
||||
process.env.KIMI_API_KEY = "test-key";
|
||||
@@ -61,13 +62,13 @@ describe("kimi-coding implicit provider (#22409)", () => {
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(providers?.["kimi-coding"]?.baseUrl).toBe("https://kimi.example.test/coding/");
|
||||
expect(providers?.kimi?.baseUrl).toBe("https://kimi.example.test/coding/");
|
||||
} finally {
|
||||
envSnapshot.restore();
|
||||
}
|
||||
});
|
||||
|
||||
it("merges explicit kimi-coding headers on top of the built-in user agent", async () => {
|
||||
it("merges explicit legacy kimi-coding headers on top of the built-in user agent", async () => {
|
||||
const agentDir = mkdtempSync(join(tmpdir(), "openclaw-test-"));
|
||||
const envSnapshot = captureEnv(["KIMI_API_KEY"]);
|
||||
process.env.KIMI_API_KEY = "test-key";
|
||||
@@ -87,7 +88,7 @@ describe("kimi-coding implicit provider (#22409)", () => {
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(providers?.["kimi-coding"]?.headers).toEqual({
|
||||
expect(providers?.kimi?.headers).toEqual({
|
||||
"User-Agent": "custom-kimi-client/1.0",
|
||||
"X-Kimi-Tenant": "tenant-a",
|
||||
});
|
||||
|
||||
@@ -5,7 +5,7 @@ import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
MOONSHOT_BASE_URL as MOONSHOT_AI_BASE_URL,
|
||||
MOONSHOT_CN_BASE_URL,
|
||||
} from "../commands/onboard-auth.models.js";
|
||||
} from "../plugins/provider-model-definitions.js";
|
||||
import { captureEnv } from "../test-utils/env.js";
|
||||
import { resolveImplicitProvidersForTest } from "./models-config.e2e-harness.js";
|
||||
import { applyNativeStreamingUsageCompat } from "./models-config.providers.js";
|
||||
|
||||
@@ -1,551 +1,35 @@
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import {
|
||||
KILOCODE_BASE_URL,
|
||||
KILOCODE_DEFAULT_CONTEXT_WINDOW,
|
||||
KILOCODE_DEFAULT_COST,
|
||||
KILOCODE_DEFAULT_MAX_TOKENS,
|
||||
KILOCODE_MODEL_CATALOG,
|
||||
} from "../providers/kilocode-shared.js";
|
||||
import {
|
||||
buildBytePlusModelDefinition,
|
||||
BYTEPLUS_BASE_URL,
|
||||
BYTEPLUS_MODEL_CATALOG,
|
||||
BYTEPLUS_CODING_BASE_URL,
|
||||
BYTEPLUS_CODING_MODEL_CATALOG,
|
||||
} from "./byteplus-models.js";
|
||||
import {
|
||||
buildDoubaoModelDefinition,
|
||||
DOUBAO_BASE_URL,
|
||||
DOUBAO_MODEL_CATALOG,
|
||||
DOUBAO_CODING_BASE_URL,
|
||||
DOUBAO_CODING_MODEL_CATALOG,
|
||||
} from "./doubao-models.js";
|
||||
import {
|
||||
buildSyntheticModelDefinition,
|
||||
SYNTHETIC_BASE_URL,
|
||||
SYNTHETIC_MODEL_CATALOG,
|
||||
} from "./synthetic-models.js";
|
||||
import {
|
||||
TOGETHER_BASE_URL,
|
||||
TOGETHER_MODEL_CATALOG,
|
||||
buildTogetherModelDefinition,
|
||||
} from "./together-models.js";
|
||||
|
||||
type ModelsConfig = NonNullable<OpenClawConfig["models"]>;
|
||||
type ProviderConfig = NonNullable<ModelsConfig["providers"]>[string];
|
||||
type ProviderModelConfig = NonNullable<ProviderConfig["models"]>[number];
|
||||
|
||||
const MINIMAX_PORTAL_BASE_URL = "https://api.minimax.io/anthropic";
|
||||
const MINIMAX_DEFAULT_MODEL_ID = "MiniMax-M2.5";
|
||||
const MINIMAX_DEFAULT_VISION_MODEL_ID = "MiniMax-VL-01";
|
||||
const MINIMAX_DEFAULT_CONTEXT_WINDOW = 200000;
|
||||
const MINIMAX_DEFAULT_MAX_TOKENS = 8192;
|
||||
const MINIMAX_API_COST = {
|
||||
input: 0.3,
|
||||
output: 1.2,
|
||||
cacheRead: 0.03,
|
||||
cacheWrite: 0.12,
|
||||
};
|
||||
|
||||
function buildMinimaxModel(params: {
|
||||
id: string;
|
||||
name: string;
|
||||
reasoning: boolean;
|
||||
input: ProviderModelConfig["input"];
|
||||
}): ProviderModelConfig {
|
||||
return {
|
||||
id: params.id,
|
||||
name: params.name,
|
||||
reasoning: params.reasoning,
|
||||
input: params.input,
|
||||
cost: MINIMAX_API_COST,
|
||||
contextWindow: MINIMAX_DEFAULT_CONTEXT_WINDOW,
|
||||
maxTokens: MINIMAX_DEFAULT_MAX_TOKENS,
|
||||
};
|
||||
}
|
||||
|
||||
function buildMinimaxTextModel(params: {
|
||||
id: string;
|
||||
name: string;
|
||||
reasoning: boolean;
|
||||
}): ProviderModelConfig {
|
||||
return buildMinimaxModel({ ...params, input: ["text"] });
|
||||
}
|
||||
|
||||
const XIAOMI_BASE_URL = "https://api.xiaomimimo.com/anthropic";
|
||||
export const XIAOMI_DEFAULT_MODEL_ID = "mimo-v2-flash";
|
||||
const XIAOMI_DEFAULT_CONTEXT_WINDOW = 262144;
|
||||
const XIAOMI_DEFAULT_MAX_TOKENS = 8192;
|
||||
const XIAOMI_DEFAULT_COST = {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
};
|
||||
|
||||
const MOONSHOT_BASE_URL = "https://api.moonshot.ai/v1";
|
||||
const MOONSHOT_DEFAULT_MODEL_ID = "kimi-k2.5";
|
||||
const MOONSHOT_DEFAULT_CONTEXT_WINDOW = 256000;
|
||||
const MOONSHOT_DEFAULT_MAX_TOKENS = 8192;
|
||||
const MOONSHOT_DEFAULT_COST = {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
};
|
||||
|
||||
const KIMI_CODING_BASE_URL = "https://api.kimi.com/coding/";
|
||||
const KIMI_CODING_USER_AGENT = "claude-code/0.1.0";
|
||||
const KIMI_CODING_DEFAULT_MODEL_ID = "k2p5";
|
||||
const KIMI_CODING_DEFAULT_CONTEXT_WINDOW = 262144;
|
||||
const KIMI_CODING_DEFAULT_MAX_TOKENS = 32768;
|
||||
const KIMI_CODING_DEFAULT_COST = {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
};
|
||||
|
||||
const QWEN_PORTAL_BASE_URL = "https://portal.qwen.ai/v1";
|
||||
const QWEN_PORTAL_DEFAULT_CONTEXT_WINDOW = 128000;
|
||||
const QWEN_PORTAL_DEFAULT_MAX_TOKENS = 8192;
|
||||
const QWEN_PORTAL_DEFAULT_COST = {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
};
|
||||
|
||||
const OPENROUTER_BASE_URL = "https://openrouter.ai/api/v1";
|
||||
const OPENROUTER_DEFAULT_MODEL_ID = "auto";
|
||||
const OPENROUTER_DEFAULT_CONTEXT_WINDOW = 200000;
|
||||
const OPENROUTER_DEFAULT_MAX_TOKENS = 8192;
|
||||
const OPENROUTER_DEFAULT_COST = {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
};
|
||||
|
||||
export const QIANFAN_BASE_URL = "https://qianfan.baidubce.com/v2";
|
||||
export const QIANFAN_DEFAULT_MODEL_ID = "deepseek-v3.2";
|
||||
const QIANFAN_DEFAULT_CONTEXT_WINDOW = 98304;
|
||||
const QIANFAN_DEFAULT_MAX_TOKENS = 32768;
|
||||
const QIANFAN_DEFAULT_COST = {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
};
|
||||
|
||||
export const MODELSTUDIO_BASE_URL = "https://coding-intl.dashscope.aliyuncs.com/v1";
|
||||
export const MODELSTUDIO_DEFAULT_MODEL_ID = "qwen3.5-plus";
|
||||
const MODELSTUDIO_DEFAULT_COST = {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
};
|
||||
|
||||
const MODELSTUDIO_MODEL_CATALOG: ReadonlyArray<ProviderModelConfig> = [
|
||||
{
|
||||
id: "qwen3.5-plus",
|
||||
name: "qwen3.5-plus",
|
||||
reasoning: false,
|
||||
input: ["text", "image"],
|
||||
cost: MODELSTUDIO_DEFAULT_COST,
|
||||
contextWindow: 1_000_000,
|
||||
maxTokens: 65_536,
|
||||
},
|
||||
{
|
||||
id: "qwen3-max-2026-01-23",
|
||||
name: "qwen3-max-2026-01-23",
|
||||
reasoning: false,
|
||||
input: ["text"],
|
||||
cost: MODELSTUDIO_DEFAULT_COST,
|
||||
contextWindow: 262_144,
|
||||
maxTokens: 65_536,
|
||||
},
|
||||
{
|
||||
id: "qwen3-coder-next",
|
||||
name: "qwen3-coder-next",
|
||||
reasoning: false,
|
||||
input: ["text"],
|
||||
cost: MODELSTUDIO_DEFAULT_COST,
|
||||
contextWindow: 262_144,
|
||||
maxTokens: 65_536,
|
||||
},
|
||||
{
|
||||
id: "qwen3-coder-plus",
|
||||
name: "qwen3-coder-plus",
|
||||
reasoning: false,
|
||||
input: ["text"],
|
||||
cost: MODELSTUDIO_DEFAULT_COST,
|
||||
contextWindow: 1_000_000,
|
||||
maxTokens: 65_536,
|
||||
},
|
||||
{
|
||||
id: "MiniMax-M2.5",
|
||||
name: "MiniMax-M2.5",
|
||||
reasoning: true,
|
||||
input: ["text"],
|
||||
cost: MODELSTUDIO_DEFAULT_COST,
|
||||
contextWindow: 1_000_000,
|
||||
maxTokens: 65_536,
|
||||
},
|
||||
{
|
||||
id: "glm-5",
|
||||
name: "glm-5",
|
||||
reasoning: false,
|
||||
input: ["text"],
|
||||
cost: MODELSTUDIO_DEFAULT_COST,
|
||||
contextWindow: 202_752,
|
||||
maxTokens: 16_384,
|
||||
},
|
||||
{
|
||||
id: "glm-4.7",
|
||||
name: "glm-4.7",
|
||||
reasoning: false,
|
||||
input: ["text"],
|
||||
cost: MODELSTUDIO_DEFAULT_COST,
|
||||
contextWindow: 202_752,
|
||||
maxTokens: 16_384,
|
||||
},
|
||||
{
|
||||
id: "kimi-k2.5",
|
||||
name: "kimi-k2.5",
|
||||
reasoning: false,
|
||||
input: ["text", "image"],
|
||||
cost: MODELSTUDIO_DEFAULT_COST,
|
||||
contextWindow: 262_144,
|
||||
maxTokens: 32_768,
|
||||
},
|
||||
];
|
||||
|
||||
const NVIDIA_BASE_URL = "https://integrate.api.nvidia.com/v1";
|
||||
const NVIDIA_DEFAULT_MODEL_ID = "nvidia/llama-3.1-nemotron-70b-instruct";
|
||||
const NVIDIA_DEFAULT_CONTEXT_WINDOW = 131072;
|
||||
const NVIDIA_DEFAULT_MAX_TOKENS = 4096;
|
||||
const NVIDIA_DEFAULT_COST = {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
};
|
||||
|
||||
const OPENAI_CODEX_BASE_URL = "https://chatgpt.com/backend-api";
|
||||
|
||||
export function buildMinimaxProvider(): ProviderConfig {
|
||||
return {
|
||||
baseUrl: MINIMAX_PORTAL_BASE_URL,
|
||||
api: "anthropic-messages",
|
||||
authHeader: true,
|
||||
models: [
|
||||
buildMinimaxModel({
|
||||
id: MINIMAX_DEFAULT_VISION_MODEL_ID,
|
||||
name: "MiniMax VL 01",
|
||||
reasoning: false,
|
||||
input: ["text", "image"],
|
||||
}),
|
||||
buildMinimaxTextModel({
|
||||
id: "MiniMax-M2.5",
|
||||
name: "MiniMax M2.5",
|
||||
reasoning: true,
|
||||
}),
|
||||
buildMinimaxTextModel({
|
||||
id: "MiniMax-M2.5-highspeed",
|
||||
name: "MiniMax M2.5 Highspeed",
|
||||
reasoning: true,
|
||||
}),
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
export function buildMinimaxPortalProvider(): ProviderConfig {
|
||||
return {
|
||||
baseUrl: MINIMAX_PORTAL_BASE_URL,
|
||||
api: "anthropic-messages",
|
||||
authHeader: true,
|
||||
models: [
|
||||
buildMinimaxModel({
|
||||
id: MINIMAX_DEFAULT_VISION_MODEL_ID,
|
||||
name: "MiniMax VL 01",
|
||||
reasoning: false,
|
||||
input: ["text", "image"],
|
||||
}),
|
||||
buildMinimaxTextModel({
|
||||
id: MINIMAX_DEFAULT_MODEL_ID,
|
||||
name: "MiniMax M2.5",
|
||||
reasoning: true,
|
||||
}),
|
||||
buildMinimaxTextModel({
|
||||
id: "MiniMax-M2.5-highspeed",
|
||||
name: "MiniMax M2.5 Highspeed",
|
||||
reasoning: true,
|
||||
}),
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
export function buildMoonshotProvider(): ProviderConfig {
|
||||
return {
|
||||
baseUrl: MOONSHOT_BASE_URL,
|
||||
api: "openai-completions",
|
||||
models: [
|
||||
{
|
||||
id: MOONSHOT_DEFAULT_MODEL_ID,
|
||||
name: "Kimi K2.5",
|
||||
reasoning: false,
|
||||
input: ["text", "image"],
|
||||
cost: MOONSHOT_DEFAULT_COST,
|
||||
contextWindow: MOONSHOT_DEFAULT_CONTEXT_WINDOW,
|
||||
maxTokens: MOONSHOT_DEFAULT_MAX_TOKENS,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
export function buildKimiCodingProvider(): ProviderConfig {
|
||||
return {
|
||||
baseUrl: KIMI_CODING_BASE_URL,
|
||||
api: "anthropic-messages",
|
||||
headers: {
|
||||
"User-Agent": KIMI_CODING_USER_AGENT,
|
||||
},
|
||||
models: [
|
||||
{
|
||||
id: KIMI_CODING_DEFAULT_MODEL_ID,
|
||||
name: "Kimi for Coding",
|
||||
reasoning: true,
|
||||
input: ["text", "image"],
|
||||
cost: KIMI_CODING_DEFAULT_COST,
|
||||
contextWindow: KIMI_CODING_DEFAULT_CONTEXT_WINDOW,
|
||||
maxTokens: KIMI_CODING_DEFAULT_MAX_TOKENS,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
export function buildQwenPortalProvider(): ProviderConfig {
|
||||
return {
|
||||
baseUrl: QWEN_PORTAL_BASE_URL,
|
||||
api: "openai-completions",
|
||||
models: [
|
||||
{
|
||||
id: "coder-model",
|
||||
name: "Qwen Coder",
|
||||
reasoning: false,
|
||||
input: ["text"],
|
||||
cost: QWEN_PORTAL_DEFAULT_COST,
|
||||
contextWindow: QWEN_PORTAL_DEFAULT_CONTEXT_WINDOW,
|
||||
maxTokens: QWEN_PORTAL_DEFAULT_MAX_TOKENS,
|
||||
},
|
||||
{
|
||||
id: "vision-model",
|
||||
name: "Qwen Vision",
|
||||
reasoning: false,
|
||||
input: ["text", "image"],
|
||||
cost: QWEN_PORTAL_DEFAULT_COST,
|
||||
contextWindow: QWEN_PORTAL_DEFAULT_CONTEXT_WINDOW,
|
||||
maxTokens: QWEN_PORTAL_DEFAULT_MAX_TOKENS,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
export function buildSyntheticProvider(): ProviderConfig {
|
||||
return {
|
||||
baseUrl: SYNTHETIC_BASE_URL,
|
||||
api: "anthropic-messages",
|
||||
models: SYNTHETIC_MODEL_CATALOG.map(buildSyntheticModelDefinition),
|
||||
};
|
||||
}
|
||||
|
||||
export function buildDoubaoProvider(): ProviderConfig {
|
||||
return {
|
||||
baseUrl: DOUBAO_BASE_URL,
|
||||
api: "openai-completions",
|
||||
models: DOUBAO_MODEL_CATALOG.map(buildDoubaoModelDefinition),
|
||||
};
|
||||
}
|
||||
|
||||
export function buildDoubaoCodingProvider(): ProviderConfig {
|
||||
return {
|
||||
baseUrl: DOUBAO_CODING_BASE_URL,
|
||||
api: "openai-completions",
|
||||
models: DOUBAO_CODING_MODEL_CATALOG.map(buildDoubaoModelDefinition),
|
||||
};
|
||||
}
|
||||
|
||||
export function buildBytePlusProvider(): ProviderConfig {
|
||||
return {
|
||||
baseUrl: BYTEPLUS_BASE_URL,
|
||||
api: "openai-completions",
|
||||
models: BYTEPLUS_MODEL_CATALOG.map(buildBytePlusModelDefinition),
|
||||
};
|
||||
}
|
||||
|
||||
export function buildBytePlusCodingProvider(): ProviderConfig {
|
||||
return {
|
||||
baseUrl: BYTEPLUS_CODING_BASE_URL,
|
||||
api: "openai-completions",
|
||||
models: BYTEPLUS_CODING_MODEL_CATALOG.map(buildBytePlusModelDefinition),
|
||||
};
|
||||
}
|
||||
|
||||
export function buildXiaomiProvider(): ProviderConfig {
|
||||
return {
|
||||
baseUrl: XIAOMI_BASE_URL,
|
||||
api: "anthropic-messages",
|
||||
models: [
|
||||
{
|
||||
id: XIAOMI_DEFAULT_MODEL_ID,
|
||||
name: "Xiaomi MiMo V2 Flash",
|
||||
reasoning: false,
|
||||
input: ["text"],
|
||||
cost: XIAOMI_DEFAULT_COST,
|
||||
contextWindow: XIAOMI_DEFAULT_CONTEXT_WINDOW,
|
||||
maxTokens: XIAOMI_DEFAULT_MAX_TOKENS,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
export function buildTogetherProvider(): ProviderConfig {
|
||||
return {
|
||||
baseUrl: TOGETHER_BASE_URL,
|
||||
api: "openai-completions",
|
||||
models: TOGETHER_MODEL_CATALOG.map(buildTogetherModelDefinition),
|
||||
};
|
||||
}
|
||||
|
||||
export function buildOpenrouterProvider(): ProviderConfig {
|
||||
return {
|
||||
baseUrl: OPENROUTER_BASE_URL,
|
||||
api: "openai-completions",
|
||||
models: [
|
||||
{
|
||||
id: OPENROUTER_DEFAULT_MODEL_ID,
|
||||
name: "OpenRouter Auto",
|
||||
reasoning: false,
|
||||
input: ["text", "image"],
|
||||
cost: OPENROUTER_DEFAULT_COST,
|
||||
contextWindow: OPENROUTER_DEFAULT_CONTEXT_WINDOW,
|
||||
maxTokens: OPENROUTER_DEFAULT_MAX_TOKENS,
|
||||
},
|
||||
{
|
||||
id: "openrouter/hunter-alpha",
|
||||
name: "Hunter Alpha",
|
||||
reasoning: true,
|
||||
input: ["text"],
|
||||
cost: OPENROUTER_DEFAULT_COST,
|
||||
contextWindow: 1048576,
|
||||
maxTokens: 65536,
|
||||
},
|
||||
{
|
||||
id: "openrouter/healer-alpha",
|
||||
name: "Healer Alpha",
|
||||
reasoning: true,
|
||||
input: ["text", "image"],
|
||||
cost: OPENROUTER_DEFAULT_COST,
|
||||
contextWindow: 262144,
|
||||
maxTokens: 65536,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
export function buildOpenAICodexProvider(): ProviderConfig {
|
||||
return {
|
||||
baseUrl: OPENAI_CODEX_BASE_URL,
|
||||
api: "openai-codex-responses",
|
||||
models: [],
|
||||
};
|
||||
}
|
||||
|
||||
export function buildQianfanProvider(): ProviderConfig {
|
||||
return {
|
||||
baseUrl: QIANFAN_BASE_URL,
|
||||
api: "openai-completions",
|
||||
models: [
|
||||
{
|
||||
id: QIANFAN_DEFAULT_MODEL_ID,
|
||||
name: "DEEPSEEK V3.2",
|
||||
reasoning: true,
|
||||
input: ["text"],
|
||||
cost: QIANFAN_DEFAULT_COST,
|
||||
contextWindow: QIANFAN_DEFAULT_CONTEXT_WINDOW,
|
||||
maxTokens: QIANFAN_DEFAULT_MAX_TOKENS,
|
||||
},
|
||||
{
|
||||
id: "ernie-5.0-thinking-preview",
|
||||
name: "ERNIE-5.0-Thinking-Preview",
|
||||
reasoning: true,
|
||||
input: ["text", "image"],
|
||||
cost: QIANFAN_DEFAULT_COST,
|
||||
contextWindow: 119000,
|
||||
maxTokens: 64000,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
export function buildModelStudioProvider(): ProviderConfig {
|
||||
return {
|
||||
baseUrl: MODELSTUDIO_BASE_URL,
|
||||
api: "openai-completions",
|
||||
models: MODELSTUDIO_MODEL_CATALOG.map((model) => ({ ...model })),
|
||||
};
|
||||
}
|
||||
|
||||
export function buildNvidiaProvider(): ProviderConfig {
|
||||
return {
|
||||
baseUrl: NVIDIA_BASE_URL,
|
||||
api: "openai-completions",
|
||||
models: [
|
||||
{
|
||||
id: NVIDIA_DEFAULT_MODEL_ID,
|
||||
name: "NVIDIA Llama 3.1 Nemotron 70B Instruct",
|
||||
reasoning: false,
|
||||
input: ["text"],
|
||||
cost: NVIDIA_DEFAULT_COST,
|
||||
contextWindow: NVIDIA_DEFAULT_CONTEXT_WINDOW,
|
||||
maxTokens: NVIDIA_DEFAULT_MAX_TOKENS,
|
||||
},
|
||||
{
|
||||
id: "meta/llama-3.3-70b-instruct",
|
||||
name: "Meta Llama 3.3 70B Instruct",
|
||||
reasoning: false,
|
||||
input: ["text"],
|
||||
cost: NVIDIA_DEFAULT_COST,
|
||||
contextWindow: 131072,
|
||||
maxTokens: 4096,
|
||||
},
|
||||
{
|
||||
id: "nvidia/mistral-nemo-minitron-8b-8k-instruct",
|
||||
name: "NVIDIA Mistral NeMo Minitron 8B Instruct",
|
||||
reasoning: false,
|
||||
input: ["text"],
|
||||
cost: NVIDIA_DEFAULT_COST,
|
||||
contextWindow: 8192,
|
||||
maxTokens: 2048,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
export function buildKilocodeProvider(): ProviderConfig {
|
||||
return {
|
||||
baseUrl: KILOCODE_BASE_URL,
|
||||
api: "openai-completions",
|
||||
models: KILOCODE_MODEL_CATALOG.map((model) => ({
|
||||
id: model.id,
|
||||
name: model.name,
|
||||
reasoning: model.reasoning,
|
||||
input: model.input,
|
||||
cost: KILOCODE_DEFAULT_COST,
|
||||
contextWindow: model.contextWindow ?? KILOCODE_DEFAULT_CONTEXT_WINDOW,
|
||||
maxTokens: model.maxTokens ?? KILOCODE_DEFAULT_MAX_TOKENS,
|
||||
})),
|
||||
};
|
||||
}
|
||||
export {
|
||||
buildBytePlusCodingProvider,
|
||||
buildBytePlusProvider,
|
||||
} from "../../extensions/byteplus/provider-catalog.js";
|
||||
export { buildKimiCodingProvider } from "../../extensions/kimi-coding/provider-catalog.js";
|
||||
export { buildKilocodeProvider } from "../../extensions/kilocode/provider-catalog.js";
|
||||
export {
|
||||
buildMinimaxPortalProvider,
|
||||
buildMinimaxProvider,
|
||||
} from "../../extensions/minimax/provider-catalog.js";
|
||||
export {
|
||||
MODELSTUDIO_BASE_URL,
|
||||
MODELSTUDIO_DEFAULT_MODEL_ID,
|
||||
buildModelStudioProvider,
|
||||
} from "../../extensions/modelstudio/provider-catalog.js";
|
||||
export { buildMoonshotProvider } from "../../extensions/moonshot/provider-catalog.js";
|
||||
export { buildNvidiaProvider } from "../../extensions/nvidia/provider-catalog.js";
|
||||
export { buildOpenAICodexProvider } from "../../extensions/openai/openai-codex-catalog.js";
|
||||
export { buildOpenrouterProvider } from "../../extensions/openrouter/provider-catalog.js";
|
||||
export {
|
||||
QIANFAN_BASE_URL,
|
||||
QIANFAN_DEFAULT_MODEL_ID,
|
||||
buildQianfanProvider,
|
||||
} from "../../extensions/qianfan/provider-catalog.js";
|
||||
export { buildQwenPortalProvider } from "../../extensions/qwen-portal-auth/provider-catalog.js";
|
||||
export { buildSyntheticProvider } from "../../extensions/synthetic/provider-catalog.js";
|
||||
export { buildTogetherProvider } from "../../extensions/together/provider-catalog.js";
|
||||
export {
|
||||
buildDoubaoCodingProvider,
|
||||
buildDoubaoProvider,
|
||||
} from "../../extensions/volcengine/provider-catalog.js";
|
||||
export {
|
||||
XIAOMI_DEFAULT_MODEL_ID,
|
||||
buildXiaomiProvider,
|
||||
} from "../../extensions/xiaomi/provider-catalog.js";
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
import {
|
||||
QIANFAN_BASE_URL,
|
||||
QIANFAN_DEFAULT_MODEL_ID,
|
||||
} from "../../extensions/qianfan/provider-catalog.js";
|
||||
import { XIAOMI_DEFAULT_MODEL_ID } from "../../extensions/xiaomi/provider-catalog.js";
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import { coerceSecretRef, resolveSecretInputRef } from "../config/types.secrets.js";
|
||||
import { isRecord } from "../utils.js";
|
||||
@@ -6,24 +11,23 @@ import { ensureAuthProfileStore, listProfilesForProvider } from "./auth-profiles
|
||||
import { discoverBedrockModels } from "./bedrock-discovery.js";
|
||||
import { normalizeGoogleModelId } from "./model-id-normalization.js";
|
||||
import { resolveOllamaApiBase } from "./models-config.providers.discovery.js";
|
||||
import {
|
||||
QIANFAN_BASE_URL,
|
||||
QIANFAN_DEFAULT_MODEL_ID,
|
||||
XIAOMI_DEFAULT_MODEL_ID,
|
||||
} from "./models-config.providers.static.js";
|
||||
export { buildKimiCodingProvider } from "../../extensions/kimi-coding/provider-catalog.js";
|
||||
export { buildKilocodeProvider } from "../../extensions/kilocode/provider-catalog.js";
|
||||
export {
|
||||
buildKimiCodingProvider,
|
||||
buildKilocodeProvider,
|
||||
buildNvidiaProvider,
|
||||
buildModelStudioProvider,
|
||||
buildQianfanProvider,
|
||||
buildXiaomiProvider,
|
||||
MODELSTUDIO_BASE_URL,
|
||||
MODELSTUDIO_DEFAULT_MODEL_ID,
|
||||
buildModelStudioProvider,
|
||||
} from "../../extensions/modelstudio/provider-catalog.js";
|
||||
export { buildNvidiaProvider } from "../../extensions/nvidia/provider-catalog.js";
|
||||
export {
|
||||
QIANFAN_BASE_URL,
|
||||
QIANFAN_DEFAULT_MODEL_ID,
|
||||
buildQianfanProvider,
|
||||
} from "../../extensions/qianfan/provider-catalog.js";
|
||||
export {
|
||||
XIAOMI_DEFAULT_MODEL_ID,
|
||||
} from "./models-config.providers.static.js";
|
||||
buildXiaomiProvider,
|
||||
} from "../../extensions/xiaomi/provider-catalog.js";
|
||||
import {
|
||||
groupPluginDiscoveryProvidersByOrder,
|
||||
normalizePluginDiscoveryResult,
|
||||
|
||||
@@ -117,6 +117,10 @@ function isChatGPTUsageLimitErrorMessage(raw: string): boolean {
|
||||
return msg.includes("hit your chatgpt usage limit") && msg.includes("try again in");
|
||||
}
|
||||
|
||||
function isRefreshTokenReused(raw: string): boolean {
|
||||
return /refresh_token_reused/i.test(raw);
|
||||
}
|
||||
|
||||
function isInstructionsRequiredError(raw: string): boolean {
|
||||
return /instructions are required/i.test(raw);
|
||||
}
|
||||
@@ -643,6 +647,15 @@ describeLive("live models (profile keys)", () => {
|
||||
logProgress(`${progressLabel}: skip (rate limit)`);
|
||||
break;
|
||||
}
|
||||
if (
|
||||
allowNotFoundSkip &&
|
||||
model.provider === "openai-codex" &&
|
||||
isRefreshTokenReused(message)
|
||||
) {
|
||||
skipped.push({ model: id, reason: message });
|
||||
logProgress(`${progressLabel}: skip (codex refresh token reused)`);
|
||||
break;
|
||||
}
|
||||
if (
|
||||
allowNotFoundSkip &&
|
||||
model.provider === "openai-codex" &&
|
||||
|
||||
@@ -167,6 +167,8 @@ function buildManager(opts?: ConstructorParameters<typeof OpenAIWebSocketManager
|
||||
return new OpenAIWebSocketManager({
|
||||
// Use faster backoff in tests to avoid slow timer waits
|
||||
backoffDelaysMs: [10, 20, 40, 80, 160],
|
||||
socketFactory: (url, options) =>
|
||||
new MockWebSocket(url, options as Record<string, unknown>) as never,
|
||||
...opts,
|
||||
});
|
||||
}
|
||||
@@ -232,6 +234,22 @@ describe("OpenAIWebSocketManager", () => {
|
||||
await connectPromise;
|
||||
});
|
||||
|
||||
it("adds OpenClaw attribution headers on the native OpenAI websocket", async () => {
|
||||
const manager = buildManager();
|
||||
const connectPromise = manager.connect("sk-test-key");
|
||||
|
||||
const sock = lastSocket();
|
||||
expect(sock.options).toMatchObject({
|
||||
headers: expect.objectContaining({
|
||||
originator: "openclaw",
|
||||
"User-Agent": expect.stringMatching(/^openclaw\//),
|
||||
}),
|
||||
});
|
||||
|
||||
sock.simulateOpen();
|
||||
await connectPromise;
|
||||
});
|
||||
|
||||
it("resolves when the connection opens", async () => {
|
||||
const manager = buildManager();
|
||||
const connectPromise = manager.connect("sk-test");
|
||||
|
||||
@@ -14,7 +14,8 @@
|
||||
*/
|
||||
|
||||
import { EventEmitter } from "node:events";
|
||||
import WebSocket from "ws";
|
||||
import WebSocket, { type ClientOptions as WebSocketClientOptions } from "ws";
|
||||
import { resolveProviderAttributionHeaders } from "./provider-attribution.js";
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// WebSocket Event Types (Server → Client)
|
||||
@@ -251,6 +252,14 @@ const MAX_RETRIES = 5;
|
||||
/** Backoff delays in ms: 1s, 2s, 4s, 8s, 16s */
|
||||
const BACKOFF_DELAYS_MS = [1000, 2000, 4000, 8000, 16000] as const;
|
||||
|
||||
function isOpenAIPublicWebSocketUrl(url: string): boolean {
|
||||
try {
|
||||
return new URL(url).hostname.toLowerCase() === "api.openai.com";
|
||||
} catch {
|
||||
return url.toLowerCase().includes("api.openai.com");
|
||||
}
|
||||
}
|
||||
|
||||
export interface OpenAIWebSocketManagerOptions {
|
||||
/** Override the default WebSocket URL (useful for testing) */
|
||||
url?: string;
|
||||
@@ -258,6 +267,8 @@ export interface OpenAIWebSocketManagerOptions {
|
||||
maxRetries?: number;
|
||||
/** Custom backoff delays in ms (default: [1000, 2000, 4000, 8000, 16000]) */
|
||||
backoffDelaysMs?: readonly number[];
|
||||
/** Custom socket factory for tests. */
|
||||
socketFactory?: (url: string, options: WebSocketClientOptions) => WebSocket;
|
||||
}
|
||||
|
||||
type InternalEvents = {
|
||||
@@ -297,12 +308,15 @@ export class OpenAIWebSocketManager extends EventEmitter<InternalEvents> {
|
||||
private readonly wsUrl: string;
|
||||
private readonly maxRetries: number;
|
||||
private readonly backoffDelaysMs: readonly number[];
|
||||
private readonly socketFactory: (url: string, options: WebSocketClientOptions) => WebSocket;
|
||||
|
||||
constructor(options: OpenAIWebSocketManagerOptions = {}) {
|
||||
super();
|
||||
this.wsUrl = options.url ?? OPENAI_WS_URL;
|
||||
this.maxRetries = options.maxRetries ?? MAX_RETRIES;
|
||||
this.backoffDelaysMs = options.backoffDelaysMs ?? BACKOFF_DELAYS_MS;
|
||||
this.socketFactory =
|
||||
options.socketFactory ?? ((url, socketOptions) => new WebSocket(url, socketOptions));
|
||||
}
|
||||
|
||||
// ─── Public API ────────────────────────────────────────────────────────────
|
||||
@@ -382,10 +396,13 @@ export class OpenAIWebSocketManager extends EventEmitter<InternalEvents> {
|
||||
return;
|
||||
}
|
||||
|
||||
const socket = new WebSocket(this.wsUrl, {
|
||||
const socket = this.socketFactory(this.wsUrl, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${this.apiKey}`,
|
||||
"OpenAI-Beta": "responses-websocket=v1",
|
||||
...(isOpenAIPublicWebSocketUrl(this.wsUrl)
|
||||
? resolveProviderAttributionHeaders("openai")
|
||||
: undefined),
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
184
src/agents/pi-bundle-mcp-tools.test.ts
Normal file
184
src/agents/pi-bundle-mcp-tools.test.ts
Normal file
@@ -0,0 +1,184 @@
|
||||
import fs from "node:fs/promises";
|
||||
import { createRequire } from "node:module";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { createBundleMcpToolRuntime } from "./pi-bundle-mcp-tools.js";
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const SDK_SERVER_MCP_PATH = require.resolve("@modelcontextprotocol/sdk/server/mcp.js");
|
||||
const SDK_SERVER_STDIO_PATH = require.resolve("@modelcontextprotocol/sdk/server/stdio.js");
|
||||
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
async function makeTempDir(prefix: string): Promise<string> {
|
||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), prefix));
|
||||
tempDirs.push(dir);
|
||||
return dir;
|
||||
}
|
||||
|
||||
async function writeExecutable(filePath: string, content: string): Promise<void> {
|
||||
await fs.mkdir(path.dirname(filePath), { recursive: true });
|
||||
await fs.writeFile(filePath, content, { encoding: "utf-8", mode: 0o755 });
|
||||
}
|
||||
|
||||
async function writeBundleProbeMcpServer(filePath: string): Promise<void> {
|
||||
await writeExecutable(
|
||||
filePath,
|
||||
`#!/usr/bin/env node
|
||||
import { McpServer } from ${JSON.stringify(SDK_SERVER_MCP_PATH)};
|
||||
import { StdioServerTransport } from ${JSON.stringify(SDK_SERVER_STDIO_PATH)};
|
||||
|
||||
const server = new McpServer({ name: "bundle-probe", version: "1.0.0" });
|
||||
server.tool("bundle_probe", "Bundle MCP probe", async () => {
|
||||
return {
|
||||
content: [{ type: "text", text: process.env.BUNDLE_PROBE_TEXT ?? "missing-probe-text" }],
|
||||
};
|
||||
});
|
||||
|
||||
await server.connect(new StdioServerTransport());
|
||||
`,
|
||||
);
|
||||
}
|
||||
|
||||
async function writeClaudeBundle(params: {
|
||||
pluginRoot: string;
|
||||
serverScriptPath: string;
|
||||
}): Promise<void> {
|
||||
await fs.mkdir(path.join(params.pluginRoot, ".claude-plugin"), { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(params.pluginRoot, ".claude-plugin", "plugin.json"),
|
||||
`${JSON.stringify({ name: "bundle-probe" }, null, 2)}\n`,
|
||||
"utf-8",
|
||||
);
|
||||
await fs.writeFile(
|
||||
path.join(params.pluginRoot, ".mcp.json"),
|
||||
`${JSON.stringify(
|
||||
{
|
||||
mcpServers: {
|
||||
bundleProbe: {
|
||||
command: "node",
|
||||
args: [path.relative(params.pluginRoot, params.serverScriptPath)],
|
||||
env: {
|
||||
BUNDLE_PROBE_TEXT: "FROM-BUNDLE",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
)}\n`,
|
||||
"utf-8",
|
||||
);
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(
|
||||
tempDirs.splice(0, tempDirs.length).map((dir) => fs.rm(dir, { recursive: true, force: true })),
|
||||
);
|
||||
});
|
||||
|
||||
describe("createBundleMcpToolRuntime", () => {
|
||||
it("loads bundle MCP tools and executes them", async () => {
|
||||
const workspaceDir = await makeTempDir("openclaw-bundle-mcp-tools-");
|
||||
const pluginRoot = path.join(workspaceDir, ".openclaw", "extensions", "bundle-probe");
|
||||
const serverScriptPath = path.join(pluginRoot, "servers", "bundle-probe.mjs");
|
||||
await writeBundleProbeMcpServer(serverScriptPath);
|
||||
await writeClaudeBundle({ pluginRoot, serverScriptPath });
|
||||
|
||||
const runtime = await createBundleMcpToolRuntime({
|
||||
workspaceDir,
|
||||
cfg: {
|
||||
plugins: {
|
||||
entries: {
|
||||
"bundle-probe": { enabled: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
expect(runtime.tools.map((tool) => tool.name)).toEqual(["bundle_probe"]);
|
||||
const result = await runtime.tools[0].execute("call-bundle-probe", {}, undefined, undefined);
|
||||
expect(result.content[0]).toMatchObject({
|
||||
type: "text",
|
||||
text: "FROM-BUNDLE",
|
||||
});
|
||||
expect(result.details).toEqual({
|
||||
mcpServer: "bundleProbe",
|
||||
mcpTool: "bundle_probe",
|
||||
});
|
||||
} finally {
|
||||
await runtime.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
it("skips bundle MCP tools that collide with existing tool names", async () => {
|
||||
const workspaceDir = await makeTempDir("openclaw-bundle-mcp-tools-");
|
||||
const pluginRoot = path.join(workspaceDir, ".openclaw", "extensions", "bundle-probe");
|
||||
const serverScriptPath = path.join(pluginRoot, "servers", "bundle-probe.mjs");
|
||||
await writeBundleProbeMcpServer(serverScriptPath);
|
||||
await writeClaudeBundle({ pluginRoot, serverScriptPath });
|
||||
|
||||
const runtime = await createBundleMcpToolRuntime({
|
||||
workspaceDir,
|
||||
cfg: {
|
||||
plugins: {
|
||||
entries: {
|
||||
"bundle-probe": { enabled: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
reservedToolNames: ["bundle_probe"],
|
||||
});
|
||||
|
||||
try {
|
||||
expect(runtime.tools).toEqual([]);
|
||||
} finally {
|
||||
await runtime.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
it("loads configured stdio MCP tools without a bundle", async () => {
|
||||
const workspaceDir = await makeTempDir("openclaw-bundle-mcp-tools-");
|
||||
const serverScriptPath = path.join(workspaceDir, "servers", "configured-probe.mjs");
|
||||
await writeBundleProbeMcpServer(serverScriptPath);
|
||||
|
||||
const runtime = await createBundleMcpToolRuntime({
|
||||
workspaceDir,
|
||||
cfg: {
|
||||
mcp: {
|
||||
servers: {
|
||||
configuredProbe: {
|
||||
command: "node",
|
||||
args: [serverScriptPath],
|
||||
env: {
|
||||
BUNDLE_PROBE_TEXT: "FROM-CONFIG",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
expect(runtime.tools.map((tool) => tool.name)).toEqual(["bundle_probe"]);
|
||||
const result = await runtime.tools[0].execute(
|
||||
"call-configured-probe",
|
||||
{},
|
||||
undefined,
|
||||
undefined,
|
||||
);
|
||||
expect(result.content[0]).toMatchObject({
|
||||
type: "text",
|
||||
text: "FROM-CONFIG",
|
||||
});
|
||||
expect(result.details).toEqual({
|
||||
mcpServer: "configuredProbe",
|
||||
mcpTool: "bundle_probe",
|
||||
});
|
||||
} finally {
|
||||
await runtime.dispose();
|
||||
}
|
||||
});
|
||||
});
|
||||
225
src/agents/pi-bundle-mcp-tools.ts
Normal file
225
src/agents/pi-bundle-mcp-tools.ts
Normal file
@@ -0,0 +1,225 @@
|
||||
import type { AgentToolResult } from "@mariozechner/pi-agent-core";
|
||||
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
||||
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
|
||||
import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import { logDebug, logWarn } from "../logger.js";
|
||||
import { loadEmbeddedPiMcpConfig } from "./embedded-pi-mcp.js";
|
||||
import {
|
||||
describeStdioMcpServerLaunchConfig,
|
||||
resolveStdioMcpServerLaunchConfig,
|
||||
} from "./mcp-stdio.js";
|
||||
import type { AnyAgentTool } from "./tools/common.js";
|
||||
|
||||
type BundleMcpToolRuntime = {
|
||||
tools: AnyAgentTool[];
|
||||
dispose: () => Promise<void>;
|
||||
};
|
||||
|
||||
type BundleMcpSession = {
|
||||
serverName: string;
|
||||
client: Client;
|
||||
transport: StdioClientTransport;
|
||||
detachStderr?: () => void;
|
||||
};
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return value !== null && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
|
||||
async function listAllTools(client: Client) {
|
||||
const tools: Awaited<ReturnType<Client["listTools"]>>["tools"] = [];
|
||||
let cursor: string | undefined;
|
||||
do {
|
||||
const page = await client.listTools(cursor ? { cursor } : undefined);
|
||||
tools.push(...page.tools);
|
||||
cursor = page.nextCursor;
|
||||
} while (cursor);
|
||||
return tools;
|
||||
}
|
||||
|
||||
function toAgentToolResult(params: {
|
||||
serverName: string;
|
||||
toolName: string;
|
||||
result: CallToolResult;
|
||||
}): AgentToolResult<unknown> {
|
||||
const content = Array.isArray(params.result.content)
|
||||
? (params.result.content as AgentToolResult<unknown>["content"])
|
||||
: [];
|
||||
const normalizedContent: AgentToolResult<unknown>["content"] =
|
||||
content.length > 0
|
||||
? content
|
||||
: params.result.structuredContent !== undefined
|
||||
? [
|
||||
{
|
||||
type: "text",
|
||||
text: JSON.stringify(params.result.structuredContent, null, 2),
|
||||
},
|
||||
]
|
||||
: ([
|
||||
{
|
||||
type: "text",
|
||||
text: JSON.stringify(
|
||||
{
|
||||
status: params.result.isError === true ? "error" : "ok",
|
||||
server: params.serverName,
|
||||
tool: params.toolName,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
},
|
||||
] as AgentToolResult<unknown>["content"]);
|
||||
const details: Record<string, unknown> = {
|
||||
mcpServer: params.serverName,
|
||||
mcpTool: params.toolName,
|
||||
};
|
||||
if (params.result.structuredContent !== undefined) {
|
||||
details.structuredContent = params.result.structuredContent;
|
||||
}
|
||||
if (params.result.isError === true) {
|
||||
details.status = "error";
|
||||
}
|
||||
return {
|
||||
content: normalizedContent,
|
||||
details,
|
||||
};
|
||||
}
|
||||
|
||||
function attachStderrLogging(serverName: string, transport: StdioClientTransport) {
|
||||
const stderr = transport.stderr;
|
||||
if (!stderr || typeof stderr.on !== "function") {
|
||||
return undefined;
|
||||
}
|
||||
const onData = (chunk: Buffer | string) => {
|
||||
const message = String(chunk).trim();
|
||||
if (!message) {
|
||||
return;
|
||||
}
|
||||
for (const line of message.split(/\r?\n/)) {
|
||||
const trimmed = line.trim();
|
||||
if (trimmed) {
|
||||
logDebug(`bundle-mcp:${serverName}: ${trimmed}`);
|
||||
}
|
||||
}
|
||||
};
|
||||
stderr.on("data", onData);
|
||||
return () => {
|
||||
if (typeof stderr.off === "function") {
|
||||
stderr.off("data", onData);
|
||||
} else if (typeof stderr.removeListener === "function") {
|
||||
stderr.removeListener("data", onData);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
async function disposeSession(session: BundleMcpSession) {
|
||||
session.detachStderr?.();
|
||||
await session.client.close().catch(() => {});
|
||||
await session.transport.close().catch(() => {});
|
||||
}
|
||||
|
||||
export async function createBundleMcpToolRuntime(params: {
|
||||
workspaceDir: string;
|
||||
cfg?: OpenClawConfig;
|
||||
reservedToolNames?: Iterable<string>;
|
||||
}): Promise<BundleMcpToolRuntime> {
|
||||
const loaded = loadEmbeddedPiMcpConfig({
|
||||
workspaceDir: params.workspaceDir,
|
||||
cfg: params.cfg,
|
||||
});
|
||||
for (const diagnostic of loaded.diagnostics) {
|
||||
logWarn(`bundle-mcp: ${diagnostic.pluginId}: ${diagnostic.message}`);
|
||||
}
|
||||
|
||||
const reservedNames = new Set(
|
||||
Array.from(params.reservedToolNames ?? [], (name) => name.trim().toLowerCase()).filter(Boolean),
|
||||
);
|
||||
const sessions: BundleMcpSession[] = [];
|
||||
const tools: AnyAgentTool[] = [];
|
||||
|
||||
try {
|
||||
for (const [serverName, rawServer] of Object.entries(loaded.mcpServers)) {
|
||||
const launch = resolveStdioMcpServerLaunchConfig(rawServer);
|
||||
if (!launch.ok) {
|
||||
logWarn(`bundle-mcp: skipped server "${serverName}" because ${launch.reason}.`);
|
||||
continue;
|
||||
}
|
||||
const launchConfig = launch.config;
|
||||
|
||||
const transport = new StdioClientTransport({
|
||||
command: launchConfig.command,
|
||||
args: launchConfig.args,
|
||||
env: launchConfig.env,
|
||||
cwd: launchConfig.cwd,
|
||||
stderr: "pipe",
|
||||
});
|
||||
const client = new Client(
|
||||
{
|
||||
name: "openclaw-bundle-mcp",
|
||||
version: "0.0.0",
|
||||
},
|
||||
{},
|
||||
);
|
||||
const session: BundleMcpSession = {
|
||||
serverName,
|
||||
client,
|
||||
transport,
|
||||
detachStderr: attachStderrLogging(serverName, transport),
|
||||
};
|
||||
|
||||
try {
|
||||
await client.connect(transport);
|
||||
const listedTools = await listAllTools(client);
|
||||
sessions.push(session);
|
||||
for (const tool of listedTools) {
|
||||
const normalizedName = tool.name.trim().toLowerCase();
|
||||
if (!normalizedName) {
|
||||
continue;
|
||||
}
|
||||
if (reservedNames.has(normalizedName)) {
|
||||
logWarn(
|
||||
`bundle-mcp: skipped tool "${tool.name}" from server "${serverName}" because the name already exists.`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
reservedNames.add(normalizedName);
|
||||
tools.push({
|
||||
name: tool.name,
|
||||
label: tool.title ?? tool.name,
|
||||
description:
|
||||
tool.description?.trim() ||
|
||||
`Provided by bundle MCP server "${serverName}" (${describeStdioMcpServerLaunchConfig(launchConfig)}).`,
|
||||
parameters: tool.inputSchema,
|
||||
execute: async (_toolCallId, input) => {
|
||||
const result = (await client.callTool({
|
||||
name: tool.name,
|
||||
arguments: isRecord(input) ? input : {},
|
||||
})) as CallToolResult;
|
||||
return toAgentToolResult({
|
||||
serverName,
|
||||
toolName: tool.name,
|
||||
result,
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
logWarn(
|
||||
`bundle-mcp: failed to start server "${serverName}" (${describeStdioMcpServerLaunchConfig(launchConfig)}): ${String(error)}`,
|
||||
);
|
||||
await disposeSession(session);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
tools,
|
||||
dispose: async () => {
|
||||
await Promise.allSettled(sessions.map((session) => disposeSession(session)));
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
await Promise.allSettled(sessions.map((session) => disposeSession(session)));
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -35,6 +35,12 @@ describe("formatAssistantErrorText", () => {
|
||||
);
|
||||
expect(formatAssistantErrorText(msg)).toContain("Context overflow");
|
||||
});
|
||||
it("returns context overflow for Ollama 'prompt too long' errors (#34005)", () => {
|
||||
const msg = makeAssistantError(
|
||||
'Ollama API error 400: {"StatusCode":400,"Status":"400 Bad Request","error":"prompt too long; exceeded max context length by 4 tokens"}',
|
||||
);
|
||||
expect(formatAssistantErrorText(msg)).toContain("Context overflow");
|
||||
});
|
||||
it("returns a reasoning-required message for mandatory reasoning endpoint errors", () => {
|
||||
const msg = makeAssistantError(
|
||||
"400 Reasoning is mandatory for this endpoint and cannot be disabled.",
|
||||
|
||||
@@ -42,6 +42,14 @@ describe("sanitizeUserFacingText", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("sanitizes Ollama prompt-too-long payloads through the context-overflow path", () => {
|
||||
const text =
|
||||
'Ollama API error 400: {"StatusCode":400,"Status":"400 Bad Request","error":"prompt too long; exceeded max context length by 4 tokens"}';
|
||||
expect(sanitizeUserFacingText(text, { errorContext: true })).toContain(
|
||||
"Context overflow: prompt too large for the model.",
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
"Changelog note: we fixed false positives for `Context overflow: prompt too large for the model. Try /reset (or /new) to start a fresh session, or use a larger-context model.` in 2026.2.9",
|
||||
"nah it failed, hit a context overflow. the prompt was too large for the model. want me to retry it with a different approach?",
|
||||
|
||||
@@ -97,6 +97,7 @@ export function isContextOverflowError(errorMessage?: string): boolean {
|
||||
lower.includes("context length exceeded") ||
|
||||
lower.includes("maximum context length") ||
|
||||
lower.includes("prompt is too long") ||
|
||||
lower.includes("prompt too long") ||
|
||||
lower.includes("exceeds model context window") ||
|
||||
lower.includes("model token limit") ||
|
||||
(hasRequestSizeExceeds && hasContextWindow) ||
|
||||
@@ -211,11 +212,12 @@ export function extractObservedOverflowTokenCount(errorMessage?: string): number
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Allow provider-wrapped API payloads such as "Ollama API error 400: {...}".
|
||||
const ERROR_PAYLOAD_PREFIX_RE =
|
||||
/^(?:error|api\s*error|apierror|openai\s*error|anthropic\s*error|gateway\s*error)[:\s-]+/i;
|
||||
/^(?:error|(?:[a-z][\w-]*\s+)?api\s*error|apierror|openai\s*error|anthropic\s*error|gateway\s*error)(?:\s+\d{3})?[:\s-]+/i;
|
||||
const FINAL_TAG_RE = /<\s*\/?\s*final\s*>/gi;
|
||||
const ERROR_PREFIX_RE =
|
||||
/^(?:error|api\s*error|openai\s*error|anthropic\s*error|gateway\s*error|request failed|failed|exception)[:\s-]+/i;
|
||||
/^(?:error|(?:[a-z][\w-]*\s+)?api\s*error|openai\s*error|anthropic\s*error|gateway\s*error|request failed|failed|exception)(?:\s+\d{3})?[:\s-]+/i;
|
||||
const CONTEXT_OVERFLOW_ERROR_HEAD_RE =
|
||||
/^(?:context overflow:|request_too_large\b|request size exceeds\b|request exceeds the maximum size\b|context length exceeded\b|maximum context length\b|prompt is too long\b|exceeds model context window\b)/i;
|
||||
const HTTP_STATUS_PREFIX_RE = /^(?:http\s*)?(\d{3})\s+(.+)$/i;
|
||||
|
||||
@@ -908,7 +908,7 @@ describe("applyExtraParamsToAgent", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("does not rewrite tool schema for kimi-coding (native Anthropic format)", () => {
|
||||
it("does not rewrite tool schema for Kimi (native Anthropic format)", () => {
|
||||
const payloads: Record<string, unknown>[] = [];
|
||||
const baseStreamFn: StreamFn = (_model, _context, options) => {
|
||||
const payload: Record<string, unknown> = {
|
||||
@@ -931,12 +931,12 @@ describe("applyExtraParamsToAgent", () => {
|
||||
};
|
||||
const agent = { streamFn: baseStreamFn };
|
||||
|
||||
applyExtraParamsToAgent(agent, undefined, "kimi-coding", "k2p5", undefined, "low");
|
||||
applyExtraParamsToAgent(agent, undefined, "kimi", "kimi-code", undefined, "low");
|
||||
|
||||
const model = {
|
||||
api: "anthropic-messages",
|
||||
provider: "kimi-coding",
|
||||
id: "k2p5",
|
||||
provider: "kimi",
|
||||
id: "kimi-code",
|
||||
baseUrl: "https://api.kimi.com/coding/",
|
||||
} as Model<"anthropic-messages">;
|
||||
const context: Context = { messages: [] };
|
||||
@@ -1160,7 +1160,8 @@ describe("applyExtraParamsToAgent", () => {
|
||||
expect(calls).toHaveLength(1);
|
||||
expect(calls[0]?.headers).toEqual({
|
||||
"HTTP-Referer": "https://openclaw.ai",
|
||||
"X-Title": "OpenClaw",
|
||||
"X-OpenRouter-Title": "OpenClaw",
|
||||
"X-OpenRouter-Categories": "cli-agent",
|
||||
"X-Custom": "1",
|
||||
});
|
||||
});
|
||||
|
||||
302
src/agents/pi-embedded-runner.bundle-mcp.e2e.test.ts
Normal file
302
src/agents/pi-embedded-runner.bundle-mcp.e2e.test.ts
Normal file
@@ -0,0 +1,302 @@
|
||||
import fs from "node:fs/promises";
|
||||
import { createRequire } from "node:module";
|
||||
import path from "node:path";
|
||||
import "./test-helpers/fast-coding-tools.js";
|
||||
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
cleanupEmbeddedPiRunnerTestWorkspace,
|
||||
createEmbeddedPiRunnerOpenAiConfig,
|
||||
createEmbeddedPiRunnerTestWorkspace,
|
||||
type EmbeddedPiRunnerTestWorkspace,
|
||||
immediateEnqueue,
|
||||
} from "./test-helpers/pi-embedded-runner-e2e-fixtures.js";
|
||||
|
||||
const E2E_TIMEOUT_MS = 20_000;
|
||||
const require = createRequire(import.meta.url);
|
||||
const SDK_SERVER_MCP_PATH = require.resolve("@modelcontextprotocol/sdk/server/mcp.js");
|
||||
const SDK_SERVER_STDIO_PATH = require.resolve("@modelcontextprotocol/sdk/server/stdio.js");
|
||||
|
||||
function createMockUsage(input: number, output: number) {
|
||||
return {
|
||||
input,
|
||||
output,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
totalTokens: input + output,
|
||||
cost: {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
total: 0,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
let streamCallCount = 0;
|
||||
let observedContexts: Array<Array<{ role?: string; content?: unknown }>> = [];
|
||||
|
||||
async function writeExecutable(filePath: string, content: string): Promise<void> {
|
||||
await fs.mkdir(path.dirname(filePath), { recursive: true });
|
||||
await fs.writeFile(filePath, content, { encoding: "utf-8", mode: 0o755 });
|
||||
}
|
||||
|
||||
async function writeBundleProbeMcpServer(filePath: string): Promise<void> {
|
||||
await writeExecutable(
|
||||
filePath,
|
||||
`#!/usr/bin/env node
|
||||
import { McpServer } from ${JSON.stringify(SDK_SERVER_MCP_PATH)};
|
||||
import { StdioServerTransport } from ${JSON.stringify(SDK_SERVER_STDIO_PATH)};
|
||||
|
||||
const server = new McpServer({ name: "bundle-probe", version: "1.0.0" });
|
||||
server.tool("bundle_probe", "Bundle MCP probe", async () => {
|
||||
return {
|
||||
content: [{ type: "text", text: process.env.BUNDLE_PROBE_TEXT ?? "missing-probe-text" }],
|
||||
};
|
||||
});
|
||||
|
||||
await server.connect(new StdioServerTransport());
|
||||
`,
|
||||
);
|
||||
}
|
||||
|
||||
async function writeClaudeBundle(params: {
|
||||
pluginRoot: string;
|
||||
serverScriptPath: string;
|
||||
}): Promise<void> {
|
||||
await fs.mkdir(path.join(params.pluginRoot, ".claude-plugin"), { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(params.pluginRoot, ".claude-plugin", "plugin.json"),
|
||||
`${JSON.stringify({ name: "bundle-probe" }, null, 2)}\n`,
|
||||
"utf-8",
|
||||
);
|
||||
await fs.writeFile(
|
||||
path.join(params.pluginRoot, ".mcp.json"),
|
||||
`${JSON.stringify(
|
||||
{
|
||||
mcpServers: {
|
||||
bundleProbe: {
|
||||
command: "node",
|
||||
args: [path.relative(params.pluginRoot, params.serverScriptPath)],
|
||||
env: {
|
||||
BUNDLE_PROBE_TEXT: "FROM-BUNDLE",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
)}\n`,
|
||||
"utf-8",
|
||||
);
|
||||
}
|
||||
|
||||
vi.mock("@mariozechner/pi-coding-agent", async () => {
|
||||
return await vi.importActual<typeof import("@mariozechner/pi-coding-agent")>(
|
||||
"@mariozechner/pi-coding-agent",
|
||||
);
|
||||
});
|
||||
|
||||
vi.mock("@mariozechner/pi-ai", async () => {
|
||||
const actual = await vi.importActual<typeof import("@mariozechner/pi-ai")>("@mariozechner/pi-ai");
|
||||
|
||||
const buildToolUseMessage = (model: { api: string; provider: string; id: string }) => ({
|
||||
role: "assistant" as const,
|
||||
content: [
|
||||
{
|
||||
type: "toolCall" as const,
|
||||
id: "tc-bundle-mcp-1",
|
||||
name: "bundle_probe",
|
||||
arguments: {},
|
||||
},
|
||||
],
|
||||
stopReason: "toolUse" as const,
|
||||
api: model.api,
|
||||
provider: model.provider,
|
||||
model: model.id,
|
||||
usage: createMockUsage(1, 1),
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
|
||||
const buildStopMessage = (
|
||||
model: { api: string; provider: string; id: string },
|
||||
text: string,
|
||||
) => ({
|
||||
role: "assistant" as const,
|
||||
content: [{ type: "text" as const, text }],
|
||||
stopReason: "stop" as const,
|
||||
api: model.api,
|
||||
provider: model.provider,
|
||||
model: model.id,
|
||||
usage: createMockUsage(1, 1),
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
|
||||
return {
|
||||
...actual,
|
||||
complete: async (model: { api: string; provider: string; id: string }) => {
|
||||
streamCallCount += 1;
|
||||
return streamCallCount === 1
|
||||
? buildToolUseMessage(model)
|
||||
: buildStopMessage(model, "BUNDLE MCP OK FROM-BUNDLE");
|
||||
},
|
||||
completeSimple: async (model: { api: string; provider: string; id: string }) => {
|
||||
streamCallCount += 1;
|
||||
return streamCallCount === 1
|
||||
? buildToolUseMessage(model)
|
||||
: buildStopMessage(model, "BUNDLE MCP OK FROM-BUNDLE");
|
||||
},
|
||||
streamSimple: (
|
||||
model: { api: string; provider: string; id: string },
|
||||
context: { messages?: Array<{ role?: string; content?: unknown }> },
|
||||
) => {
|
||||
streamCallCount += 1;
|
||||
const messages = (context.messages ?? []).map((message) => ({ ...message }));
|
||||
observedContexts.push(messages);
|
||||
const stream = actual.createAssistantMessageEventStream();
|
||||
queueMicrotask(() => {
|
||||
if (streamCallCount === 1) {
|
||||
stream.push({
|
||||
type: "done",
|
||||
reason: "toolUse",
|
||||
message: buildToolUseMessage(model),
|
||||
});
|
||||
stream.end();
|
||||
return;
|
||||
}
|
||||
|
||||
const toolResultText = messages.flatMap((message) =>
|
||||
Array.isArray(message.content)
|
||||
? (message.content as Array<{ type?: string; text?: string }>)
|
||||
.filter((entry) => entry.type === "text" && typeof entry.text === "string")
|
||||
.map((entry) => entry.text ?? "")
|
||||
: [],
|
||||
);
|
||||
const sawBundleResult = toolResultText.some((text) => text.includes("FROM-BUNDLE"));
|
||||
if (!sawBundleResult) {
|
||||
stream.push({
|
||||
type: "done",
|
||||
reason: "error",
|
||||
message: {
|
||||
role: "assistant" as const,
|
||||
content: [],
|
||||
stopReason: "error" as const,
|
||||
errorMessage: "bundle MCP tool result missing from context",
|
||||
api: model.api,
|
||||
provider: model.provider,
|
||||
model: model.id,
|
||||
usage: createMockUsage(1, 0),
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
});
|
||||
stream.end();
|
||||
return;
|
||||
}
|
||||
|
||||
stream.push({
|
||||
type: "done",
|
||||
reason: "stop",
|
||||
message: buildStopMessage(model, "BUNDLE MCP OK FROM-BUNDLE"),
|
||||
});
|
||||
stream.end();
|
||||
});
|
||||
return stream;
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
let runEmbeddedPiAgent: typeof import("./pi-embedded-runner/run.js").runEmbeddedPiAgent;
|
||||
let e2eWorkspace: EmbeddedPiRunnerTestWorkspace | undefined;
|
||||
let agentDir: string;
|
||||
let workspaceDir: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
vi.useRealTimers();
|
||||
({ runEmbeddedPiAgent } = await import("./pi-embedded-runner/run.js"));
|
||||
e2eWorkspace = await createEmbeddedPiRunnerTestWorkspace("openclaw-bundle-mcp-pi-");
|
||||
({ agentDir, workspaceDir } = e2eWorkspace);
|
||||
}, 180_000);
|
||||
|
||||
afterAll(async () => {
|
||||
await cleanupEmbeddedPiRunnerTestWorkspace(e2eWorkspace);
|
||||
e2eWorkspace = undefined;
|
||||
});
|
||||
|
||||
const readSessionMessages = async (sessionFile: string) => {
|
||||
const raw = await fs.readFile(sessionFile, "utf-8");
|
||||
return raw
|
||||
.split(/\r?\n/)
|
||||
.filter(Boolean)
|
||||
.map(
|
||||
(line) =>
|
||||
JSON.parse(line) as { type?: string; message?: { role?: string; content?: unknown } },
|
||||
)
|
||||
.filter((entry) => entry.type === "message")
|
||||
.map((entry) => entry.message) as Array<{ role?: string; content?: unknown }>;
|
||||
};
|
||||
|
||||
describe("runEmbeddedPiAgent bundle MCP e2e", () => {
|
||||
it(
|
||||
"loads bundle MCP into Pi, executes the MCP tool, and includes the result in the follow-up turn",
|
||||
{ timeout: E2E_TIMEOUT_MS },
|
||||
async () => {
|
||||
streamCallCount = 0;
|
||||
observedContexts = [];
|
||||
|
||||
const sessionFile = path.join(workspaceDir, "session-bundle-mcp-e2e.jsonl");
|
||||
const pluginRoot = path.join(workspaceDir, ".openclaw", "extensions", "bundle-probe");
|
||||
const serverScriptPath = path.join(pluginRoot, "servers", "bundle-probe.mjs");
|
||||
await writeBundleProbeMcpServer(serverScriptPath);
|
||||
await writeClaudeBundle({ pluginRoot, serverScriptPath });
|
||||
|
||||
const cfg = {
|
||||
...createEmbeddedPiRunnerOpenAiConfig(["mock-bundle-mcp"]),
|
||||
plugins: {
|
||||
entries: {
|
||||
"bundle-probe": { enabled: true },
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const result = await runEmbeddedPiAgent({
|
||||
sessionId: "bundle-mcp-e2e",
|
||||
sessionKey: "agent:test:bundle-mcp-e2e",
|
||||
sessionFile,
|
||||
workspaceDir,
|
||||
config: cfg,
|
||||
prompt: "Use the bundle MCP tool and report its result.",
|
||||
provider: "openai",
|
||||
model: "mock-bundle-mcp",
|
||||
timeoutMs: 10_000,
|
||||
agentDir,
|
||||
runId: "run-bundle-mcp-e2e",
|
||||
enqueue: immediateEnqueue,
|
||||
});
|
||||
|
||||
expect(result.meta.stopReason).toBe("stop");
|
||||
expect(result.payloads?.[0]?.text).toContain("BUNDLE MCP OK FROM-BUNDLE");
|
||||
expect(streamCallCount).toBe(2);
|
||||
|
||||
const followUpContext = observedContexts[1] ?? [];
|
||||
const followUpTexts = followUpContext.flatMap((message) =>
|
||||
Array.isArray(message.content)
|
||||
? (message.content as Array<{ type?: string; text?: string }>)
|
||||
.filter((entry) => entry.type === "text" && typeof entry.text === "string")
|
||||
.map((entry) => entry.text ?? "")
|
||||
: [],
|
||||
);
|
||||
expect(followUpTexts.some((text) => text.includes("FROM-BUNDLE"))).toBe(true);
|
||||
|
||||
const messages = await readSessionMessages(sessionFile);
|
||||
const toolResults = messages.filter((message) => message?.role === "toolResult");
|
||||
const toolResultText = toolResults.flatMap((message) =>
|
||||
Array.isArray(message.content)
|
||||
? (message.content as Array<{ type?: string; text?: string }>)
|
||||
.filter((entry) => entry.type === "text" && typeof entry.text === "string")
|
||||
.map((entry) => entry.text ?? "")
|
||||
: [],
|
||||
);
|
||||
expect(toolResultText.some((text) => text.includes("FROM-BUNDLE"))).toBe(true);
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -19,11 +19,11 @@ import { createInternalHookEvent, triggerInternalHook } from "../../hooks/intern
|
||||
import { getMachineDisplayName } from "../../infra/machine-name.js";
|
||||
import { generateSecureToken } from "../../infra/secure-random.js";
|
||||
import { getMemorySearchManager } from "../../memory/index.js";
|
||||
import { resolveSignalReactionLevel } from "../../plugin-sdk-internal/signal.js";
|
||||
import { resolveSignalReactionLevel } from "../../plugin-sdk/signal.js";
|
||||
import {
|
||||
resolveTelegramInlineButtonsScope,
|
||||
resolveTelegramReactionLevel,
|
||||
} from "../../plugin-sdk-internal/telegram.js";
|
||||
} from "../../plugin-sdk/telegram.js";
|
||||
import { getGlobalHookRunner } from "../../plugins/hook-runner-global.js";
|
||||
import { prepareProviderRuntimeAuth } from "../../plugins/provider-runtime.js";
|
||||
import { type enqueueCommand, enqueueCommandInLane } from "../../process/command-queue.js";
|
||||
@@ -53,6 +53,7 @@ import { supportsModelTools } from "../model-tool-support.js";
|
||||
import { ensureOpenClawModelsJson } from "../models-config.js";
|
||||
import { createConfiguredOllamaStreamFn } from "../ollama-stream.js";
|
||||
import { resolveOwnerDisplaySetting } from "../owner-display.js";
|
||||
import { createBundleMcpToolRuntime } from "../pi-bundle-mcp-tools.js";
|
||||
import {
|
||||
ensureSessionHeader,
|
||||
validateAnthropicTurns,
|
||||
@@ -584,12 +585,24 @@ export async function compactEmbeddedPiSessionDirect(
|
||||
modelContextWindowTokens: ctxInfo.tokens,
|
||||
modelAuthMode: resolveModelAuthMode(model.provider, params.config),
|
||||
});
|
||||
const toolsEnabled = supportsModelTools(runtimeModel);
|
||||
const tools = sanitizeToolsForGoogle({
|
||||
tools: supportsModelTools(runtimeModel) ? toolsRaw : [],
|
||||
tools: toolsEnabled ? toolsRaw : [],
|
||||
provider,
|
||||
});
|
||||
const allowedToolNames = collectAllowedToolNames({ tools });
|
||||
logToolSchemasForGoogle({ tools, provider });
|
||||
const bundleMcpRuntime = toolsEnabled
|
||||
? await createBundleMcpToolRuntime({
|
||||
workspaceDir: effectiveWorkspace,
|
||||
cfg: params.config,
|
||||
reservedToolNames: tools.map((tool) => tool.name),
|
||||
})
|
||||
: undefined;
|
||||
const effectiveTools =
|
||||
bundleMcpRuntime && bundleMcpRuntime.tools.length > 0
|
||||
? [...tools, ...bundleMcpRuntime.tools]
|
||||
: tools;
|
||||
const allowedToolNames = collectAllowedToolNames({ tools: effectiveTools });
|
||||
logToolSchemasForGoogle({ tools: effectiveTools, provider });
|
||||
const machineName = await getMachineDisplayName();
|
||||
const runtimeChannel = normalizeMessageChannel(params.messageChannel ?? params.messageProvider);
|
||||
let runtimeCapabilities = runtimeChannel
|
||||
@@ -706,7 +719,7 @@ export async function compactEmbeddedPiSessionDirect(
|
||||
reactionGuidance,
|
||||
messageToolHints,
|
||||
sandboxInfo,
|
||||
tools,
|
||||
tools: effectiveTools,
|
||||
modelAliasLines: buildModelAliasLines(params.config),
|
||||
userTimezone,
|
||||
userTime,
|
||||
@@ -769,7 +782,7 @@ export async function compactEmbeddedPiSessionDirect(
|
||||
}
|
||||
|
||||
const { builtInTools, customTools } = splitSdkTools({
|
||||
tools,
|
||||
tools: effectiveTools,
|
||||
sandboxEnabled: !!sandbox?.enabled,
|
||||
});
|
||||
|
||||
@@ -1061,6 +1074,7 @@ export async function compactEmbeddedPiSessionDirect(
|
||||
clearPendingOnTimeout: true,
|
||||
});
|
||||
session.dispose();
|
||||
await bundleMcpRuntime?.dispose();
|
||||
}
|
||||
} finally {
|
||||
await sessionLock.release();
|
||||
|
||||
110
src/agents/pi-embedded-runner/extra-params.openai.test.ts
Normal file
110
src/agents/pi-embedded-runner/extra-params.openai.test.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
import type { StreamFn } from "@mariozechner/pi-agent-core";
|
||||
import type { Context, Model } from "@mariozechner/pi-ai";
|
||||
import { createAssistantMessageEventStream } from "@mariozechner/pi-ai";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { captureEnv } from "../../test-utils/env.js";
|
||||
import { applyExtraParamsToAgent } from "./extra-params.js";
|
||||
|
||||
type CapturedCall = {
|
||||
headers?: Record<string, string>;
|
||||
};
|
||||
|
||||
function applyAndCapture(params: {
|
||||
provider: string;
|
||||
modelId: string;
|
||||
baseUrl?: string;
|
||||
callerHeaders?: Record<string, string>;
|
||||
}): CapturedCall {
|
||||
const captured: CapturedCall = {};
|
||||
const baseStreamFn: StreamFn = (model, _context, options) => {
|
||||
captured.headers = options?.headers;
|
||||
options?.onPayload?.({}, model);
|
||||
return createAssistantMessageEventStream();
|
||||
};
|
||||
const agent = { streamFn: baseStreamFn };
|
||||
|
||||
applyExtraParamsToAgent(agent, undefined, params.provider, params.modelId);
|
||||
|
||||
const model = {
|
||||
api: "openai-responses",
|
||||
provider: params.provider,
|
||||
id: params.modelId,
|
||||
baseUrl: params.baseUrl,
|
||||
} as Model<"openai-responses">;
|
||||
const context: Context = { messages: [] };
|
||||
|
||||
void agent.streamFn?.(model, context, { headers: params.callerHeaders });
|
||||
|
||||
return captured;
|
||||
}
|
||||
|
||||
describe("extra-params: OpenAI attribution", () => {
|
||||
const envSnapshot = captureEnv(["OPENCLAW_VERSION"]);
|
||||
|
||||
afterEach(() => {
|
||||
envSnapshot.restore();
|
||||
});
|
||||
|
||||
it("injects originator and release-based user agent for native OpenAI", () => {
|
||||
process.env.OPENCLAW_VERSION = "2026.3.14";
|
||||
|
||||
const { headers } = applyAndCapture({
|
||||
provider: "openai",
|
||||
modelId: "gpt-5.4",
|
||||
baseUrl: "https://api.openai.com/v1",
|
||||
});
|
||||
|
||||
expect(headers).toEqual({
|
||||
originator: "openclaw",
|
||||
"User-Agent": "openclaw/2026.3.14",
|
||||
});
|
||||
});
|
||||
|
||||
it("overrides caller-supplied OpenAI attribution headers", () => {
|
||||
process.env.OPENCLAW_VERSION = "2026.3.14";
|
||||
|
||||
const { headers } = applyAndCapture({
|
||||
provider: "openai",
|
||||
modelId: "gpt-5.4",
|
||||
baseUrl: "https://api.openai.com/v1",
|
||||
callerHeaders: {
|
||||
originator: "spoofed",
|
||||
"User-Agent": "spoofed/0.0.0",
|
||||
"X-Custom": "1",
|
||||
},
|
||||
});
|
||||
|
||||
expect(headers).toEqual({
|
||||
originator: "openclaw",
|
||||
"User-Agent": "openclaw/2026.3.14",
|
||||
"X-Custom": "1",
|
||||
});
|
||||
});
|
||||
|
||||
it("does not inject attribution on non-native OpenAI-compatible base URLs", () => {
|
||||
process.env.OPENCLAW_VERSION = "2026.3.14";
|
||||
|
||||
const { headers } = applyAndCapture({
|
||||
provider: "openai",
|
||||
modelId: "gpt-5.4",
|
||||
baseUrl: "https://proxy.example.com/v1",
|
||||
});
|
||||
|
||||
expect(headers).toBeUndefined();
|
||||
});
|
||||
|
||||
it("injects attribution for ChatGPT-backed OpenAI Codex traffic", () => {
|
||||
process.env.OPENCLAW_VERSION = "2026.3.14";
|
||||
|
||||
const { headers } = applyAndCapture({
|
||||
provider: "openai-codex",
|
||||
modelId: "gpt-5.4",
|
||||
baseUrl: "https://chatgpt.com/backend-api",
|
||||
});
|
||||
|
||||
expect(headers).toEqual({
|
||||
originator: "openclaw",
|
||||
"User-Agent": "openclaw/2026.3.14",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
import {
|
||||
createCodexDefaultTransportWrapper,
|
||||
createCodexNativeWebSearchWrapper,
|
||||
createOpenAIAttributionHeadersWrapper,
|
||||
createOpenAIDefaultTransportWrapper,
|
||||
createOpenAIFastModeWrapper,
|
||||
createOpenAIResponsesContextManagementWrapper,
|
||||
@@ -266,7 +267,7 @@ function createParallelToolCallsWrapper(
|
||||
|
||||
/**
|
||||
* Apply extra params (like temperature) to an agent's streamFn.
|
||||
* Also adds OpenRouter app attribution headers when using the OpenRouter provider.
|
||||
* Also applies verified provider-specific request wrappers, such as OpenRouter attribution.
|
||||
*
|
||||
* @internal Exported for testing
|
||||
*/
|
||||
@@ -306,12 +307,15 @@ export function applyExtraParamsToAgent(
|
||||
},
|
||||
}) ?? merged;
|
||||
|
||||
if (provider === "openai-codex") {
|
||||
// Default Codex to WebSocket-first when nothing else specifies transport.
|
||||
agent.streamFn = createCodexDefaultTransportWrapper(agent.streamFn);
|
||||
} else if (provider === "openai") {
|
||||
// Default OpenAI Responses to WebSocket-first with transparent SSE fallback.
|
||||
agent.streamFn = createOpenAIDefaultTransportWrapper(agent.streamFn);
|
||||
if (provider === "openai" || provider === "openai-codex") {
|
||||
if (provider === "openai") {
|
||||
// Default OpenAI Responses to WebSocket-first with transparent SSE fallback.
|
||||
agent.streamFn = createOpenAIDefaultTransportWrapper(agent.streamFn);
|
||||
} else {
|
||||
// Default Codex to WebSocket-first when nothing else specifies transport.
|
||||
agent.streamFn = createCodexDefaultTransportWrapper(agent.streamFn);
|
||||
}
|
||||
agent.streamFn = createOpenAIAttributionHeadersWrapper(agent.streamFn);
|
||||
}
|
||||
const wrappedStreamFn = createStreamFnWithExtraParams(
|
||||
agent.streamFn,
|
||||
|
||||
@@ -1129,13 +1129,13 @@ describe("resolveModel", () => {
|
||||
|
||||
it("lets provider config override registry-found kimi user agent headers", () => {
|
||||
mockDiscoveredModel({
|
||||
provider: "kimi-coding",
|
||||
modelId: "k2p5",
|
||||
provider: "kimi",
|
||||
modelId: "kimi-code",
|
||||
templateModel: {
|
||||
...buildForwardCompatTemplate({
|
||||
id: "k2p5",
|
||||
name: "Kimi for Coding",
|
||||
provider: "kimi-coding",
|
||||
id: "kimi-code",
|
||||
name: "Kimi Code",
|
||||
provider: "kimi",
|
||||
api: "anthropic-messages",
|
||||
baseUrl: "https://api.kimi.com/coding/",
|
||||
}),
|
||||
@@ -1146,7 +1146,7 @@ describe("resolveModel", () => {
|
||||
const cfg = {
|
||||
models: {
|
||||
providers: {
|
||||
"kimi-coding": {
|
||||
kimi: {
|
||||
headers: {
|
||||
"User-Agent": "custom-kimi-client/1.0",
|
||||
"X-Kimi-Tenant": "tenant-a",
|
||||
@@ -1156,8 +1156,9 @@ describe("resolveModel", () => {
|
||||
},
|
||||
} as unknown as OpenClawConfig;
|
||||
|
||||
const result = resolveModel("kimi-coding", "k2p5", "/tmp/agent", cfg);
|
||||
const result = resolveModel("kimi", "kimi-code", "/tmp/agent", cfg);
|
||||
expect(result.error).toBeUndefined();
|
||||
expect(result.model?.id).toBe("kimi-for-coding");
|
||||
expect((result.model as unknown as { headers?: Record<string, string> }).headers).toEqual({
|
||||
"User-Agent": "custom-kimi-client/1.0",
|
||||
"X-Kimi-Tenant": "tenant-a",
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
patchCodexNativeWebSearchPayload,
|
||||
resolveCodexNativeSearchActivation,
|
||||
} from "../codex-native-web-search.js";
|
||||
import { resolveProviderAttributionHeaders } from "../provider-attribution.js";
|
||||
import { log } from "./logger.js";
|
||||
import { streamWithPayloadPatch } from "./stream-payload-utils.js";
|
||||
|
||||
@@ -47,6 +48,40 @@ function isOpenAIPublicApiBaseUrl(baseUrl: unknown): boolean {
|
||||
}
|
||||
}
|
||||
|
||||
function isOpenAICodexBaseUrl(baseUrl: unknown): boolean {
|
||||
if (typeof baseUrl !== "string" || !baseUrl.trim()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
return new URL(baseUrl).hostname.toLowerCase() === "chatgpt.com";
|
||||
} catch {
|
||||
return baseUrl.toLowerCase().includes("chatgpt.com");
|
||||
}
|
||||
}
|
||||
|
||||
function shouldApplyOpenAIAttributionHeaders(model: {
|
||||
api?: unknown;
|
||||
provider?: unknown;
|
||||
baseUrl?: unknown;
|
||||
}): "openai" | "openai-codex" | undefined {
|
||||
if (
|
||||
model.provider === "openai" &&
|
||||
(model.api === "openai-completions" || model.api === "openai-responses") &&
|
||||
isOpenAIPublicApiBaseUrl(model.baseUrl)
|
||||
) {
|
||||
return "openai";
|
||||
}
|
||||
if (
|
||||
model.provider === "openai-codex" &&
|
||||
(model.api === "openai-codex-responses" || model.api === "openai-responses") &&
|
||||
isOpenAICodexBaseUrl(model.baseUrl)
|
||||
) {
|
||||
return "openai-codex";
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function shouldForceResponsesStore(model: {
|
||||
api?: unknown;
|
||||
provider?: unknown;
|
||||
@@ -415,3 +450,22 @@ export function createOpenAIDefaultTransportWrapper(baseStreamFn: StreamFn | und
|
||||
return underlying(model, context, mergedOptions);
|
||||
};
|
||||
}
|
||||
|
||||
export function createOpenAIAttributionHeadersWrapper(
|
||||
baseStreamFn: StreamFn | undefined,
|
||||
): StreamFn {
|
||||
const underlying = baseStreamFn ?? streamSimple;
|
||||
return (model, context, options) => {
|
||||
const attributionProvider = shouldApplyOpenAIAttributionHeaders(model);
|
||||
if (!attributionProvider) {
|
||||
return underlying(model, context, options);
|
||||
}
|
||||
return underlying(model, context, {
|
||||
...options,
|
||||
headers: {
|
||||
...options?.headers,
|
||||
...resolveProviderAttributionHeaders(attributionProvider),
|
||||
},
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
38
src/agents/pi-embedded-runner/proxy-stream-wrappers.test.ts
Normal file
38
src/agents/pi-embedded-runner/proxy-stream-wrappers.test.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import type { StreamFn } from "@mariozechner/pi-agent-core";
|
||||
import type { Context, Model } from "@mariozechner/pi-ai";
|
||||
import { createAssistantMessageEventStream } from "@mariozechner/pi-ai";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { createOpenRouterWrapper } from "./proxy-stream-wrappers.js";
|
||||
|
||||
describe("proxy stream wrappers", () => {
|
||||
it("adds OpenRouter attribution headers to stream options", () => {
|
||||
const calls: Array<{ headers?: Record<string, string> }> = [];
|
||||
const baseStreamFn: StreamFn = (_model, _context, options) => {
|
||||
calls.push({
|
||||
headers: options?.headers,
|
||||
});
|
||||
return createAssistantMessageEventStream();
|
||||
};
|
||||
|
||||
const wrapped = createOpenRouterWrapper(baseStreamFn);
|
||||
const model = {
|
||||
api: "openai-completions",
|
||||
provider: "openrouter",
|
||||
id: "openrouter/auto",
|
||||
} as Model<"openai-completions">;
|
||||
const context: Context = { messages: [] };
|
||||
|
||||
void wrapped(model, context, { headers: { "X-Custom": "1" } });
|
||||
|
||||
expect(calls).toEqual([
|
||||
{
|
||||
headers: {
|
||||
"HTTP-Referer": "https://openclaw.ai",
|
||||
"X-OpenRouter-Title": "OpenClaw",
|
||||
"X-OpenRouter-Categories": "cli-agent",
|
||||
"X-Custom": "1",
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -1,11 +1,7 @@
|
||||
import type { StreamFn } from "@mariozechner/pi-agent-core";
|
||||
import { streamSimple } from "@mariozechner/pi-ai";
|
||||
import type { ThinkLevel } from "../../auto-reply/thinking.js";
|
||||
|
||||
const OPENROUTER_APP_HEADERS: Record<string, string> = {
|
||||
"HTTP-Referer": "https://openclaw.ai",
|
||||
"X-Title": "OpenClaw",
|
||||
};
|
||||
import { resolveProviderAttributionHeaders } from "../provider-attribution.js";
|
||||
const KILOCODE_FEATURE_HEADER = "X-KILOCODE-FEATURE";
|
||||
const KILOCODE_FEATURE_DEFAULT = "openclaw";
|
||||
const KILOCODE_FEATURE_ENV_VAR = "KILOCODE_FEATURE";
|
||||
@@ -105,10 +101,11 @@ export function createOpenRouterWrapper(
|
||||
const underlying = baseStreamFn ?? streamSimple;
|
||||
return (model, context, options) => {
|
||||
const onPayload = options?.onPayload;
|
||||
const attributionHeaders = resolveProviderAttributionHeaders("openrouter");
|
||||
return underlying(model, context, {
|
||||
...options,
|
||||
headers: {
|
||||
...OPENROUTER_APP_HEADERS,
|
||||
...attributionHeaders,
|
||||
...options?.headers,
|
||||
},
|
||||
onPayload: (payload) => {
|
||||
|
||||
@@ -16,11 +16,11 @@ import {
|
||||
ensureGlobalUndiciStreamTimeouts,
|
||||
} from "../../../infra/net/undici-global-dispatcher.js";
|
||||
import { MAX_IMAGE_BYTES } from "../../../media/constants.js";
|
||||
import { resolveSignalReactionLevel } from "../../../plugin-sdk-internal/signal.js";
|
||||
import { resolveSignalReactionLevel } from "../../../plugin-sdk/signal.js";
|
||||
import {
|
||||
resolveTelegramInlineButtonsScope,
|
||||
resolveTelegramReactionLevel,
|
||||
} from "../../../plugin-sdk-internal/telegram.js";
|
||||
} from "../../../plugin-sdk/telegram.js";
|
||||
import { getGlobalHookRunner } from "../../../plugins/hook-runner-global.js";
|
||||
import type {
|
||||
PluginHookAgentContext,
|
||||
@@ -59,6 +59,7 @@ import { supportsModelTools } from "../../model-tool-support.js";
|
||||
import { createConfiguredOllamaStreamFn } from "../../ollama-stream.js";
|
||||
import { createOpenAIWebSocketStreamFn, releaseWsSession } from "../../openai-ws-stream.js";
|
||||
import { resolveOwnerDisplaySetting } from "../../owner-display.js";
|
||||
import { createBundleMcpToolRuntime } from "../../pi-bundle-mcp-tools.js";
|
||||
import {
|
||||
downgradeOpenAIFunctionCallReasoningPairs,
|
||||
isCloudCodeAssistFormatError,
|
||||
@@ -1009,7 +1010,7 @@ function wrapStreamRepairMalformedToolCallArguments(
|
||||
if (!loggedRepairIndices.has(event.contentIndex)) {
|
||||
loggedRepairIndices.add(event.contentIndex);
|
||||
log.warn(
|
||||
`repairing kimi-coding tool call arguments after ${repair.trailingSuffix.length} trailing chars`,
|
||||
`repairing Kimi tool call arguments after ${repair.trailingSuffix.length} trailing chars`,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
@@ -1064,7 +1065,7 @@ export function wrapStreamFnRepairMalformedToolCallArguments(baseFn: StreamFn):
|
||||
}
|
||||
|
||||
function shouldRepairMalformedAnthropicToolCallArguments(provider?: string): boolean {
|
||||
return normalizeProviderId(provider ?? "") === "kimi-coding";
|
||||
return normalizeProviderId(provider ?? "") === "kimi";
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -1548,11 +1549,25 @@ export async function runEmbeddedAttempt(
|
||||
provider: params.provider,
|
||||
});
|
||||
const clientTools = toolsEnabled ? params.clientTools : undefined;
|
||||
const bundleMcpRuntime = toolsEnabled
|
||||
? await createBundleMcpToolRuntime({
|
||||
workspaceDir: effectiveWorkspace,
|
||||
cfg: params.config,
|
||||
reservedToolNames: [
|
||||
...tools.map((tool) => tool.name),
|
||||
...(clientTools?.map((tool) => tool.function.name) ?? []),
|
||||
],
|
||||
})
|
||||
: undefined;
|
||||
const effectiveTools =
|
||||
bundleMcpRuntime && bundleMcpRuntime.tools.length > 0
|
||||
? [...tools, ...bundleMcpRuntime.tools]
|
||||
: tools;
|
||||
const allowedToolNames = collectAllowedToolNames({
|
||||
tools,
|
||||
tools: effectiveTools,
|
||||
clientTools,
|
||||
});
|
||||
logToolSchemasForGoogle({ tools, provider: params.provider });
|
||||
logToolSchemasForGoogle({ tools: effectiveTools, provider: params.provider });
|
||||
|
||||
const machineName = await getMachineDisplayName();
|
||||
const runtimeChannel = normalizeMessageChannel(params.messageChannel ?? params.messageProvider);
|
||||
@@ -1674,7 +1689,7 @@ export async function runEmbeddedAttempt(
|
||||
runtimeInfo,
|
||||
messageToolHints,
|
||||
sandboxInfo,
|
||||
tools,
|
||||
tools: effectiveTools,
|
||||
modelAliasLines: buildModelAliasLines(params.config),
|
||||
userTimezone,
|
||||
userTime,
|
||||
@@ -1709,7 +1724,7 @@ export async function runEmbeddedAttempt(
|
||||
bootstrapFiles: hookAdjustedBootstrapFiles,
|
||||
injectedFiles: contextFiles,
|
||||
skillsPrompt,
|
||||
tools,
|
||||
tools: effectiveTools,
|
||||
});
|
||||
const systemPromptOverride = createSystemPromptOverride(appendPrompt);
|
||||
let systemPromptText = systemPromptOverride();
|
||||
@@ -1809,7 +1824,7 @@ export async function runEmbeddedAttempt(
|
||||
const hookRunner = getGlobalHookRunner();
|
||||
|
||||
const { builtInTools, customTools } = splitSdkTools({
|
||||
tools,
|
||||
tools: effectiveTools,
|
||||
sandboxEnabled: !!sandbox?.enabled,
|
||||
});
|
||||
|
||||
@@ -2870,6 +2885,7 @@ export async function runEmbeddedAttempt(
|
||||
});
|
||||
session?.dispose();
|
||||
releaseWsSession(params.sessionId);
|
||||
await bundleMcpRuntime?.dispose();
|
||||
await sessionLock.release();
|
||||
}
|
||||
} finally {
|
||||
|
||||
@@ -3,6 +3,7 @@ import { describe, expect, it } from "vitest";
|
||||
import { castAgentMessage } from "../test-helpers/agent-message-fixtures.js";
|
||||
import {
|
||||
CONTEXT_LIMIT_TRUNCATION_NOTICE,
|
||||
PREEMPTIVE_CONTEXT_OVERFLOW_MESSAGE,
|
||||
PREEMPTIVE_TOOL_RESULT_COMPACTION_PLACEHOLDER,
|
||||
installToolResultContextGuard,
|
||||
} from "./tool-result-context-guard.js";
|
||||
@@ -268,4 +269,63 @@ describe("installToolResultContextGuard", () => {
|
||||
expect(oldResult.details).toBeUndefined();
|
||||
expect(newResult.details).toBeUndefined();
|
||||
});
|
||||
|
||||
it("throws preemptive context overflow when context exceeds 90% after tool-result compaction", async () => {
|
||||
const agent = makeGuardableAgent();
|
||||
|
||||
installToolResultContextGuard({
|
||||
agent,
|
||||
// contextBudgetChars = 1000 * 4 * 0.75 = 3000
|
||||
// preemptiveOverflowChars = 1000 * 4 * 0.9 = 3600
|
||||
contextWindowTokens: 1_000,
|
||||
});
|
||||
|
||||
// Large user message (non-compactable) pushes context past 90% threshold.
|
||||
const contextForNextCall = [makeUser("u".repeat(3_700)), makeToolResult("call_1", "small")];
|
||||
|
||||
await expect(
|
||||
agent.transformContext?.(contextForNextCall, new AbortController().signal),
|
||||
).rejects.toThrow(PREEMPTIVE_CONTEXT_OVERFLOW_MESSAGE);
|
||||
});
|
||||
|
||||
it("does not throw when context is under 90% after tool-result compaction", async () => {
|
||||
const agent = makeGuardableAgent();
|
||||
|
||||
installToolResultContextGuard({
|
||||
agent,
|
||||
contextWindowTokens: 1_000,
|
||||
});
|
||||
|
||||
// Context well under the 3600-char preemptive threshold.
|
||||
const contextForNextCall = [makeUser("u".repeat(1_000)), makeToolResult("call_1", "small")];
|
||||
|
||||
await expect(
|
||||
agent.transformContext?.(contextForNextCall, new AbortController().signal),
|
||||
).resolves.not.toThrow();
|
||||
});
|
||||
|
||||
it("compacts tool results before checking the preemptive overflow threshold", async () => {
|
||||
const agent = makeGuardableAgent();
|
||||
|
||||
installToolResultContextGuard({
|
||||
agent,
|
||||
contextWindowTokens: 1_000,
|
||||
});
|
||||
|
||||
// Large user message + large tool result. The guard should compact the tool
|
||||
// result first, then check the overflow threshold. Even after compaction the
|
||||
// user content alone pushes past 90%, so the overflow error fires.
|
||||
const contextForNextCall = [
|
||||
makeUser("u".repeat(3_700)),
|
||||
makeToolResult("call_old", "x".repeat(2_000)),
|
||||
];
|
||||
|
||||
await expect(
|
||||
agent.transformContext?.(contextForNextCall, new AbortController().signal),
|
||||
).rejects.toThrow(PREEMPTIVE_CONTEXT_OVERFLOW_MESSAGE);
|
||||
|
||||
// Tool result should have been compacted before the overflow check.
|
||||
const toolResultText = getToolResultText(contextForNextCall[1]);
|
||||
expect(toolResultText).toBe(PREEMPTIVE_TOOL_RESULT_COMPACTION_PLACEHOLDER);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -14,6 +14,9 @@ import {
|
||||
// Keep a conservative input budget to absorb tokenizer variance and provider framing overhead.
|
||||
const CONTEXT_INPUT_HEADROOM_RATIO = 0.75;
|
||||
const SINGLE_TOOL_RESULT_CONTEXT_SHARE = 0.5;
|
||||
// High-water mark: if context exceeds this ratio after tool-result compaction,
|
||||
// trigger full session compaction via the existing overflow recovery cascade.
|
||||
const PREEMPTIVE_OVERFLOW_RATIO = 0.9;
|
||||
|
||||
export const CONTEXT_LIMIT_TRUNCATION_NOTICE = "[truncated: output exceeded context limit]";
|
||||
const CONTEXT_LIMIT_TRUNCATION_SUFFIX = `\n${CONTEXT_LIMIT_TRUNCATION_NOTICE}`;
|
||||
@@ -21,6 +24,9 @@ const CONTEXT_LIMIT_TRUNCATION_SUFFIX = `\n${CONTEXT_LIMIT_TRUNCATION_NOTICE}`;
|
||||
export const PREEMPTIVE_TOOL_RESULT_COMPACTION_PLACEHOLDER =
|
||||
"[compacted: tool output removed to free context]";
|
||||
|
||||
export const PREEMPTIVE_CONTEXT_OVERFLOW_MESSAGE =
|
||||
"Preemptive context overflow: estimated context size exceeds safe threshold during tool loop";
|
||||
|
||||
type GuardableTransformContext = (
|
||||
messages: AgentMessage[],
|
||||
signal: AbortSignal,
|
||||
@@ -196,6 +202,10 @@ export function installToolResultContextGuard(params: {
|
||||
contextWindowTokens * TOOL_RESULT_CHARS_PER_TOKEN_ESTIMATE * SINGLE_TOOL_RESULT_CONTEXT_SHARE,
|
||||
),
|
||||
);
|
||||
const preemptiveOverflowChars = Math.max(
|
||||
contextBudgetChars,
|
||||
Math.floor(contextWindowTokens * CHARS_PER_TOKEN_ESTIMATE * PREEMPTIVE_OVERFLOW_RATIO),
|
||||
);
|
||||
|
||||
// Agent.transformContext is private in pi-coding-agent, so access it via a
|
||||
// narrow runtime view to keep callsites type-safe while preserving behavior.
|
||||
@@ -214,6 +224,18 @@ export function installToolResultContextGuard(params: {
|
||||
maxSingleToolResultChars,
|
||||
});
|
||||
|
||||
// After tool-result compaction, check if context still exceeds the high-water mark.
|
||||
// If it does, non-tool-result content dominates and only full LLM-based session
|
||||
// compaction can reduce context size. Throwing a context overflow error triggers
|
||||
// the existing overflow recovery cascade in run.ts.
|
||||
const postEnforcementChars = estimateContextChars(
|
||||
contextMessages,
|
||||
createMessageCharEstimateCache(),
|
||||
);
|
||||
if (postEnforcementChars > preemptiveOverflowChars) {
|
||||
throw new Error(PREEMPTIVE_CONTEXT_OVERFLOW_MESSAGE);
|
||||
}
|
||||
|
||||
return contextMessages;
|
||||
}) as GuardableTransformContext;
|
||||
|
||||
|
||||
@@ -28,8 +28,6 @@ const mockSummarizeInStages = vi.mocked(compactionModule.summarizeInStages);
|
||||
const {
|
||||
collectToolFailures,
|
||||
formatToolFailuresSection,
|
||||
trimToolResultsForSummarization,
|
||||
restoreOriginalToolResultsForKeptMessages,
|
||||
splitPreservedRecentTurns,
|
||||
formatPreservedTurnsSection,
|
||||
buildCompactionStructureInstructions,
|
||||
@@ -47,26 +45,6 @@ const {
|
||||
SAFETY_MARGIN,
|
||||
} = __testing;
|
||||
|
||||
function readTextBlocks(message: AgentMessage): string {
|
||||
const content = (message as { content?: unknown }).content;
|
||||
if (typeof content === "string") {
|
||||
return content;
|
||||
}
|
||||
if (!Array.isArray(content)) {
|
||||
return "";
|
||||
}
|
||||
return content
|
||||
.map((block) => {
|
||||
if (!block || typeof block !== "object") {
|
||||
return "";
|
||||
}
|
||||
const text = (block as { text?: unknown }).text;
|
||||
return typeof text === "string" ? text : "";
|
||||
})
|
||||
.filter(Boolean)
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
function stubSessionManager(): ExtensionContext["sessionManager"] {
|
||||
const stub: ExtensionContext["sessionManager"] = {
|
||||
getCwd: () => "/stub",
|
||||
@@ -256,116 +234,6 @@ describe("compaction-safeguard tool failures", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("compaction-safeguard toolResult trimming", () => {
|
||||
it("truncates oversized tool results and compacts older entries to stay within budget", () => {
|
||||
const messages: AgentMessage[] = Array.from({ length: 9 }, (_unused, index) => ({
|
||||
role: "toolResult",
|
||||
toolCallId: `call-${index}`,
|
||||
toolName: "read",
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: `head-${index}\n${"x".repeat(25_000)}\ntail-${index}`,
|
||||
},
|
||||
],
|
||||
timestamp: index + 1,
|
||||
})) as AgentMessage[];
|
||||
|
||||
const trimmed = trimToolResultsForSummarization(messages);
|
||||
|
||||
expect(trimmed.stats.truncatedCount).toBe(9);
|
||||
expect(trimmed.stats.compactedCount).toBe(1);
|
||||
expect(readTextBlocks(trimmed.messages[0])).toBe("");
|
||||
expect(trimmed.stats.afterChars).toBeLessThan(trimmed.stats.beforeChars);
|
||||
expect(readTextBlocks(trimmed.messages[8])).toContain("head-8");
|
||||
expect(readTextBlocks(trimmed.messages[8])).toContain(
|
||||
"[...tool result truncated for compaction budget...]",
|
||||
);
|
||||
expect(readTextBlocks(trimmed.messages[8])).toContain("tail-8");
|
||||
});
|
||||
|
||||
it("restores kept tool results after prune for both toolCallId and toolUseId", () => {
|
||||
const originalMessages: AgentMessage[] = [
|
||||
{ role: "user", content: "keep these tool results", timestamp: 1 },
|
||||
{
|
||||
role: "toolResult",
|
||||
toolCallId: "call-1",
|
||||
toolName: "read",
|
||||
content: [{ type: "text", text: "original call payload" }],
|
||||
timestamp: 2,
|
||||
} as AgentMessage,
|
||||
{
|
||||
role: "toolResult",
|
||||
toolUseId: "use-1",
|
||||
toolName: "exec",
|
||||
content: [{ type: "text", text: "original use payload" }],
|
||||
timestamp: 3,
|
||||
} as unknown as AgentMessage,
|
||||
];
|
||||
const prunedMessages: AgentMessage[] = [
|
||||
originalMessages[0],
|
||||
{
|
||||
role: "toolResult",
|
||||
toolCallId: "call-1",
|
||||
toolName: "read",
|
||||
content: [{ type: "text", text: "trimmed call payload" }],
|
||||
timestamp: 2,
|
||||
} as AgentMessage,
|
||||
{
|
||||
role: "toolResult",
|
||||
toolUseId: "use-1",
|
||||
toolName: "exec",
|
||||
content: [{ type: "text", text: "trimmed use payload" }],
|
||||
timestamp: 3,
|
||||
} as unknown as AgentMessage,
|
||||
];
|
||||
|
||||
const restored = restoreOriginalToolResultsForKeptMessages({
|
||||
prunedMessages,
|
||||
originalMessages,
|
||||
});
|
||||
|
||||
expect(readTextBlocks(restored[1])).toBe("original call payload");
|
||||
expect(readTextBlocks(restored[2])).toBe("original use payload");
|
||||
});
|
||||
|
||||
it("extracts identifiers from the trimmed kept payloads after prune restore", () => {
|
||||
const hiddenIdentifier = "DEADBEEF12345678";
|
||||
const restored = restoreOriginalToolResultsForKeptMessages({
|
||||
prunedMessages: [
|
||||
{ role: "user", content: "recent ask", timestamp: 1 },
|
||||
{
|
||||
role: "toolResult",
|
||||
toolCallId: "call-1",
|
||||
toolName: "read",
|
||||
content: [{ type: "text", text: "placeholder" }],
|
||||
timestamp: 2,
|
||||
} as AgentMessage,
|
||||
],
|
||||
originalMessages: [
|
||||
{ role: "user", content: "recent ask", timestamp: 1 },
|
||||
{
|
||||
role: "toolResult",
|
||||
toolCallId: "call-1",
|
||||
toolName: "read",
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: `visible head ${"a".repeat(16_000)}${hiddenIdentifier}${"b".repeat(16_000)} visible tail`,
|
||||
},
|
||||
],
|
||||
timestamp: 2,
|
||||
} as AgentMessage,
|
||||
],
|
||||
});
|
||||
|
||||
const trimmed = trimToolResultsForSummarization(restored).messages;
|
||||
const identifierSeedText = trimmed.map((message) => readTextBlocks(message)).join("\n");
|
||||
|
||||
expect(extractOpaqueIdentifiers(identifierSeedText)).not.toContain(hiddenIdentifier);
|
||||
});
|
||||
});
|
||||
|
||||
describe("computeAdaptiveChunkRatio", () => {
|
||||
const CONTEXT_WINDOW = 200_000;
|
||||
|
||||
|
||||
@@ -407,179 +407,6 @@ function formatPreservedTurnsSection(messages: AgentMessage[]): string {
|
||||
return `\n\n## Recent turns preserved verbatim\n${lines.join("\n")}`;
|
||||
}
|
||||
|
||||
type ToolResultSummaryTrimStats = {
|
||||
truncatedCount: number;
|
||||
compactedCount: number;
|
||||
beforeChars: number;
|
||||
afterChars: number;
|
||||
};
|
||||
|
||||
const COMPACTION_SUMMARY_TOOL_RESULT_MAX_CHARS = 2_500;
|
||||
const COMPACTION_SUMMARY_TOOL_RESULT_TOTAL_CHARS_BUDGET = 20_000;
|
||||
const COMPACTION_SUMMARY_TOOL_RESULT_TRUNCATION_NOTICE =
|
||||
"[...tool result truncated for compaction budget...]";
|
||||
const COMPACTION_SUMMARY_TOOL_RESULT_COMPACTED_NOTICE =
|
||||
"[tool result compacted due to global compaction budget]";
|
||||
const COMPACTION_SUMMARY_TOOL_RESULT_NON_TEXT_NOTICE = "[non-text tool result content omitted]";
|
||||
|
||||
function getToolResultTextFromContent(content: unknown): string {
|
||||
if (!Array.isArray(content)) {
|
||||
return "";
|
||||
}
|
||||
const parts: string[] = [];
|
||||
for (const block of content) {
|
||||
if (!block || typeof block !== "object") {
|
||||
continue;
|
||||
}
|
||||
const text = (block as { text?: unknown }).text;
|
||||
if (typeof text === "string" && text.length > 0) {
|
||||
parts.push(text);
|
||||
}
|
||||
}
|
||||
return parts.join("\n");
|
||||
}
|
||||
|
||||
function hasNonTextToolResultContent(content: unknown): boolean {
|
||||
if (!Array.isArray(content)) {
|
||||
return false;
|
||||
}
|
||||
return content.some((block) => {
|
||||
if (!block || typeof block !== "object") {
|
||||
return false;
|
||||
}
|
||||
const t = (block as { type?: unknown }).type;
|
||||
return t !== "text";
|
||||
});
|
||||
}
|
||||
|
||||
function replaceToolResultContentForSummary(msg: AgentMessage, text: string): AgentMessage {
|
||||
return {
|
||||
...(msg as unknown as Record<string, unknown>),
|
||||
content: [{ type: "text", text }],
|
||||
} as AgentMessage;
|
||||
}
|
||||
|
||||
function trimToolResultsForSummarization(messages: AgentMessage[]): {
|
||||
messages: AgentMessage[];
|
||||
stats: ToolResultSummaryTrimStats;
|
||||
} {
|
||||
const next = [...messages];
|
||||
let truncatedCount = 0;
|
||||
let compactedCount = 0;
|
||||
let beforeChars = 0;
|
||||
|
||||
for (let i = 0; i < next.length; i += 1) {
|
||||
const msg = next[i];
|
||||
if ((msg as { role?: unknown }).role !== "toolResult") {
|
||||
continue;
|
||||
}
|
||||
const content = (msg as { content?: unknown }).content;
|
||||
const text = getToolResultTextFromContent(content);
|
||||
const hasNonText = hasNonTextToolResultContent(content);
|
||||
beforeChars += text.length;
|
||||
|
||||
let normalized = text;
|
||||
if (normalized.length === 0 && hasNonText) {
|
||||
normalized = COMPACTION_SUMMARY_TOOL_RESULT_NON_TEXT_NOTICE;
|
||||
}
|
||||
|
||||
if (normalized.length > COMPACTION_SUMMARY_TOOL_RESULT_MAX_CHARS) {
|
||||
const separator = `\n\n${COMPACTION_SUMMARY_TOOL_RESULT_TRUNCATION_NOTICE}\n\n`;
|
||||
const available = Math.max(0, COMPACTION_SUMMARY_TOOL_RESULT_MAX_CHARS - separator.length);
|
||||
const tailBudget = Math.floor(available * 0.35);
|
||||
const headBudget = Math.max(0, available - tailBudget);
|
||||
const head = normalized.slice(0, headBudget);
|
||||
const tail = tailBudget > 0 ? normalized.slice(-tailBudget) : "";
|
||||
normalized = `${head}${separator}${tail}`;
|
||||
truncatedCount += 1;
|
||||
}
|
||||
|
||||
if (hasNonText || normalized !== text) {
|
||||
next[i] = replaceToolResultContentForSummary(msg, normalized);
|
||||
}
|
||||
}
|
||||
|
||||
let runningChars = 0;
|
||||
for (let i = next.length - 1; i >= 0; i -= 1) {
|
||||
const msg = next[i];
|
||||
if ((msg as { role?: unknown }).role !== "toolResult") {
|
||||
continue;
|
||||
}
|
||||
const text = getToolResultTextFromContent((msg as { content?: unknown }).content);
|
||||
if (runningChars + text.length <= COMPACTION_SUMMARY_TOOL_RESULT_TOTAL_CHARS_BUDGET) {
|
||||
runningChars += text.length;
|
||||
continue;
|
||||
}
|
||||
const placeholderLen = COMPACTION_SUMMARY_TOOL_RESULT_COMPACTED_NOTICE.length;
|
||||
const remainingBudget = Math.max(
|
||||
0,
|
||||
COMPACTION_SUMMARY_TOOL_RESULT_TOTAL_CHARS_BUDGET - runningChars,
|
||||
);
|
||||
const replacementText =
|
||||
remainingBudget >= placeholderLen ? COMPACTION_SUMMARY_TOOL_RESULT_COMPACTED_NOTICE : "";
|
||||
next[i] = replaceToolResultContentForSummary(msg, replacementText);
|
||||
runningChars += replacementText.length;
|
||||
compactedCount += 1;
|
||||
}
|
||||
|
||||
let afterChars = 0;
|
||||
for (const msg of next) {
|
||||
if ((msg as { role?: unknown }).role !== "toolResult") {
|
||||
continue;
|
||||
}
|
||||
afterChars += getToolResultTextFromContent((msg as { content?: unknown }).content).length;
|
||||
}
|
||||
|
||||
return {
|
||||
messages: next,
|
||||
stats: { truncatedCount, compactedCount, beforeChars, afterChars },
|
||||
};
|
||||
}
|
||||
|
||||
function getToolResultStableId(message: AgentMessage): string | null {
|
||||
if ((message as { role?: unknown }).role !== "toolResult") {
|
||||
return null;
|
||||
}
|
||||
const toolCallId = (message as { toolCallId?: unknown }).toolCallId;
|
||||
if (typeof toolCallId === "string" && toolCallId.length > 0) {
|
||||
return `call:${toolCallId}`;
|
||||
}
|
||||
const toolUseId = (message as { toolUseId?: unknown }).toolUseId;
|
||||
if (typeof toolUseId === "string" && toolUseId.length > 0) {
|
||||
return `use:${toolUseId}`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function restoreOriginalToolResultsForKeptMessages(params: {
|
||||
prunedMessages: AgentMessage[];
|
||||
originalMessages: AgentMessage[];
|
||||
}): AgentMessage[] {
|
||||
const originalByStableId = new Map<string, AgentMessage[]>();
|
||||
for (const message of params.originalMessages) {
|
||||
const stableId = getToolResultStableId(message);
|
||||
if (!stableId) {
|
||||
continue;
|
||||
}
|
||||
const bucket = originalByStableId.get(stableId) ?? [];
|
||||
bucket.push(message);
|
||||
originalByStableId.set(stableId, bucket);
|
||||
}
|
||||
|
||||
return params.prunedMessages.map((message) => {
|
||||
const stableId = getToolResultStableId(message);
|
||||
if (!stableId) {
|
||||
return message;
|
||||
}
|
||||
const bucket = originalByStableId.get(stableId);
|
||||
if (!bucket || bucket.length === 0) {
|
||||
return message;
|
||||
}
|
||||
const restored = bucket.shift();
|
||||
return restored ?? message;
|
||||
});
|
||||
}
|
||||
|
||||
function wrapUntrustedInstructionBlock(label: string, text: string): string {
|
||||
return wrapUntrustedPromptDataBlock({
|
||||
label,
|
||||
@@ -928,18 +755,6 @@ export default function compactionSafeguardExtension(api: ExtensionAPI): void {
|
||||
const modelContextWindow = resolveContextWindowTokens(model);
|
||||
const contextWindowTokens = runtime?.contextWindowTokens ?? modelContextWindow;
|
||||
const turnPrefixMessages = preparation.turnPrefixMessages ?? [];
|
||||
const prefixTrimmedForBudget = trimToolResultsForSummarization(turnPrefixMessages);
|
||||
if (
|
||||
prefixTrimmedForBudget.stats.truncatedCount > 0 ||
|
||||
prefixTrimmedForBudget.stats.compactedCount > 0
|
||||
) {
|
||||
log.warn(
|
||||
`Compaction safeguard: pre-trimmed prefix toolResult payloads for budgeting ` +
|
||||
`(truncated=${prefixTrimmedForBudget.stats.truncatedCount}, compacted=${prefixTrimmedForBudget.stats.compactedCount}, ` +
|
||||
`chars=${prefixTrimmedForBudget.stats.beforeChars}->${prefixTrimmedForBudget.stats.afterChars})`,
|
||||
);
|
||||
}
|
||||
const prefixMessagesForSummary = prefixTrimmedForBudget.messages;
|
||||
let messagesToSummarize = preparation.messagesToSummarize;
|
||||
const recentTurnsPreserve = resolveRecentTurnsPreserve(runtime?.recentTurnsPreserve);
|
||||
const qualityGuardEnabled = runtime?.qualityGuardEnabled ?? false;
|
||||
@@ -959,44 +774,28 @@ export default function compactionSafeguardExtension(api: ExtensionAPI): void {
|
||||
let droppedSummary: string | undefined;
|
||||
|
||||
if (tokensBefore !== undefined) {
|
||||
const budgetTrimmedForSummary = trimToolResultsForSummarization(messagesToSummarize);
|
||||
if (
|
||||
budgetTrimmedForSummary.stats.truncatedCount > 0 ||
|
||||
budgetTrimmedForSummary.stats.compactedCount > 0
|
||||
) {
|
||||
log.warn(
|
||||
`Compaction safeguard: pre-trimmed toolResult payloads for budgeting ` +
|
||||
`(truncated=${budgetTrimmedForSummary.stats.truncatedCount}, compacted=${budgetTrimmedForSummary.stats.compactedCount}, ` +
|
||||
`chars=${budgetTrimmedForSummary.stats.beforeChars}->${budgetTrimmedForSummary.stats.afterChars})`,
|
||||
);
|
||||
}
|
||||
const summarizableTokens =
|
||||
estimateMessagesTokens(budgetTrimmedForSummary.messages) +
|
||||
estimateMessagesTokens(prefixMessagesForSummary);
|
||||
estimateMessagesTokens(messagesToSummarize) + estimateMessagesTokens(turnPrefixMessages);
|
||||
const newContentTokens = Math.max(0, Math.floor(tokensBefore - summarizableTokens));
|
||||
// Apply SAFETY_MARGIN so token underestimates don't trigger unnecessary pruning
|
||||
const maxHistoryTokens = Math.floor(contextWindowTokens * maxHistoryShare * SAFETY_MARGIN);
|
||||
|
||||
if (newContentTokens > maxHistoryTokens) {
|
||||
const originalMessagesBeforePrune = messagesToSummarize;
|
||||
const pruned = pruneHistoryForContextShare({
|
||||
messages: budgetTrimmedForSummary.messages,
|
||||
messages: messagesToSummarize,
|
||||
maxContextTokens: contextWindowTokens,
|
||||
maxHistoryShare,
|
||||
parts: 2,
|
||||
});
|
||||
if (pruned.droppedChunks > 0) {
|
||||
const historyRatio = (summarizableTokens / contextWindowTokens) * 100;
|
||||
const newContentRatio = (newContentTokens / contextWindowTokens) * 100;
|
||||
log.warn(
|
||||
`Compaction safeguard: summarizable history uses ${historyRatio.toFixed(
|
||||
`Compaction safeguard: new content uses ${newContentRatio.toFixed(
|
||||
1,
|
||||
)}% of context; dropped ${pruned.droppedChunks} older chunk(s) ` +
|
||||
`(${pruned.droppedMessages} messages) to fit history budget.`,
|
||||
);
|
||||
messagesToSummarize = restoreOriginalToolResultsForKeptMessages({
|
||||
prunedMessages: pruned.messages,
|
||||
originalMessages: originalMessagesBeforePrune,
|
||||
});
|
||||
messagesToSummarize = pruned.messages;
|
||||
|
||||
// Summarize dropped messages so context isn't lost
|
||||
if (pruned.droppedMessagesList.length > 0) {
|
||||
@@ -1010,19 +809,8 @@ export default function compactionSafeguardExtension(api: ExtensionAPI): void {
|
||||
Math.floor(contextWindowTokens * droppedChunkRatio) -
|
||||
SUMMARIZATION_OVERHEAD_TOKENS,
|
||||
);
|
||||
const droppedTrimmed = trimToolResultsForSummarization(pruned.droppedMessagesList);
|
||||
if (
|
||||
droppedTrimmed.stats.truncatedCount > 0 ||
|
||||
droppedTrimmed.stats.compactedCount > 0
|
||||
) {
|
||||
log.warn(
|
||||
`Compaction safeguard: trimmed dropped toolResult payloads before summarize ` +
|
||||
`(truncated=${droppedTrimmed.stats.truncatedCount}, compacted=${droppedTrimmed.stats.compactedCount}, ` +
|
||||
`chars=${droppedTrimmed.stats.beforeChars}->${droppedTrimmed.stats.afterChars})`,
|
||||
);
|
||||
}
|
||||
droppedSummary = await summarizeInStages({
|
||||
messages: droppedTrimmed.messages,
|
||||
messages: pruned.droppedMessagesList,
|
||||
model,
|
||||
apiKey,
|
||||
signal,
|
||||
@@ -1054,21 +842,8 @@ export default function compactionSafeguardExtension(api: ExtensionAPI): void {
|
||||
});
|
||||
messagesToSummarize = summaryTargetMessages;
|
||||
const preservedTurnsSection = formatPreservedTurnsSection(preservedRecentMessages);
|
||||
const latestUserAsk = extractLatestUserAsk([
|
||||
...messagesToSummarize,
|
||||
...prefixMessagesForSummary,
|
||||
]);
|
||||
const summaryTrimmed = trimToolResultsForSummarization(messagesToSummarize);
|
||||
if (summaryTrimmed.stats.truncatedCount > 0 || summaryTrimmed.stats.compactedCount > 0) {
|
||||
log.warn(
|
||||
`Compaction safeguard: trimmed toolResult payloads before summarize ` +
|
||||
`(truncated=${summaryTrimmed.stats.truncatedCount}, compacted=${summaryTrimmed.stats.compactedCount}, ` +
|
||||
`chars=${summaryTrimmed.stats.beforeChars}->${summaryTrimmed.stats.afterChars})`,
|
||||
);
|
||||
}
|
||||
|
||||
const identifierSourceMessages = [...summaryTrimmed.messages, ...prefixMessagesForSummary];
|
||||
const identifierSeedText = identifierSourceMessages
|
||||
const latestUserAsk = extractLatestUserAsk([...messagesToSummarize, ...turnPrefixMessages]);
|
||||
const identifierSeedText = [...messagesToSummarize, ...turnPrefixMessages]
|
||||
.slice(-10)
|
||||
.map((message) => extractMessageText(message))
|
||||
.filter(Boolean)
|
||||
@@ -1078,7 +853,7 @@ export default function compactionSafeguardExtension(api: ExtensionAPI): void {
|
||||
// Use adaptive chunk ratio based on message sizes, reserving headroom for
|
||||
// the summarization prompt, system prompt, previous summary, and reasoning budget
|
||||
// that generateSummary adds on top of the serialized conversation chunk.
|
||||
const allMessages = [...summaryTrimmed.messages, ...prefixMessagesForSummary];
|
||||
const allMessages = [...messagesToSummarize, ...turnPrefixMessages];
|
||||
const adaptiveRatio = computeAdaptiveChunkRatio(allMessages, contextWindowTokens);
|
||||
const maxChunkTokens = Math.max(
|
||||
1,
|
||||
@@ -1100,9 +875,9 @@ export default function compactionSafeguardExtension(api: ExtensionAPI): void {
|
||||
let summaryWithPreservedTurns = "";
|
||||
try {
|
||||
const historySummary =
|
||||
summaryTrimmed.messages.length > 0
|
||||
messagesToSummarize.length > 0
|
||||
? await summarizeInStages({
|
||||
messages: summaryTrimmed.messages,
|
||||
messages: messagesToSummarize,
|
||||
model,
|
||||
apiKey,
|
||||
signal,
|
||||
@@ -1116,9 +891,9 @@ export default function compactionSafeguardExtension(api: ExtensionAPI): void {
|
||||
: buildStructuredFallbackSummary(effectivePreviousSummary, summarizationInstructions);
|
||||
|
||||
summaryWithoutPreservedTurns = historySummary;
|
||||
if (preparation.isSplitTurn && prefixMessagesForSummary.length > 0) {
|
||||
if (preparation.isSplitTurn && turnPrefixMessages.length > 0) {
|
||||
const prefixSummary = await summarizeInStages({
|
||||
messages: prefixMessagesForSummary,
|
||||
messages: turnPrefixMessages,
|
||||
model,
|
||||
apiKey,
|
||||
signal,
|
||||
@@ -1218,8 +993,6 @@ export default function compactionSafeguardExtension(api: ExtensionAPI): void {
|
||||
export const __testing = {
|
||||
collectToolFailures,
|
||||
formatToolFailuresSection,
|
||||
trimToolResultsForSummarization,
|
||||
restoreOriginalToolResultsForKeptMessages,
|
||||
splitPreservedRecentTurns,
|
||||
formatPreservedTurnsSection,
|
||||
buildCompactionStructureInstructions,
|
||||
|
||||
@@ -79,6 +79,106 @@ describe("loadEnabledBundlePiSettingsSnapshot", () => {
|
||||
expect(snapshot.compaction?.keepRecentTokens).toBe(64_000);
|
||||
});
|
||||
|
||||
it("loads enabled bundle MCP servers into the Pi settings snapshot", async () => {
|
||||
const workspaceDir = await tempDirs.make("openclaw-workspace-");
|
||||
const pluginRoot = await tempDirs.make("openclaw-bundle-");
|
||||
await fs.mkdir(path.join(pluginRoot, ".claude-plugin"), { recursive: true });
|
||||
await fs.mkdir(path.join(pluginRoot, "servers"), { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(pluginRoot, ".claude-plugin", "plugin.json"),
|
||||
JSON.stringify({
|
||||
name: "claude-bundle",
|
||||
}),
|
||||
"utf-8",
|
||||
);
|
||||
await fs.writeFile(
|
||||
path.join(pluginRoot, ".mcp.json"),
|
||||
JSON.stringify({
|
||||
mcpServers: {
|
||||
bundleProbe: {
|
||||
command: "node",
|
||||
args: ["./servers/probe.mjs"],
|
||||
},
|
||||
},
|
||||
}),
|
||||
"utf-8",
|
||||
);
|
||||
hoisted.loadPluginManifestRegistry.mockReturnValue(
|
||||
buildRegistry({ pluginRoot, settingsFiles: [] }),
|
||||
);
|
||||
|
||||
const snapshot = loadEnabledBundlePiSettingsSnapshot({
|
||||
cwd: workspaceDir,
|
||||
cfg: {
|
||||
plugins: {
|
||||
entries: {
|
||||
"claude-bundle": { enabled: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(snapshot.mcpServers).toEqual({
|
||||
bundleProbe: {
|
||||
command: "node",
|
||||
args: [path.join(pluginRoot, "servers", "probe.mjs")],
|
||||
cwd: pluginRoot,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("lets top-level MCP config override bundle MCP defaults", async () => {
|
||||
const workspaceDir = await tempDirs.make("openclaw-workspace-");
|
||||
const pluginRoot = await tempDirs.make("openclaw-bundle-");
|
||||
await fs.mkdir(path.join(pluginRoot, ".claude-plugin"), { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(pluginRoot, ".claude-plugin", "plugin.json"),
|
||||
JSON.stringify({
|
||||
name: "claude-bundle",
|
||||
}),
|
||||
"utf-8",
|
||||
);
|
||||
await fs.writeFile(
|
||||
path.join(pluginRoot, ".mcp.json"),
|
||||
JSON.stringify({
|
||||
mcpServers: {
|
||||
sharedServer: {
|
||||
command: "node",
|
||||
args: ["./servers/bundle.mjs"],
|
||||
},
|
||||
},
|
||||
}),
|
||||
"utf-8",
|
||||
);
|
||||
hoisted.loadPluginManifestRegistry.mockReturnValue(
|
||||
buildRegistry({ pluginRoot, settingsFiles: [] }),
|
||||
);
|
||||
|
||||
const snapshot = loadEnabledBundlePiSettingsSnapshot({
|
||||
cwd: workspaceDir,
|
||||
cfg: {
|
||||
mcp: {
|
||||
servers: {
|
||||
sharedServer: {
|
||||
url: "https://example.com/mcp",
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: {
|
||||
entries: {
|
||||
"claude-bundle": { enabled: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(snapshot.mcpServers).toEqual({
|
||||
sharedServer: {
|
||||
url: "https://example.com/mcp",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("ignores disabled bundle plugins", async () => {
|
||||
const workspaceDir = await tempDirs.make("openclaw-workspace-");
|
||||
const pluginRoot = await tempDirs.make("openclaw-bundle-");
|
||||
|
||||
@@ -93,4 +93,34 @@ describe("buildEmbeddedPiSettingsSnapshot", () => {
|
||||
expect(snapshot.compaction?.reserveTokens).toBe(32_000);
|
||||
expect(snapshot.hideThinkingBlock).toBe(true);
|
||||
});
|
||||
|
||||
it("lets project Pi settings override bundle MCP defaults", () => {
|
||||
const snapshot = buildEmbeddedPiSettingsSnapshot({
|
||||
globalSettings,
|
||||
pluginSettings: {
|
||||
mcpServers: {
|
||||
bundleProbe: {
|
||||
command: "node",
|
||||
args: ["/plugins/probe.mjs"],
|
||||
},
|
||||
},
|
||||
},
|
||||
projectSettings: {
|
||||
mcpServers: {
|
||||
bundleProbe: {
|
||||
command: "deno",
|
||||
args: ["/workspace/probe.ts"],
|
||||
},
|
||||
},
|
||||
},
|
||||
policy: "sanitize",
|
||||
});
|
||||
|
||||
expect(snapshot.mcpServers).toEqual({
|
||||
bundleProbe: {
|
||||
command: "deno",
|
||||
args: ["/workspace/probe.ts"],
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -8,6 +8,7 @@ import { createSubsystemLogger } from "../logging/subsystem.js";
|
||||
import { normalizePluginsConfig, resolveEffectiveEnableState } from "../plugins/config-state.js";
|
||||
import { loadPluginManifestRegistry } from "../plugins/manifest-registry.js";
|
||||
import { isRecord } from "../utils.js";
|
||||
import { loadEmbeddedPiMcpConfig } from "./embedded-pi-mcp.js";
|
||||
import { applyPiCompactionSettingsFromConfig } from "./pi-settings.js";
|
||||
|
||||
const log = createSubsystemLogger("embedded-pi-settings");
|
||||
@@ -107,6 +108,19 @@ export function loadEnabledBundlePiSettingsSnapshot(params: {
|
||||
}
|
||||
}
|
||||
|
||||
const embeddedPiMcp = loadEmbeddedPiMcpConfig({
|
||||
workspaceDir,
|
||||
cfg: params.cfg,
|
||||
});
|
||||
for (const diagnostic of embeddedPiMcp.diagnostics) {
|
||||
log.warn(`bundle MCP skipped for ${diagnostic.pluginId}: ${diagnostic.message}`);
|
||||
}
|
||||
if (Object.keys(embeddedPiMcp.mcpServers).length > 0) {
|
||||
snapshot = applyMergePatch(snapshot, {
|
||||
mcpServers: embeddedPiMcp.mcpServers,
|
||||
}) as PiSettingsSnapshot;
|
||||
}
|
||||
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
|
||||
114
src/agents/provider-attribution.test.ts
Normal file
114
src/agents/provider-attribution.test.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
listProviderAttributionPolicies,
|
||||
resolveProviderAttributionHeaders,
|
||||
resolveProviderAttributionIdentity,
|
||||
resolveProviderAttributionPolicy,
|
||||
} from "./provider-attribution.js";
|
||||
|
||||
describe("provider attribution", () => {
|
||||
it("resolves the canonical OpenClaw product and runtime version", () => {
|
||||
const identity = resolveProviderAttributionIdentity({
|
||||
OPENCLAW_VERSION: "2026.3.99",
|
||||
});
|
||||
|
||||
expect(identity).toEqual({
|
||||
product: "OpenClaw",
|
||||
version: "2026.3.99",
|
||||
});
|
||||
});
|
||||
|
||||
it("returns a documented OpenRouter attribution policy", () => {
|
||||
const policy = resolveProviderAttributionPolicy("openrouter", {
|
||||
OPENCLAW_VERSION: "2026.3.14",
|
||||
});
|
||||
|
||||
expect(policy).toEqual({
|
||||
provider: "openrouter",
|
||||
enabledByDefault: true,
|
||||
verification: "vendor-documented",
|
||||
hook: "request-headers",
|
||||
docsUrl: "https://openrouter.ai/docs/app-attribution",
|
||||
reviewNote: "Documented app attribution headers. Verified in OpenClaw runtime wrapper.",
|
||||
product: "OpenClaw",
|
||||
version: "2026.3.14",
|
||||
headers: {
|
||||
"HTTP-Referer": "https://openclaw.ai",
|
||||
"X-OpenRouter-Title": "OpenClaw",
|
||||
"X-OpenRouter-Categories": "cli-agent",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("normalizes aliases when resolving provider headers", () => {
|
||||
expect(
|
||||
resolveProviderAttributionHeaders("OpenRouter", {
|
||||
OPENCLAW_VERSION: "2026.3.14",
|
||||
}),
|
||||
).toEqual({
|
||||
"HTTP-Referer": "https://openclaw.ai",
|
||||
"X-OpenRouter-Title": "OpenClaw",
|
||||
"X-OpenRouter-Categories": "cli-agent",
|
||||
});
|
||||
});
|
||||
|
||||
it("returns a hidden-spec OpenAI attribution policy", () => {
|
||||
expect(resolveProviderAttributionPolicy("openai", { OPENCLAW_VERSION: "2026.3.14" })).toEqual({
|
||||
provider: "openai",
|
||||
enabledByDefault: true,
|
||||
verification: "vendor-hidden-api-spec",
|
||||
hook: "request-headers",
|
||||
reviewNote:
|
||||
"OpenAI native traffic supports hidden originator/User-Agent attribution. Verified against the Codex wire contract.",
|
||||
product: "OpenClaw",
|
||||
version: "2026.3.14",
|
||||
headers: {
|
||||
originator: "openclaw",
|
||||
"User-Agent": "openclaw/2026.3.14",
|
||||
},
|
||||
});
|
||||
expect(resolveProviderAttributionHeaders("openai", { OPENCLAW_VERSION: "2026.3.14" })).toEqual({
|
||||
originator: "openclaw",
|
||||
"User-Agent": "openclaw/2026.3.14",
|
||||
});
|
||||
});
|
||||
|
||||
it("returns a hidden-spec OpenAI Codex attribution policy", () => {
|
||||
expect(
|
||||
resolveProviderAttributionPolicy("openai-codex", { OPENCLAW_VERSION: "2026.3.14" }),
|
||||
).toEqual({
|
||||
provider: "openai-codex",
|
||||
enabledByDefault: true,
|
||||
verification: "vendor-hidden-api-spec",
|
||||
hook: "request-headers",
|
||||
reviewNote:
|
||||
"OpenAI Codex ChatGPT-backed traffic supports the same hidden originator/User-Agent attribution contract.",
|
||||
product: "OpenClaw",
|
||||
version: "2026.3.14",
|
||||
headers: {
|
||||
originator: "openclaw",
|
||||
"User-Agent": "openclaw/2026.3.14",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("lists the current attribution support matrix", () => {
|
||||
expect(
|
||||
listProviderAttributionPolicies({ OPENCLAW_VERSION: "2026.3.14" }).map((policy) => [
|
||||
policy.provider,
|
||||
policy.enabledByDefault,
|
||||
policy.verification,
|
||||
policy.hook,
|
||||
]),
|
||||
).toEqual([
|
||||
["openrouter", true, "vendor-documented", "request-headers"],
|
||||
["openai", true, "vendor-hidden-api-spec", "request-headers"],
|
||||
["openai-codex", true, "vendor-hidden-api-spec", "request-headers"],
|
||||
["anthropic", false, "vendor-sdk-hook-only", "default-headers"],
|
||||
["google", false, "vendor-sdk-hook-only", "user-agent-extra"],
|
||||
["groq", false, "vendor-sdk-hook-only", "default-headers"],
|
||||
["mistral", false, "vendor-sdk-hook-only", "custom-user-agent"],
|
||||
["together", false, "vendor-sdk-hook-only", "default-headers"],
|
||||
]);
|
||||
});
|
||||
});
|
||||
174
src/agents/provider-attribution.ts
Normal file
174
src/agents/provider-attribution.ts
Normal file
@@ -0,0 +1,174 @@
|
||||
import type { RuntimeVersionEnv } from "../version.js";
|
||||
import { resolveRuntimeServiceVersion } from "../version.js";
|
||||
import { normalizeProviderId } from "./model-selection.js";
|
||||
|
||||
export type ProviderAttributionVerification =
|
||||
| "vendor-documented"
|
||||
| "vendor-hidden-api-spec"
|
||||
| "vendor-sdk-hook-only"
|
||||
| "internal-runtime";
|
||||
|
||||
export type ProviderAttributionHook =
|
||||
| "request-headers"
|
||||
| "default-headers"
|
||||
| "user-agent-extra"
|
||||
| "custom-user-agent";
|
||||
|
||||
export type ProviderAttributionPolicy = {
|
||||
provider: string;
|
||||
enabledByDefault: boolean;
|
||||
verification: ProviderAttributionVerification;
|
||||
hook?: ProviderAttributionHook;
|
||||
docsUrl?: string;
|
||||
reviewNote?: string;
|
||||
product: string;
|
||||
version: string;
|
||||
headers?: Record<string, string>;
|
||||
};
|
||||
|
||||
export type ProviderAttributionIdentity = Pick<ProviderAttributionPolicy, "product" | "version">;
|
||||
|
||||
const OPENCLAW_ATTRIBUTION_PRODUCT = "OpenClaw";
|
||||
const OPENCLAW_ATTRIBUTION_ORIGINATOR = "openclaw";
|
||||
|
||||
export function resolveProviderAttributionIdentity(
|
||||
env: RuntimeVersionEnv = process.env as RuntimeVersionEnv,
|
||||
): ProviderAttributionIdentity {
|
||||
return {
|
||||
product: OPENCLAW_ATTRIBUTION_PRODUCT,
|
||||
version: resolveRuntimeServiceVersion(env),
|
||||
};
|
||||
}
|
||||
|
||||
function buildOpenRouterAttributionPolicy(
|
||||
env: RuntimeVersionEnv = process.env as RuntimeVersionEnv,
|
||||
): ProviderAttributionPolicy {
|
||||
const identity = resolveProviderAttributionIdentity(env);
|
||||
return {
|
||||
provider: "openrouter",
|
||||
enabledByDefault: true,
|
||||
verification: "vendor-documented",
|
||||
hook: "request-headers",
|
||||
docsUrl: "https://openrouter.ai/docs/app-attribution",
|
||||
reviewNote: "Documented app attribution headers. Verified in OpenClaw runtime wrapper.",
|
||||
...identity,
|
||||
headers: {
|
||||
"HTTP-Referer": "https://openclaw.ai",
|
||||
"X-OpenRouter-Title": identity.product,
|
||||
"X-OpenRouter-Categories": "cli-agent",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function buildOpenAIAttributionPolicy(
|
||||
env: RuntimeVersionEnv = process.env as RuntimeVersionEnv,
|
||||
): ProviderAttributionPolicy {
|
||||
const identity = resolveProviderAttributionIdentity(env);
|
||||
return {
|
||||
provider: "openai",
|
||||
enabledByDefault: true,
|
||||
verification: "vendor-hidden-api-spec",
|
||||
hook: "request-headers",
|
||||
reviewNote:
|
||||
"OpenAI native traffic supports hidden originator/User-Agent attribution. Verified against the Codex wire contract.",
|
||||
...identity,
|
||||
headers: {
|
||||
originator: OPENCLAW_ATTRIBUTION_ORIGINATOR,
|
||||
"User-Agent": `${OPENCLAW_ATTRIBUTION_ORIGINATOR}/${identity.version}`,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function buildOpenAICodexAttributionPolicy(
|
||||
env: RuntimeVersionEnv = process.env as RuntimeVersionEnv,
|
||||
): ProviderAttributionPolicy {
|
||||
const identity = resolveProviderAttributionIdentity(env);
|
||||
return {
|
||||
provider: "openai-codex",
|
||||
enabledByDefault: true,
|
||||
verification: "vendor-hidden-api-spec",
|
||||
hook: "request-headers",
|
||||
reviewNote:
|
||||
"OpenAI Codex ChatGPT-backed traffic supports the same hidden originator/User-Agent attribution contract.",
|
||||
...identity,
|
||||
headers: {
|
||||
originator: OPENCLAW_ATTRIBUTION_ORIGINATOR,
|
||||
"User-Agent": `${OPENCLAW_ATTRIBUTION_ORIGINATOR}/${identity.version}`,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function buildSdkHookOnlyPolicy(
|
||||
provider: string,
|
||||
hook: ProviderAttributionHook,
|
||||
reviewNote: string,
|
||||
env: RuntimeVersionEnv = process.env as RuntimeVersionEnv,
|
||||
): ProviderAttributionPolicy {
|
||||
return {
|
||||
provider,
|
||||
enabledByDefault: false,
|
||||
verification: "vendor-sdk-hook-only",
|
||||
hook,
|
||||
reviewNote,
|
||||
...resolveProviderAttributionIdentity(env),
|
||||
};
|
||||
}
|
||||
|
||||
export function listProviderAttributionPolicies(
|
||||
env: RuntimeVersionEnv = process.env as RuntimeVersionEnv,
|
||||
): ProviderAttributionPolicy[] {
|
||||
return [
|
||||
buildOpenRouterAttributionPolicy(env),
|
||||
buildOpenAIAttributionPolicy(env),
|
||||
buildOpenAICodexAttributionPolicy(env),
|
||||
buildSdkHookOnlyPolicy(
|
||||
"anthropic",
|
||||
"default-headers",
|
||||
"Anthropic JS SDK exposes defaultHeaders, but app attribution is not yet verified.",
|
||||
env,
|
||||
),
|
||||
buildSdkHookOnlyPolicy(
|
||||
"google",
|
||||
"user-agent-extra",
|
||||
"Google GenAI JS SDK exposes userAgentExtra/httpOptions, but provider-side attribution is not yet verified.",
|
||||
env,
|
||||
),
|
||||
buildSdkHookOnlyPolicy(
|
||||
"groq",
|
||||
"default-headers",
|
||||
"Groq JS SDK exposes defaultHeaders, but app attribution is not yet verified.",
|
||||
env,
|
||||
),
|
||||
buildSdkHookOnlyPolicy(
|
||||
"mistral",
|
||||
"custom-user-agent",
|
||||
"Mistral JS SDK exposes a custom userAgent option, but app attribution is not yet verified.",
|
||||
env,
|
||||
),
|
||||
buildSdkHookOnlyPolicy(
|
||||
"together",
|
||||
"default-headers",
|
||||
"Together JS SDK exposes defaultHeaders, but app attribution is not yet verified.",
|
||||
env,
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
export function resolveProviderAttributionPolicy(
|
||||
provider?: string | null,
|
||||
env: RuntimeVersionEnv = process.env as RuntimeVersionEnv,
|
||||
): ProviderAttributionPolicy | undefined {
|
||||
const normalized = normalizeProviderId(provider ?? "");
|
||||
return listProviderAttributionPolicies(env).find((policy) => policy.provider === normalized);
|
||||
}
|
||||
|
||||
export function resolveProviderAttributionHeaders(
|
||||
provider?: string | null,
|
||||
env: RuntimeVersionEnv = process.env as RuntimeVersionEnv,
|
||||
): Record<string, string> | undefined {
|
||||
const policy = resolveProviderAttributionPolicy(provider, env);
|
||||
if (!policy?.enabledByDefault) {
|
||||
return undefined;
|
||||
}
|
||||
return policy.headers;
|
||||
}
|
||||
@@ -30,7 +30,7 @@ const resolveProviderCapabilitiesWithPluginMock = vi.fn((params: { provider: str
|
||||
geminiThoughtSignatureSanitization: true,
|
||||
geminiThoughtSignatureModelHints: ["gemini"],
|
||||
};
|
||||
case "kimi-coding":
|
||||
case "kimi":
|
||||
return {
|
||||
preserveAnthropicThinkingSignatures: false,
|
||||
};
|
||||
@@ -84,9 +84,7 @@ describe("resolveProviderCapabilities", () => {
|
||||
});
|
||||
|
||||
it("normalizes kimi aliases to the same capability set", () => {
|
||||
expect(resolveProviderCapabilities("kimi-coding")).toEqual(
|
||||
resolveProviderCapabilities("kimi-code"),
|
||||
);
|
||||
expect(resolveProviderCapabilities("kimi")).toEqual(resolveProviderCapabilities("kimi-code"));
|
||||
expect(resolveProviderCapabilities("kimi-code")).toEqual({
|
||||
anthropicToolSchemaMode: "native",
|
||||
anthropicToolChoiceMode: "native",
|
||||
@@ -131,7 +129,7 @@ describe("resolveProviderCapabilities", () => {
|
||||
});
|
||||
|
||||
it("treats kimi aliases as native anthropic tool payload providers", () => {
|
||||
expect(requiresOpenAiCompatibleAnthropicToolPayload("kimi-coding")).toBe(false);
|
||||
expect(requiresOpenAiCompatibleAnthropicToolPayload("kimi")).toBe(false);
|
||||
expect(requiresOpenAiCompatibleAnthropicToolPayload("kimi-code")).toBe(false);
|
||||
expect(requiresOpenAiCompatibleAnthropicToolPayload("anthropic")).toBe(false);
|
||||
});
|
||||
|
||||
@@ -12,8 +12,8 @@ export function normalizeProviderId(provider: string): string {
|
||||
if (normalized === "qwen") {
|
||||
return "qwen-portal";
|
||||
}
|
||||
if (normalized === "kimi-code") {
|
||||
return "kimi-coding";
|
||||
if (normalized === "kimi" || normalized === "kimi-code" || normalized === "kimi-coding") {
|
||||
return "kimi";
|
||||
}
|
||||
if (normalized === "bedrock" || normalized === "aws-bedrock") {
|
||||
return "amazon-bedrock";
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import path from "node:path";
|
||||
import { CHANNEL_IDS } from "../../channels/registry.js";
|
||||
import { CHANNEL_IDS } from "../../channels/ids.js";
|
||||
import { STATE_DIR } from "../../config/paths.js";
|
||||
|
||||
export const DEFAULT_SANDBOX_WORKSPACE_ROOT = path.join(STATE_DIR, "sandboxes");
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
import path from "node:path";
|
||||
import { isPathInside } from "../../infra/path-guards.js";
|
||||
import type { SandboxBackendCommandParams, SandboxBackendCommandResult } from "./backend.js";
|
||||
import { SANDBOX_PINNED_MUTATION_PYTHON } from "./fs-bridge-mutation-helper.js";
|
||||
import type { SandboxFsBridge, SandboxFsStat, SandboxResolvedPath } from "./fs-bridge.js";
|
||||
import {
|
||||
isPathInsideContainerRoot,
|
||||
normalizeContainerPath as normalizeSandboxContainerPath,
|
||||
} from "./path-utils.js";
|
||||
import type { SandboxContext } from "./types.js";
|
||||
|
||||
type ResolvedRemotePath = SandboxResolvedPath & {
|
||||
@@ -496,23 +501,10 @@ class RemoteShellSandboxFsBridge implements SandboxFsBridge {
|
||||
}
|
||||
|
||||
function normalizeContainerPath(value: string): string {
|
||||
const normalized = path.posix.normalize(value.trim() || "/");
|
||||
const normalized = normalizeSandboxContainerPath(value.trim() || "/");
|
||||
return normalized.startsWith("/") ? normalized : `/${normalized}`;
|
||||
}
|
||||
|
||||
function isPathInsideContainerRoot(root: string, candidate: string): boolean {
|
||||
const normalizedRoot = normalizeContainerPath(root);
|
||||
const normalizedCandidate = normalizeContainerPath(candidate);
|
||||
return (
|
||||
normalizedCandidate === normalizedRoot || normalizedCandidate.startsWith(`${normalizedRoot}/`)
|
||||
);
|
||||
}
|
||||
|
||||
function isPathInside(root: string, candidate: string): boolean {
|
||||
const relative = path.relative(root, candidate);
|
||||
return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
|
||||
}
|
||||
|
||||
function toPosixRelative(root: string, candidate: string): string {
|
||||
return path.relative(root, candidate).split(path.sep).filter(Boolean).join(path.posix.sep);
|
||||
}
|
||||
|
||||
@@ -19,8 +19,8 @@ import {
|
||||
setChannelPermissionDiscord,
|
||||
uploadEmojiDiscord,
|
||||
uploadStickerDiscord,
|
||||
} from "../../plugin-sdk-internal/discord.js";
|
||||
import { getPresence } from "../../plugin-sdk-internal/discord.js";
|
||||
} from "../../plugin-sdk/discord.js";
|
||||
import { getPresence } from "../../plugin-sdk/discord.js";
|
||||
import {
|
||||
type ActionGate,
|
||||
jsonResult,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { AgentToolResult } from "@mariozechner/pi-agent-core";
|
||||
import type { DiscordActionConfig } from "../../config/config.js";
|
||||
import type { OpenClawConfig } from "../../config/config.js";
|
||||
import { readBooleanParam } from "../../plugin-sdk/boolean-param.js";
|
||||
import {
|
||||
createThreadDiscord,
|
||||
deleteMessageDiscord,
|
||||
@@ -22,16 +23,9 @@ import {
|
||||
sendStickerDiscord,
|
||||
sendVoiceMessageDiscord,
|
||||
unpinMessageDiscord,
|
||||
} from "../../plugin-sdk-internal/discord.js";
|
||||
import type {
|
||||
DiscordSendComponents,
|
||||
DiscordSendEmbeds,
|
||||
} from "../../plugin-sdk-internal/discord.js";
|
||||
import {
|
||||
readDiscordComponentSpec,
|
||||
resolveDiscordChannelId,
|
||||
} from "../../plugin-sdk-internal/discord.js";
|
||||
import { readBooleanParam } from "../../plugin-sdk/boolean-param.js";
|
||||
} from "../../plugin-sdk/discord.js";
|
||||
import type { DiscordSendComponents, DiscordSendEmbeds } from "../../plugin-sdk/discord.js";
|
||||
import { readDiscordComponentSpec, resolveDiscordChannelId } from "../../plugin-sdk/discord.js";
|
||||
import { resolvePollMaxSelections } from "../../polls.js";
|
||||
import { withNormalizedTimestamp } from "../date-time.js";
|
||||
import { assertMediaNotDataUrl } from "../sandbox-paths.js";
|
||||
@@ -188,8 +182,8 @@ export async function handleDiscordMessagingAction(
|
||||
}
|
||||
const channelId = resolveChannelId();
|
||||
const permissions = accountId
|
||||
? await fetchChannelPermissionsDiscord(channelId, { accountId })
|
||||
: await fetchChannelPermissionsDiscord(channelId);
|
||||
? await fetchChannelPermissionsDiscord(channelId, { ...cfgOptions, accountId })
|
||||
: await fetchChannelPermissionsDiscord(channelId, cfgOptions);
|
||||
return jsonResult({ ok: true, permissions });
|
||||
}
|
||||
case "fetchMessage": {
|
||||
@@ -212,8 +206,8 @@ export async function handleDiscordMessagingAction(
|
||||
);
|
||||
}
|
||||
const message = accountId
|
||||
? await fetchMessageDiscord(channelId, messageId, { accountId })
|
||||
: await fetchMessageDiscord(channelId, messageId);
|
||||
? await fetchMessageDiscord(channelId, messageId, { ...cfgOptions, accountId })
|
||||
: await fetchMessageDiscord(channelId, messageId, cfgOptions);
|
||||
return jsonResult({
|
||||
ok: true,
|
||||
message: normalizeMessage(message),
|
||||
@@ -234,8 +228,8 @@ export async function handleDiscordMessagingAction(
|
||||
around: readStringParam(params, "around"),
|
||||
};
|
||||
const messages = accountId
|
||||
? await readMessagesDiscord(channelId, query, { accountId })
|
||||
: await readMessagesDiscord(channelId, query);
|
||||
? await readMessagesDiscord(channelId, query, { ...cfgOptions, accountId })
|
||||
: await readMessagesDiscord(channelId, query, cfgOptions);
|
||||
return jsonResult({
|
||||
ok: true,
|
||||
messages: messages.map((message) => normalizeMessage(message)),
|
||||
@@ -344,8 +338,8 @@ export async function handleDiscordMessagingAction(
|
||||
required: true,
|
||||
});
|
||||
const message = accountId
|
||||
? await editMessageDiscord(channelId, messageId, { content }, { accountId })
|
||||
: await editMessageDiscord(channelId, messageId, { content });
|
||||
? await editMessageDiscord(channelId, messageId, { content }, { ...cfgOptions, accountId })
|
||||
: await editMessageDiscord(channelId, messageId, { content }, cfgOptions);
|
||||
return jsonResult({ ok: true, message });
|
||||
}
|
||||
case "deleteMessage": {
|
||||
@@ -357,9 +351,9 @@ export async function handleDiscordMessagingAction(
|
||||
required: true,
|
||||
});
|
||||
if (accountId) {
|
||||
await deleteMessageDiscord(channelId, messageId, { accountId });
|
||||
await deleteMessageDiscord(channelId, messageId, { ...cfgOptions, accountId });
|
||||
} else {
|
||||
await deleteMessageDiscord(channelId, messageId);
|
||||
await deleteMessageDiscord(channelId, messageId, cfgOptions);
|
||||
}
|
||||
return jsonResult({ ok: true });
|
||||
}
|
||||
@@ -381,8 +375,8 @@ export async function handleDiscordMessagingAction(
|
||||
appliedTags: appliedTags ?? undefined,
|
||||
};
|
||||
const thread = accountId
|
||||
? await createThreadDiscord(channelId, payload, { accountId })
|
||||
: await createThreadDiscord(channelId, payload);
|
||||
? await createThreadDiscord(channelId, payload, { ...cfgOptions, accountId })
|
||||
: await createThreadDiscord(channelId, payload, cfgOptions);
|
||||
return jsonResult({ ok: true, thread });
|
||||
}
|
||||
case "threadList": {
|
||||
@@ -405,15 +399,18 @@ export async function handleDiscordMessagingAction(
|
||||
before,
|
||||
limit,
|
||||
},
|
||||
{ accountId },
|
||||
{ ...cfgOptions, accountId },
|
||||
)
|
||||
: await listThreadsDiscord({
|
||||
guildId,
|
||||
channelId,
|
||||
includeArchived,
|
||||
before,
|
||||
limit,
|
||||
});
|
||||
: await listThreadsDiscord(
|
||||
{
|
||||
guildId,
|
||||
channelId,
|
||||
includeArchived,
|
||||
before,
|
||||
limit,
|
||||
},
|
||||
cfgOptions,
|
||||
);
|
||||
return jsonResult({ ok: true, threads });
|
||||
}
|
||||
case "threadReply": {
|
||||
@@ -444,9 +441,9 @@ export async function handleDiscordMessagingAction(
|
||||
required: true,
|
||||
});
|
||||
if (accountId) {
|
||||
await pinMessageDiscord(channelId, messageId, { accountId });
|
||||
await pinMessageDiscord(channelId, messageId, { ...cfgOptions, accountId });
|
||||
} else {
|
||||
await pinMessageDiscord(channelId, messageId);
|
||||
await pinMessageDiscord(channelId, messageId, cfgOptions);
|
||||
}
|
||||
return jsonResult({ ok: true });
|
||||
}
|
||||
@@ -459,9 +456,9 @@ export async function handleDiscordMessagingAction(
|
||||
required: true,
|
||||
});
|
||||
if (accountId) {
|
||||
await unpinMessageDiscord(channelId, messageId, { accountId });
|
||||
await unpinMessageDiscord(channelId, messageId, { ...cfgOptions, accountId });
|
||||
} else {
|
||||
await unpinMessageDiscord(channelId, messageId);
|
||||
await unpinMessageDiscord(channelId, messageId, cfgOptions);
|
||||
}
|
||||
return jsonResult({ ok: true });
|
||||
}
|
||||
@@ -471,8 +468,8 @@ export async function handleDiscordMessagingAction(
|
||||
}
|
||||
const channelId = resolveChannelId();
|
||||
const pins = accountId
|
||||
? await listPinsDiscord(channelId, { accountId })
|
||||
: await listPinsDiscord(channelId);
|
||||
? await listPinsDiscord(channelId, { ...cfgOptions, accountId })
|
||||
: await listPinsDiscord(channelId, cfgOptions);
|
||||
return jsonResult({ ok: true, pins: pins.map((pin) => normalizeMessage(pin)) });
|
||||
}
|
||||
case "searchMessages": {
|
||||
@@ -501,15 +498,18 @@ export async function handleDiscordMessagingAction(
|
||||
authorIds: authorIdList.length ? authorIdList : undefined,
|
||||
limit,
|
||||
},
|
||||
{ accountId },
|
||||
{ ...cfgOptions, accountId },
|
||||
)
|
||||
: await searchMessagesDiscord({
|
||||
guildId,
|
||||
content,
|
||||
channelIds: channelIdList.length ? channelIdList : undefined,
|
||||
authorIds: authorIdList.length ? authorIdList : undefined,
|
||||
limit,
|
||||
});
|
||||
: await searchMessagesDiscord(
|
||||
{
|
||||
guildId,
|
||||
content,
|
||||
channelIds: channelIdList.length ? channelIdList : undefined,
|
||||
authorIds: authorIdList.length ? authorIdList : undefined,
|
||||
limit,
|
||||
},
|
||||
cfgOptions,
|
||||
);
|
||||
if (!results || typeof results !== "object") {
|
||||
return jsonResult({ ok: true, results });
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
hasAnyGuildPermissionDiscord,
|
||||
kickMemberDiscord,
|
||||
timeoutMemberDiscord,
|
||||
} from "../../plugin-sdk-internal/discord.js";
|
||||
} from "../../plugin-sdk/discord.js";
|
||||
import { type ActionGate, jsonResult, readStringParam } from "./common.js";
|
||||
import {
|
||||
isDiscordModerationAction,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { Activity, UpdatePresenceData } from "@buape/carbon/gateway";
|
||||
import type { AgentToolResult } from "@mariozechner/pi-agent-core";
|
||||
import type { DiscordActionConfig } from "../../config/config.js";
|
||||
import { getGateway } from "../../plugin-sdk-internal/discord.js";
|
||||
import { getGateway } from "../../plugin-sdk/discord.js";
|
||||
import { type ActionGate, jsonResult, readStringParam } from "./common.js";
|
||||
|
||||
const ACTIVITY_TYPE_MAP: Record<string, number> = {
|
||||
|
||||
@@ -211,6 +211,24 @@ describe("handleDiscordMessagingAction", () => {
|
||||
expect(payload.messages[0].timestampUtc).toBe(new Date(expectedMs).toISOString());
|
||||
});
|
||||
|
||||
it("threads provided cfg into readMessages calls", async () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
discord: {
|
||||
token: "token",
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
await handleDiscordMessagingAction(
|
||||
"readMessages",
|
||||
{ channelId: "C1" },
|
||||
enableAllActions,
|
||||
{},
|
||||
cfg,
|
||||
);
|
||||
expect(readMessagesDiscord).toHaveBeenCalledWith("C1", expect.any(Object), { cfg });
|
||||
});
|
||||
|
||||
it("adds normalized timestamps to fetchMessage payloads", async () => {
|
||||
fetchMessageDiscord.mockResolvedValueOnce({
|
||||
id: "1",
|
||||
@@ -229,6 +247,24 @@ describe("handleDiscordMessagingAction", () => {
|
||||
expect(payload.message?.timestampUtc).toBe(new Date(expectedMs).toISOString());
|
||||
});
|
||||
|
||||
it("threads provided cfg into fetchMessage calls", async () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
discord: {
|
||||
token: "token",
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
await handleDiscordMessagingAction(
|
||||
"fetchMessage",
|
||||
{ guildId: "G1", channelId: "C1", messageId: "M1" },
|
||||
enableAllActions,
|
||||
{},
|
||||
cfg,
|
||||
);
|
||||
expect(fetchMessageDiscord).toHaveBeenCalledWith("C1", "M1", { cfg });
|
||||
});
|
||||
|
||||
it("adds normalized timestamps to listPins payloads", async () => {
|
||||
listPinsDiscord.mockResolvedValueOnce([{ id: "1", timestamp: "2026-01-15T12:00:00.000Z" }]);
|
||||
|
||||
@@ -338,12 +374,17 @@ describe("handleDiscordMessagingAction", () => {
|
||||
},
|
||||
enableAllActions,
|
||||
);
|
||||
expect(createThreadDiscord).toHaveBeenCalledWith("C1", {
|
||||
name: "Forum thread",
|
||||
messageId: undefined,
|
||||
autoArchiveMinutes: undefined,
|
||||
content: "Initial forum post body",
|
||||
});
|
||||
expect(createThreadDiscord).toHaveBeenCalledWith(
|
||||
"C1",
|
||||
{
|
||||
name: "Forum thread",
|
||||
messageId: undefined,
|
||||
autoArchiveMinutes: undefined,
|
||||
content: "Initial forum post body",
|
||||
appliedTags: undefined,
|
||||
},
|
||||
{},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { AgentToolResult } from "@mariozechner/pi-agent-core";
|
||||
import type { OpenClawConfig } from "../../config/config.js";
|
||||
import { createDiscordActionGate } from "../../plugin-sdk-internal/discord.js";
|
||||
import { createDiscordActionGate } from "../../plugin-sdk/discord.js";
|
||||
import { readStringParam } from "./common.js";
|
||||
import { handleDiscordGuildAction } from "./discord-actions-guild.js";
|
||||
import { handleDiscordMessagingAction } from "./discord-actions-messaging.js";
|
||||
|
||||
@@ -32,6 +32,7 @@ async function withTempAgentDir<T>(run: (agentDir: string) => Promise<T>): Promi
|
||||
const ONE_PIXEL_PNG_B64 =
|
||||
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/woAAn8B9FD5fHAAAAAASUVORK5CYII=";
|
||||
const ONE_PIXEL_GIF_B64 = "R0lGODlhAQABAIABAP///wAAACwAAAAAAQABAAACAkQBADs=";
|
||||
const ONE_PIXEL_JPEG_B64 = "QUJDRA==";
|
||||
|
||||
async function withTempWorkspacePng(
|
||||
cb: (args: { workspaceDir: string; imagePath: string }) => Promise<void>,
|
||||
@@ -736,10 +737,10 @@ describe("image tool MiniMax VLM routing", () => {
|
||||
|
||||
const res = await tool.execute("t1", {
|
||||
prompt: "Compare these images.",
|
||||
images: [`data:image/png;base64,${pngB64}`, `data:image/gif;base64,${ONE_PIXEL_GIF_B64}`],
|
||||
images: [`data:image/png;base64,${pngB64}`, `data:image/jpeg;base64,${ONE_PIXEL_JPEG_B64}`],
|
||||
});
|
||||
|
||||
expect(fetch).toHaveBeenCalledTimes(1);
|
||||
expect(fetch).toHaveBeenCalledTimes(2);
|
||||
const details = res.details as
|
||||
| {
|
||||
images?: Array<{ image: string }>;
|
||||
@@ -756,12 +757,12 @@ describe("image tool MiniMax VLM routing", () => {
|
||||
image: `data:image/png;base64,${pngB64}`,
|
||||
images: [
|
||||
`data:image/png;base64,${pngB64}`,
|
||||
`data:image/gif;base64,${ONE_PIXEL_GIF_B64}`,
|
||||
`data:image/gif;base64,${ONE_PIXEL_GIF_B64}`,
|
||||
`data:image/jpeg;base64,${ONE_PIXEL_JPEG_B64}`,
|
||||
`data:image/jpeg;base64,${ONE_PIXEL_JPEG_B64}`,
|
||||
],
|
||||
});
|
||||
|
||||
expect(fetch).toHaveBeenCalledTimes(1);
|
||||
expect(fetch).toHaveBeenCalledTimes(2);
|
||||
const dedupedDetails = deduped.details as
|
||||
| {
|
||||
images?: Array<{ image: string }>;
|
||||
@@ -776,7 +777,7 @@ describe("image tool MiniMax VLM routing", () => {
|
||||
maxImages: 1,
|
||||
});
|
||||
|
||||
expect(fetch).toHaveBeenCalledTimes(1);
|
||||
expect(fetch).toHaveBeenCalledTimes(2);
|
||||
expect(tooMany.details).toMatchObject({
|
||||
error: "too_many_images",
|
||||
count: 2,
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { type Context, complete } from "@mariozechner/pi-ai";
|
||||
import { Type } from "@sinclair/typebox";
|
||||
import type { OpenClawConfig } from "../../config/config.js";
|
||||
import { getMediaUnderstandingProvider } from "../../media-understanding/providers/index.js";
|
||||
import { buildProviderRegistry } from "../../media-understanding/runner.js";
|
||||
import { loadWebMedia } from "../../plugin-sdk/web-media.js";
|
||||
import { resolveUserPath } from "../../utils.js";
|
||||
import { isMinimaxVlmModel, isMinimaxVlmProvider, minimaxUnderstandImage } from "../minimax-vlm.js";
|
||||
import { isMinimaxVlmProvider } from "../minimax-vlm.js";
|
||||
import {
|
||||
coerceImageAssistantText,
|
||||
coerceImageModelConfig,
|
||||
@@ -14,17 +15,12 @@ import {
|
||||
import {
|
||||
applyImageModelConfigDefaults,
|
||||
buildTextToolResult,
|
||||
resolveModelFromRegistry,
|
||||
resolveMediaToolLocalRoots,
|
||||
resolveModelRuntimeApiKey,
|
||||
resolvePromptAndModelOverride,
|
||||
} from "./media-tool-shared.js";
|
||||
import { hasAuthForProvider, resolveDefaultModelRef } from "./model-config.helpers.js";
|
||||
import {
|
||||
createSandboxBridgeReadFile,
|
||||
discoverAuthStorage,
|
||||
discoverModels,
|
||||
ensureOpenClawModelsJson,
|
||||
resolveSandboxedBridgeMediaPath,
|
||||
runWithImageModelFallback,
|
||||
type AnyAgentTool,
|
||||
@@ -168,27 +164,6 @@ function pickMaxBytes(cfg?: OpenClawConfig, maxBytesMb?: number): number | undef
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function buildImageContext(
|
||||
prompt: string,
|
||||
images: Array<{ base64: string; mimeType: string }>,
|
||||
): Context {
|
||||
const content: Array<
|
||||
{ type: "text"; text: string } | { type: "image"; data: string; mimeType: string }
|
||||
> = [{ type: "text", text: prompt }];
|
||||
for (const img of images) {
|
||||
content.push({ type: "image", data: img.base64, mimeType: img.mimeType });
|
||||
}
|
||||
return {
|
||||
messages: [
|
||||
{
|
||||
role: "user",
|
||||
content,
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
type ImageSandboxConfig = {
|
||||
root: string;
|
||||
bridge: SandboxFsBridge;
|
||||
@@ -200,7 +175,7 @@ async function runImagePrompt(params: {
|
||||
imageModelConfig: ImageModelConfig;
|
||||
modelOverride?: string;
|
||||
prompt: string;
|
||||
images: Array<{ base64: string; mimeType: string }>;
|
||||
images: Array<{ buffer: Buffer; mimeType: string }>;
|
||||
}): Promise<{
|
||||
text: string;
|
||||
provider: string;
|
||||
@@ -208,50 +183,75 @@ async function runImagePrompt(params: {
|
||||
attempts: Array<{ provider: string; model: string; error: string }>;
|
||||
}> {
|
||||
const effectiveCfg = applyImageModelConfigDefaults(params.cfg, params.imageModelConfig);
|
||||
|
||||
await ensureOpenClawModelsJson(effectiveCfg, params.agentDir);
|
||||
const authStorage = discoverAuthStorage(params.agentDir);
|
||||
const modelRegistry = discoverModels(authStorage, params.agentDir);
|
||||
const providerCfg: OpenClawConfig = effectiveCfg ?? {};
|
||||
const providerRegistry = buildProviderRegistry(undefined, providerCfg);
|
||||
|
||||
const result = await runWithImageModelFallback({
|
||||
cfg: effectiveCfg,
|
||||
modelOverride: params.modelOverride,
|
||||
run: async (provider, modelId) => {
|
||||
const model = resolveModelFromRegistry({ modelRegistry, provider, modelId });
|
||||
if (!model.input?.includes("image")) {
|
||||
throw new Error(`Model does not support images: ${provider}/${modelId}`);
|
||||
const imageProvider = getMediaUnderstandingProvider(provider, providerRegistry);
|
||||
if (!imageProvider) {
|
||||
throw new Error(`No media-understanding provider registered for ${provider}`);
|
||||
}
|
||||
const apiKey = await resolveModelRuntimeApiKey({
|
||||
model,
|
||||
cfg: effectiveCfg,
|
||||
agentDir: params.agentDir,
|
||||
authStorage,
|
||||
});
|
||||
|
||||
// MiniMax VLM only supports a single image; use the first one.
|
||||
if (isMinimaxVlmModel(model.provider, model.id)) {
|
||||
const first = params.images[0];
|
||||
const imageDataUrl = `data:${first.mimeType};base64,${first.base64}`;
|
||||
const text = await minimaxUnderstandImage({
|
||||
apiKey,
|
||||
if (params.images.length > 1 && imageProvider.describeImages) {
|
||||
const described = await imageProvider.describeImages({
|
||||
images: params.images.map((image, index) => ({
|
||||
buffer: image.buffer,
|
||||
fileName: `image-${index + 1}`,
|
||||
mime: image.mimeType,
|
||||
})),
|
||||
provider,
|
||||
model: modelId,
|
||||
prompt: params.prompt,
|
||||
imageDataUrl,
|
||||
modelBaseUrl: model.baseUrl,
|
||||
maxTokens: resolveImageToolMaxTokens(undefined),
|
||||
timeoutMs: 30_000,
|
||||
cfg: providerCfg,
|
||||
agentDir: params.agentDir,
|
||||
});
|
||||
return { text, provider: model.provider, model: model.id };
|
||||
return { text: described.text, provider, model: described.model ?? modelId };
|
||||
}
|
||||
if (!imageProvider.describeImage) {
|
||||
throw new Error(`Provider does not support image analysis: ${provider}`);
|
||||
}
|
||||
if (params.images.length === 1) {
|
||||
const image = params.images[0];
|
||||
const described = await imageProvider.describeImage({
|
||||
buffer: image.buffer,
|
||||
fileName: "image-1",
|
||||
mime: image.mimeType,
|
||||
provider,
|
||||
model: modelId,
|
||||
prompt: params.prompt,
|
||||
maxTokens: resolveImageToolMaxTokens(undefined),
|
||||
timeoutMs: 30_000,
|
||||
cfg: providerCfg,
|
||||
agentDir: params.agentDir,
|
||||
});
|
||||
return { text: described.text, provider, model: described.model ?? modelId };
|
||||
}
|
||||
|
||||
const context = buildImageContext(params.prompt, params.images);
|
||||
const message = await complete(model, context, {
|
||||
apiKey,
|
||||
maxTokens: resolveImageToolMaxTokens(model.maxTokens),
|
||||
});
|
||||
const text = coerceImageAssistantText({
|
||||
message,
|
||||
provider: model.provider,
|
||||
model: model.id,
|
||||
});
|
||||
return { text, provider: model.provider, model: model.id };
|
||||
const parts: string[] = [];
|
||||
for (const [index, image] of params.images.entries()) {
|
||||
const described = await imageProvider.describeImage({
|
||||
buffer: image.buffer,
|
||||
fileName: `image-${index + 1}`,
|
||||
mime: image.mimeType,
|
||||
provider,
|
||||
model: modelId,
|
||||
prompt: `${params.prompt}\n\nDescribe image ${index + 1} of ${params.images.length}.`,
|
||||
maxTokens: resolveImageToolMaxTokens(undefined),
|
||||
timeoutMs: 30_000,
|
||||
cfg: providerCfg,
|
||||
agentDir: params.agentDir,
|
||||
});
|
||||
parts.push(`Image ${index + 1}:\n${described.text.trim()}`);
|
||||
}
|
||||
return {
|
||||
text: parts.join("\n\n").trim(),
|
||||
provider,
|
||||
model: modelId,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
@@ -383,7 +383,7 @@ export function createImageTool(options?: {
|
||||
|
||||
// MARK: - Load and resolve each image
|
||||
const loadedImages: Array<{
|
||||
base64: string;
|
||||
buffer: Buffer;
|
||||
mimeType: string;
|
||||
resolvedImage: string;
|
||||
rewrittenFrom?: string;
|
||||
@@ -469,9 +469,8 @@ export function createImageTool(options?: {
|
||||
("contentType" in media && media.contentType) ||
|
||||
("mimeType" in media && media.mimeType) ||
|
||||
"image/png";
|
||||
const base64 = media.buffer.toString("base64");
|
||||
loadedImages.push({
|
||||
base64,
|
||||
buffer: media.buffer,
|
||||
mimeType,
|
||||
resolvedImage,
|
||||
...(resolvedPathInfo.rewrittenFrom
|
||||
@@ -487,7 +486,7 @@ export function createImageTool(options?: {
|
||||
imageModelConfig,
|
||||
modelOverride,
|
||||
prompt: promptRaw,
|
||||
images: loadedImages.map((img) => ({ base64: img.base64, mimeType: img.mimeType })),
|
||||
images: loadedImages.map((img) => ({ buffer: img.buffer, mimeType: img.mimeType })),
|
||||
});
|
||||
|
||||
const imageDetails =
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { ChannelMessageCapability } from "../../channels/plugins/message-capabilities.js";
|
||||
import type { ChannelMessageActionName, ChannelPlugin } from "../../channels/plugins/types.js";
|
||||
import type { MessageActionRunResult } from "../../infra/outbound/message-action-runner.js";
|
||||
@@ -8,6 +8,11 @@ import { createMessageTool } from "./message-tool.js";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
runMessageAction: vi.fn(),
|
||||
loadConfig: vi.fn(() => ({})),
|
||||
resolveCommandSecretRefsViaGateway: vi.fn(async ({ config }: { config: unknown }) => ({
|
||||
resolvedConfig: config,
|
||||
diagnostics: [],
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock("../../infra/outbound/message-action-runner.js", async () => {
|
||||
@@ -20,6 +25,18 @@ vi.mock("../../infra/outbound/message-action-runner.js", async () => {
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../../config/config.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../../config/config.js")>();
|
||||
return {
|
||||
...actual,
|
||||
loadConfig: mocks.loadConfig,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../../cli/command-secret-gateway.js", () => ({
|
||||
resolveCommandSecretRefsViaGateway: mocks.resolveCommandSecretRefsViaGateway,
|
||||
}));
|
||||
|
||||
function mockSendResult(overrides: { channel?: string; to?: string } = {}) {
|
||||
mocks.runMessageAction.mockClear();
|
||||
mocks.runMessageAction.mockResolvedValue({
|
||||
@@ -41,6 +58,15 @@ function getActionEnum(properties: Record<string, unknown>) {
|
||||
return (properties.action as { enum?: string[] } | undefined)?.enum ?? [];
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
mocks.runMessageAction.mockReset();
|
||||
mocks.loadConfig.mockReset().mockReturnValue({});
|
||||
mocks.resolveCommandSecretRefsViaGateway.mockReset().mockImplementation(async ({ config }) => ({
|
||||
resolvedConfig: config,
|
||||
diagnostics: [],
|
||||
}));
|
||||
});
|
||||
|
||||
function createChannelPlugin(params: {
|
||||
id: string;
|
||||
label: string;
|
||||
@@ -101,6 +127,49 @@ async function executeSend(params: {
|
||||
| undefined;
|
||||
}
|
||||
|
||||
describe("message tool secret scoping", () => {
|
||||
it("scopes command-time secret resolution to the selected channel/account", async () => {
|
||||
mockSendResult({ channel: "discord", to: "discord:123" });
|
||||
mocks.loadConfig.mockReturnValue({
|
||||
channels: {
|
||||
discord: {
|
||||
token: { source: "env", provider: "default", id: "DISCORD_TOKEN" },
|
||||
accounts: {
|
||||
ops: { token: { source: "env", provider: "default", id: "DISCORD_OPS_TOKEN" } },
|
||||
chat: { token: { source: "env", provider: "default", id: "DISCORD_CHAT_TOKEN" } },
|
||||
},
|
||||
},
|
||||
slack: {
|
||||
botToken: { source: "env", provider: "default", id: "SLACK_BOT_TOKEN" },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const tool = createMessageTool({
|
||||
currentChannelProvider: "discord",
|
||||
agentAccountId: "ops",
|
||||
});
|
||||
|
||||
await tool.execute("1", {
|
||||
action: "send",
|
||||
target: "channel:123",
|
||||
message: "hi",
|
||||
});
|
||||
|
||||
const secretResolveCall = mocks.resolveCommandSecretRefsViaGateway.mock.calls[0]?.[0] as {
|
||||
targetIds?: Set<string>;
|
||||
allowedPaths?: Set<string>;
|
||||
};
|
||||
expect(secretResolveCall.targetIds).toBeInstanceOf(Set);
|
||||
expect(
|
||||
[...(secretResolveCall.targetIds ?? [])].every((id) => id.startsWith("channels.discord.")),
|
||||
).toBe(true);
|
||||
expect(secretResolveCall.allowedPaths).toEqual(
|
||||
new Set(["channels.discord.token", "channels.discord.accounts.ops.token"]),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("message tool agent routing", () => {
|
||||
it("derives agentId from the session key", async () => {
|
||||
mockSendResult();
|
||||
|
||||
@@ -12,7 +12,8 @@ import {
|
||||
type ChannelMessageActionName,
|
||||
} from "../../channels/plugins/types.js";
|
||||
import { resolveCommandSecretRefsViaGateway } from "../../cli/command-secret-gateway.js";
|
||||
import { getChannelsCommandSecretTargetIds } from "../../cli/command-secret-targets.js";
|
||||
import { getScopedChannelsCommandSecretTargets } from "../../cli/command-secret-targets.js";
|
||||
import { resolveMessageSecretScope } from "../../cli/message-secret-scope.js";
|
||||
import type { OpenClawConfig } from "../../config/config.js";
|
||||
import { loadConfig } from "../../config/config.js";
|
||||
import { GATEWAY_CLIENT_IDS, GATEWAY_CLIENT_MODES } from "../../gateway/protocol/client-info.js";
|
||||
@@ -820,19 +821,35 @@ export function createMessageTool(options?: MessageToolOptions): AnyAgentTool {
|
||||
}
|
||||
}
|
||||
|
||||
const cfg = options?.config
|
||||
? options.config
|
||||
: (
|
||||
await resolveCommandSecretRefsViaGateway({
|
||||
config: loadConfig(),
|
||||
commandName: "tools.message",
|
||||
targetIds: getChannelsCommandSecretTargetIds(),
|
||||
mode: "enforce_resolved",
|
||||
})
|
||||
).resolvedConfig;
|
||||
const action = readStringParam(params, "action", {
|
||||
required: true,
|
||||
}) as ChannelMessageActionName;
|
||||
let cfg = options?.config;
|
||||
if (!cfg) {
|
||||
const loadedRaw = loadConfig();
|
||||
const scope = resolveMessageSecretScope({
|
||||
channel: params.channel,
|
||||
target: params.target,
|
||||
targets: params.targets,
|
||||
fallbackChannel: options?.currentChannelProvider,
|
||||
accountId: params.accountId,
|
||||
fallbackAccountId: agentAccountId,
|
||||
});
|
||||
const scopedTargets = getScopedChannelsCommandSecretTargets({
|
||||
config: loadedRaw,
|
||||
channel: scope.channel,
|
||||
accountId: scope.accountId,
|
||||
});
|
||||
cfg = (
|
||||
await resolveCommandSecretRefsViaGateway({
|
||||
config: loadedRaw,
|
||||
commandName: "tools.message",
|
||||
targetIds: scopedTargets.targetIds,
|
||||
...(scopedTargets.allowedPaths ? { allowedPaths: scopedTargets.allowedPaths } : {}),
|
||||
mode: "enforce_resolved",
|
||||
})
|
||||
).resolvedConfig;
|
||||
}
|
||||
const requireExplicitTarget = options?.requireExplicitTarget === true;
|
||||
if (requireExplicitTarget && actionNeedsExplicitTarget(action)) {
|
||||
const explicitTarget =
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { AgentToolResult } from "@mariozechner/pi-agent-core";
|
||||
import type { OpenClawConfig } from "../../config/config.js";
|
||||
import { resolveSlackAccount } from "../../plugin-sdk/account-resolution.js";
|
||||
import {
|
||||
deleteSlackMessage,
|
||||
downloadSlackFile,
|
||||
@@ -15,14 +16,13 @@ import {
|
||||
removeSlackReaction,
|
||||
sendSlackMessage,
|
||||
unpinSlackMessage,
|
||||
} from "../../plugin-sdk-internal/slack.js";
|
||||
} from "../../plugin-sdk/slack.js";
|
||||
import {
|
||||
parseSlackBlocksInput,
|
||||
parseSlackTarget,
|
||||
recordSlackThreadParticipation,
|
||||
resolveSlackAccount,
|
||||
resolveSlackChannelId,
|
||||
} from "../../plugin-sdk-internal/slack.js";
|
||||
} from "../../plugin-sdk/slack.js";
|
||||
import { withNormalizedTimestamp } from "../date-time.js";
|
||||
import {
|
||||
createActionGate,
|
||||
|
||||
@@ -1,17 +1,15 @@
|
||||
import type { AgentToolResult } from "@mariozechner/pi-agent-core";
|
||||
import type { OpenClawConfig } from "../../config/config.js";
|
||||
import { readBooleanParam } from "../../plugin-sdk/boolean-param.js";
|
||||
import {
|
||||
createTelegramActionGate,
|
||||
resolveTelegramPollActionGateState,
|
||||
} from "../../plugin-sdk-internal/telegram.js";
|
||||
import type {
|
||||
TelegramButtonStyle,
|
||||
TelegramInlineButtons,
|
||||
} from "../../plugin-sdk-internal/telegram.js";
|
||||
} from "../../plugin-sdk/telegram.js";
|
||||
import type { TelegramButtonStyle, TelegramInlineButtons } from "../../plugin-sdk/telegram.js";
|
||||
import {
|
||||
resolveTelegramInlineButtonsScope,
|
||||
resolveTelegramTargetChatType,
|
||||
} from "../../plugin-sdk-internal/telegram.js";
|
||||
} from "../../plugin-sdk/telegram.js";
|
||||
import {
|
||||
createForumTopicTelegram,
|
||||
deleteMessageTelegram,
|
||||
@@ -21,14 +19,13 @@ import {
|
||||
sendMessageTelegram,
|
||||
sendPollTelegram,
|
||||
sendStickerTelegram,
|
||||
} from "../../plugin-sdk-internal/telegram.js";
|
||||
} from "../../plugin-sdk/telegram.js";
|
||||
import {
|
||||
getCacheStats,
|
||||
resolveTelegramReactionLevel,
|
||||
resolveTelegramToken,
|
||||
searchStickers,
|
||||
} from "../../plugin-sdk-internal/telegram.js";
|
||||
import { readBooleanParam } from "../../plugin-sdk/boolean-param.js";
|
||||
} from "../../plugin-sdk/telegram.js";
|
||||
import { resolvePollMaxSelections } from "../../polls.js";
|
||||
import {
|
||||
jsonResult,
|
||||
|
||||
@@ -1,148 +1,29 @@
|
||||
import type { OpenClawConfig } from "../../config/config.js";
|
||||
import { normalizeResolvedSecretInputString } from "../../config/types.secrets.js";
|
||||
import { logVerbose } from "../../globals.js";
|
||||
import { resolvePluginWebSearchProviders } from "../../plugins/web-search-providers.js";
|
||||
import type { RuntimeWebSearchMetadata } from "../../secrets/runtime-web-tools.types.js";
|
||||
import { normalizeSecretInput } from "../../utils/normalize-secret-input.js";
|
||||
import { __testing as runtimeTesting } from "../../web-search/runtime.js";
|
||||
import type { AnyAgentTool } from "./common.js";
|
||||
import { jsonResult } from "./common.js";
|
||||
import { __testing as coreTesting } from "./web-search-core.js";
|
||||
|
||||
type WebSearchConfig = NonNullable<OpenClawConfig["tools"]>["web"] extends infer Web
|
||||
? Web extends { search?: infer Search }
|
||||
? Search
|
||||
: undefined
|
||||
: undefined;
|
||||
|
||||
function resolveSearchConfig(cfg?: OpenClawConfig): WebSearchConfig {
|
||||
const search = cfg?.tools?.web?.search;
|
||||
if (!search || typeof search !== "object") {
|
||||
return undefined;
|
||||
}
|
||||
return search as WebSearchConfig;
|
||||
}
|
||||
|
||||
function resolveSearchEnabled(params: { search?: WebSearchConfig; sandboxed?: boolean }): boolean {
|
||||
if (typeof params.search?.enabled === "boolean") {
|
||||
return params.search.enabled;
|
||||
}
|
||||
if (params.sandboxed) {
|
||||
return true;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function readProviderEnvValue(envVars: string[]): string | undefined {
|
||||
for (const envVar of envVars) {
|
||||
const value = normalizeSecretInput(process.env[envVar]);
|
||||
if (value) {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function hasProviderCredential(providerId: string, search: WebSearchConfig | undefined): boolean {
|
||||
const providers = resolvePluginWebSearchProviders({
|
||||
bundledAllowlistCompat: true,
|
||||
});
|
||||
const provider = providers.find((entry) => entry.id === providerId);
|
||||
if (!provider) {
|
||||
return false;
|
||||
}
|
||||
const rawValue = provider.getCredentialValue(search as Record<string, unknown> | undefined);
|
||||
const fromConfig = normalizeSecretInput(
|
||||
normalizeResolvedSecretInputString({
|
||||
value: rawValue,
|
||||
path:
|
||||
providerId === "brave"
|
||||
? "tools.web.search.apiKey"
|
||||
: `tools.web.search.${providerId}.apiKey`,
|
||||
}),
|
||||
);
|
||||
return Boolean(fromConfig || readProviderEnvValue(provider.envVars));
|
||||
}
|
||||
|
||||
function resolveSearchProvider(search?: WebSearchConfig): string {
|
||||
const providers = resolvePluginWebSearchProviders({
|
||||
bundledAllowlistCompat: true,
|
||||
});
|
||||
const raw =
|
||||
search && "provider" in search && typeof search.provider === "string"
|
||||
? search.provider.trim().toLowerCase()
|
||||
: "";
|
||||
|
||||
if (raw) {
|
||||
const explicit = providers.find((provider) => provider.id === raw);
|
||||
if (explicit) {
|
||||
return explicit.id;
|
||||
}
|
||||
}
|
||||
|
||||
if (!raw) {
|
||||
for (const provider of providers) {
|
||||
if (!hasProviderCredential(provider.id, search)) {
|
||||
continue;
|
||||
}
|
||||
logVerbose(
|
||||
`web_search: no provider configured, auto-detected "${provider.id}" from available API keys`,
|
||||
);
|
||||
return provider.id;
|
||||
}
|
||||
}
|
||||
|
||||
return providers[0]?.id ?? "brave";
|
||||
}
|
||||
import {
|
||||
__testing as coreTesting,
|
||||
createWebSearchTool as createWebSearchToolCore,
|
||||
} from "./web-search-core.js";
|
||||
|
||||
export function createWebSearchTool(options?: {
|
||||
config?: OpenClawConfig;
|
||||
sandboxed?: boolean;
|
||||
runtimeWebSearch?: RuntimeWebSearchMetadata;
|
||||
}): AnyAgentTool | null {
|
||||
const search = resolveSearchConfig(options?.config);
|
||||
if (!resolveSearchEnabled({ search, sandboxed: options?.sandboxed })) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const providers = resolvePluginWebSearchProviders({
|
||||
config: options?.config,
|
||||
bundledAllowlistCompat: true,
|
||||
});
|
||||
if (providers.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const providerId =
|
||||
options?.runtimeWebSearch?.selectedProvider ??
|
||||
options?.runtimeWebSearch?.providerConfigured ??
|
||||
resolveSearchProvider(search);
|
||||
const provider =
|
||||
providers.find((entry) => entry.id === providerId) ??
|
||||
providers.find((entry) => entry.id === resolveSearchProvider(search)) ??
|
||||
providers[0];
|
||||
if (!provider) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const definition = provider.createTool({
|
||||
config: options?.config,
|
||||
searchConfig: search as Record<string, unknown> | undefined,
|
||||
runtimeMetadata: options?.runtimeWebSearch,
|
||||
});
|
||||
if (!definition) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
label: "Web Search",
|
||||
name: "web_search",
|
||||
description: definition.description,
|
||||
parameters: definition.parameters,
|
||||
execute: async (_toolCallId, args) => jsonResult(await definition.execute(args)),
|
||||
};
|
||||
return createWebSearchToolCore(options);
|
||||
}
|
||||
|
||||
export const __testing = {
|
||||
...coreTesting,
|
||||
resolveSearchProvider,
|
||||
resolveSearchProvider: (
|
||||
search?: OpenClawConfig["tools"] extends infer Tools
|
||||
? Tools extends { web?: infer Web }
|
||||
? Web extends { search?: infer Search }
|
||||
? Search
|
||||
: undefined
|
||||
: undefined
|
||||
: undefined,
|
||||
) => runtimeTesting.resolveWebSearchProviderId({ search }),
|
||||
};
|
||||
|
||||
@@ -325,9 +325,16 @@ describe("web_search provider proxy dispatch", () => {
|
||||
|
||||
describe("web_search perplexity Search API", () => {
|
||||
const priorFetch = global.fetch;
|
||||
const savedEnv = { ...process.env };
|
||||
|
||||
beforeEach(() => {
|
||||
delete process.env.PERPLEXITY_API_KEY;
|
||||
delete process.env.OPENROUTER_API_KEY;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
process.env = { ...savedEnv };
|
||||
global.fetch = priorFetch;
|
||||
webSearchTesting.SEARCH_CACHE.clear();
|
||||
});
|
||||
@@ -462,9 +469,16 @@ describe("web_search perplexity Search API", () => {
|
||||
|
||||
describe("web_search perplexity OpenRouter compatibility", () => {
|
||||
const priorFetch = global.fetch;
|
||||
const savedEnv = { ...process.env };
|
||||
|
||||
beforeEach(() => {
|
||||
delete process.env.PERPLEXITY_API_KEY;
|
||||
delete process.env.OPENROUTER_API_KEY;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
process.env = { ...savedEnv };
|
||||
global.fetch = priorFetch;
|
||||
webSearchTesting.SEARCH_CACHE.clear();
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { AgentToolResult } from "@mariozechner/pi-agent-core";
|
||||
import type { OpenClawConfig } from "../../config/config.js";
|
||||
import { sendReactionWhatsApp } from "../../plugin-sdk-internal/whatsapp.js";
|
||||
import { sendReactionWhatsApp } from "../../plugin-sdk/whatsapp.js";
|
||||
import { createActionGate, jsonResult, readReactionParams, readStringParam } from "./common.js";
|
||||
import { resolveAuthorizedWhatsAppOutboundTarget } from "./whatsapp-target-auth.js";
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { OpenClawConfig } from "../../config/config.js";
|
||||
import { resolveWhatsAppAccount } from "../../plugin-sdk-internal/whatsapp.js";
|
||||
import { resolveWhatsAppAccount } from "../../plugin-sdk/whatsapp.js";
|
||||
import { resolveWhatsAppOutboundTarget } from "../../whatsapp/resolve-outbound-target.js";
|
||||
import { ToolAuthorizationError } from "./common.js";
|
||||
|
||||
|
||||
@@ -114,16 +114,16 @@ describe("resolveTranscriptPolicy", () => {
|
||||
preserveSignatures: false,
|
||||
},
|
||||
{
|
||||
title: "kimi-coding provider",
|
||||
provider: "kimi-coding",
|
||||
modelId: "k2p5",
|
||||
title: "Kimi provider",
|
||||
provider: "kimi",
|
||||
modelId: "kimi-code",
|
||||
modelApi: "anthropic-messages" as const,
|
||||
preserveSignatures: false,
|
||||
},
|
||||
{
|
||||
title: "kimi-code alias",
|
||||
provider: "kimi-code",
|
||||
modelId: "k2p5",
|
||||
modelId: "kimi-code",
|
||||
modelApi: "anthropic-messages" as const,
|
||||
preserveSignatures: false,
|
||||
},
|
||||
|
||||
@@ -51,6 +51,16 @@ const formatConfigArgs: CommandArgsFormatter = (values) =>
|
||||
},
|
||||
});
|
||||
|
||||
const formatMcpArgs: CommandArgsFormatter = (values) =>
|
||||
formatActionArgs(values, {
|
||||
formatKnownAction: (action, path) => {
|
||||
if (action === "show" || action === "get") {
|
||||
return path ? `${action} ${path}` : action;
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
});
|
||||
|
||||
const formatDebugArgs: CommandArgsFormatter = (values) =>
|
||||
formatActionArgs(values, {
|
||||
formatKnownAction: (action) => {
|
||||
@@ -124,6 +134,7 @@ const formatExecArgs: CommandArgsFormatter = (values) => {
|
||||
|
||||
export const COMMAND_ARG_FORMATTERS: Record<string, CommandArgsFormatter> = {
|
||||
config: formatConfigArgs,
|
||||
mcp: formatMcpArgs,
|
||||
debug: formatDebugArgs,
|
||||
queue: formatQueueArgs,
|
||||
exec: formatExecArgs,
|
||||
|
||||
@@ -452,6 +452,34 @@ function buildChatCommands(): ChatCommandDefinition[] {
|
||||
argsParsing: "none",
|
||||
formatArgs: COMMAND_ARG_FORMATTERS.config,
|
||||
}),
|
||||
defineChatCommand({
|
||||
key: "mcp",
|
||||
nativeName: "mcp",
|
||||
description: "Show or set embedded Pi MCP servers.",
|
||||
textAlias: "/mcp",
|
||||
category: "management",
|
||||
args: [
|
||||
{
|
||||
name: "action",
|
||||
description: "show | get | set | unset",
|
||||
type: "string",
|
||||
choices: ["show", "get", "set", "unset"],
|
||||
},
|
||||
{
|
||||
name: "path",
|
||||
description: "MCP server name",
|
||||
type: "string",
|
||||
},
|
||||
{
|
||||
name: "value",
|
||||
description: "JSON config for set",
|
||||
type: "string",
|
||||
captureRemaining: true,
|
||||
},
|
||||
],
|
||||
argsParsing: "none",
|
||||
formatArgs: COMMAND_ARG_FORMATTERS.mcp,
|
||||
}),
|
||||
defineChatCommand({
|
||||
key: "debug",
|
||||
nativeName: "debug",
|
||||
|
||||
@@ -99,6 +99,9 @@ export function isCommandEnabled(cfg: OpenClawConfig, commandKey: string): boole
|
||||
if (commandKey === "config") {
|
||||
return isCommandFlagEnabled(cfg, "config");
|
||||
}
|
||||
if (commandKey === "mcp") {
|
||||
return isCommandFlagEnabled(cfg, "mcp");
|
||||
}
|
||||
if (commandKey === "debug") {
|
||||
return isCommandFlagEnabled(cfg, "debug");
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ import { logVerbose } from "../../globals.js";
|
||||
import {
|
||||
isTelegramExecApprovalApprover,
|
||||
isTelegramExecApprovalClientEnabled,
|
||||
} from "../../plugin-sdk-internal/telegram.js";
|
||||
} from "../../plugin-sdk/telegram.js";
|
||||
import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../../utils/message-channel.js";
|
||||
import { requireGatewayClientScopeForInternalChannel } from "./command-gates.js";
|
||||
import type { CommandHandler } from "./commands-types.js";
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
handleStatusCommand,
|
||||
handleWhoamiCommand,
|
||||
} from "./commands-info.js";
|
||||
import { handleMcpCommand } from "./commands-mcp.js";
|
||||
import { handleModelsCommand } from "./commands-models.js";
|
||||
import { handlePluginCommand } from "./commands-plugin.js";
|
||||
import {
|
||||
@@ -194,6 +195,7 @@ export async function handleCommands(params: HandleCommandsParams): Promise<Comm
|
||||
handleWhoamiCommand,
|
||||
handleSubagentsCommand,
|
||||
handleAcpCommand,
|
||||
handleMcpCommand,
|
||||
handleConfigCommand,
|
||||
handleDebugCommand,
|
||||
handleModelsCommand,
|
||||
|
||||
93
src/auto-reply/reply/commands-mcp.test.ts
Normal file
93
src/auto-reply/reply/commands-mcp.test.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import type { OpenClawConfig } from "../../config/config.js";
|
||||
import { withTempHome } from "../../config/home-env.test-harness.js";
|
||||
import { handleCommands } from "./commands-core.js";
|
||||
import { buildCommandTestParams } from "./commands.test-harness.js";
|
||||
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
async function createWorkspace(): Promise<string> {
|
||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-command-mcp-"));
|
||||
tempDirs.push(dir);
|
||||
return dir;
|
||||
}
|
||||
|
||||
function buildCfg(): OpenClawConfig {
|
||||
return {
|
||||
commands: {
|
||||
text: true,
|
||||
mcp: true,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("handleCommands /mcp", () => {
|
||||
afterEach(async () => {
|
||||
await Promise.all(
|
||||
tempDirs.splice(0).map((dir) => fs.rm(dir, { recursive: true, force: true })),
|
||||
);
|
||||
});
|
||||
|
||||
it("writes MCP config and shows it back", async () => {
|
||||
await withTempHome("openclaw-command-mcp-home-", async () => {
|
||||
const workspaceDir = await createWorkspace();
|
||||
const setParams = buildCommandTestParams(
|
||||
'/mcp set context7={"command":"uvx","args":["context7-mcp"]}',
|
||||
buildCfg(),
|
||||
undefined,
|
||||
{ workspaceDir },
|
||||
);
|
||||
setParams.command.senderIsOwner = true;
|
||||
|
||||
const setResult = await handleCommands(setParams);
|
||||
expect(setResult.reply?.text).toContain('MCP server "context7" saved');
|
||||
|
||||
const showParams = buildCommandTestParams("/mcp show context7", buildCfg(), undefined, {
|
||||
workspaceDir,
|
||||
});
|
||||
showParams.command.senderIsOwner = true;
|
||||
const showResult = await handleCommands(showParams);
|
||||
expect(showResult.reply?.text).toContain('"command": "uvx"');
|
||||
expect(showResult.reply?.text).toContain('"args": [');
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects internal writes without operator.admin", async () => {
|
||||
await withTempHome("openclaw-command-mcp-home-", async () => {
|
||||
const workspaceDir = await createWorkspace();
|
||||
const params = buildCommandTestParams(
|
||||
'/mcp set context7={"command":"uvx","args":["context7-mcp"]}',
|
||||
buildCfg(),
|
||||
{
|
||||
Provider: "webchat",
|
||||
Surface: "webchat",
|
||||
GatewayClientScopes: ["operator.write"],
|
||||
},
|
||||
{ workspaceDir },
|
||||
);
|
||||
params.command.senderIsOwner = true;
|
||||
|
||||
const result = await handleCommands(params);
|
||||
expect(result.reply?.text).toContain("requires operator.admin");
|
||||
});
|
||||
});
|
||||
|
||||
it("accepts non-stdio MCP config at the config layer", async () => {
|
||||
await withTempHome("openclaw-command-mcp-home-", async () => {
|
||||
const workspaceDir = await createWorkspace();
|
||||
const params = buildCommandTestParams(
|
||||
'/mcp set remote={"url":"https://example.com/mcp"}',
|
||||
buildCfg(),
|
||||
undefined,
|
||||
{ workspaceDir },
|
||||
);
|
||||
params.command.senderIsOwner = true;
|
||||
|
||||
const result = await handleCommands(params);
|
||||
expect(result.reply?.text).toContain('MCP server "remote" saved');
|
||||
});
|
||||
});
|
||||
});
|
||||
134
src/auto-reply/reply/commands-mcp.ts
Normal file
134
src/auto-reply/reply/commands-mcp.ts
Normal file
@@ -0,0 +1,134 @@
|
||||
import {
|
||||
listConfiguredMcpServers,
|
||||
setConfiguredMcpServer,
|
||||
unsetConfiguredMcpServer,
|
||||
} from "../../config/mcp-config.js";
|
||||
import { isInternalMessageChannel } from "../../utils/message-channel.js";
|
||||
import {
|
||||
rejectNonOwnerCommand,
|
||||
rejectUnauthorizedCommand,
|
||||
requireCommandFlagEnabled,
|
||||
requireGatewayClientScopeForInternalChannel,
|
||||
} from "./command-gates.js";
|
||||
import type { CommandHandler } from "./commands-types.js";
|
||||
import { parseMcpCommand } from "./mcp-commands.js";
|
||||
|
||||
function renderJsonBlock(label: string, value: unknown): string {
|
||||
return `${label}\n\`\`\`json\n${JSON.stringify(value, null, 2)}\n\`\`\``;
|
||||
}
|
||||
|
||||
export const handleMcpCommand: CommandHandler = async (params, allowTextCommands) => {
|
||||
if (!allowTextCommands) {
|
||||
return null;
|
||||
}
|
||||
const mcpCommand = parseMcpCommand(params.command.commandBodyNormalized);
|
||||
if (!mcpCommand) {
|
||||
return null;
|
||||
}
|
||||
const unauthorized = rejectUnauthorizedCommand(params, "/mcp");
|
||||
if (unauthorized) {
|
||||
return unauthorized;
|
||||
}
|
||||
const allowInternalReadOnlyShow =
|
||||
mcpCommand.action === "show" && isInternalMessageChannel(params.command.channel);
|
||||
const nonOwner = allowInternalReadOnlyShow ? null : rejectNonOwnerCommand(params, "/mcp");
|
||||
if (nonOwner) {
|
||||
return nonOwner;
|
||||
}
|
||||
const disabled = requireCommandFlagEnabled(params.cfg, {
|
||||
label: "/mcp",
|
||||
configKey: "mcp",
|
||||
});
|
||||
if (disabled) {
|
||||
return disabled;
|
||||
}
|
||||
if (mcpCommand.action === "error") {
|
||||
return {
|
||||
shouldContinue: false,
|
||||
reply: { text: `⚠️ ${mcpCommand.message}` },
|
||||
};
|
||||
}
|
||||
|
||||
if (mcpCommand.action === "show") {
|
||||
const loaded = await listConfiguredMcpServers();
|
||||
if (!loaded.ok) {
|
||||
return {
|
||||
shouldContinue: false,
|
||||
reply: { text: `⚠️ ${loaded.error}` },
|
||||
};
|
||||
}
|
||||
if (mcpCommand.name) {
|
||||
const server = loaded.mcpServers[mcpCommand.name];
|
||||
if (!server) {
|
||||
return {
|
||||
shouldContinue: false,
|
||||
reply: { text: `🔌 No MCP server named "${mcpCommand.name}" in ${loaded.path}.` },
|
||||
};
|
||||
}
|
||||
return {
|
||||
shouldContinue: false,
|
||||
reply: {
|
||||
text: renderJsonBlock(`🔌 MCP server "${mcpCommand.name}" (${loaded.path})`, server),
|
||||
},
|
||||
};
|
||||
}
|
||||
if (Object.keys(loaded.mcpServers).length === 0) {
|
||||
return {
|
||||
shouldContinue: false,
|
||||
reply: { text: `🔌 No MCP servers configured in ${loaded.path}.` },
|
||||
};
|
||||
}
|
||||
return {
|
||||
shouldContinue: false,
|
||||
reply: {
|
||||
text: renderJsonBlock(`🔌 MCP servers (${loaded.path})`, loaded.mcpServers),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const missingAdminScope = requireGatewayClientScopeForInternalChannel(params, {
|
||||
label: "/mcp write",
|
||||
allowedScopes: ["operator.admin"],
|
||||
missingText: "❌ /mcp set|unset requires operator.admin for gateway clients.",
|
||||
});
|
||||
if (missingAdminScope) {
|
||||
return missingAdminScope;
|
||||
}
|
||||
|
||||
if (mcpCommand.action === "set") {
|
||||
const result = await setConfiguredMcpServer({
|
||||
name: mcpCommand.name,
|
||||
server: mcpCommand.value,
|
||||
});
|
||||
if (!result.ok) {
|
||||
return {
|
||||
shouldContinue: false,
|
||||
reply: { text: `⚠️ ${result.error}` },
|
||||
};
|
||||
}
|
||||
return {
|
||||
shouldContinue: false,
|
||||
reply: {
|
||||
text: `🔌 MCP server "${mcpCommand.name}" saved to ${result.path}.`,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const result = await unsetConfiguredMcpServer({ name: mcpCommand.name });
|
||||
if (!result.ok) {
|
||||
return {
|
||||
shouldContinue: false,
|
||||
reply: { text: `⚠️ ${result.error}` },
|
||||
};
|
||||
}
|
||||
if (!result.removed) {
|
||||
return {
|
||||
shouldContinue: false,
|
||||
reply: { text: `🔌 No MCP server named "${mcpCommand.name}" in ${result.path}.` },
|
||||
};
|
||||
}
|
||||
return {
|
||||
shouldContinue: false,
|
||||
reply: { text: `🔌 MCP server "${mcpCommand.name}" removed from ${result.path}.` },
|
||||
};
|
||||
};
|
||||
@@ -16,7 +16,7 @@ import {
|
||||
calculateTotalPages,
|
||||
getModelsPageSize,
|
||||
type ProviderInfo,
|
||||
} from "../../plugin-sdk-internal/telegram.js";
|
||||
} from "../../plugin-sdk/telegram.js";
|
||||
import type { ReplyPayload } from "../types.js";
|
||||
import { rejectUnauthorizedCommand } from "./command-gates.js";
|
||||
import type { CommandHandler } from "./commands-types.js";
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { logVerbose } from "../../globals.js";
|
||||
import { listSpeechProviders, normalizeSpeechProviderId } from "../../tts/provider-registry.js";
|
||||
import {
|
||||
getLastTtsAttempt,
|
||||
getTtsMaxLength,
|
||||
@@ -54,7 +55,7 @@ function ttsUsage(): ReplyPayload {
|
||||
`• /tts summary [on|off] — View/change auto-summary\n` +
|
||||
`• /tts audio <text> — Generate audio from text\n\n` +
|
||||
`**Providers:**\n` +
|
||||
`• edge — Free, fast (default)\n` +
|
||||
`• microsoft — Microsoft Edge-backed speech (default fallback)\n` +
|
||||
`• openai — High quality (requires API key)\n` +
|
||||
`• elevenlabs — Premium voices (requires API key)\n\n` +
|
||||
`**Text Limit (default: 1500, max: 4096):**\n` +
|
||||
@@ -62,7 +63,7 @@ function ttsUsage(): ReplyPayload {
|
||||
`• Summary ON: AI summarizes, then generates audio\n` +
|
||||
`• Summary OFF: Truncates text, then generates audio\n\n` +
|
||||
`**Examples:**\n` +
|
||||
`/tts provider edge\n` +
|
||||
`/tts provider microsoft\n` +
|
||||
`/tts limit 2000\n` +
|
||||
`/tts audio Hello, this is a test!`,
|
||||
};
|
||||
@@ -161,7 +162,7 @@ export const handleTtsCommands: CommandHandler = async (params, allowTextCommand
|
||||
if (!args.trim()) {
|
||||
const hasOpenAI = Boolean(resolveTtsApiKey(config, "openai"));
|
||||
const hasElevenLabs = Boolean(resolveTtsApiKey(config, "elevenlabs"));
|
||||
const hasEdge = isTtsProviderConfigured(config, "edge");
|
||||
const hasMicrosoft = isTtsProviderConfigured(config, "microsoft", params.cfg);
|
||||
return {
|
||||
shouldContinue: false,
|
||||
reply: {
|
||||
@@ -170,21 +171,23 @@ export const handleTtsCommands: CommandHandler = async (params, allowTextCommand
|
||||
`Primary: ${currentProvider}\n` +
|
||||
`OpenAI key: ${hasOpenAI ? "✅" : "❌"}\n` +
|
||||
`ElevenLabs key: ${hasElevenLabs ? "✅" : "❌"}\n` +
|
||||
`Edge enabled: ${hasEdge ? "✅" : "❌"}\n` +
|
||||
`Usage: /tts provider openai | elevenlabs | edge`,
|
||||
`Microsoft enabled: ${hasMicrosoft ? "✅" : "❌"}\n` +
|
||||
`Usage: /tts provider openai | elevenlabs | microsoft`,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const requested = args.trim().toLowerCase();
|
||||
if (requested !== "openai" && requested !== "elevenlabs" && requested !== "edge") {
|
||||
const knownProviders = new Set(listSpeechProviders(params.cfg).map((provider) => provider.id));
|
||||
if (requested !== "edge" && !knownProviders.has(requested)) {
|
||||
return { shouldContinue: false, reply: ttsUsage() };
|
||||
}
|
||||
|
||||
const nextProvider = normalizeSpeechProviderId(requested) ?? requested;
|
||||
setTtsProvider(prefsPath, requested);
|
||||
return {
|
||||
shouldContinue: false,
|
||||
reply: { text: `✅ TTS provider set to ${requested}.` },
|
||||
reply: { text: `✅ TTS provider set to ${nextProvider}.` },
|
||||
};
|
||||
}
|
||||
|
||||
@@ -249,7 +252,7 @@ export const handleTtsCommands: CommandHandler = async (params, allowTextCommand
|
||||
if (action === "status") {
|
||||
const enabled = isTtsEnabled(config, prefsPath);
|
||||
const provider = getTtsProvider(config, prefsPath);
|
||||
const hasKey = isTtsProviderConfigured(config, provider);
|
||||
const hasKey = isTtsProviderConfigured(config, provider, params.cfg);
|
||||
const maxLength = getTtsMaxLength(prefsPath);
|
||||
const summarize = isSummarizationEnabled(prefsPath);
|
||||
const last = getLastTtsAttempt();
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
} from "../../agents/model-selection.js";
|
||||
import type { OpenClawConfig } from "../../config/config.js";
|
||||
import type { SessionEntry } from "../../config/sessions.js";
|
||||
import { buildBrowseProvidersButton } from "../../plugin-sdk-internal/telegram.js";
|
||||
import { buildBrowseProvidersButton } from "../../plugin-sdk/telegram.js";
|
||||
import { shortenHomePath } from "../../utils.js";
|
||||
import { resolveSelectedAndActiveModel } from "../model-runtime.js";
|
||||
import type { ReplyPayload } from "../types.js";
|
||||
|
||||
24
src/auto-reply/reply/mcp-commands.ts
Normal file
24
src/auto-reply/reply/mcp-commands.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { parseStandardSetUnsetSlashCommand } from "./commands-setunset-standard.js";
|
||||
|
||||
export type McpCommand =
|
||||
| { action: "show"; name?: string }
|
||||
| { action: "set"; name: string; value: unknown }
|
||||
| { action: "unset"; name: string }
|
||||
| { action: "error"; message: string };
|
||||
|
||||
export function parseMcpCommand(raw: string): McpCommand | null {
|
||||
return parseStandardSetUnsetSlashCommand<McpCommand>({
|
||||
raw,
|
||||
slash: "/mcp",
|
||||
invalidMessage: "Invalid /mcp syntax.",
|
||||
usageMessage: "Usage: /mcp show|set|unset",
|
||||
onKnownAction: (action, args) => {
|
||||
if (action === "show" || action === "get") {
|
||||
return { action: "show", name: args || undefined };
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
onSet: (name, value) => ({ action: "set", name, value }),
|
||||
onUnset: (name) => ({ action: "unset", name }),
|
||||
});
|
||||
}
|
||||
@@ -6,7 +6,7 @@ vi.mock("../../agents/model-catalog.js", () => ({
|
||||
loadModelCatalog: vi.fn(async () => [
|
||||
{ provider: "anthropic", id: "claude-opus-4-5", name: "Claude Opus 4.5" },
|
||||
{ provider: "inferencer", id: "deepseek-v3-4bit-mlx", name: "DeepSeek V3" },
|
||||
{ provider: "kimi-coding", id: "k2p5", name: "Kimi K2.5" },
|
||||
{ provider: "kimi", id: "kimi-code", name: "Kimi Code" },
|
||||
{ provider: "openai", id: "gpt-4o-mini", name: "GPT-4o mini" },
|
||||
{ provider: "openai", id: "gpt-4o", name: "GPT-4o" },
|
||||
]),
|
||||
@@ -222,12 +222,12 @@ describe("createModelSelectionState respects session model override", () => {
|
||||
const state = await resolveState(
|
||||
makeEntry({
|
||||
providerOverride: "kimi-coding",
|
||||
modelOverride: "k2p5",
|
||||
modelOverride: "kimi-code",
|
||||
}),
|
||||
);
|
||||
|
||||
expect(state.provider).toBe("kimi-coding");
|
||||
expect(state.model).toBe("k2p5");
|
||||
expect(state.provider).toBe("kimi");
|
||||
expect(state.model).toBe("kimi-code");
|
||||
});
|
||||
|
||||
it("falls back to default when no modelOverride is set", async () => {
|
||||
@@ -241,8 +241,8 @@ describe("createModelSelectionState respects session model override", () => {
|
||||
// From issue #14783: stored override should beat last-used fallback model.
|
||||
const state = await resolveState(
|
||||
makeEntry({
|
||||
model: "k2p5",
|
||||
modelProvider: "kimi-coding",
|
||||
model: "kimi-code",
|
||||
modelProvider: "kimi",
|
||||
contextTokens: 262_000,
|
||||
providerOverride: "anthropic",
|
||||
modelOverride: "claude-opus-4-5",
|
||||
|
||||
@@ -91,6 +91,8 @@ const createRegistry = (channels: PluginRegistry["channels"]): PluginRegistry =>
|
||||
enabled: true,
|
||||
})),
|
||||
providers: [],
|
||||
speechProviders: [],
|
||||
mediaUnderstandingProviders: [],
|
||||
webSearchProviders: [],
|
||||
gatewayHandlers: {},
|
||||
httpRoutes: [],
|
||||
|
||||
@@ -3,7 +3,7 @@ import type {
|
||||
MediaUnderstandingDecision,
|
||||
MediaUnderstandingOutput,
|
||||
} from "../media-understanding/types.js";
|
||||
import type { StickerMetadata } from "../plugin-sdk-internal/telegram.js";
|
||||
import type { StickerMetadata } from "../plugin-sdk/telegram.js";
|
||||
import type { InputProvenance } from "../sessions/input-provenance.js";
|
||||
import type { InternalMessageChannel } from "../utils/message-channel.js";
|
||||
import type { CommandArgs } from "./commands-registry.types.js";
|
||||
|
||||
@@ -12,6 +12,8 @@ export type ThinkingCatalogEntry = {
|
||||
};
|
||||
|
||||
const BASE_THINKING_LEVELS: ThinkLevel[] = ["off", "minimal", "low", "medium", "high", "adaptive"];
|
||||
const ANTHROPIC_CLAUDE_46_MODEL_RE = /^claude-(?:opus|sonnet)-4(?:\.|-)6(?:$|[-.])/i;
|
||||
const AMAZON_BEDROCK_CLAUDE_46_MODEL_RE = /claude-(?:opus|sonnet)-4(?:\.|-)6(?:$|[-.])/i;
|
||||
|
||||
export function normalizeProviderId(provider?: string | null): string {
|
||||
if (!provider) {
|
||||
@@ -101,6 +103,14 @@ export function resolveThinkingDefaultForModel(params: {
|
||||
model: string;
|
||||
catalog?: ThinkingCatalogEntry[];
|
||||
}): ThinkLevel {
|
||||
const normalizedProvider = normalizeProviderId(params.provider);
|
||||
const modelId = params.model.trim();
|
||||
if (normalizedProvider === "anthropic" && ANTHROPIC_CLAUDE_46_MODEL_RE.test(modelId)) {
|
||||
return "adaptive";
|
||||
}
|
||||
if (normalizedProvider === "amazon-bedrock" && AMAZON_BEDROCK_CLAUDE_46_MODEL_RE.test(modelId)) {
|
||||
return "adaptive";
|
||||
}
|
||||
const candidate = params.catalog?.find(
|
||||
(entry) => entry.provider === params.provider && entry.id === params.model,
|
||||
);
|
||||
|
||||
@@ -7,15 +7,11 @@ export {
|
||||
monitorWebChannel,
|
||||
resolveHeartbeatRecipients,
|
||||
runWebHeartbeatOnce,
|
||||
} from "./plugin-sdk-internal/whatsapp.js";
|
||||
export {
|
||||
extractMediaPlaceholder,
|
||||
extractText,
|
||||
monitorWebInbox,
|
||||
} from "./plugin-sdk-internal/whatsapp.js";
|
||||
export { loginWeb } from "./plugin-sdk-internal/whatsapp.js";
|
||||
export { loadWebMedia, optimizeImageToJpeg } from "./plugin-sdk-internal/whatsapp.js";
|
||||
export { sendMessageWhatsApp } from "./plugin-sdk-internal/whatsapp.js";
|
||||
} from "./plugin-sdk/whatsapp.js";
|
||||
export { extractMediaPlaceholder, extractText, monitorWebInbox } from "./plugin-sdk/whatsapp.js";
|
||||
export { loginWeb } from "./plugin-sdk/whatsapp.js";
|
||||
export { loadWebMedia, optimizeImageToJpeg } from "./plugin-sdk/whatsapp.js";
|
||||
export { sendMessageWhatsApp } from "./plugin-sdk/whatsapp.js";
|
||||
export {
|
||||
createWaSocket,
|
||||
formatError,
|
||||
@@ -26,4 +22,4 @@ export {
|
||||
WA_WEB_AUTH_DIR,
|
||||
waitForWaConnection,
|
||||
webAuthExists,
|
||||
} from "./plugin-sdk-internal/whatsapp.js";
|
||||
} from "./plugin-sdk/whatsapp.js";
|
||||
|
||||
18
src/channels/ids.ts
Normal file
18
src/channels/ids.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
// Keep built-in channel IDs in a leaf module so shared config/sandbox code can
|
||||
// reference them without importing channel registry helpers that may pull in
|
||||
// plugin runtime state.
|
||||
export const CHAT_CHANNEL_ORDER = [
|
||||
"telegram",
|
||||
"whatsapp",
|
||||
"discord",
|
||||
"irc",
|
||||
"googlechat",
|
||||
"slack",
|
||||
"signal",
|
||||
"imessage",
|
||||
"line",
|
||||
] as const;
|
||||
|
||||
export type ChatChannelId = (typeof CHAT_CHANNEL_ORDER)[number];
|
||||
|
||||
export const CHANNEL_IDS = [...CHAT_CHANNEL_ORDER] as const;
|
||||
@@ -1,2 +1,2 @@
|
||||
// Public entrypoint for the Discord channel action adapter.
|
||||
export * from "../../../plugin-sdk-internal/discord.js";
|
||||
export * from "../../../plugin-sdk/discord.js";
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { createActionGate, jsonResult, readStringParam } from "../../../agents/tools/common.js";
|
||||
import { resolveSignalAccount } from "../../../plugin-sdk/account-resolution.js";
|
||||
import {
|
||||
listEnabledSignalAccounts,
|
||||
removeReactionSignal,
|
||||
resolveSignalAccount,
|
||||
resolveSignalReactionLevel,
|
||||
sendReactionSignal,
|
||||
} from "../../../plugin-sdk-internal/signal.js";
|
||||
} from "../../../plugin-sdk/signal.js";
|
||||
import type { ChannelMessageActionAdapter, ChannelMessageActionName } from "../types.js";
|
||||
import { resolveReactionMessageId } from "./reaction-message-id.js";
|
||||
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
// Public entrypoint for the Telegram channel action adapter.
|
||||
export * from "../../../plugin-sdk-internal/telegram.js";
|
||||
export * from "../../../plugin-sdk/telegram.js";
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
// Shim: keep legacy import path while the runtime loads the plugin SDK surface.
|
||||
export * from "../../../plugin-sdk-internal/whatsapp.js";
|
||||
export * from "../../../plugin-sdk/whatsapp.js";
|
||||
|
||||
12
src/channels/plugins/contracts/directory.contract.test.ts
Normal file
12
src/channels/plugins/contracts/directory.contract.test.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { describe } from "vitest";
|
||||
import { directoryContractRegistry } from "./registry.js";
|
||||
import { installChannelDirectoryContractSuite } from "./suites.js";
|
||||
|
||||
for (const entry of directoryContractRegistry) {
|
||||
describe(`${entry.id} directory contract`, () => {
|
||||
installChannelDirectoryContractSuite({
|
||||
plugin: entry.plugin,
|
||||
invokeLookups: entry.invokeLookups,
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -1,10 +1,12 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
actionContractRegistry,
|
||||
directoryContractRegistry,
|
||||
pluginContractRegistry,
|
||||
setupContractRegistry,
|
||||
statusContractRegistry,
|
||||
surfaceContractRegistry,
|
||||
threadingContractRegistry,
|
||||
type ChannelPluginSurface,
|
||||
} from "./registry.js";
|
||||
|
||||
@@ -70,4 +72,26 @@ describe("channel contract registry", () => {
|
||||
expect(statusSurfaceIds.has(entry.id)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("only installs deep threading coverage for plugins that declare threading", () => {
|
||||
const threadingSurfaceIds = new Set(
|
||||
surfaceContractRegistry
|
||||
.filter((entry) => entry.surfaces.includes("threading"))
|
||||
.map((entry) => entry.id),
|
||||
);
|
||||
for (const entry of threadingContractRegistry) {
|
||||
expect(threadingSurfaceIds.has(entry.id)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("only installs deep directory coverage for plugins that declare directory", () => {
|
||||
const directorySurfaceIds = new Set(
|
||||
surfaceContractRegistry
|
||||
.filter((entry) => entry.surfaces.includes("directory"))
|
||||
.map((entry) => entry.id),
|
||||
);
|
||||
for (const entry of directoryContractRegistry) {
|
||||
expect(directorySurfaceIds.has(entry.id)).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -84,6 +84,17 @@ type SurfaceContractEntry = {
|
||||
surfaces: readonly ChannelPluginSurface[];
|
||||
};
|
||||
|
||||
type ThreadingContractEntry = {
|
||||
id: string;
|
||||
plugin: Pick<ChannelPlugin, "id" | "threading">;
|
||||
};
|
||||
|
||||
type DirectoryContractEntry = {
|
||||
id: string;
|
||||
plugin: Pick<ChannelPlugin, "id" | "directory">;
|
||||
invokeLookups: boolean;
|
||||
};
|
||||
|
||||
const telegramListActionsMock = vi.fn();
|
||||
const telegramGetCapabilitiesMock = vi.fn();
|
||||
const discordListActionsMock = vi.fn();
|
||||
@@ -672,3 +683,20 @@ export const surfaceContractRegistry: SurfaceContractEntry[] = [
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export const threadingContractRegistry: ThreadingContractEntry[] = surfaceContractRegistry
|
||||
.filter((entry) => entry.surfaces.includes("threading"))
|
||||
.map((entry) => ({
|
||||
id: entry.id,
|
||||
plugin: entry.plugin,
|
||||
}));
|
||||
|
||||
const directoryShapeOnlyIds = new Set(["matrix", "whatsapp", "zalouser"]);
|
||||
|
||||
export const directoryContractRegistry: DirectoryContractEntry[] = surfaceContractRegistry
|
||||
.filter((entry) => entry.surfaces.includes("directory"))
|
||||
.map((entry) => ({
|
||||
id: entry.id,
|
||||
plugin: entry.plugin,
|
||||
invokeLookups: !directoryShapeOnlyIds.has(entry.id),
|
||||
}));
|
||||
|
||||
151
src/channels/plugins/contracts/session-binding.contract.test.ts
Normal file
151
src/channels/plugins/contracts/session-binding.contract.test.ts
Normal file
@@ -0,0 +1,151 @@
|
||||
import { beforeEach, describe, expect } from "vitest";
|
||||
import {
|
||||
__testing as feishuThreadBindingTesting,
|
||||
createFeishuThreadBindingManager,
|
||||
} from "../../../../extensions/feishu/src/thread-bindings.js";
|
||||
import {
|
||||
__testing as telegramThreadBindingTesting,
|
||||
createTelegramThreadBindingManager,
|
||||
} from "../../../../extensions/telegram/src/thread-bindings.js";
|
||||
import type { OpenClawConfig } from "../../../config/config.js";
|
||||
import {
|
||||
__testing as sessionBindingTesting,
|
||||
getSessionBindingService,
|
||||
} from "../../../infra/outbound/session-binding-service.js";
|
||||
import { installSessionBindingContractSuite } from "./suites.js";
|
||||
|
||||
const baseCfg = {
|
||||
session: { mainKey: "main", scope: "per-sender" },
|
||||
} satisfies OpenClawConfig;
|
||||
|
||||
beforeEach(() => {
|
||||
sessionBindingTesting.resetSessionBindingAdaptersForTests();
|
||||
feishuThreadBindingTesting.resetFeishuThreadBindingsForTests();
|
||||
telegramThreadBindingTesting.resetTelegramThreadBindingsForTests();
|
||||
});
|
||||
|
||||
describe("feishu session binding contract", () => {
|
||||
installSessionBindingContractSuite({
|
||||
expectedCapabilities: {
|
||||
adapterAvailable: true,
|
||||
bindSupported: true,
|
||||
unbindSupported: true,
|
||||
placements: ["current"],
|
||||
},
|
||||
getCapabilities: () => {
|
||||
createFeishuThreadBindingManager({ cfg: baseCfg, accountId: "default" });
|
||||
return getSessionBindingService().getCapabilities({
|
||||
channel: "feishu",
|
||||
accountId: "default",
|
||||
});
|
||||
},
|
||||
bindAndResolve: async () => {
|
||||
createFeishuThreadBindingManager({ cfg: baseCfg, accountId: "default" });
|
||||
const service = getSessionBindingService();
|
||||
const binding = await service.bind({
|
||||
targetSessionKey: "agent:codex:acp:binding:feishu:default:abc123",
|
||||
targetKind: "session",
|
||||
conversation: {
|
||||
channel: "feishu",
|
||||
accountId: "default",
|
||||
conversationId: "oc_group_chat:topic:om_topic_root",
|
||||
parentConversationId: "oc_group_chat",
|
||||
},
|
||||
placement: "current",
|
||||
metadata: {
|
||||
agentId: "codex",
|
||||
label: "codex-main",
|
||||
},
|
||||
});
|
||||
expect(
|
||||
service.resolveByConversation({
|
||||
channel: "feishu",
|
||||
accountId: "default",
|
||||
conversationId: "oc_group_chat:topic:om_topic_root",
|
||||
}),
|
||||
)?.toMatchObject({
|
||||
targetSessionKey: "agent:codex:acp:binding:feishu:default:abc123",
|
||||
});
|
||||
return binding;
|
||||
},
|
||||
cleanup: async () => {
|
||||
const manager = createFeishuThreadBindingManager({ cfg: baseCfg, accountId: "default" });
|
||||
manager.stop();
|
||||
expect(
|
||||
getSessionBindingService().resolveByConversation({
|
||||
channel: "feishu",
|
||||
accountId: "default",
|
||||
conversationId: "oc_group_chat:topic:om_topic_root",
|
||||
}),
|
||||
).toBeNull();
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
describe("telegram session binding contract", () => {
|
||||
installSessionBindingContractSuite({
|
||||
expectedCapabilities: {
|
||||
adapterAvailable: true,
|
||||
bindSupported: true,
|
||||
unbindSupported: true,
|
||||
placements: ["current"],
|
||||
},
|
||||
getCapabilities: () => {
|
||||
createTelegramThreadBindingManager({
|
||||
accountId: "default",
|
||||
persist: false,
|
||||
enableSweeper: false,
|
||||
});
|
||||
return getSessionBindingService().getCapabilities({
|
||||
channel: "telegram",
|
||||
accountId: "default",
|
||||
});
|
||||
},
|
||||
bindAndResolve: async () => {
|
||||
createTelegramThreadBindingManager({
|
||||
accountId: "default",
|
||||
persist: false,
|
||||
enableSweeper: false,
|
||||
});
|
||||
const service = getSessionBindingService();
|
||||
const binding = await service.bind({
|
||||
targetSessionKey: "agent:main:subagent:child-1",
|
||||
targetKind: "subagent",
|
||||
conversation: {
|
||||
channel: "telegram",
|
||||
accountId: "default",
|
||||
conversationId: "-100200300:topic:77",
|
||||
},
|
||||
placement: "current",
|
||||
metadata: {
|
||||
boundBy: "user-1",
|
||||
},
|
||||
});
|
||||
expect(
|
||||
service.resolveByConversation({
|
||||
channel: "telegram",
|
||||
accountId: "default",
|
||||
conversationId: "-100200300:topic:77",
|
||||
}),
|
||||
)?.toMatchObject({
|
||||
targetSessionKey: "agent:main:subagent:child-1",
|
||||
});
|
||||
return binding;
|
||||
},
|
||||
cleanup: async () => {
|
||||
const manager = createTelegramThreadBindingManager({
|
||||
accountId: "default",
|
||||
persist: false,
|
||||
enableSweeper: false,
|
||||
});
|
||||
manager.stop();
|
||||
expect(
|
||||
getSessionBindingService().resolveByConversation({
|
||||
channel: "telegram",
|
||||
accountId: "default",
|
||||
conversationId: "-100200300:topic:77",
|
||||
}),
|
||||
).toBeNull();
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -5,13 +5,22 @@ import type {
|
||||
ResolveProviderRuntimeGroupPolicyParams,
|
||||
RuntimeGroupPolicyResolution,
|
||||
} from "../../../config/runtime-group-policy.js";
|
||||
import type {
|
||||
SessionBindingCapabilities,
|
||||
SessionBindingRecord,
|
||||
} from "../../../infra/outbound/session-binding-service.js";
|
||||
import { createNonExitingRuntime } from "../../../runtime.js";
|
||||
import { normalizeChatType } from "../../chat-type.js";
|
||||
import { resolveConversationLabel } from "../../conversation-label.js";
|
||||
import { validateSenderIdentity } from "../../sender-identity.js";
|
||||
import type {
|
||||
ChannelAccountSnapshot,
|
||||
ChannelAccountState,
|
||||
ChannelDirectoryEntry,
|
||||
ChannelFocusedBindingContext,
|
||||
ChannelReplyTransport,
|
||||
ChannelSetupInput,
|
||||
ChannelThreadingToolContext,
|
||||
} from "../types.core.js";
|
||||
import type {
|
||||
ChannelMessageActionName,
|
||||
@@ -23,6 +32,69 @@ function sortStrings(values: readonly string[]) {
|
||||
return [...values].toSorted((left, right) => left.localeCompare(right));
|
||||
}
|
||||
|
||||
const contractRuntime = createNonExitingRuntime();
|
||||
function expectDirectoryEntryShape(entry: ChannelDirectoryEntry) {
|
||||
expect(["user", "group", "channel"]).toContain(entry.kind);
|
||||
expect(typeof entry.id).toBe("string");
|
||||
expect(entry.id.trim()).not.toBe("");
|
||||
if (entry.name !== undefined) {
|
||||
expect(typeof entry.name).toBe("string");
|
||||
}
|
||||
if (entry.handle !== undefined) {
|
||||
expect(typeof entry.handle).toBe("string");
|
||||
}
|
||||
if (entry.avatarUrl !== undefined) {
|
||||
expect(typeof entry.avatarUrl).toBe("string");
|
||||
}
|
||||
if (entry.rank !== undefined) {
|
||||
expect(typeof entry.rank).toBe("number");
|
||||
}
|
||||
}
|
||||
|
||||
function expectThreadingToolContextShape(context: ChannelThreadingToolContext) {
|
||||
if (context.currentChannelId !== undefined) {
|
||||
expect(typeof context.currentChannelId).toBe("string");
|
||||
}
|
||||
if (context.currentChannelProvider !== undefined) {
|
||||
expect(typeof context.currentChannelProvider).toBe("string");
|
||||
}
|
||||
if (context.currentThreadTs !== undefined) {
|
||||
expect(typeof context.currentThreadTs).toBe("string");
|
||||
}
|
||||
if (context.currentMessageId !== undefined) {
|
||||
expect(["string", "number"]).toContain(typeof context.currentMessageId);
|
||||
}
|
||||
if (context.replyToMode !== undefined) {
|
||||
expect(["off", "first", "all"]).toContain(context.replyToMode);
|
||||
}
|
||||
if (context.hasRepliedRef !== undefined) {
|
||||
expect(typeof context.hasRepliedRef).toBe("object");
|
||||
}
|
||||
if (context.skipCrossContextDecoration !== undefined) {
|
||||
expect(typeof context.skipCrossContextDecoration).toBe("boolean");
|
||||
}
|
||||
}
|
||||
|
||||
function expectReplyTransportShape(transport: ChannelReplyTransport) {
|
||||
if (transport.replyToId !== undefined && transport.replyToId !== null) {
|
||||
expect(typeof transport.replyToId).toBe("string");
|
||||
}
|
||||
if (transport.threadId !== undefined && transport.threadId !== null) {
|
||||
expect(["string", "number"]).toContain(typeof transport.threadId);
|
||||
}
|
||||
}
|
||||
|
||||
function expectFocusedBindingShape(binding: ChannelFocusedBindingContext) {
|
||||
expect(typeof binding.conversationId).toBe("string");
|
||||
expect(binding.conversationId.trim()).not.toBe("");
|
||||
if (binding.parentConversationId !== undefined) {
|
||||
expect(typeof binding.parentConversationId).toBe("string");
|
||||
}
|
||||
expect(["current", "child"]).toContain(binding.placement);
|
||||
expect(typeof binding.labelNoun).toBe("string");
|
||||
expect(binding.labelNoun.trim()).not.toBe("");
|
||||
}
|
||||
|
||||
export function installChannelPluginContractSuite(params: {
|
||||
plugin: Pick<ChannelPlugin, "id" | "meta" | "capabilities" | "config">;
|
||||
}) {
|
||||
@@ -228,6 +300,188 @@ export function installChannelSurfaceContractSuite(params: {
|
||||
});
|
||||
}
|
||||
|
||||
export function installChannelThreadingContractSuite(params: {
|
||||
plugin: Pick<ChannelPlugin, "id" | "threading">;
|
||||
}) {
|
||||
it("exposes the base threading contract", () => {
|
||||
expect(params.plugin.threading).toBeDefined();
|
||||
});
|
||||
|
||||
it("keeps threading return values normalized", () => {
|
||||
const threading = params.plugin.threading;
|
||||
expect(threading).toBeDefined();
|
||||
|
||||
if (threading?.resolveReplyToMode) {
|
||||
expect(
|
||||
["off", "first", "all"].includes(
|
||||
threading.resolveReplyToMode({
|
||||
cfg: {} as OpenClawConfig,
|
||||
accountId: "default",
|
||||
chatType: "group",
|
||||
}),
|
||||
),
|
||||
).toBe(true);
|
||||
}
|
||||
|
||||
const repliedRef = { value: false };
|
||||
const toolContext = threading?.buildToolContext?.({
|
||||
cfg: {} as OpenClawConfig,
|
||||
accountId: "default",
|
||||
context: {
|
||||
Channel: "group:test",
|
||||
From: "user:test",
|
||||
To: "group:test",
|
||||
ChatType: "group",
|
||||
CurrentMessageId: "msg-1",
|
||||
ReplyToId: "msg-0",
|
||||
ReplyToIdFull: "thread-0",
|
||||
MessageThreadId: "thread-0",
|
||||
NativeChannelId: "native:test",
|
||||
},
|
||||
hasRepliedRef: repliedRef,
|
||||
});
|
||||
|
||||
if (toolContext) {
|
||||
expectThreadingToolContextShape(toolContext);
|
||||
if (toolContext.hasRepliedRef) {
|
||||
expect(toolContext.hasRepliedRef).toBe(repliedRef);
|
||||
}
|
||||
}
|
||||
|
||||
const autoThreadId = threading?.resolveAutoThreadId?.({
|
||||
cfg: {} as OpenClawConfig,
|
||||
accountId: "default",
|
||||
to: "group:test",
|
||||
toolContext,
|
||||
replyToId: null,
|
||||
});
|
||||
if (autoThreadId !== undefined) {
|
||||
expect(typeof autoThreadId).toBe("string");
|
||||
expect(autoThreadId.trim()).not.toBe("");
|
||||
}
|
||||
|
||||
const replyTransport = threading?.resolveReplyTransport?.({
|
||||
cfg: {} as OpenClawConfig,
|
||||
accountId: "default",
|
||||
threadId: "thread-0",
|
||||
replyToId: "msg-0",
|
||||
});
|
||||
if (replyTransport) {
|
||||
expectReplyTransportShape(replyTransport);
|
||||
}
|
||||
|
||||
const focusedBinding = threading?.resolveFocusedBinding?.({
|
||||
cfg: {} as OpenClawConfig,
|
||||
accountId: "default",
|
||||
context: {
|
||||
Channel: "group:test",
|
||||
From: "user:test",
|
||||
To: "group:test",
|
||||
ChatType: "group",
|
||||
CurrentMessageId: "msg-1",
|
||||
ReplyToId: "msg-0",
|
||||
ReplyToIdFull: "thread-0",
|
||||
MessageThreadId: "thread-0",
|
||||
NativeChannelId: "native:test",
|
||||
},
|
||||
});
|
||||
if (focusedBinding) {
|
||||
expectFocusedBindingShape(focusedBinding);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function installChannelDirectoryContractSuite(params: {
|
||||
plugin: Pick<ChannelPlugin, "id" | "directory">;
|
||||
invokeLookups?: boolean;
|
||||
}) {
|
||||
it("exposes the base directory contract", async () => {
|
||||
const directory = params.plugin.directory;
|
||||
expect(directory).toBeDefined();
|
||||
|
||||
if (params.invokeLookups === false) {
|
||||
return;
|
||||
}
|
||||
const self = await directory?.self?.({
|
||||
cfg: {} as OpenClawConfig,
|
||||
accountId: "default",
|
||||
runtime: contractRuntime,
|
||||
});
|
||||
if (self) {
|
||||
expectDirectoryEntryShape(self);
|
||||
}
|
||||
|
||||
const peers =
|
||||
(await directory?.listPeers?.({
|
||||
cfg: {} as OpenClawConfig,
|
||||
accountId: "default",
|
||||
query: "",
|
||||
limit: 5,
|
||||
runtime: contractRuntime,
|
||||
})) ?? [];
|
||||
expect(Array.isArray(peers)).toBe(true);
|
||||
for (const peer of peers) {
|
||||
expectDirectoryEntryShape(peer);
|
||||
}
|
||||
|
||||
const groups =
|
||||
(await directory?.listGroups?.({
|
||||
cfg: {} as OpenClawConfig,
|
||||
accountId: "default",
|
||||
query: "",
|
||||
limit: 5,
|
||||
runtime: contractRuntime,
|
||||
})) ?? [];
|
||||
expect(Array.isArray(groups)).toBe(true);
|
||||
for (const group of groups) {
|
||||
expectDirectoryEntryShape(group);
|
||||
}
|
||||
|
||||
if (directory?.listGroupMembers && groups[0]?.id) {
|
||||
const members = await directory.listGroupMembers({
|
||||
cfg: {} as OpenClawConfig,
|
||||
accountId: "default",
|
||||
groupId: groups[0].id,
|
||||
limit: 5,
|
||||
runtime: contractRuntime,
|
||||
});
|
||||
expect(Array.isArray(members)).toBe(true);
|
||||
for (const member of members) {
|
||||
expectDirectoryEntryShape(member);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function installSessionBindingContractSuite(params: {
|
||||
getCapabilities: () => SessionBindingCapabilities;
|
||||
bindAndResolve: () => Promise<SessionBindingRecord>;
|
||||
cleanup: () => Promise<void> | void;
|
||||
expectedCapabilities: SessionBindingCapabilities;
|
||||
}) {
|
||||
it("registers the expected session binding capabilities", () => {
|
||||
expect(params.getCapabilities()).toEqual(params.expectedCapabilities);
|
||||
});
|
||||
|
||||
it("binds and resolves a session binding through the shared service", async () => {
|
||||
const binding = await params.bindAndResolve();
|
||||
expect(typeof binding.bindingId).toBe("string");
|
||||
expect(binding.bindingId.trim()).not.toBe("");
|
||||
expect(typeof binding.targetSessionKey).toBe("string");
|
||||
expect(binding.targetSessionKey.trim()).not.toBe("");
|
||||
expect(["session", "subagent"]).toContain(binding.targetKind);
|
||||
expect(typeof binding.conversation.channel).toBe("string");
|
||||
expect(typeof binding.conversation.accountId).toBe("string");
|
||||
expect(typeof binding.conversation.conversationId).toBe("string");
|
||||
expect(["active", "ending", "ended"]).toContain(binding.status);
|
||||
expect(typeof binding.boundAt).toBe("number");
|
||||
});
|
||||
|
||||
it("cleans up registered bindings", async () => {
|
||||
await params.cleanup();
|
||||
});
|
||||
}
|
||||
|
||||
type ChannelSetupContractCase<ResolvedAccount> = {
|
||||
name: string;
|
||||
cfg: OpenClawConfig;
|
||||
|
||||
11
src/channels/plugins/contracts/threading.contract.test.ts
Normal file
11
src/channels/plugins/contracts/threading.contract.test.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { describe } from "vitest";
|
||||
import { threadingContractRegistry } from "./registry.js";
|
||||
import { installChannelThreadingContractSuite } from "./suites.js";
|
||||
|
||||
for (const entry of threadingContractRegistry) {
|
||||
describe(`${entry.id} threading contract`, () => {
|
||||
installChannelThreadingContractSuite({
|
||||
plugin: entry.plugin,
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -10,7 +10,7 @@ import type {
|
||||
GroupToolPolicyConfig,
|
||||
} from "../../config/types.tools.js";
|
||||
import { resolveExactLineGroupConfigKey } from "../../line/group-keys.js";
|
||||
import { inspectSlackAccount } from "../../plugin-sdk-internal/slack.js";
|
||||
import { inspectSlackAccount } from "../../plugin-sdk/slack.js";
|
||||
import { normalizeAtHashSlug, normalizeHyphenSlug } from "../../shared/string-normalization.js";
|
||||
import type { ChannelGroupContext } from "./types.js";
|
||||
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type { OpenClawConfig } from "../../config/config.js";
|
||||
import { setActivePluginRegistry } from "../../plugins/runtime.js";
|
||||
import { defaultRuntime } from "../../runtime.js";
|
||||
import {
|
||||
createChannelTestPluginBase,
|
||||
createTestRegistry,
|
||||
} from "../../test-utils/channel-plugins.js";
|
||||
import {
|
||||
__testing,
|
||||
channelSupportsMessageCapability,
|
||||
channelSupportsMessageCapabilityForChannel,
|
||||
listChannelMessageActions,
|
||||
listChannelMessageCapabilities,
|
||||
listChannelMessageCapabilitiesForChannel,
|
||||
} from "./message-actions.js";
|
||||
@@ -56,8 +59,12 @@ function activateMessageActionTestRegistry() {
|
||||
}
|
||||
|
||||
describe("message action capability checks", () => {
|
||||
const errorSpy = vi.spyOn(defaultRuntime, "error").mockImplementation(() => undefined);
|
||||
|
||||
afterEach(() => {
|
||||
setActivePluginRegistry(emptyRegistry);
|
||||
__testing.resetLoggedMessageActionErrors();
|
||||
errorSpy.mockClear();
|
||||
});
|
||||
|
||||
it("aggregates capabilities across plugins", () => {
|
||||
@@ -122,4 +129,36 @@ describe("message action capability checks", () => {
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it("skips crashing action/capability discovery paths and logs once", () => {
|
||||
const crashingPlugin: ChannelPlugin = {
|
||||
...createChannelTestPluginBase({
|
||||
id: "discord",
|
||||
label: "Discord",
|
||||
capabilities: { chatTypes: ["direct", "group"] },
|
||||
config: {
|
||||
listAccountIds: () => ["default"],
|
||||
},
|
||||
}),
|
||||
actions: {
|
||||
listActions: () => {
|
||||
throw new Error("boom");
|
||||
},
|
||||
getCapabilities: () => {
|
||||
throw new Error("boom");
|
||||
},
|
||||
},
|
||||
};
|
||||
setActivePluginRegistry(
|
||||
createTestRegistry([{ pluginId: "discord", source: "test", plugin: crashingPlugin }]),
|
||||
);
|
||||
|
||||
expect(listChannelMessageActions({} as OpenClawConfig)).toEqual(["send", "broadcast"]);
|
||||
expect(listChannelMessageCapabilities({} as OpenClawConfig)).toEqual([]);
|
||||
expect(errorSpy).toHaveBeenCalledTimes(2);
|
||||
|
||||
expect(listChannelMessageActions({} as OpenClawConfig)).toEqual(["send", "broadcast"]);
|
||||
expect(listChannelMessageCapabilities({} as OpenClawConfig)).toEqual([]);
|
||||
expect(errorSpy).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { AgentToolResult } from "@mariozechner/pi-agent-core";
|
||||
import type { OpenClawConfig } from "../../config/config.js";
|
||||
import { defaultRuntime } from "../../runtime.js";
|
||||
import { getChannelPlugin, listChannelPlugins } from "./index.js";
|
||||
import type { ChannelMessageCapability } from "./message-capabilities.js";
|
||||
import type { ChannelMessageActionContext, ChannelMessageActionName } from "./types.js";
|
||||
@@ -16,13 +17,54 @@ function requiresTrustedRequesterSender(ctx: ChannelMessageActionContext): boole
|
||||
);
|
||||
}
|
||||
|
||||
const loggedMessageActionErrors = new Set<string>();
|
||||
|
||||
function logMessageActionError(params: {
|
||||
pluginId: string;
|
||||
operation: "listActions" | "getCapabilities";
|
||||
error: unknown;
|
||||
}) {
|
||||
const message = params.error instanceof Error ? params.error.message : String(params.error);
|
||||
const key = `${params.pluginId}:${params.operation}:${message}`;
|
||||
if (loggedMessageActionErrors.has(key)) {
|
||||
return;
|
||||
}
|
||||
loggedMessageActionErrors.add(key);
|
||||
const stack = params.error instanceof Error && params.error.stack ? params.error.stack : null;
|
||||
defaultRuntime.error?.(
|
||||
`[message-actions] ${params.pluginId}.actions.${params.operation} failed: ${stack ?? message}`,
|
||||
);
|
||||
}
|
||||
|
||||
function runListActionsSafely(params: {
|
||||
pluginId: string;
|
||||
cfg: OpenClawConfig;
|
||||
listActions: NonNullable<ChannelActions["listActions"]>;
|
||||
}): ChannelMessageActionName[] {
|
||||
try {
|
||||
const listed = params.listActions({ cfg: params.cfg });
|
||||
return Array.isArray(listed) ? listed : [];
|
||||
} catch (error) {
|
||||
logMessageActionError({
|
||||
pluginId: params.pluginId,
|
||||
operation: "listActions",
|
||||
error,
|
||||
});
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export function listChannelMessageActions(cfg: OpenClawConfig): ChannelMessageActionName[] {
|
||||
const actions = new Set<ChannelMessageActionName>(["send", "broadcast"]);
|
||||
for (const plugin of listChannelPlugins()) {
|
||||
const list = plugin.actions?.listActions?.({ cfg });
|
||||
if (!list) {
|
||||
if (!plugin.actions?.listActions) {
|
||||
continue;
|
||||
}
|
||||
const list = runListActionsSafely({
|
||||
pluginId: plugin.id,
|
||||
cfg,
|
||||
listActions: plugin.actions.listActions,
|
||||
});
|
||||
for (const action of list) {
|
||||
actions.add(action);
|
||||
}
|
||||
@@ -30,11 +72,21 @@ export function listChannelMessageActions(cfg: OpenClawConfig): ChannelMessageAc
|
||||
return Array.from(actions);
|
||||
}
|
||||
|
||||
function listCapabilities(
|
||||
actions: ChannelActions,
|
||||
cfg: OpenClawConfig,
|
||||
): readonly ChannelMessageCapability[] {
|
||||
return actions.getCapabilities?.({ cfg }) ?? [];
|
||||
function listCapabilities(params: {
|
||||
pluginId: string;
|
||||
actions: ChannelActions;
|
||||
cfg: OpenClawConfig;
|
||||
}): readonly ChannelMessageCapability[] {
|
||||
try {
|
||||
return params.actions.getCapabilities?.({ cfg: params.cfg }) ?? [];
|
||||
} catch (error) {
|
||||
logMessageActionError({
|
||||
pluginId: params.pluginId,
|
||||
operation: "getCapabilities",
|
||||
error,
|
||||
});
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export function listChannelMessageCapabilities(cfg: OpenClawConfig): ChannelMessageCapability[] {
|
||||
@@ -43,7 +95,11 @@ export function listChannelMessageCapabilities(cfg: OpenClawConfig): ChannelMess
|
||||
if (!plugin.actions) {
|
||||
continue;
|
||||
}
|
||||
for (const capability of listCapabilities(plugin.actions, cfg)) {
|
||||
for (const capability of listCapabilities({
|
||||
pluginId: plugin.id,
|
||||
actions: plugin.actions,
|
||||
cfg,
|
||||
})) {
|
||||
capabilities.add(capability);
|
||||
}
|
||||
}
|
||||
@@ -58,7 +114,15 @@ export function listChannelMessageCapabilitiesForChannel(params: {
|
||||
return [];
|
||||
}
|
||||
const plugin = getChannelPlugin(params.channel as Parameters<typeof getChannelPlugin>[0]);
|
||||
return plugin?.actions ? Array.from(listCapabilities(plugin.actions, params.cfg)) : [];
|
||||
return plugin?.actions
|
||||
? Array.from(
|
||||
listCapabilities({
|
||||
pluginId: plugin.id,
|
||||
actions: plugin.actions,
|
||||
cfg: params.cfg,
|
||||
}),
|
||||
)
|
||||
: [];
|
||||
}
|
||||
|
||||
export function channelSupportsMessageCapability(
|
||||
@@ -95,3 +159,9 @@ export async function dispatchChannelMessageAction(
|
||||
}
|
||||
return await plugin.actions.handleAction(ctx);
|
||||
}
|
||||
|
||||
export const __testing = {
|
||||
resetLoggedMessageActionErrors() {
|
||||
loggedMessageActionErrors.clear();
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { OpenClawConfig } from "../../config/config.js";
|
||||
import { DEFAULT_ACCOUNT_ID } from "../../routing/session-key.js";
|
||||
import { applySetupAccountConfigPatch } from "./setup-helpers.js";
|
||||
import {
|
||||
applySetupAccountConfigPatch,
|
||||
createPatchedAccountSetupAdapter,
|
||||
prepareScopedSetupConfig,
|
||||
} from "./setup-helpers.js";
|
||||
|
||||
function asConfig(value: unknown): OpenClawConfig {
|
||||
return value as OpenClawConfig;
|
||||
@@ -79,3 +83,121 @@ describe("applySetupAccountConfigPatch", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("createPatchedAccountSetupAdapter", () => {
|
||||
it("stores default-account patch at channel root", () => {
|
||||
const adapter = createPatchedAccountSetupAdapter({
|
||||
channelKey: "zalo",
|
||||
buildPatch: (input) => ({ botToken: input.token }),
|
||||
});
|
||||
|
||||
const next = adapter.applyAccountConfig({
|
||||
cfg: asConfig({ channels: { zalo: { enabled: false } } }),
|
||||
accountId: DEFAULT_ACCOUNT_ID,
|
||||
input: { name: "Personal", token: "tok" },
|
||||
});
|
||||
|
||||
expect(next.channels?.zalo).toMatchObject({
|
||||
enabled: true,
|
||||
name: "Personal",
|
||||
botToken: "tok",
|
||||
});
|
||||
});
|
||||
|
||||
it("migrates base name into the default account before patching a named account", () => {
|
||||
const adapter = createPatchedAccountSetupAdapter({
|
||||
channelKey: "zalo",
|
||||
buildPatch: (input) => ({ botToken: input.token }),
|
||||
});
|
||||
|
||||
const next = adapter.applyAccountConfig({
|
||||
cfg: asConfig({
|
||||
channels: {
|
||||
zalo: {
|
||||
name: "Personal",
|
||||
accounts: {
|
||||
work: { botToken: "old" },
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
accountId: "Work Team",
|
||||
input: { name: "Work", token: "new" },
|
||||
});
|
||||
|
||||
expect(next.channels?.zalo).toMatchObject({
|
||||
accounts: {
|
||||
default: { name: "Personal" },
|
||||
work: { botToken: "old" },
|
||||
"work-team": { enabled: true, name: "Work", botToken: "new" },
|
||||
},
|
||||
});
|
||||
expect(next.channels?.zalo).not.toHaveProperty("name");
|
||||
});
|
||||
|
||||
it("can store the default account in accounts.default", () => {
|
||||
const adapter = createPatchedAccountSetupAdapter({
|
||||
channelKey: "whatsapp",
|
||||
alwaysUseAccounts: true,
|
||||
buildPatch: (input) => ({ authDir: input.authDir }),
|
||||
});
|
||||
|
||||
const next = adapter.applyAccountConfig({
|
||||
cfg: asConfig({ channels: { whatsapp: {} } }),
|
||||
accountId: DEFAULT_ACCOUNT_ID,
|
||||
input: { name: "Phone", authDir: "/tmp/auth" },
|
||||
});
|
||||
|
||||
expect(next.channels?.whatsapp).toMatchObject({
|
||||
accounts: {
|
||||
default: {
|
||||
enabled: true,
|
||||
name: "Phone",
|
||||
authDir: "/tmp/auth",
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(next.channels?.whatsapp).not.toHaveProperty("enabled");
|
||||
expect(next.channels?.whatsapp).not.toHaveProperty("authDir");
|
||||
});
|
||||
});
|
||||
|
||||
describe("prepareScopedSetupConfig", () => {
|
||||
it("stores the name and migrates it for named accounts when requested", () => {
|
||||
const next = prepareScopedSetupConfig({
|
||||
cfg: asConfig({
|
||||
channels: {
|
||||
bluebubbles: {
|
||||
name: "Personal",
|
||||
},
|
||||
},
|
||||
}),
|
||||
channelKey: "bluebubbles",
|
||||
accountId: "Work Team",
|
||||
name: "Work",
|
||||
migrateBaseName: true,
|
||||
});
|
||||
|
||||
expect(next.channels?.bluebubbles).toMatchObject({
|
||||
accounts: {
|
||||
default: { name: "Personal" },
|
||||
"work-team": { name: "Work" },
|
||||
},
|
||||
});
|
||||
expect(next.channels?.bluebubbles).not.toHaveProperty("name");
|
||||
});
|
||||
|
||||
it("keeps the base shape for the default account when migration is disabled", () => {
|
||||
const next = prepareScopedSetupConfig({
|
||||
cfg: asConfig({ channels: { irc: { enabled: true } } }),
|
||||
channelKey: "irc",
|
||||
accountId: DEFAULT_ACCOUNT_ID,
|
||||
name: "Libera",
|
||||
});
|
||||
|
||||
expect(next.channels?.irc).toMatchObject({
|
||||
enabled: true,
|
||||
name: "Libera",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import type { OpenClawConfig } from "../../config/config.js";
|
||||
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../../routing/session-key.js";
|
||||
import type { ChannelSetupAdapter } from "./types.adapters.js";
|
||||
import type { ChannelSetupInput } from "./types.core.js";
|
||||
|
||||
type ChannelSectionBase = {
|
||||
name?: string;
|
||||
@@ -120,6 +122,31 @@ export function migrateBaseNameToDefaultAccount(params: {
|
||||
} as OpenClawConfig;
|
||||
}
|
||||
|
||||
export function prepareScopedSetupConfig(params: {
|
||||
cfg: OpenClawConfig;
|
||||
channelKey: string;
|
||||
accountId: string;
|
||||
name?: string;
|
||||
alwaysUseAccounts?: boolean;
|
||||
migrateBaseName?: boolean;
|
||||
}): OpenClawConfig {
|
||||
const namedConfig = applyAccountNameToChannelSection({
|
||||
cfg: params.cfg,
|
||||
channelKey: params.channelKey,
|
||||
accountId: params.accountId,
|
||||
name: params.name,
|
||||
alwaysUseAccounts: params.alwaysUseAccounts,
|
||||
});
|
||||
if (!params.migrateBaseName || normalizeAccountId(params.accountId) === DEFAULT_ACCOUNT_ID) {
|
||||
return namedConfig;
|
||||
}
|
||||
return migrateBaseNameToDefaultAccount({
|
||||
cfg: namedConfig,
|
||||
channelKey: params.channelKey,
|
||||
alwaysUseAccounts: params.alwaysUseAccounts,
|
||||
});
|
||||
}
|
||||
|
||||
export function applySetupAccountConfigPatch(params: {
|
||||
cfg: OpenClawConfig;
|
||||
channelKey: string;
|
||||
@@ -134,6 +161,49 @@ export function applySetupAccountConfigPatch(params: {
|
||||
});
|
||||
}
|
||||
|
||||
export function createPatchedAccountSetupAdapter(params: {
|
||||
channelKey: string;
|
||||
alwaysUseAccounts?: boolean;
|
||||
ensureChannelEnabled?: boolean;
|
||||
ensureAccountEnabled?: boolean;
|
||||
validateInput?: ChannelSetupAdapter["validateInput"];
|
||||
buildPatch: (input: ChannelSetupInput) => Record<string, unknown>;
|
||||
}): ChannelSetupAdapter {
|
||||
return {
|
||||
resolveAccountId: ({ accountId }) => normalizeAccountId(accountId),
|
||||
applyAccountName: ({ cfg, accountId, name }) =>
|
||||
prepareScopedSetupConfig({
|
||||
cfg,
|
||||
channelKey: params.channelKey,
|
||||
accountId,
|
||||
name,
|
||||
alwaysUseAccounts: params.alwaysUseAccounts,
|
||||
}),
|
||||
validateInput: params.validateInput,
|
||||
applyAccountConfig: ({ cfg, accountId, input }) => {
|
||||
const next = prepareScopedSetupConfig({
|
||||
cfg,
|
||||
channelKey: params.channelKey,
|
||||
accountId,
|
||||
name: input.name,
|
||||
alwaysUseAccounts: params.alwaysUseAccounts,
|
||||
migrateBaseName: !params.alwaysUseAccounts,
|
||||
});
|
||||
const patch = params.buildPatch(input);
|
||||
return patchScopedAccountConfig({
|
||||
cfg: next,
|
||||
channelKey: params.channelKey,
|
||||
accountId,
|
||||
patch,
|
||||
accountPatch: patch,
|
||||
ensureChannelEnabled: params.ensureChannelEnabled ?? !params.alwaysUseAccounts,
|
||||
ensureAccountEnabled: params.ensureAccountEnabled ?? true,
|
||||
scopeDefaultToAccounts: params.alwaysUseAccounts,
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function patchScopedAccountConfig(params: {
|
||||
cfg: OpenClawConfig;
|
||||
channelKey: string;
|
||||
@@ -142,6 +212,7 @@ export function patchScopedAccountConfig(params: {
|
||||
accountPatch?: Record<string, unknown>;
|
||||
ensureChannelEnabled?: boolean;
|
||||
ensureAccountEnabled?: boolean;
|
||||
scopeDefaultToAccounts?: boolean;
|
||||
}): OpenClawConfig {
|
||||
const accountId = normalizeAccountId(params.accountId);
|
||||
const channels = params.cfg.channels as Record<string, unknown> | undefined;
|
||||
@@ -156,7 +227,7 @@ export function patchScopedAccountConfig(params: {
|
||||
const ensureAccountEnabled = params.ensureAccountEnabled ?? ensureChannelEnabled;
|
||||
const patch = params.patch;
|
||||
const accountPatch = params.accountPatch ?? patch;
|
||||
if (accountId === DEFAULT_ACCOUNT_ID) {
|
||||
if (accountId === DEFAULT_ACCOUNT_ID && !params.scopeDefaultToAccounts) {
|
||||
return {
|
||||
...params.cfg,
|
||||
channels: {
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import {
|
||||
promptSecretRefForSetup,
|
||||
resolveSecretInputModeForEnvSelection,
|
||||
} from "../../commands/auth-choice.apply-helpers.js";
|
||||
import type { OpenClawConfig } from "../../config/config.js";
|
||||
import type { DmPolicy, GroupPolicy } from "../../config/types.js";
|
||||
import type { SecretInput } from "../../config/types.secrets.js";
|
||||
import {
|
||||
promptSecretRefForSetup,
|
||||
resolveSecretInputModeForEnvSelection,
|
||||
} from "../../plugins/provider-auth-input.js";
|
||||
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../../routing/session-key.js";
|
||||
import type { WizardPrompter } from "../../wizard/prompts.js";
|
||||
import {
|
||||
|
||||
61
src/channels/plugins/setup-wizard-proxy.ts
Normal file
61
src/channels/plugins/setup-wizard-proxy.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import type { OpenClawConfig } from "../../config/config.js";
|
||||
import type { ChannelSetupDmPolicy } from "./setup-wizard-types.js";
|
||||
import type { ChannelSetupWizard } from "./setup-wizard.js";
|
||||
|
||||
type PromptAllowFromParams = Parameters<NonNullable<ChannelSetupDmPolicy["promptAllowFrom"]>>[0];
|
||||
type ResolveAllowFromEntriesParams = Parameters<
|
||||
NonNullable<ChannelSetupWizard["allowFrom"]>["resolveEntries"]
|
||||
>[0];
|
||||
type ResolveAllowFromEntriesResult = Awaited<
|
||||
ReturnType<NonNullable<ChannelSetupWizard["allowFrom"]>["resolveEntries"]>
|
||||
>;
|
||||
type ResolveGroupAllowlistParams = Parameters<
|
||||
NonNullable<NonNullable<ChannelSetupWizard["groupAccess"]>["resolveAllowlist"]>
|
||||
>[0];
|
||||
|
||||
export function createAllowlistSetupWizardProxy<TGroupResolved>(params: {
|
||||
loadWizard: () => Promise<ChannelSetupWizard>;
|
||||
createBase: (handlers: {
|
||||
promptAllowFrom: (params: PromptAllowFromParams) => Promise<OpenClawConfig>;
|
||||
resolveAllowFromEntries: (
|
||||
params: ResolveAllowFromEntriesParams,
|
||||
) => Promise<ResolveAllowFromEntriesResult>;
|
||||
resolveGroupAllowlist: (params: ResolveGroupAllowlistParams) => Promise<TGroupResolved>;
|
||||
}) => ChannelSetupWizard;
|
||||
fallbackResolvedGroupAllowlist: (entries: string[]) => TGroupResolved;
|
||||
}) {
|
||||
return params.createBase({
|
||||
promptAllowFrom: async ({ cfg, prompter, accountId }) => {
|
||||
const wizard = await params.loadWizard();
|
||||
if (!wizard.dmPolicy?.promptAllowFrom) {
|
||||
return cfg;
|
||||
}
|
||||
return await wizard.dmPolicy.promptAllowFrom({ cfg, prompter, accountId });
|
||||
},
|
||||
resolveAllowFromEntries: async ({ cfg, accountId, credentialValues, entries }) => {
|
||||
const wizard = await params.loadWizard();
|
||||
if (!wizard.allowFrom) {
|
||||
return entries.map((input) => ({ input, resolved: false, id: null }));
|
||||
}
|
||||
return await wizard.allowFrom.resolveEntries({
|
||||
cfg,
|
||||
accountId,
|
||||
credentialValues,
|
||||
entries,
|
||||
});
|
||||
},
|
||||
resolveGroupAllowlist: async ({ cfg, accountId, credentialValues, entries, prompter }) => {
|
||||
const wizard = await params.loadWizard();
|
||||
if (!wizard.groupAccess?.resolveAllowlist) {
|
||||
return params.fallbackResolvedGroupAllowlist(entries);
|
||||
}
|
||||
return (await wizard.groupAccess.resolveAllowlist({
|
||||
cfg,
|
||||
accountId,
|
||||
credentialValues,
|
||||
entries,
|
||||
prompter,
|
||||
})) as TGroupResolved;
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -4,8 +4,8 @@ import {
|
||||
isSlackInteractiveRepliesEnabled,
|
||||
listSlackMessageActions,
|
||||
resolveSlackChannelId,
|
||||
} from "../../plugin-sdk-internal/slack.js";
|
||||
import { handleSlackMessageAction } from "../../plugin-sdk/slack-message-actions.js";
|
||||
handleSlackMessageAction,
|
||||
} from "../../plugin-sdk/slack.js";
|
||||
import type { ChannelMessageActionAdapter } from "./types.js";
|
||||
|
||||
export function createSlackActions(providerId: string): ChannelMessageActionAdapter {
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
export { inspectDiscordAccount } from "../plugin-sdk-internal/discord.js";
|
||||
export type { InspectedDiscordAccount } from "../plugin-sdk-internal/discord.js";
|
||||
export { inspectDiscordAccount } from "../plugin-sdk/discord.js";
|
||||
export type { InspectedDiscordAccount } from "../plugin-sdk/discord.js";
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
export { inspectSlackAccount } from "../plugin-sdk-internal/slack.js";
|
||||
export type { InspectedSlackAccount } from "../plugin-sdk-internal/slack.js";
|
||||
export { inspectSlackAccount } from "../plugin-sdk/slack.js";
|
||||
export type { InspectedSlackAccount } from "../plugin-sdk/slack.js";
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user