feat(tools): support batched tool search queries (#118623)

* feat(tools): support batched tool search queries

* fix(tools): harden batched tool search

* fix(tools): preserve batch search contracts

* fix(tools): bound batch search processing

* fix(tools): bound batch candidate metadata

* fix(tools): preserve callable batch candidates

* docs(tools): align batch search QA proof

* refactor(tools): isolate search request parsing

* test(qa): strengthen structured search proof

* test(qa): align structured directory proof

* fix(tools): preserve scalar search compatibility

* test(qa): disable batch scenario retries

* test(qa): allow direct-only tools in structured proof

* test(qa): verify nested catalog call result

* test(qa): remove obsolete result scanner
This commit is contained in:
Jesse Merhi
2026-08-05 16:28:10 +10:00
committed by GitHub
parent f3186b0876
commit 95d7d95515
14 changed files with 1551 additions and 129 deletions
+40 -3
View File
@@ -214,6 +214,40 @@ The structured fallback mode exposes the same operations as tools:
- `tool_describe`
- `tool_call`
`tool_search` accepts either the existing single-query shape or a batch of
independent queries:
```json
{
"query": "today's calendar events",
"limit": 3
}
```
```json
{
"queries": [
{ "query": "today's calendar events", "limit": 3 },
{ "query": "Slack messages needing attention", "limit": 3 }
]
}
```
Single-query calls continue to return the compact candidate array directly.
Batch calls return `{ results: [{ query, candidates }] }` in request order. Each
query uses the same effective catalog, ranking, filtering, and per-query limit
as an ordinary search; a candidate may appear in more than one result group.
Descriptions are compacted before output. If the complete batch would exceed
the 4,000-character response budget, lower-ranked candidates are removed and
the response includes `truncated: true`. A result group that lost candidates
also includes `truncated: true`, so an empty truncated group cannot be mistaken
for a query that had no matches.
Omitted per-query limits use `searchDefaultLimit`. The effective limits in one
batch may request at most 50 candidates in total. A batch accepts at most 16
queries, with at most 512 characters per query and 512 UTF-8 bytes across the
serialized query list. Invalid batches fail as one request, while a valid query
with no matches returns an empty `candidates` array.
Directory mode exposes:
- `tool_search`
@@ -355,15 +389,16 @@ answer:
## E2E validation
The QA Lab gateway scenario proves both paths with the OpenClaw runtime:
The QA Lab gateway scenario proves all three paths with the OpenClaw runtime:
```bash
pnpm openclaw qa suite --provider-mode mock-openai --scenario tool-search-gateway-e2e
```
It creates a temporary fake plugin with a large tool catalog, starts the mock
OpenAI provider, starts a Gateway once in direct mode and once with Tool Search
enabled, then compares provider request payloads and session logs.
OpenAI provider, then runs the Gateway in direct, code-mode Tool Search, and
structured Tool Search modes. It compares provider request payloads for direct
and code mode, then verifies session logs and tool flow across all three lanes.
The regression proves:
@@ -373,6 +408,8 @@ The regression proves:
4. Tool Search exposes only the compact bridge plus any direct-only tools.
5. The Tool Search request payload is smaller for the large fake catalog.
6. Session logs show the expected tool-call counts and bridged call telemetry.
7. Structured mode resolves two queries with one `tool_search` call before the
selected plugin tool runs through `tool_call`.
## Failure behavior
+30 -69
View File
@@ -303,21 +303,6 @@ function sessionLogScanText(line: string): string | null {
}
}
async function countNeedlesInFile(filePath: string, needles: Record<string, string>) {
const text = await fs.readFile(filePath, "utf8").catch(() => "");
const counts = createCounts(needles);
for (const line of text.split(/\r?\n/u)) {
const scanText = sessionLogScanText(line);
if (scanText === null) {
continue;
}
for (const [key, needle] of Object.entries(needles)) {
counts[key] = (counts[key] ?? 0) + countOccurrences(scanText, needle);
}
}
return counts;
}
function resolveAgentSqlitePathFromSessionsDir(sessionsDir: string): string | null {
if (path.basename(sessionsDir) !== "sessions") {
return null;
@@ -325,11 +310,25 @@ function resolveAgentSqlitePathFromSessionsDir(sessionsDir: string): string | nu
return path.join(path.dirname(sessionsDir), "agent", "openclaw-agent.sqlite");
}
function countNeedlesInSqliteTranscriptEvents(
sqlitePath: string,
needles: Record<string, string>,
): Record<string, number> {
const counts = createCounts(needles);
async function visitSessionLogEvents(
sessionsDir: string,
visit: (eventJson: string) => void,
): Promise<void> {
const files = await fs.readdir(sessionsDir, { recursive: true }).catch(() => []);
for (const file of files) {
if (typeof file !== "string" || !file.endsWith(".jsonl")) {
continue;
}
const text = await fs.readFile(path.join(sessionsDir, file), "utf8").catch(() => "");
for (const line of text.split(/\r?\n/u)) {
visit(line);
}
}
const sqlitePath = resolveAgentSqlitePathFromSessionsDir(sessionsDir);
if (!sqlitePath) {
return;
}
let db: DatabaseSync | null = null;
try {
db = openNodeSqliteDatabase(sqlitePath, { readOnly: true });
@@ -337,73 +336,35 @@ function countNeedlesInSqliteTranscriptEvents(
.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'transcript_events'")
.get();
if (!hasTranscriptEvents) {
return counts;
return;
}
const rows = db.prepare("SELECT event_json FROM transcript_events ORDER BY session_id, seq");
for (const row of rows.iterate() as Iterable<{ event_json?: unknown }>) {
if (typeof row.event_json !== "string") {
continue;
}
const scanText = sessionLogScanText(row.event_json);
if (scanText === null) {
continue;
}
for (const [key, needle] of Object.entries(needles)) {
counts[key] = (counts[key] ?? 0) + countOccurrences(scanText, needle);
if (typeof row.event_json === "string") {
visit(row.event_json);
}
}
return counts;
} catch {
return counts;
// Missing or unreadable stores contribute no events.
} finally {
db?.close();
}
}
async function countNeedlesInSqliteTranscriptStore(
sessionsDir: string,
needles: Record<string, string>,
): Promise<Record<string, number>> {
const sqlitePath = resolveAgentSqlitePathFromSessionsDir(sessionsDir);
if (!sqlitePath) {
return createCounts(needles);
}
try {
const stat = await fs.stat(sqlitePath);
if (!stat.isFile()) {
return createCounts(needles);
}
} catch {
return createCounts(needles);
}
return countNeedlesInSqliteTranscriptEvents(sqlitePath, needles);
}
export async function countSessionLogMentions(params: {
sessionsDir: string;
needles: Record<string, string>;
}): Promise<Record<string, number>> {
const counts = createCounts(params.needles);
const files = await fs.readdir(params.sessionsDir, { recursive: true }).catch(() => []);
for (const file of files) {
if (typeof file !== "string" || !file.endsWith(".jsonl")) {
continue;
await visitSessionLogEvents(params.sessionsDir, (eventJson) => {
const scanText = sessionLogScanText(eventJson);
if (scanText === null) {
return;
}
const fileCounts = await countNeedlesInFile(
path.join(params.sessionsDir, file),
params.needles,
);
for (const [key, count] of Object.entries(fileCounts)) {
counts[key] = (counts[key] ?? 0) + count;
for (const [key, needle] of Object.entries(params.needles)) {
counts[key] = (counts[key] ?? 0) + countOccurrences(scanText, needle);
}
}
const sqliteCounts = await countNeedlesInSqliteTranscriptStore(
params.sessionsDir,
params.needles,
);
for (const [key, count] of Object.entries(sqliteCounts)) {
counts[key] = (counts[key] ?? 0) + count;
}
});
return counts;
}
@@ -1,10 +1,13 @@
// QA Lab mock provider tool planning and memory fixtures.
import { createHash } from "node:crypto";
import { isRecord } from "openclaw/plugin-sdk/string-coerce-runtime";
import { QA_LAB_WEB_SEARCH_DENIED_INPUT_QUERY } from "../../qa-web-search-provider.js";
import type { StreamEvent } from "./mock-openai-contracts.js";
let mockFunctionCallSequence = 0;
export const QA_TOOL_SEARCH_SECONDARY_TARGET = "fake_plugin_tool_01";
function normalizePromptPathCandidate(candidate: string) {
const trimmed = candidate.trim().replace(/^`+|`+$/g, "");
if (!trimmed) {
@@ -216,6 +219,21 @@ export function extractToolSearchTarget(text: string): string | null {
return match?.[1]?.trim() || null;
}
export function toolSearchOutputHasCandidate(output: unknown, targetTool: string): boolean {
if (!isRecord(output) || !Array.isArray(output.results)) {
return false;
}
return output.results.some(
(result) =>
isRecord(result) &&
Array.isArray(result.candidates) &&
result.candidates.some(
(candidate) =>
isRecord(candidate) && (candidate.name === targetTool || candidate.id === targetTool),
),
);
}
export function buildQaToolSearchArgs(
targetTool: string,
failureMode: boolean,
@@ -5,7 +5,7 @@ import { WebSocket } from "ws";
import { readQaMockRequestCursor } from "../shared/debug-request-cursor.js";
import { adaptAnthropicToolCallIds } from "./mock-anthropic-wire.js";
import type { StreamEvent } from "./mock-openai-contracts.js";
import { readTargetFromPrompt } from "./mock-openai-tooling.js";
import { QA_TOOL_SEARCH_SECONDARY_TARGET, readTargetFromPrompt } from "./mock-openai-tooling.js";
import { startQaMockOpenAiServer } from "./server.js";
type MockServer = { baseUrl: string };
@@ -5262,6 +5262,151 @@ Update and merge these partial structured summaries.`,
expect(String(toolPlanOutput.arguments)).toContain("current");
});
it("plans one structured batch search for the Tool Search gateway fixture", async () => {
const server = await startMockServer();
const targetTool = "fake_plugin_tool_17";
const response = await expectNonStreamingResponses(server, {
tools: [{ type: "function", name: "tool_search" }],
input: [
makeUserInput(
`tool search qa check target=${targetTool}. Call exactly that tool once and then summarize.`,
),
],
});
const toolPlanOutput = outputItem(await response.json());
expect(toolPlanOutput.type).toBe("function_call");
expect(toolPlanOutput.name).toBe("tool_search");
expect(JSON.parse(String(toolPlanOutput.arguments))).toEqual({
queries: [
{ query: targetTool, limit: 1 },
{ query: QA_TOOL_SEARCH_SECONDARY_TARGET, limit: 1 },
],
});
});
it("prefers a directly declared target over structured catalog search", async () => {
const server = await startMockServer();
const targetTool = "web_fetch";
const response = await expectNonStreamingResponses(server, {
tools: [
{ type: "function", name: "tool_search" },
{ type: "function", name: targetTool },
],
input: [
makeUserInput(
`tool search qa check target=${targetTool}. Call exactly that tool once and then summarize.`,
),
],
});
const toolPlanOutput = outputItem(await response.json());
expect(toolPlanOutput.name).toBe(targetTool);
expect(JSON.parse(String(toolPlanOutput.arguments))).toEqual({
url: "https://example.com/",
maxChars: 500,
});
});
it("calls the selected catalog tool after a structured batch search", async () => {
const server = await startMockServer();
const targetTool = "fake_plugin_tool_17";
const response = await expectNonStreamingResponses(server, {
tools: [
{ type: "function", name: "tool_search" },
{ type: "function", name: "tool_call" },
],
input: [
makeUserInput(
`tool search qa check target=${targetTool}. Call exactly that tool once and then summarize.`,
),
{
type: "function_call",
call_id: "call_tool_search_1",
name: "tool_search",
arguments: JSON.stringify({
queries: [
{ query: targetTool, limit: 1 },
{ query: QA_TOOL_SEARCH_SECONDARY_TARGET, limit: 1 },
],
}),
},
makeToolOutputWithCallId(
"call_tool_search_1",
JSON.stringify({
results: [
{ query: targetTool, candidates: [{ name: targetTool }] },
{
query: QA_TOOL_SEARCH_SECONDARY_TARGET,
candidates: [{ name: QA_TOOL_SEARCH_SECONDARY_TARGET }],
},
],
}),
),
],
});
const toolPlanOutput = outputItem(await response.json());
expect(toolPlanOutput.type).toBe("function_call");
expect(toolPlanOutput.name).toBe("tool_call");
expect(JSON.parse(String(toolPlanOutput.arguments))).toMatchObject({ id: targetTool });
});
it("does not call a catalog tool when structured search returns no matching candidate", async () => {
const server = await startMockServer();
const targetTool = "fake_plugin_tool_17";
const response = await expectNonStreamingResponses(server, {
tools: [
{ type: "function", name: "tool_search" },
{ type: "function", name: "tool_call" },
],
input: [
makeUserInput(
`tool search qa check target=${targetTool}. Call exactly that tool once and then summarize.`,
),
{
type: "function_call",
call_id: "call_tool_search_1",
name: "tool_search",
arguments: JSON.stringify({ queries: [{ query: targetTool, limit: 1 }] }),
},
makeToolOutputWithCallId(
"call_tool_search_1",
JSON.stringify({ results: [{ query: targetTool, candidates: [] }] }),
),
],
});
expect(outputItem(await response.json()).name).not.toBe("tool_call");
});
it("does not repeat a catalog call after its result mentions the target", async () => {
const server = await startMockServer();
const targetTool = "fake_plugin_tool_17";
const response = await expectNonStreamingResponses(server, {
tools: [{ type: "function", name: "tool_call" }],
input: [
makeUserInput(
`tool search qa check target=${targetTool}. Call exactly that tool once and then summarize.`,
),
{
type: "function_call",
call_id: "call_target_1",
name: "tool_call",
arguments: JSON.stringify({ id: targetTool, args: {} }),
},
makeToolOutputWithCallId("call_target_1", `failed to call ${targetTool}`),
],
});
expect(outputItem(await response.json()).name).not.toBe("tool_call");
});
it("plans the explicit web_fetch fixture prompt as the canonical direct call", async () => {
const server = await startMockServer();
const prompt =
@@ -171,7 +171,9 @@ import {
buildToolCallEventsWithArgs as buildRawToolCallEventsWithArgs,
extractOrbitCode,
extractToolSearchTarget,
toolSearchOutputHasCandidate,
buildQaToolSearchArgs,
QA_TOOL_SEARCH_SECONDARY_TARGET,
isActiveMemorySubagentPrompt,
isSnackRecallPrompt,
extractSnackPreference,
@@ -963,22 +965,32 @@ async function buildResponsesPayload(
return buildToolCallEventsWithArgs("read", { path: "LOOP_STEADY.txt" });
}
if (
(QA_TOOL_SEARCH_PROMPT_RE.test(allInputText) ||
QA_TOOL_SEARCH_FAILURE_PROMPT_RE.test(allInputText)) &&
!hasCompletedToolOutput
QA_TOOL_SEARCH_PROMPT_RE.test(allInputText) ||
QA_TOOL_SEARCH_FAILURE_PROMPT_RE.test(allInputText)
) {
const targetTool = extractToolSearchTarget(allInputText);
const plannedArgs = targetTool
? buildQaToolSearchArgs(targetTool, QA_TOOL_SEARCH_FAILURE_PROMPT_RE.test(allInputText))
: {};
if (
targetTool &&
hasCompletedToolOutput &&
completedToolName === "tool_search" &&
!toolOutput.includes("FAKE_PLUGIN_OK") &&
toolSearchOutputHasCandidate(parseToolOutputJson(toolOutput), targetTool) &&
hasDeclaredTool(body, "tool_call")
) {
return buildToolCallEventsWithArgs("tool_call", { id: targetTool, args: plannedArgs });
}
if (
!hasCompletedToolOutput &&
targetTool &&
findNamedToolDefinition(toolDeclarationBody, targetTool)?.type === "custom" &&
typeof plannedArgs.input === "string"
) {
return buildToolCallEventsWithArgs(targetTool, plannedArgs);
}
if (targetTool && hasDeclaredTool(body, "tool_search_code")) {
if (!hasCompletedToolOutput && targetTool && hasDeclaredTool(body, "tool_search_code")) {
return buildToolCallEventsWithArgs("tool_search_code", {
code: [
`const hits = await openclaw.tools.search(${JSON.stringify(targetTool)}, { limit: 1 });`,
@@ -988,7 +1000,24 @@ async function buildResponsesPayload(
].join("\n"),
});
}
if (targetTool && (hasDeclaredTool(body, targetTool) || isQaToolSearchFixture(allInputText))) {
if (
!hasCompletedToolOutput &&
targetTool &&
!hasDeclaredTool(body, targetTool) &&
hasDeclaredTool(body, "tool_search")
) {
return buildToolCallEventsWithArgs("tool_search", {
queries: [
{ query: targetTool, limit: 1 },
{ query: QA_TOOL_SEARCH_SECONDARY_TARGET, limit: 1 },
],
});
}
if (
!hasCompletedToolOutput &&
targetTool &&
(hasDeclaredTool(body, targetTool) || isQaToolSearchFixture(allInputText))
) {
return buildToolCallEventsWithArgs(targetTool, plannedArgs);
}
}
@@ -10,6 +10,7 @@ import {
outputText,
outputToolNames,
} from "./fixture-utils.js";
import { QA_TOOL_SEARCH_SECONDARY_TARGET } from "./providers/mock-openai/mock-openai-tooling.js";
import {
qaMockRequestCursorUrl,
qaMockRequestsAfterUrl,
@@ -17,6 +18,7 @@ import {
} from "./providers/shared/debug-request-cursor.js";
import type { QaSuiteRuntimeEnv } from "./suite-runtime-types.js";
import {
assertToolSearchBatchLaneResult,
assertToolSearchLaneResults,
fetchJson,
readToolSearchGatewayFetchLimits,
@@ -125,7 +127,7 @@ describe("tool search gateway e2e fetch helper", () => {
});
describe("tool search gateway e2e session log scanner", () => {
it("does not count target mentions from user prompt records", async () => {
it("counts JSONL mentions without treating prompt text as a call", async () => {
const stateDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-tool-search-log-"));
try {
const sessionsDir = path.join(stateDir, "agents", "qa", "sessions");
@@ -145,6 +147,13 @@ describe("tool search gateway e2e session log scanner", () => {
content: "FAKE_PLUGIN_OK fake_plugin_tool_17",
},
}),
JSON.stringify({
message: {
role: "toolResult",
toolName: "fake_plugin_tool_17",
content: [{ type: "text", text: "FAKE_PLUGIN_OK" }],
},
}),
"",
].join("\n"),
"utf8",
@@ -159,7 +168,7 @@ describe("tool search gateway e2e session log scanner", () => {
},
}),
).resolves.toEqual({
fake_plugin_tool_17: 1,
fake_plugin_tool_17: 2,
tool_search_code: 0,
});
} finally {
@@ -208,6 +217,18 @@ describe("tool search gateway e2e session log scanner", () => {
}),
2,
);
insert.run(
"sqlite-session",
3,
JSON.stringify({
message: {
role: "toolResult",
toolName: "fake_plugin_tool_17",
content: [{ type: "text", text: "FAKE_PLUGIN_OK" }],
},
}),
3,
);
await expect(
countSessionLogMentions({
@@ -219,7 +240,7 @@ describe("tool search gateway e2e session log scanner", () => {
},
}),
).resolves.toEqual({
fake_plugin_tool_17: 1,
fake_plugin_tool_17: 2,
quoted_call: 1,
tool_search_code: 1,
});
@@ -235,7 +256,8 @@ describe("tool search gateway e2e lane result", () => {
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-tool-search-lane-"));
const configPath = path.join(tempRoot, "openclaw.json");
const inputPrefix = "i".repeat(499);
const toolOutputPrefix = "o".repeat(3_999);
const searchOutput = '{"results":[{"query":"first"}]}';
const toolOutput = `${"o".repeat(3_999)}😀tail`;
await fs.writeFile(configPath, "{}\n", "utf8");
const jsonResponse = (body: unknown) =>
new Response(JSON.stringify(body), {
@@ -247,12 +269,23 @@ describe("tool search gateway e2e lane result", () => {
.mockResolvedValueOnce(jsonResponse({ output: [], status: "completed" }))
.mockResolvedValueOnce(
jsonResponse([
{
body: { tools: [] },
plannedToolName: "tool_search",
raw: "{}",
},
{
body: { tools: [] },
plannedToolName: "tool_call",
raw: "{}",
toolOutput: searchOutput,
},
{
allInputText: `${inputPrefix}😀tail\n### Deferred Tool Schemas\n- fake_plugin_tool_17: Fake plugin target`,
body: { tools: [] },
plannedToolName: "fake_plugin_tool_17",
raw: "{}",
toolOutput: `${toolOutputPrefix}😀tail`,
toolOutput,
},
]),
);
@@ -304,7 +337,9 @@ describe("tool search gateway e2e lane result", () => {
});
expect(result.providerInputSnippet).toBe(inputPrefix);
expect(result.providerToolOutputSnippet).toBe(toolOutputPrefix);
expect(result.providerToolOutputSnippet).toBe(
`${searchOutput}\n${"o".repeat(4_000 - searchOutput.length - 1)}`,
);
expect(result.providerDirectoryContainsTarget).toBe(true);
expect(result.targetToolIdentity).toEqual({
source: "plugin",
@@ -360,6 +395,10 @@ describe("tool search gateway e2e lane assertions", () => {
source: "plugin",
pluginId: "tool-search-e2e-fixture",
};
const providerToolCallResult = {
tool: { name: targetTool },
result: { details: { status: "ok", tool: targetTool } },
};
const normal = {
gatewayOutputText: `FAKE_PLUGIN_OK ${targetTool}`,
providerDeclaredToolCount: 36,
@@ -393,6 +432,286 @@ describe("tool search gateway e2e lane assertions", () => {
).not.toThrow();
});
it("accepts one structured batch search followed by one catalog call", () => {
expect(() =>
assertToolSearchBatchLaneResult({
targetTool,
tools: {
status: "completed",
targetToolIdentity,
providerToolCallResult,
gatewayOutputText: `FAKE_PLUGIN_OK ${targetTool}`,
providerDeclaredToolCount: 3,
providerDeclaredToolNames: ["tool_search", "tool_describe", "tool_call"],
providerDirectoryContainsTarget: true,
providerPlannedTools: ["tool_search", "tool_call"],
providerRawBytes: 4_000,
providerToolOutputSnippet: JSON.stringify({
results: [
{ query: targetTool, candidates: [{ name: targetTool }] },
{
query: QA_TOOL_SEARCH_SECONDARY_TARGET,
candidates: [{ name: QA_TOOL_SEARCH_SECONDARY_TARGET }],
},
],
}),
providerToolSearchResult: {
results: [
{ query: targetTool, candidates: [{ name: targetTool }] },
{
query: QA_TOOL_SEARCH_SECONDARY_TARGET,
candidates: [{ name: QA_TOOL_SEARCH_SECONDARY_TARGET }],
},
],
},
sessionLogToolMentions: {
tool_search: 1,
tool_call: 1,
[targetTool]: 1,
},
},
}),
).not.toThrow();
});
it("rejects structured proof that splits discovery across outer calls", () => {
expect(() =>
assertToolSearchBatchLaneResult({
targetTool,
tools: {
status: "completed",
targetToolIdentity,
providerToolCallResult,
gatewayOutputText: `FAKE_PLUGIN_OK ${targetTool}`,
providerDeclaredToolCount: 3,
providerDeclaredToolNames: ["tool_search", "tool_describe", "tool_call"],
providerDirectoryContainsTarget: true,
providerPlannedTools: ["tool_search", "tool_search", "tool_call"],
providerRawBytes: 4_000,
providerToolOutputSnippet: JSON.stringify({
results: [{ query: targetTool, candidates: [{ name: targetTool }] }],
}),
sessionLogToolMentions: {
tool_search: 2,
tool_call: 1,
[targetTool]: 1,
},
},
}),
).toThrow("structured lane did not use one batch search");
});
it.each([
{
label: "omits a grouped result",
status: "completed",
plannedTools: ["tool_search", "tool_call"],
result: { results: [{ query: targetTool, candidates: [{ name: targetTool }] }] },
mentions: { tool_search: 1, tool_call: 1, [targetTool]: 1 },
error: "did not return both grouped search results",
},
{
label: "reorders grouped results",
status: "completed",
plannedTools: ["tool_search", "tool_call"],
result: {
results: [
{
query: QA_TOOL_SEARCH_SECONDARY_TARGET,
candidates: [{ name: QA_TOOL_SEARCH_SECONDARY_TARGET }],
},
{ query: targetTool, candidates: [{ name: targetTool }] },
],
},
mentions: { tool_search: 1, tool_call: 1, [targetTool]: 1 },
error: "did not return both grouped search results",
},
{
label: "reuses the first query candidate for the second group",
status: "completed",
plannedTools: ["tool_search", "tool_call"],
result: {
results: [
{ query: targetTool, candidates: [{ name: targetTool }] },
{ query: QA_TOOL_SEARCH_SECONDARY_TARGET, candidates: [{ name: targetTool }] },
],
},
mentions: { tool_search: 1, tool_call: 1, [targetTool]: 1 },
error: "did not return both grouped search results",
},
{
label: "calls before searching",
status: "completed",
plannedTools: ["tool_call", "tool_search"],
result: {
results: [
{ query: targetTool, candidates: [{ name: targetTool }] },
{
query: QA_TOOL_SEARCH_SECONDARY_TARGET,
candidates: [{ name: QA_TOOL_SEARCH_SECONDARY_TARGET }],
},
],
},
mentions: { tool_search: 1, tool_call: 1, [targetTool]: 1 },
error: "did not use one batch search followed by one catalog call",
},
{
label: "omits bridge telemetry",
status: "completed",
plannedTools: ["tool_search", "tool_call"],
result: {
results: [
{ query: targetTool, candidates: [{ name: targetTool }] },
{
query: QA_TOOL_SEARCH_SECONDARY_TARGET,
candidates: [{ name: QA_TOOL_SEARCH_SECONDARY_TARGET }],
},
],
},
mentions: { tool_search: 0, tool_call: 0, [targetTool]: 1 },
error: "session log did not record search and call mentions",
},
{
label: "returns an incomplete response",
status: "incomplete",
plannedTools: ["tool_search", "tool_call"],
result: {
results: [
{ query: targetTool, candidates: [{ name: targetTool }] },
{
query: QA_TOOL_SEARCH_SECONDARY_TARGET,
candidates: [{ name: QA_TOOL_SEARCH_SECONDARY_TARGET }],
},
],
},
mentions: { tool_search: 1, tool_call: 1, [targetTool]: 1 },
error: "did not complete successfully",
},
])(
"rejects structured proof that $label",
({ status, plannedTools, result, mentions, error }) => {
expect(() =>
assertToolSearchBatchLaneResult({
targetTool,
tools: {
status,
targetToolIdentity,
providerToolCallResult,
gatewayOutputText: `FAKE_PLUGIN_OK ${targetTool}`,
providerDeclaredToolCount: 3,
providerDeclaredToolNames: ["tool_search", "tool_describe", "tool_call"],
providerDirectoryContainsTarget: true,
providerPlannedTools: plannedTools,
providerRawBytes: 4_000,
providerToolOutputSnippet: JSON.stringify(result),
providerToolSearchResult: result,
sessionLogToolMentions: mentions,
},
}),
).toThrow(error);
},
);
it.each([
{
label: "omits a control tool",
declaredToolNames: ["tool_search", "tool_call"],
directoryContainsTarget: true,
},
{
label: "omits the target directory",
declaredToolNames: ["tool_search", "tool_describe", "tool_call"],
directoryContainsTarget: false,
},
])("rejects structured proof that $label", ({ declaredToolNames, directoryContainsTarget }) => {
const result = {
results: [
{ query: targetTool, candidates: [{ name: targetTool }] },
{
query: QA_TOOL_SEARCH_SECONDARY_TARGET,
candidates: [{ name: QA_TOOL_SEARCH_SECONDARY_TARGET }],
},
],
};
expect(() =>
assertToolSearchBatchLaneResult({
targetTool,
tools: {
status: "completed",
targetToolIdentity,
providerToolCallResult,
gatewayOutputText: `FAKE_PLUGIN_OK ${targetTool}`,
providerDeclaredToolCount: declaredToolNames.length,
providerDeclaredToolNames: declaredToolNames,
providerDirectoryContainsTarget: directoryContainsTarget,
providerPlannedTools: ["tool_search", "tool_call"],
providerRawBytes: 4_000,
providerToolSearchResult: result,
sessionLogToolMentions: { tool_search: 1, tool_call: 1, [targetTool]: 1 },
},
}),
).toThrow("structured lane did not expose its bounded directory with all three control tools");
});
it("rejects structured proof without a typed target tool result", () => {
const result = {
results: [
{ query: targetTool, candidates: [{ name: targetTool }] },
{
query: QA_TOOL_SEARCH_SECONDARY_TARGET,
candidates: [{ name: QA_TOOL_SEARCH_SECONDARY_TARGET }],
},
],
};
expect(() =>
assertToolSearchBatchLaneResult({
targetTool,
tools: {
status: "completed",
targetToolIdentity,
gatewayOutputText: `FAKE_PLUGIN_OK ${targetTool}`,
providerDeclaredToolCount: 3,
providerDeclaredToolNames: ["tool_search", "tool_describe", "tool_call"],
providerDirectoryContainsTarget: true,
providerPlannedTools: ["tool_search", "tool_call"],
providerRawBytes: 4_000,
providerToolSearchResult: result,
sessionLogToolMentions: { tool_search: 1, tool_call: 1, [targetTool]: 2 },
},
}),
).toThrow(`structured lane did not call ${targetTool}`);
});
it("rejects structured tools.effective ownership outside the fixture plugin", () => {
const result = {
results: [
{ query: targetTool, candidates: [{ name: targetTool }] },
{
query: QA_TOOL_SEARCH_SECONDARY_TARGET,
candidates: [{ name: QA_TOOL_SEARCH_SECONDARY_TARGET }],
},
],
};
expect(() =>
assertToolSearchBatchLaneResult({
targetTool,
tools: {
status: "completed",
targetToolIdentity: { source: "core", pluginId: "" },
providerToolCallResult,
gatewayOutputText: `FAKE_PLUGIN_OK ${targetTool}`,
providerDeclaredToolCount: 3,
providerDeclaredToolNames: ["tool_search", "tool_describe", "tool_call"],
providerDirectoryContainsTarget: true,
providerPlannedTools: ["tool_search", "tool_call"],
providerRawBytes: 4_000,
providerToolSearchResult: result,
sessionLogToolMentions: { tool_search: 1, tool_call: 1, [targetTool]: 2 },
},
}),
).toThrow(`tools.effective did not attribute ${targetTool} to plugin`);
});
it("preserves surrogate pairs in both lane debug output snippets", () => {
const outputPrefix = `FAKE_PLUGIN_OK ${targetTool} `;
const normalOutput = `${outputPrefix}${"n".repeat(299 - outputPrefix.length)}`;
@@ -3,6 +3,7 @@ import fs from "node:fs/promises";
import path from "node:path";
import process from "node:process";
import { pathToFileURL } from "node:url";
import { isRecord } from "openclaw/plugin-sdk/string-coerce-runtime";
import { truncateUtf16Safe } from "openclaw/plugin-sdk/text-utility-runtime";
import {
countSessionLogMentions,
@@ -14,6 +15,7 @@ import {
subtractMentionCounts,
type QaFixtureFetchJsonOptions,
} from "./fixture-utils.js";
import { QA_TOOL_SEARCH_SECONDARY_TARGET } from "./providers/mock-openai/mock-openai-tooling.js";
import {
qaMockRequestCursorUrl,
qaMockRequestsAfterUrl,
@@ -22,7 +24,7 @@ import {
import { liveTurnTimeoutMs } from "./suite-runtime-agent-common.js";
import type { QaSuiteRuntimeEnv } from "./suite-runtime-types.js";
type Lane = "normal" | "code";
type Lane = "normal" | "code" | "tools";
type LaneResult = {
lane: Lane;
@@ -32,7 +34,10 @@ type LaneResult = {
providerSystemPromptChars: number;
providerInputSnippet: string;
providerToolOutputSnippet: string;
providerToolSearchResult?: unknown;
providerToolCallResult?: unknown;
providerDeclaredToolCount: number;
providerDeclaredToolNames: string[];
providerDirectoryContainsTarget: boolean;
providerPlannedTools: string[];
gatewayOutputToolNames: string[];
@@ -50,6 +55,8 @@ type LaneResultSummary = Pick<
| "providerDirectoryContainsTarget"
| "providerPlannedTools"
| "providerRawBytes"
| "providerToolCallResult"
| "providerToolSearchResult"
| "gatewayOutputText"
| "sessionLogToolMentions"
| "targetToolIdentity"
@@ -76,6 +83,17 @@ function assert(condition: unknown, message: string): asserts condition {
}
}
function parseJson(text: string | undefined): unknown {
if (!text) {
return undefined;
}
try {
return JSON.parse(text);
} catch {
return undefined;
}
}
export function readToolSearchGatewayFetchLimits(
env: NodeJS.ProcessEnv = process.env,
): ToolSearchGatewayFetchLimits {
@@ -135,6 +153,8 @@ async function countToolSearchSessionLogMentions(params: { stateDir: string; tar
sessionsDir: path.join(params.stateDir, "agents", "qa", "sessions"),
needles: {
tool_search_code: "tool_search_code",
tool_search: "tool_search",
tool_call: "tool_call",
[params.targetTool]: params.targetTool,
},
});
@@ -283,12 +303,17 @@ function applyLaneConfig(
...new Set([
...(Array.isArray(tools.alsoAllow) ? tools.alsoAllow : []),
FAKE_PLUGIN_ID,
...(params.lane === "code"
...(params.lane !== "normal"
? ["tool_search_code", "tool_search", "tool_describe", "tool_call"]
: []),
]),
],
toolSearch: params.lane === "code",
toolSearch:
params.lane === "code"
? true
: params.lane === "tools"
? { enabled: true, mode: "tools" }
: false,
};
const gateway = (cfg.gateway && typeof cfg.gateway === "object" ? cfg.gateway : {}) as Record<
@@ -442,6 +467,24 @@ export async function runToolSearchGatewayLane(params: {
plannedToolName?: string;
}>;
const lastRequest = laneRequests.at(-1) ?? {};
// The last provider request contains the terminal target result, while earlier
// requests contain discovery results needed to prove the complete bridge flow.
const providerToolOutputs = laneRequests
.map((request) => request.toolOutput)
.filter((value): value is string => typeof value === "string" && value.length > 0)
.join("\n");
const toolCallRequestIndex = laneRequests.findIndex(
(request) => request.plannedToolName === "tool_call",
);
const providerToolSearchResult = parseJson(
toolCallRequestIndex >= 0 ? laneRequests[toolCallRequestIndex]?.toolOutput : undefined,
);
const providerToolCallResult = parseJson(
toolCallRequestIndex >= 0
? laneRequests.slice(toolCallRequestIndex + 1).find((request) => request.toolOutput)
?.toolOutput
: undefined,
);
// Responses providers may carry system text in instructions or input items;
// inspect the full recorded prompt so late directory entries are not lost.
const providerPromptText = [lastRequest.instructions, lastRequest.allInputText]
@@ -467,10 +510,17 @@ export async function runToolSearchGatewayLane(params: {
lastRequest.allInputText ?? lastRequest.prompt ?? "",
500,
),
providerToolOutputSnippet: truncateUtf16Safe(lastRequest.toolOutput ?? "", 4_000),
providerToolOutputSnippet: truncateUtf16Safe(providerToolOutputs, 4_000),
providerToolSearchResult,
providerToolCallResult,
providerDeclaredToolCount: Array.isArray(lastRequest.body?.tools)
? lastRequest.body.tools.length
: 0,
providerDeclaredToolNames: Array.isArray(lastRequest.body?.tools)
? lastRequest.body.tools.flatMap((tool) =>
isRecord(tool) && typeof tool.name === "string" ? [tool.name] : [],
)
: [],
providerDirectoryContainsTarget:
providerPromptText.includes("### Deferred Tool Schemas") &&
providerPromptText.includes(`- ${params.fixture.targetTool}`),
@@ -566,3 +616,94 @@ export function assertToolSearchLaneResults(params: {
);
}
}
export function assertToolSearchBatchLaneResult(params: {
tools: LaneResultSummary & Pick<LaneResult, "status" | "providerDeclaredToolNames">;
targetTool: string;
}) {
const { targetTool, tools } = params;
const debug = () =>
JSON.stringify(
{
plannedTools: tools.providerPlannedTools,
toolOutput: tools.providerToolOutputSnippet,
output: truncateUtf16Safe(tools.gatewayOutputText, 300),
mentions: tools.sessionLogToolMentions,
toolCallResult: tools.providerToolCallResult,
declaredToolCount: tools.providerDeclaredToolCount,
declaredToolNames: tools.providerDeclaredToolNames,
directoryContainsTarget: tools.providerDirectoryContainsTarget,
},
null,
2,
);
assert(tools.status === "completed", `structured lane did not complete successfully: ${debug()}`);
const structuredControlTools = new Set(["tool_search", "tool_describe", "tool_call"]);
assert(
[...structuredControlTools].every((name) => tools.providerDeclaredToolNames.includes(name)) &&
tools.providerDirectoryContainsTarget,
`structured lane did not expose its bounded directory with all three control tools: ${debug()}`,
);
assert(
tools.providerPlannedTools.filter((name) => name === "tool_search").length === 1 &&
tools.providerPlannedTools.filter((name) => name === "tool_call").length === 1 &&
tools.providerPlannedTools.indexOf("tool_search") <
tools.providerPlannedTools.indexOf("tool_call"),
`structured lane did not use one batch search followed by one catalog call: ${debug()}`,
);
const batchResult = tools.providerToolSearchResult;
const groups =
isRecord(batchResult) && Array.isArray(batchResult.results) ? batchResult.results : [];
const targetGroup = groups[0];
const catalogGroup = groups[1];
assert(
groups.length === 2 &&
isRecord(targetGroup) &&
targetGroup.query === targetTool &&
Array.isArray(targetGroup.candidates) &&
targetGroup.candidates.length === 1 &&
targetGroup.candidates.some(
(candidate) =>
isRecord(candidate) && (candidate.name === targetTool || candidate.id === targetTool),
) &&
isRecord(catalogGroup) &&
catalogGroup.query === QA_TOOL_SEARCH_SECONDARY_TARGET &&
Array.isArray(catalogGroup.candidates) &&
catalogGroup.candidates.length === 1 &&
catalogGroup.candidates.some(
(candidate) =>
isRecord(candidate) &&
(candidate.name === QA_TOOL_SEARCH_SECONDARY_TARGET ||
candidate.id === QA_TOOL_SEARCH_SECONDARY_TARGET),
),
`structured lane did not return both grouped search results: ${debug()}`,
);
const toolCallResult = tools.providerToolCallResult;
const calledTool = isRecord(toolCallResult) ? toolCallResult.tool : undefined;
const callResult = isRecord(toolCallResult) ? toolCallResult.result : undefined;
const callDetails = isRecord(callResult) ? callResult.details : undefined;
assert(
tools.gatewayOutputText.includes("FAKE_PLUGIN_OK") &&
tools.gatewayOutputText.includes(targetTool) &&
isRecord(calledTool) &&
calledTool.name === targetTool &&
isRecord(callDetails) &&
callDetails.status === "ok" &&
callDetails.tool === targetTool,
`structured lane did not call ${targetTool}: ${debug()}`,
);
assert(
(tools.sessionLogToolMentions.tool_search ?? 0) > 0 &&
(tools.sessionLogToolMentions.tool_call ?? 0) > 0,
`structured lane session log did not record search and call mentions: ${debug()}`,
);
assert(
!tools.providerPlannedTools.includes(targetTool),
`structured lane exposed direct provider tool ${targetTool}: ${debug()}`,
);
assert(
tools.targetToolIdentity.source === "plugin" &&
tools.targetToolIdentity.pluginId === FAKE_PLUGIN_ID,
`tools.effective did not attribute ${targetTool} to plugin ${FAKE_PLUGIN_ID}: ${debug()}`,
);
}
@@ -9,13 +9,14 @@ scenario:
- plugins.plugin-tools
secondary:
- plugins.tool-plugin-invocation
objective: Verify the Tool Search gateway QA Lab flow keeps a large plugin-owned tool catalog behind the compact bridge while still invoking the selected plugin tool.
objective: Verify Tool Search keeps a large plugin-owned catalog compact, supports structured batch discovery, and still invokes the selected plugin tool.
successCriteria:
- Direct mode exposes the fake plugin tool schemas and calls the selected plugin tool.
- Both lanes report the selected tool through tools.effective with source=plugin and pluginId=tool-search-e2e-fixture.
- All three lanes report the selected tool through tools.effective with source=plugin and pluginId=tool-search-e2e-fixture.
- Tool Search code mode exposes only the compact bridge to the provider.
- Tool Search code mode automatically advertises the selected tool in its bounded system-prompt directory.
- The compact bridge calls the same selected plugin tool and records bridge plus target tool mentions in session logs.
- Structured Tool Search resolves two independent queries in one outer call, then records a typed result for the selected plugin tool.
- The Tool Search request payload is smaller than direct tool exposure for the large fake catalog.
docsRefs:
- docs/tools/tool-search.md
@@ -26,9 +27,10 @@ scenario:
- extensions/qa-lab/src/tool-search-gateway.fixture.test.ts
execution:
kind: flow
retryCount: 0
suiteIsolation: isolated
isolationReason: Mutates gateway plugin/tool-search config and restarts the QA gateway between direct and compact lanes.
summary: Flow-backed gateway E2E for compact Tool Search bridge and plugin-owned tool invocation.
isolationReason: Mutates gateway plugin/tool-search config and restarts the QA gateway between direct, compact, and structured lanes.
summary: Flow-backed gateway E2E for direct, compact, and structured Tool Search with plugin-owned tool invocation.
config:
requiredProviderMode: mock-openai
targetTool: fake_plugin_tool_17
@@ -56,7 +58,7 @@ flow:
expr: config.toolCount
detailsExpr: toolSearchFixture
- name: compares direct and compact Tool Search gateway lanes
- name: compares direct, compact, and structured Tool Search gateway lanes
actions:
- call: toolSearch.runToolSearchGatewayLane
saveAs: normalLane
@@ -74,6 +76,14 @@ flow:
fixture:
ref: toolSearchFixture
lane: code
- call: toolSearch.runToolSearchGatewayLane
saveAs: toolsLane
args:
- env:
ref: env
fixture:
ref: toolSearchFixture
lane: tools
- call: toolSearch.assertToolSearchLaneResults
args:
- normal:
@@ -82,6 +92,12 @@ flow:
ref: codeLane
targetTool:
ref: toolSearchFixture.targetTool
- call: toolSearch.assertToolSearchBatchLaneResult
args:
- tools:
ref: toolsLane
targetTool:
ref: toolSearchFixture.targetTool
detailsExpr: |-
({
targetTool: toolSearchFixture.targetTool,
@@ -93,8 +109,13 @@ flow:
compactRawBytes: codeLane.providerRawBytes,
directPlannedTools: normalLane.providerPlannedTools,
compactPlannedTools: codeLane.providerPlannedTools,
structuredPlannedTools: toolsLane.providerPlannedTools,
structuredToolOutput: toolsLane.providerToolOutputSnippet,
directMentions: normalLane.sessionLogToolMentions,
compactMentions: codeLane.sessionLogToolMentions,
directEffectiveTool: normalLane.targetToolIdentity,
compactEffectiveTool: codeLane.targetToolIdentity,
structuredEffectiveTool: toolsLane.targetToolIdentity,
structuredMentions: toolsLane.sessionLogToolMentions,
structuredTargetToolResults: toolsLane.sessionLogTargetToolResults,
})
+6 -2
View File
@@ -1,6 +1,10 @@
import { isRecord } from "@openclaw/normalization-core/record-coerce";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import type { ToolSearchConfig, ToolSearchMode } from "./tool-search-types.js";
import {
MAX_TOOL_SEARCH_RESULTS,
type ToolSearchConfig,
type ToolSearchMode,
} from "./tool-search-types.js";
const DEFAULT_CODE_TIMEOUT_MS = 10_000;
const DEFAULT_SEARCH_LIMIT = 8;
@@ -50,7 +54,7 @@ export function resolveToolSearchConfig(config?: OpenClawConfig): ToolSearchConf
const configured = Object.keys(raw).some((key) => key !== "enabled");
const maxSearchLimit = Math.max(
1,
Math.min(50, readInteger(raw.maxSearchLimit, DEFAULT_MAX_SEARCH_LIMIT)),
Math.min(MAX_TOOL_SEARCH_RESULTS, readInteger(raw.maxSearchLimit, DEFAULT_MAX_SEARCH_LIMIT)),
);
return {
enabled: readBoolean(raw.enabled, configured),
+104
View File
@@ -0,0 +1,104 @@
import { isRecord } from "@openclaw/normalization-core/record-coerce";
import { Guard } from "typebox/guard";
import {
MAX_TOOL_SEARCH_BATCH_QUERIES,
MAX_TOOL_SEARCH_BATCH_QUERY_BYTES,
MAX_TOOL_SEARCH_BATCH_QUERY_GRAPHEMES,
MAX_TOOL_SEARCH_RESULTS,
type ToolSearchConfig,
type ToolSearchRequest,
} from "./tool-search-types.js";
import { asToolParamsRecord, ToolInputError } from "./tools/common.js";
export function readToolSearchLimit(value: unknown, config: ToolSearchConfig): number {
if (value === undefined) {
return config.searchDefaultLimit;
}
if (typeof value !== "number" || !Number.isInteger(value) || value < 1) {
throw new ToolInputError("limit must be a positive integer.");
}
return Math.min(value, config.maxSearchLimit);
}
function readBatchToolSearchQuery(value: unknown, field: string, maxGraphemes?: number): string {
if (typeof value !== "string" || !value.trim()) {
throw new ToolInputError(`${field} must be a non-empty string.`);
}
const query = value.trim();
if (maxGraphemes !== undefined && !Guard.IsMaxLength(query, maxGraphemes)) {
throw new ToolInputError(`${field} must not exceed ${maxGraphemes} characters.`);
}
return query;
}
function readToolSearchArgs(
args: unknown,
config: ToolSearchConfig,
): { query: string; limit: number } {
const params = asToolParamsRecord(args);
const query = params.query;
if (typeof query !== "string") {
throw new ToolInputError("query must be a string.");
}
const options = isRecord(params.options) ? params.options : undefined;
return {
query,
limit: readToolSearchLimit(params.limit ?? options?.limit, config),
};
}
export function readToolSearchRequest(args: unknown, config: ToolSearchConfig): ToolSearchRequest {
const params = asToolParamsRecord(args);
const hasQuery = params.query !== undefined;
const hasQueries = params.queries !== undefined;
if (hasQuery === hasQueries) {
throw new ToolInputError("provide exactly one of query or queries.");
}
if (hasQuery) {
return { kind: "single", search: readToolSearchArgs(params, config) };
}
if (params.limit !== undefined || params.options !== undefined) {
throw new ToolInputError("set limit on each batch query, not on the batch request.");
}
if (!Array.isArray(params.queries) || params.queries.length === 0) {
throw new ToolInputError("queries must be a non-empty array.");
}
if (params.queries.length > MAX_TOOL_SEARCH_BATCH_QUERIES) {
throw new ToolInputError(
`queries may contain at most ${MAX_TOOL_SEARCH_BATCH_QUERIES} entries.`,
);
}
const searches = params.queries.map((value, index) => {
if (!isRecord(value)) {
throw new ToolInputError(`queries[${index}] must be an object.`);
}
const query = readBatchToolSearchQuery(
value.query,
`queries[${index}].query`,
MAX_TOOL_SEARCH_BATCH_QUERY_GRAPHEMES,
);
try {
return { query, limit: readToolSearchLimit(value.limit, config) };
} catch (error) {
if (error instanceof ToolInputError) {
throw new ToolInputError(`queries[${index}].${error.message}`);
}
throw error;
}
});
const requestedResults = searches.reduce((total, search) => total + search.limit, 0);
if (requestedResults > MAX_TOOL_SEARCH_RESULTS) {
throw new ToolInputError(
`batch queries resolve to ${requestedResults} results, but may request at most ${MAX_TOOL_SEARCH_RESULTS} in total. An omitted limit counts as ${config.searchDefaultLimit}; set smaller per-query limits and retry.`,
);
}
const serializedQueries = JSON.stringify(searches.map((search) => search.query));
const serializedQueryBytes = new TextEncoder().encode(serializedQueries).byteLength;
if (serializedQueryBytes > MAX_TOOL_SEARCH_BATCH_QUERY_BYTES) {
throw new ToolInputError(
`serialized batch query text may use at most ${MAX_TOOL_SEARCH_BATCH_QUERY_BYTES} UTF-8 bytes.`,
);
}
return { kind: "batch", searches };
}
+1 -26
View File
@@ -34,6 +34,7 @@ import {
tokenizeDocument,
tokenizeQuery,
} from "./tool-search-ranking.js";
import { readToolSearchLimit } from "./tool-search-request.js";
import { snapshotToolSearchTargetTranscriptResult } from "./tool-search-transcript.js";
import type {
CatalogSource,
@@ -210,32 +211,6 @@ export function readToolSearchId(args: unknown): string {
return value.trim();
}
function readToolSearchLimit(value: unknown, config: ToolSearchConfig): number {
if (value === undefined) {
return config.searchDefaultLimit;
}
if (typeof value !== "number" || !Number.isInteger(value) || value < 1) {
throw new ToolInputError("limit must be a positive integer.");
}
return Math.min(value, config.maxSearchLimit);
}
export function readToolSearchArgs(
args: unknown,
config: ToolSearchConfig,
): { query: string; limit: number } {
const params = asToolParamsRecord(args);
const query = params.query;
if (typeof query !== "string") {
throw new ToolInputError("query must be a string.");
}
const options = isRecord(params.options) ? params.options : undefined;
return {
query,
limit: readToolSearchLimit(params.limit ?? options?.limit, config),
};
}
export function readToolSearchCallArgs(
args: unknown,
catalog?: ToolSearchCatalogSession,
+10
View File
@@ -12,6 +12,13 @@ export const TOOL_SEARCH_CODE_MODE_TOOL_NAME = "tool_search_code";
export const TOOL_SEARCH_RAW_TOOL_NAME = "tool_search";
export const TOOL_DESCRIBE_RAW_TOOL_NAME = "tool_describe";
export const TOOL_CALL_RAW_TOOL_NAME = "tool_call";
// One model-visible search response, including a batch, may expose at most this many candidates.
export const MAX_TOOL_SEARCH_RESULTS = 50;
export const MAX_TOOL_SEARCH_BATCH_QUERIES = 16;
export const MAX_TOOL_SEARCH_BATCH_QUERY_GRAPHEMES = 512;
// Includes JSON escaping and multibyte text echoed to identify batch result groups.
export const MAX_TOOL_SEARCH_BATCH_QUERY_BYTES = 512;
export const MAX_TOOL_SEARCH_BATCH_RESPONSE_CHARS = 4_000;
export const TOOL_SEARCH_CONTROL_TOOL_NAMES = new Set([
TOOL_SEARCH_CODE_MODE_TOOL_NAME,
@@ -27,6 +34,9 @@ export const TOOL_SCHEMA_DIRECTORY_CONTROL_TOOL_NAMES = new Set([
]);
export type ToolSearchMode = "code" | "tools" | "directory";
export type ToolSearchRequest =
| { kind: "single"; search: { query: string; limit: number } }
| { kind: "batch"; searches: Array<{ query: string; limit: number }> };
export type CatalogSource = "openclaw" | "mcp" | "client";
export type CatalogTool = AnyAgentTool | ToolDefinition;
export type CatalogVisibilityOptions = {
+497
View File
@@ -196,12 +196,509 @@ describe("Tool Search", () => {
expect(Value.Check(limitSearchTool.parameters, input)).toBe(valid);
});
it("accepts bounded structured batch queries in the tool schema", () => {
expect(JSON.stringify(limitSearchTool.parameters)).toContain(
"serialized query strings may use at most 512 UTF-8 bytes in total",
);
expect(
Value.Check(limitSearchTool.parameters, {
queries: [
{ query: "today's calendar events", limit: 3 },
{ query: "Slack messages needing attention", limit: 3 },
],
}),
).toBe(true);
expect(Value.Check(limitSearchTool.parameters, { queries: [] })).toBe(false);
expect(
Value.Check(limitSearchTool.parameters, {
queries: Array.from({ length: 17 }, (_, index) => ({ query: `query ${index}`, limit: 1 })),
}),
).toBe(false);
});
it.each([5.5, 0, -1])("rejects runtime limit %s", async (limit) => {
await expect(limitSearchTool.execute("call-limit", { query: "test", limit })).rejects.toThrow(
"limit must be a positive integer",
);
});
it.each([
{
label: "missing request",
input: {},
error: "provide exactly one of query or queries",
},
{
label: "mixed single and batch request",
input: { query: "calendar", queries: [{ query: "Slack" }] },
error: "provide exactly one of query or queries",
},
{
label: "empty batch",
input: { queries: [] },
error: "queries must be a non-empty array",
},
{
label: "empty batch query",
input: { queries: [{ query: " " }] },
error: "queries[0].query must be a non-empty string",
},
{
label: "top-level batch limit",
input: { queries: [{ query: "calendar" }], limit: 1 },
error: "set limit on each batch query",
},
])("rejects $label", async ({ input, error }) => {
await expect(limitSearchTool.execute("call-invalid-batch", input)).rejects.toThrow(error);
});
it.each(["", " "])("preserves scalar empty-query compatibility for %j", async (query) => {
expect(Value.Check(limitSearchTool.parameters, { query })).toBe(true);
const catalogRef = createToolSearchCatalogRef();
registerHeadlessToolSearchCatalog({
catalogRef,
tools: [pluginTool("fake_empty_query", "empty query compatibility surface")],
});
const searchTool = expectDefined(
createToolSearchTools({ catalogRef }).find((tool) => tool.name === TOOL_SEARCH_RAW_TOOL_NAME),
"empty scalar query search tool",
);
await expect(searchTool.execute("call-empty-query", { query })).resolves.toMatchObject({
details: [],
});
});
it("rejects batches whose effective result limits exceed the shared budget", async () => {
const searchTool = expectDefined(
createToolSearchTools({
config: {
tools: { toolSearch: { enabled: true, mode: "tools", maxSearchLimit: 50 } },
} as never,
}).find((tool) => tool.name === TOOL_SEARCH_RAW_TOOL_NAME),
"batch budget search tool",
);
await expect(
searchTool.execute("call-batch-budget", {
queries: [
{ query: "calendar", limit: 25 },
{ query: "Slack", limit: 26 },
],
}),
).rejects.toThrow("resolve to 51 results, but may request at most 50 in total");
await expect(
searchTool.execute("call-default-batch-budget", {
queries: Array.from({ length: 7 }, (_, index) => ({ query: `surface ${index}` })),
}),
).rejects.toThrow(
"resolve to 56 results, but may request at most 50 in total. An omitted limit counts as 8; set smaller per-query limits and retry",
);
expect(JSON.stringify(searchTool.parameters)).toContain(
"Their effective limits may total at most 50; an omitted item limit counts as 8",
);
expect(JSON.stringify(searchTool.parameters)).toContain(
"Maximum results for this query. Defaults to 8 when omitted.",
);
});
it("preserves scalar query length compatibility while bounding batch query echo", async () => {
const longScalarQuery = "q".repeat(4097);
expect(Value.Check(limitSearchTool.parameters, { query: longScalarQuery })).toBe(true);
const catalogRef = createToolSearchCatalogRef();
registerHeadlessToolSearchCatalog({
catalogRef,
tools: [pluginTool("fake_long_query", "long scalar query surface")],
});
const searchTool = expectDefined(
createToolSearchTools({ catalogRef }).find((tool) => tool.name === TOOL_SEARCH_RAW_TOOL_NAME),
"long query search tool",
);
await expect(
searchTool.execute("call-long-query", { query: longScalarQuery }),
).resolves.toBeDefined();
await expect(
limitSearchTool.execute("call-long-batch-query", {
queries: [{ query: "q".repeat(512) }, { query: "r" }],
}),
).rejects.toThrow("serialized batch query text may use at most 512 UTF-8 bytes");
await expect(
limitSearchTool.execute("call-multibyte-batch-query", {
queries: [{ query: "😀".repeat(128) }],
}),
).rejects.toThrow("serialized batch query text may use at most 512 UTF-8 bytes");
});
it("uses the schema's grapheme length semantics at runtime", async () => {
const query = "😀".repeat(3_000);
expect(Value.Check(limitSearchTool.parameters, { query })).toBe(true);
const catalogRef = createToolSearchCatalogRef();
registerHeadlessToolSearchCatalog({
catalogRef,
tools: [pluginTool("fake_unicode", "unicode search surface")],
});
const searchTool = expectDefined(
createToolSearchTools({ catalogRef }).find((tool) => tool.name === TOOL_SEARCH_RAW_TOOL_NAME),
"unicode query search tool",
);
await expect(searchTool.execute("call-unicode-query", { query })).resolves.toBeDefined();
});
it("accepts the documented batch boundaries without deduplicating queries", async () => {
const catalogRef = createToolSearchCatalogRef();
const config = {
tools: {
toolSearch: {
enabled: true,
mode: "tools",
searchDefaultLimit: 1,
maxSearchLimit: 10,
},
},
} as never;
applyToolSearchCatalog({
tools: [
fakeTool(TOOL_SEARCH_RAW_TOOL_NAME, "search"),
fakeTool(TOOL_DESCRIBE_RAW_TOOL_NAME, "describe"),
fakeTool(TOOL_CALL_RAW_TOOL_NAME, "call"),
pluginTool("fake_boundary", "boundary duplicate surface"),
],
config,
catalogRef,
});
const searchTool = expectDefined(
createToolSearchTools({ config, catalogRef }).find(
(tool) => tool.name === TOOL_SEARCH_RAW_TOOL_NAME,
),
"boundary batch search tool",
);
const duplicateQueries = Array.from({ length: 16 }, () => ({
query: "boundary duplicate",
}));
const duplicateResult = await searchTool.execute("call-sixteen-queries", {
queries: duplicateQueries,
});
expect(resultDetails(duplicateResult).results).toHaveLength(16);
expect(catalogRef.current?.searchCount).toBe(16);
const clampedResult = await searchTool.execute("call-exact-result-budget", {
queries: Array.from({ length: 5 }, (_, index) => ({
query: `boundary ${index}`,
limit: 999,
})),
});
expect(resultDetails(clampedResult).results).toHaveLength(5);
expect(catalogRef.current?.searchCount).toBe(21);
});
it("validates every batch item before executing any search", async () => {
const catalogRef = createToolSearchCatalogRef();
const config = { tools: { toolSearch: { enabled: true, mode: "tools" } } } as never;
applyToolSearchCatalog({
tools: [
fakeTool(TOOL_SEARCH_RAW_TOOL_NAME, "search"),
fakeTool(TOOL_DESCRIBE_RAW_TOOL_NAME, "describe"),
fakeTool(TOOL_CALL_RAW_TOOL_NAME, "call"),
pluginTool("fake_atomic", "atomic validation surface"),
],
config,
catalogRef,
});
const searchTool = expectDefined(
createToolSearchTools({ config, catalogRef }).find(
(tool) => tool.name === TOOL_SEARCH_RAW_TOOL_NAME,
),
"atomic batch search tool",
);
await expect(
searchTool.execute("call-invalid-later-item", {
queries: [{ query: "atomic validation" }, { query: " " }],
}),
).rejects.toThrow("queries[1].query must be a non-empty string");
expect(catalogRef.current?.searchCount).toBe(0);
});
it("compacts descriptions and bounds the serialized batch response", async () => {
const catalogRef = createToolSearchCatalogRef();
const config = {
tools: { toolSearch: { enabled: true, mode: "tools", maxSearchLimit: 10 } },
} as never;
const longDescription = `large surface ${"description ".repeat(200)}`;
const catalogTools = Array.from({ length: 10 }, (_, index) =>
pluginTool(`fake_large_${index}`, `${longDescription}${index}`),
);
applyToolSearchCatalog({
tools: [
fakeTool(TOOL_SEARCH_RAW_TOOL_NAME, "search"),
fakeTool(TOOL_DESCRIBE_RAW_TOOL_NAME, "describe"),
fakeTool(TOOL_CALL_RAW_TOOL_NAME, "call"),
...catalogTools,
],
config,
catalogRef,
});
const searchTool = expectDefined(
createToolSearchTools({ config, catalogRef }).find(
(tool) => tool.name === TOOL_SEARCH_RAW_TOOL_NAME,
),
"bounded response search tool",
);
const scalar = await searchTool.execute("call-full-scalar-description", {
query: "fake_large_0",
limit: 1,
});
expect(scalar.details).toEqual([
expect.objectContaining({ description: `${longDescription}0` }),
]);
const fullRanking = await searchTool.execute("call-untruncated-ranking", {
query: "large surface",
limit: 10,
});
const rankedIds = (fullRanking.details as Array<{ id: string }>).map(
(candidate) => candidate.id,
);
const result = await searchTool.execute("call-bounded-response", {
queries: Array.from({ length: 5 }, () => ({ query: "large surface", limit: 10 })),
});
const details = resultDetails(result);
expect(details.truncated).toBe(true);
expect(JSON.stringify(details, null, 2).length).toBeLessThanOrEqual(4_000);
expect(JSON.stringify(details)).not.toContain("description ".repeat(20));
const retainedCounts = (details.results as Array<{ candidates: unknown[] }>).map(
(group) => group.candidates.length,
);
expect(Math.max(...retainedCounts) - Math.min(...retainedCounts)).toBeLessThanOrEqual(1);
expect(retainedCounts.every((count) => count > 0)).toBe(true);
for (const group of details.results as Array<{ candidates: Array<{ id: string }> }>) {
expect(group.candidates.map((candidate) => candidate.id)).toEqual(
rankedIds.slice(0, group.candidates.length),
);
}
const manyGroups = resultDetails(
await searchTool.execute("call-bounded-many-groups", {
queries: Array.from({ length: 16 }, () => ({ query: "large surface", limit: 1 })),
}),
);
expect(JSON.stringify(manyGroups, null, 2).length).toBeLessThanOrEqual(4_000);
for (const group of manyGroups.results as Array<{
candidates: Array<{ id: string }>;
truncated?: true;
}>) {
if (group.candidates.length === 0) {
expect(group.truncated).toBe(true);
} else {
expect(group.candidates[0]?.id).toBe(rankedIds[0]);
}
}
});
it("bounds untrusted description work before normalizing repeated batch matches", async () => {
const catalogRef = createToolSearchCatalogRef();
const config = {
tools: { toolSearch: { enabled: true, mode: "tools", maxSearchLimit: 10 } },
} as never;
const hugeDescription = `large remote surface ${" ".repeat(2_000_000)}unbounded tail`;
applyToolSearchCatalog({
tools: [
fakeTool(TOOL_SEARCH_RAW_TOOL_NAME, "search"),
fakeTool(TOOL_DESCRIBE_RAW_TOOL_NAME, "describe"),
fakeTool(TOOL_CALL_RAW_TOOL_NAME, "call"),
pluginTool("fake_remote_large", hugeDescription),
],
config,
catalogRef,
});
const searchTool = expectDefined(
createToolSearchTools({ config, catalogRef }).find(
(tool) => tool.name === TOOL_SEARCH_RAW_TOOL_NAME,
),
"untrusted description search tool",
);
const result = resultDetails(
await searchTool.execute("call-repeated-huge-description", {
queries: Array.from({ length: 16 }, () => ({ query: "large remote surface", limit: 1 })),
}),
);
expect(JSON.stringify(result, null, 2).length).toBeLessThanOrEqual(4_000);
expect(JSON.stringify(result)).not.toContain("unbounded tail");
const retainedDescriptions = (
result.results as Array<{ candidates: Array<{ description: string }> }>
).flatMap((group) => group.candidates.map((candidate) => candidate.description));
expect(retainedDescriptions.length).toBeGreaterThan(0);
for (const description of retainedDescriptions) {
expect(description.length).toBeLessThanOrEqual(180);
}
});
it("preserves bounded callable identity while dropping oversized optional metadata", async () => {
const catalogRef = createToolSearchCatalogRef();
const config = {
tools: { toolSearch: { enabled: true, mode: "tools", maxSearchLimit: 10 } },
} as never;
applyToolSearchCatalog({
tools: [
fakeTool(TOOL_SEARCH_RAW_TOOL_NAME, "search"),
fakeTool(TOOL_DESCRIBE_RAW_TOOL_NAME, "describe"),
fakeTool(TOOL_CALL_RAW_TOOL_NAME, "call"),
mcpPluginTool("remote_large_label", "oversized metadata"),
],
config,
catalogRef,
});
const remoteEntry = expectDefined(
catalogRef.current?.entries.find((entry) => entry.name === "remote_large_label"),
"remote metadata catalog entry",
);
remoteEntry.label = "m".repeat(20_000);
const clientTool = fakeTool(`client_large_name_${"n".repeat(20_000)}`, "oversized metadata");
addClientToolsToToolSearchCatalog({ tools: [clientTool], config, catalogRef });
const searchTool = expectDefined(
createToolSearchTools({ config, catalogRef }).find(
(tool) => tool.name === TOOL_SEARCH_RAW_TOOL_NAME,
),
"untrusted metadata search tool",
);
const result = resultDetails(
await searchTool.execute("call-repeated-huge-metadata", {
queries: Array.from({ length: 16 }, () => ({
query: "oversized metadata",
limit: 2,
})),
}),
);
expect(JSON.stringify(result, null, 2).length).toBeLessThanOrEqual(4_000);
expect(result.truncated).toBe(true);
const groups = result.results as Array<{
candidates: Array<{ id: string; label?: string; name: string }>;
truncated?: true;
}>;
const retained = groups.flatMap((group) => group.candidates);
expect(retained.length).toBeGreaterThan(0);
for (const group of groups) {
expect(group.truncated).toBe(true);
for (const candidate of group.candidates) {
expect(candidate).toEqual(
expect.objectContaining({
id: "mcp:remoteDemo:remote_large_label",
name: "remote_large_label",
}),
);
expect(candidate.label).toBeUndefined();
}
}
expect(retained).toEqual(
expect.arrayContaining([
expect.objectContaining({
id: "mcp:remoteDemo:remote_large_label",
name: "remote_large_label",
}),
]),
);
});
it("searches batch queries independently while preserving scalar results", async () => {
const catalogRef = createToolSearchCatalogRef();
const shared = pluginTool(
"fake_attention",
"Find calendar events and Slack messages needing attention",
);
const config = {
tools: { toolSearch: { enabled: true, mode: "tools", maxSearchLimit: 50 } },
} as never;
applyToolSearchCatalog({
tools: [
fakeTool(TOOL_SEARCH_RAW_TOOL_NAME, "search"),
fakeTool(TOOL_DESCRIBE_RAW_TOOL_NAME, "describe"),
fakeTool(TOOL_CALL_RAW_TOOL_NAME, "call"),
shared,
],
config,
catalogRef,
});
const searchTool = expectDefined(
createToolSearchTools({ config, catalogRef }).find(
(tool) => tool.name === TOOL_SEARCH_RAW_TOOL_NAME,
),
"structured batch search tool",
);
const scalar = await searchTool.execute("call-scalar-search", {
query: "calendar events",
limit: 1,
});
expect(scalar.details).toEqual([
expect.objectContaining({ name: "fake_attention", source: "openclaw" }),
]);
const batch = await searchTool.execute("call-batch-search", {
queries: [
{ query: " calendar events ", limit: 1 },
{ query: "Slack messages", limit: 1 },
{ query: "zzzzunmatched", limit: 1 },
],
});
expect(batch.details).toEqual({
results: [
{
query: "calendar events",
candidates: [expect.objectContaining({ name: "fake_attention", source: "openclaw" })],
},
{
query: "Slack messages",
candidates: [expect.objectContaining({ name: "fake_attention", source: "openclaw" })],
},
{ query: "zzzzunmatched", candidates: [] },
],
});
expect(catalogRef.current?.searchCount).toBe(4);
});
it("uses the same structured batch contract in directory mode", async () => {
const catalogRef = createToolSearchCatalogRef();
const config = {
tools: { toolSearch: { enabled: true, mode: "directory" } },
} as never;
applyToolSchemaDirectoryCatalog({
tools: [
fakeTool(TOOL_SEARCH_RAW_TOOL_NAME, "search"),
fakeTool(TOOL_DESCRIBE_RAW_TOOL_NAME, "describe"),
fakeTool(TOOL_CALL_RAW_TOOL_NAME, "call"),
pluginTool("fake_directory_calendar", "Read directory calendar events"),
],
config,
catalogRef,
});
const searchTool = expectDefined(
createToolSearchTools({ config, catalogRef }).find(
(tool) => tool.name === TOOL_SEARCH_RAW_TOOL_NAME,
),
"directory batch search tool",
);
const result = await searchTool.execute("call-directory-batch-search", {
queries: [{ query: "directory calendar", limit: 1 }],
});
expect(result.details).toEqual({
results: [
{
query: "directory calendar",
candidates: [expect.objectContaining({ name: "fake_directory_calendar" })],
},
],
});
expect(catalogRef.current?.searchCount).toBe(1);
});
it("keeps direct-only tools visible and out of the structured catalog", () => {
const catalogRef = createToolSearchCatalogRef();
const computer = directOnlyTool("computer", "Control a desktop");
+169 -8
View File
@@ -1,5 +1,6 @@
/** Tool Search catalog compaction for large OpenClaw, MCP, and client tool inventories. */
import { normalizeStringEntries } from "@openclaw/normalization-core/string-normalization";
import { truncateUtf16Safe } from "@openclaw/normalization-core/utf16-slice";
import { Type } from "typebox";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import type { HookContext } from "./agent-tools.before-tool-call.js";
@@ -27,15 +28,20 @@ import {
} from "./tool-search-config.js";
import { applyToolSchemaDirectoryCatalog } from "./tool-search-directory.js";
import { MAX_TOOL_SCHEMA_DIRECTORY_PROMPT_CHARS } from "./tool-search-directory.js";
import { readToolSearchRequest } from "./tool-search-request.js";
import {
formatToolSearchControlError,
formatToolSearchControlResult,
readToolSearchArgs,
readToolSearchCallArgs,
readToolSearchId,
ToolSearchRuntime,
} from "./tool-search-runtime.js";
import {
MAX_TOOL_SEARCH_BATCH_QUERIES,
MAX_TOOL_SEARCH_BATCH_QUERY_BYTES,
MAX_TOOL_SEARCH_BATCH_QUERY_GRAPHEMES,
MAX_TOOL_SEARCH_BATCH_RESPONSE_CHARS,
MAX_TOOL_SEARCH_RESULTS,
TOOL_CALL_RAW_TOOL_NAME,
TOOL_DESCRIBE_RAW_TOOL_NAME,
TOOL_SEARCH_CODE_MODE_TOOL_NAME,
@@ -79,6 +85,125 @@ export type {
ToolSearchToolContext,
} from "./tool-search-types.js";
type ToolSearchCandidate = Awaited<ReturnType<ToolSearchRuntime["search"]>>[number];
type ToolSearchBatchGroup = {
query: string;
candidates: ToolSearchCandidate[];
truncated?: true;
};
const MAX_BATCH_CANDIDATE_DESCRIPTION_CHARS = 180;
const MAX_BATCH_CANDIDATE_DESCRIPTION_SCAN_CHARS = MAX_BATCH_CANDIDATE_DESCRIPTION_CHARS * 4;
const MAX_BATCH_CANDIDATE_METADATA_CHARS = 2_000;
function compactBatchCandidateDescription(candidate: ToolSearchCandidate): ToolSearchCandidate {
// Remote catalog descriptions are untrusted. Bound the scanned prefix before
// normalization so repeated batch matches cannot amplify attacker-sized text.
const prefix = truncateUtf16Safe(
candidate.description,
MAX_BATCH_CANDIDATE_DESCRIPTION_SCAN_CHARS,
);
const normalized = prefix.replace(/\s+/g, " ").trim();
if (
prefix.length === candidate.description.length &&
normalized.length <= MAX_BATCH_CANDIDATE_DESCRIPTION_CHARS
) {
return { ...candidate, description: normalized };
}
const compacted = truncateUtf16Safe(
normalized,
MAX_BATCH_CANDIDATE_DESCRIPTION_CHARS - 3,
).trimEnd();
return {
...candidate,
description: `${compacted}...`,
};
}
function compactBatchCandidate(candidate: ToolSearchCandidate): ToolSearchCandidate | undefined {
// Callable identity must stay exact. Optional provenance/display metadata is
// omitted when it would make a repeated batch candidate attacker-sized.
const mandatoryChars =
candidate.id.length + candidate.source.length + candidate.name.length + candidate.input.length;
if (mandatoryChars > MAX_BATCH_CANDIDATE_METADATA_CHARS) {
return undefined;
}
let remaining = MAX_BATCH_CANDIDATE_METADATA_CHARS - mandatoryChars;
const retain = (value: string | undefined): string | undefined => {
if (value === undefined || value.length > remaining) {
return undefined;
}
remaining -= value.length;
return value;
};
const sourceName = retain(candidate.sourceName);
const label = retain(candidate.label);
const mcpChars = candidate.mcp
? candidate.mcp.serverName.length +
candidate.mcp.safeServerName.length +
candidate.mcp.toolName.length +
candidate.mcp.operation.length
: 0;
const mcp = candidate.mcp && mcpChars <= remaining ? candidate.mcp : undefined;
if (mcp) {
remaining -= mcpChars;
}
const output = retain(candidate.output);
return {
...compactBatchCandidateDescription(candidate),
sourceName,
label,
mcp,
output,
};
}
function boundToolSearchBatchResponse(results: ToolSearchBatchGroup[]): {
results: ToolSearchBatchGroup[];
truncated?: true;
} {
const bounded: ToolSearchBatchGroup[] = results.map((result) => {
const candidates = result.candidates
.map(compactBatchCandidate)
.filter((candidate): candidate is ToolSearchCandidate => candidate !== undefined);
const groupTruncated = candidates.length < result.candidates.length;
return {
...result,
candidates,
...(groupTruncated ? { truncated: true as const } : {}),
};
});
let truncated = bounded.some((result) => result.truncated);
const render = () => ({ results: bounded, ...(truncated ? { truncated: true as const } : {}) });
while (JSON.stringify(render(), null, 2).length > MAX_TOOL_SEARCH_BATCH_RESPONSE_CHARS) {
const removable = bounded
.map((result, index) => ({
index,
rank: result.candidates.length,
candidate: result.candidates.at(-1),
}))
.filter(
(item): item is { index: number; rank: number; candidate: ToolSearchCandidate } =>
item.candidate !== undefined,
)
.toSorted(
(a, b) =>
b.rank - a.rank ||
JSON.stringify(b.candidate).length - JSON.stringify(a.candidate).length ||
a.index - b.index,
)[0];
if (!removable) {
break;
}
const group = bounded[removable.index];
group?.candidates.pop();
if (group) {
group.truncated = true;
}
truncated = true;
}
return render();
}
function shouldExposeControlTool(name: string, mode: ToolSearchMode): boolean {
if (name === TOOL_SEARCH_CODE_MODE_TOOL_NAME) {
return mode === "code";
@@ -187,18 +312,54 @@ export function createToolSearchTools(ctx: ToolSearchToolContext): AnyAgentTool[
name: TOOL_SEARCH_RAW_TOOL_NAME,
label: "Tool Search",
description:
"Search the effective Tool Search catalog. Query in English: matching is lexical against tool names and descriptions, which are written in English, so a query in another language will usually match nothing. Pass an exact result id or name to tool_call; use tool_describe only when you need its input schema.",
"Search the effective Tool Search catalog. Pass exactly one of query for one search or queries for several independent searches in one call. Batch results stay grouped in request order. Queries must be in English: matching is lexical against tool names and descriptions, which are written in English, so another language will usually match nothing. Pass an exact result id or name to tool_call; use tool_describe only when you need its input schema.",
parameters: Type.Object({
query: Type.String({
description: "Search query, in English. Describe the capability you need.",
}),
query: Type.Optional(
Type.String({
description:
"Single search query, in English. Do not set this when queries is present.",
}),
),
limit: Type.Optional(
Type.Integer({ minimum: 1, description: "Maximum number of results." }),
Type.Integer({ minimum: 1, description: "Maximum number of single-search results." }),
),
queries: Type.Optional(
Type.Array(
Type.Object({
query: Type.String({
minLength: 1,
maxLength: MAX_TOOL_SEARCH_BATCH_QUERY_GRAPHEMES,
description: "Search query, in English. Describe the capability you need.",
}),
limit: Type.Optional(
Type.Integer({
minimum: 1,
description: `Maximum results for this query. Defaults to ${config.searchDefaultLimit} when omitted.`,
}),
),
}),
{
minItems: 1,
maxItems: MAX_TOOL_SEARCH_BATCH_QUERIES,
description: `Independent searches. Do not set query when this is present. Their effective limits may total at most ${MAX_TOOL_SEARCH_RESULTS}; an omitted item limit counts as ${config.searchDefaultLimit}. The serialized query strings may use at most ${MAX_TOOL_SEARCH_BATCH_QUERY_BYTES} UTF-8 bytes in total.`,
},
),
),
}),
execute: async (_toolCallId: string, args: unknown): Promise<AgentToolResult<unknown>> => {
const search = readToolSearchArgs(args, config);
return jsonResult(await runtime.search(search.query, { limit: search.limit }));
const request = readToolSearchRequest(args, config);
if (request.kind === "single") {
return jsonResult(
await runtime.search(request.search.query, { limit: request.search.limit }),
);
}
const results = await Promise.all(
request.searches.map(async (search) => ({
query: search.query,
candidates: await runtime.search(search.query, { limit: search.limit }),
})),
);
return jsonResult(boundToolSearchBatchResponse(results));
},
},
{