376 lines
12 KiB
TypeScript
376 lines
12 KiB
TypeScript
import { Type } from "@sinclair/typebox";
|
|
import { readNumberParam, readStringParam } from "../../../src/agents/tools/common.js";
|
|
import type { SearchConfigRecord } from "../../../src/agents/tools/web-search-provider-common.js";
|
|
import {
|
|
buildSearchCacheKey,
|
|
DEFAULT_SEARCH_COUNT,
|
|
MAX_SEARCH_COUNT,
|
|
readCachedSearchPayload,
|
|
readConfiguredSecretString,
|
|
readProviderEnvValue,
|
|
resolveSearchCacheTtlMs,
|
|
resolveSearchCount,
|
|
resolveSearchTimeoutSeconds,
|
|
withTrustedWebSearchEndpoint,
|
|
writeCachedSearchPayload,
|
|
} from "../../../src/agents/tools/web-search-provider-common.js";
|
|
import {
|
|
resolveProviderWebSearchPluginConfig,
|
|
setProviderWebSearchPluginConfigValue,
|
|
} from "../../../src/agents/tools/web-search-provider-config.js";
|
|
import type { OpenClawConfig } from "../../../src/config/config.js";
|
|
import type {
|
|
WebSearchProviderPlugin,
|
|
WebSearchProviderToolDefinition,
|
|
} from "../../../src/plugins/types.js";
|
|
import { wrapWebContent } from "../../../src/security/external-content.js";
|
|
|
|
const DEFAULT_KIMI_BASE_URL = "https://api.moonshot.ai/v1";
|
|
const DEFAULT_KIMI_MODEL = "moonshot-v1-128k";
|
|
const KIMI_WEB_SEARCH_TOOL = {
|
|
type: "builtin_function",
|
|
function: { name: "$web_search" },
|
|
} as const;
|
|
|
|
type KimiConfig = {
|
|
apiKey?: string;
|
|
baseUrl?: string;
|
|
model?: string;
|
|
};
|
|
|
|
type KimiToolCall = {
|
|
id?: string;
|
|
type?: string;
|
|
function?: {
|
|
name?: string;
|
|
arguments?: string;
|
|
};
|
|
};
|
|
|
|
type KimiMessage = {
|
|
role?: string;
|
|
content?: string;
|
|
reasoning_content?: string;
|
|
tool_calls?: KimiToolCall[];
|
|
};
|
|
|
|
type KimiSearchResponse = {
|
|
choices?: Array<{
|
|
finish_reason?: string;
|
|
message?: KimiMessage;
|
|
}>;
|
|
search_results?: Array<{
|
|
title?: string;
|
|
url?: string;
|
|
content?: string;
|
|
}>;
|
|
};
|
|
|
|
function resolveKimiConfig(config?: OpenClawConfig, searchConfig?: SearchConfigRecord): KimiConfig {
|
|
const pluginConfig = resolveProviderWebSearchPluginConfig(config, "moonshot");
|
|
if (pluginConfig) {
|
|
return pluginConfig as KimiConfig;
|
|
}
|
|
const kimi = (searchConfig as Record<string, unknown> | undefined)?.kimi;
|
|
return kimi && typeof kimi === "object" && !Array.isArray(kimi) ? (kimi as KimiConfig) : {};
|
|
}
|
|
|
|
function resolveKimiApiKey(kimi?: KimiConfig): string | undefined {
|
|
return (
|
|
readConfiguredSecretString(kimi?.apiKey, "plugins.entries.moonshot.config.webSearch.apiKey") ??
|
|
readProviderEnvValue(["KIMI_API_KEY", "MOONSHOT_API_KEY"])
|
|
);
|
|
}
|
|
|
|
function resolveKimiModel(kimi?: KimiConfig): string {
|
|
const model = typeof kimi?.model === "string" ? kimi.model.trim() : "";
|
|
return model || DEFAULT_KIMI_MODEL;
|
|
}
|
|
|
|
function resolveKimiBaseUrl(kimi?: KimiConfig): string {
|
|
const baseUrl = typeof kimi?.baseUrl === "string" ? kimi.baseUrl.trim() : "";
|
|
return baseUrl || DEFAULT_KIMI_BASE_URL;
|
|
}
|
|
|
|
function extractKimiMessageText(message: KimiMessage | undefined): string | undefined {
|
|
const content = message?.content?.trim();
|
|
if (content) {
|
|
return content;
|
|
}
|
|
const reasoning = message?.reasoning_content?.trim();
|
|
return reasoning || undefined;
|
|
}
|
|
|
|
function extractKimiCitations(data: KimiSearchResponse): string[] {
|
|
const citations = (data.search_results ?? [])
|
|
.map((entry) => entry.url?.trim())
|
|
.filter((url): url is string => Boolean(url));
|
|
|
|
for (const toolCall of data.choices?.[0]?.message?.tool_calls ?? []) {
|
|
const rawArguments = toolCall.function?.arguments;
|
|
if (!rawArguments) {
|
|
continue;
|
|
}
|
|
try {
|
|
const parsed = JSON.parse(rawArguments) as {
|
|
search_results?: Array<{ url?: string }>;
|
|
url?: string;
|
|
};
|
|
if (typeof parsed.url === "string" && parsed.url.trim()) {
|
|
citations.push(parsed.url.trim());
|
|
}
|
|
for (const result of parsed.search_results ?? []) {
|
|
if (typeof result.url === "string" && result.url.trim()) {
|
|
citations.push(result.url.trim());
|
|
}
|
|
}
|
|
} catch {
|
|
// ignore malformed tool arguments
|
|
}
|
|
}
|
|
|
|
return [...new Set(citations)];
|
|
}
|
|
|
|
function buildKimiToolResultContent(data: KimiSearchResponse): string {
|
|
return JSON.stringify({
|
|
search_results: (data.search_results ?? []).map((entry) => ({
|
|
title: entry.title ?? "",
|
|
url: entry.url ?? "",
|
|
content: entry.content ?? "",
|
|
})),
|
|
});
|
|
}
|
|
|
|
async function runKimiSearch(params: {
|
|
query: string;
|
|
apiKey: string;
|
|
baseUrl: string;
|
|
model: string;
|
|
timeoutSeconds: number;
|
|
}): Promise<{ content: string; citations: string[] }> {
|
|
const endpoint = `${params.baseUrl.trim().replace(/\/$/, "")}/chat/completions`;
|
|
const messages: Array<Record<string, unknown>> = [{ role: "user", content: params.query }];
|
|
const collectedCitations = new Set<string>();
|
|
|
|
for (let round = 0; round < 3; round += 1) {
|
|
const next = await withTrustedWebSearchEndpoint(
|
|
{
|
|
url: endpoint,
|
|
timeoutSeconds: params.timeoutSeconds,
|
|
init: {
|
|
method: "POST",
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
Authorization: `Bearer ${params.apiKey}`,
|
|
},
|
|
body: JSON.stringify({
|
|
model: params.model,
|
|
messages,
|
|
tools: [KIMI_WEB_SEARCH_TOOL],
|
|
}),
|
|
},
|
|
},
|
|
async (
|
|
res,
|
|
): Promise<{ done: true; content: string; citations: string[] } | { done: false }> => {
|
|
if (!res.ok) {
|
|
const detail = await res.text();
|
|
throw new Error(`Kimi API error (${res.status}): ${detail || res.statusText}`);
|
|
}
|
|
|
|
const data = (await res.json()) as KimiSearchResponse;
|
|
for (const citation of extractKimiCitations(data)) {
|
|
collectedCitations.add(citation);
|
|
}
|
|
const choice = data.choices?.[0];
|
|
const message = choice?.message;
|
|
const text = extractKimiMessageText(message);
|
|
const toolCalls = message?.tool_calls ?? [];
|
|
|
|
if (choice?.finish_reason !== "tool_calls" || toolCalls.length === 0) {
|
|
return { done: true, content: text ?? "No response", citations: [...collectedCitations] };
|
|
}
|
|
|
|
messages.push({
|
|
role: "assistant",
|
|
content: message?.content ?? "",
|
|
...(message?.reasoning_content ? { reasoning_content: message.reasoning_content } : {}),
|
|
tool_calls: toolCalls,
|
|
});
|
|
|
|
const toolContent = buildKimiToolResultContent(data);
|
|
let pushed = false;
|
|
for (const toolCall of toolCalls) {
|
|
const toolCallId = toolCall.id?.trim();
|
|
if (!toolCallId) {
|
|
continue;
|
|
}
|
|
pushed = true;
|
|
messages.push({ role: "tool", tool_call_id: toolCallId, content: toolContent });
|
|
}
|
|
if (!pushed) {
|
|
return { done: true, content: text ?? "No response", citations: [...collectedCitations] };
|
|
}
|
|
return { done: false };
|
|
},
|
|
);
|
|
|
|
if (next.done) {
|
|
return { content: next.content, citations: next.citations };
|
|
}
|
|
}
|
|
|
|
return {
|
|
content: "Search completed but no final answer was produced.",
|
|
citations: [...collectedCitations],
|
|
};
|
|
}
|
|
|
|
function createKimiSchema() {
|
|
return Type.Object({
|
|
query: Type.String({ description: "Search query string." }),
|
|
count: Type.Optional(
|
|
Type.Number({
|
|
description: "Number of results to return (1-10).",
|
|
minimum: 1,
|
|
maximum: MAX_SEARCH_COUNT,
|
|
}),
|
|
),
|
|
country: Type.Optional(Type.String({ description: "Not supported by Kimi." })),
|
|
language: Type.Optional(Type.String({ description: "Not supported by Kimi." })),
|
|
freshness: Type.Optional(Type.String({ description: "Not supported by Kimi." })),
|
|
date_after: Type.Optional(Type.String({ description: "Not supported by Kimi." })),
|
|
date_before: Type.Optional(Type.String({ description: "Not supported by Kimi." })),
|
|
});
|
|
}
|
|
|
|
function createKimiToolDefinition(
|
|
config?: OpenClawConfig,
|
|
searchConfig?: SearchConfigRecord,
|
|
): WebSearchProviderToolDefinition {
|
|
return {
|
|
description:
|
|
"Search the web using Kimi by Moonshot. Returns AI-synthesized answers with citations from native $web_search.",
|
|
parameters: createKimiSchema(),
|
|
execute: async (args) => {
|
|
const params = args as Record<string, unknown>;
|
|
for (const name of ["country", "language", "freshness", "date_after", "date_before"]) {
|
|
if (readStringParam(params, name)) {
|
|
const label =
|
|
name === "country"
|
|
? "country filtering"
|
|
: name === "language"
|
|
? "language filtering"
|
|
: name === "freshness"
|
|
? "freshness filtering"
|
|
: "date_after/date_before filtering";
|
|
return {
|
|
error: name.startsWith("date_") ? "unsupported_date_filter" : `unsupported_${name}`,
|
|
message: `${label} is not supported by the kimi provider. Only Brave and Perplexity support ${name === "country" ? "country filtering" : name === "language" ? "language filtering" : name === "freshness" ? "freshness" : "date filtering"}.`,
|
|
docs: "https://docs.openclaw.ai/tools/web",
|
|
};
|
|
}
|
|
}
|
|
|
|
const kimiConfig = resolveKimiConfig(config, searchConfig);
|
|
const apiKey = resolveKimiApiKey(kimiConfig);
|
|
if (!apiKey) {
|
|
return {
|
|
error: "missing_kimi_api_key",
|
|
message:
|
|
"web_search (kimi) needs a Moonshot API key. Set KIMI_API_KEY or MOONSHOT_API_KEY in the Gateway environment, or configure plugins.entries.moonshot.config.webSearch.apiKey.",
|
|
docs: "https://docs.openclaw.ai/tools/web",
|
|
};
|
|
}
|
|
|
|
const query = readStringParam(params, "query", { required: true });
|
|
const count =
|
|
readNumberParam(params, "count", { integer: true }) ??
|
|
searchConfig?.maxResults ??
|
|
undefined;
|
|
const model = resolveKimiModel(kimiConfig);
|
|
const baseUrl = resolveKimiBaseUrl(kimiConfig);
|
|
const cacheKey = buildSearchCacheKey([
|
|
"kimi",
|
|
query,
|
|
resolveSearchCount(count, DEFAULT_SEARCH_COUNT),
|
|
baseUrl,
|
|
model,
|
|
]);
|
|
const cached = readCachedSearchPayload(cacheKey);
|
|
if (cached) {
|
|
return cached;
|
|
}
|
|
|
|
const start = Date.now();
|
|
const result = await runKimiSearch({
|
|
query,
|
|
apiKey,
|
|
baseUrl,
|
|
model,
|
|
timeoutSeconds: resolveSearchTimeoutSeconds(searchConfig),
|
|
});
|
|
const payload = {
|
|
query,
|
|
provider: "kimi",
|
|
model,
|
|
tookMs: Date.now() - start,
|
|
externalContent: {
|
|
untrusted: true,
|
|
source: "web_search",
|
|
provider: "kimi",
|
|
wrapped: true,
|
|
},
|
|
content: wrapWebContent(result.content),
|
|
citations: result.citations,
|
|
};
|
|
writeCachedSearchPayload(cacheKey, payload, resolveSearchCacheTtlMs(searchConfig));
|
|
return payload;
|
|
},
|
|
};
|
|
}
|
|
|
|
export function createKimiWebSearchProvider(): WebSearchProviderPlugin {
|
|
return {
|
|
id: "kimi",
|
|
label: "Kimi (Moonshot)",
|
|
hint: "Moonshot web search",
|
|
envVars: ["KIMI_API_KEY", "MOONSHOT_API_KEY"],
|
|
placeholder: "sk-...",
|
|
signupUrl: "https://platform.moonshot.cn/",
|
|
docsUrl: "https://docs.openclaw.ai/tools/web",
|
|
autoDetectOrder: 40,
|
|
credentialPath: "plugins.entries.moonshot.config.webSearch.apiKey",
|
|
inactiveSecretPaths: ["plugins.entries.moonshot.config.webSearch.apiKey"],
|
|
getCredentialValue: (searchConfig) => {
|
|
const kimi = searchConfig?.kimi;
|
|
return kimi && typeof kimi === "object" && !Array.isArray(kimi)
|
|
? (kimi as Record<string, unknown>).apiKey
|
|
: undefined;
|
|
},
|
|
setCredentialValue: (searchConfigTarget, value) => {
|
|
const scoped = searchConfigTarget.kimi;
|
|
if (!scoped || typeof scoped !== "object" || Array.isArray(scoped)) {
|
|
searchConfigTarget.kimi = { apiKey: value };
|
|
return;
|
|
}
|
|
(scoped as Record<string, unknown>).apiKey = value;
|
|
},
|
|
getConfiguredCredentialValue: (config) =>
|
|
resolveProviderWebSearchPluginConfig(config, "moonshot")?.apiKey,
|
|
setConfiguredCredentialValue: (configTarget, value) => {
|
|
setProviderWebSearchPluginConfigValue(configTarget, "moonshot", "apiKey", value);
|
|
},
|
|
createTool: (ctx) =>
|
|
createKimiToolDefinition(ctx.config, ctx.searchConfig as SearchConfigRecord | undefined),
|
|
};
|
|
}
|
|
|
|
export const __testing = {
|
|
resolveKimiApiKey,
|
|
resolveKimiModel,
|
|
resolveKimiBaseUrl,
|
|
extractKimiCitations,
|
|
} as const;
|