blob: d35b5a40417bdb38c9f5bd6944495fa85fd46661 [file]
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import type { ToolAvailabilityDiagnostic } from '@maka/core/usage-stats/types';
import MiniSearch from 'minisearch';
import { z } from 'zod';
import { estimateTokens } from './context-budget-helpers.js';
import { canonicalizeToolSet, stableHash, toolSchemaCharsForDiagnostics } from './request-shape.js';
import type { MakaTool, ToolGating } from './tool-runtime.js';
/** Canonical name of Maka's provider-independent deferred-tool search connector. */
export const TOOL_SEARCH_NAME = 'tool_search';
/** Provider-safe alias used because OpenAI Responses reserves `tool_search`. */
export const TOOL_SEARCH_PROVIDER_NAME = 'maka_tool_search';
export const TOOL_SEARCH_DEFAULT_LIMIT = 8;
export const TOOL_SEARCH_MAX_LIMIT = 20;
export const TOOL_SEARCH_MAX_SCHEMA_CHARS = 64 * 1024;
/** Frequent baseline that a group declaration may never defer. */
const DIRECT_TOOL_NAMES: ReadonlySet<string> = new Set([
'Bash',
'Read',
'ArchiveRead',
'Write',
'Edit',
'Glob',
'Grep',
'WebFetch',
'AskUserQuestion',
'StopBackgroundTask',
]);
/** A discoverable source whose members are searched and activated individually. */
export interface ToolGroup {
id: string;
toolNames: readonly string[];
label?: string;
description?: string;
}
export interface ToolAvailabilityConfig {
/** Search-space presentation metadata derived from the current bound tools. */
groups?: readonly ToolGroup[];
}
export interface ToolSearchResult {
readonly activated: string[];
readonly blocked?: {
readonly name: string;
readonly reason: 'schema_too_large' | 'schema_budget_exhausted';
readonly schemaChars: number;
};
}
export function toolAvailabilityHash(config: ToolAvailabilityConfig): `sha256:${string}` {
return stableHash({
groups: (config.groups ?? []).map((group) => ({
id: group.id,
toolNames: [...new Set(group.toolNames)].sort(compareExactString),
...(group.label !== undefined ? { label: group.label } : {}),
...(group.description !== undefined ? { description: group.description } : {}),
})),
});
}
function compareExactString(left: string, right: string): number {
return left < right ? -1 : left > right ? 1 : 0;
}
/** Everything the backend needs for one turn. */
export interface ToolAvailabilityPlan {
/** Full dispatch set (sorted bound tools + search connector + repair fallback). */
providerTools: MakaTool[];
/** Step-0 model-visible subset. */
activeTools: string[];
/** Recomputes the provider-visible subset from the turn-owned activation map. */
projectActiveTools?: (_options?: unknown) => { activeTools: string[] };
/** Tool names the repair path matches against; tracks the current step snapshot. */
currentRepairToolNames: () => string[];
/** Execute-boundary gating against the immutable step-start snapshot. */
gating?: ToolGating;
diagnostics: (
activeTools: readonly string[],
visibleToolSchemaChars: number,
) => ToolAvailabilityDiagnostic | undefined;
}
interface CatalogGroup {
id: string;
toolNames: string[];
label?: string;
description?: string;
}
interface SearchDocument {
id: string;
name: string;
searchText: string;
}
/**
* Immutable, backend-scoped bound-tool catalog and MiniSearch index.
*
* Mutable activation belongs to the per-send TurnScope and is passed to
* prepare(). Constructing one AiSdkBackend therefore constructs one index; all
* turns on that backend reuse it without sharing activation state.
*/
export class ToolAvailabilityRuntime {
private readonly tools: readonly MakaTool[];
private readonly toolsByName: ReadonlyMap<string, MakaTool>;
private readonly groups: readonly CatalogGroup[];
private readonly searchableNames: ReadonlySet<string>;
private readonly directNames: ReadonlySet<string>;
private readonly searchIndex?: MiniSearch<SearchDocument>;
constructor(
tools: readonly MakaTool[],
config: ToolAvailabilityConfig | undefined,
private readonly invalidTool: MakaTool,
) {
if (tools.some((tool) => tool.name === TOOL_SEARCH_NAME)) {
throw new Error(`Tool name "${TOOL_SEARCH_NAME}" is reserved by Runtime`);
}
if (tools.some((tool) => tool.name === TOOL_SEARCH_PROVIDER_NAME)) {
throw new Error(`Tool name "${TOOL_SEARCH_PROVIDER_NAME}" is reserved by Runtime`);
}
this.tools = [...tools];
this.toolsByName = new Map(tools.map((tool) => [tool.name, tool]));
const known = new Set(this.toolsByName.keys());
const claimed = new Set<string>();
const groups: CatalogGroup[] = [];
for (const group of config?.groups ?? []) {
if (!group.id) continue;
const members: string[] = [];
for (const name of group.toolNames) {
// The first source to claim a currently bound tool owns its inventory row.
if (!known.has(name) || claimed.has(name) || DIRECT_TOOL_NAMES.has(name)) continue;
claimed.add(name);
members.push(name);
}
if (members.length === 0) continue;
members.sort(compareExactString);
groups.push({
id: group.id,
toolNames: members,
...(group.label !== undefined ? { label: group.label } : {}),
...(group.description !== undefined ? { description: group.description } : {}),
});
}
this.groups = groups;
this.searchableNames = claimed;
this.directNames = new Set([...known].filter((name) => !claimed.has(name)));
if (claimed.size > 0) {
const groupByToolName = new Map(
groups.flatMap((group) => group.toolNames.map((name) => [name, group] as const)),
);
const index = new MiniSearch<SearchDocument>({
fields: ['name', 'searchText'],
storeFields: ['name'],
idField: 'id',
searchOptions: {
boost: { name: 4, searchText: 1 },
combineWith: 'OR',
prefix: true,
fuzzy: 0.2,
},
});
index.addAll(
[...claimed].map((name) => {
const tool = this.toolsByName.get(name)!;
const group = groupByToolName.get(name);
return {
id: name,
name,
searchText: [
name.replaceAll('_', ' '),
tool.description,
group?.id,
group?.label,
group?.description,
]
.filter((value): value is string => value !== undefined && value.length > 0)
.join(' '),
};
}),
);
this.searchIndex = index;
}
}
prepare(
activeTools: Map<string, MakaTool>,
requiredToolNames: ReadonlySet<string> = new Set(),
): ToolAvailabilityPlan {
if (!this.searchIndex) {
const canonical = canonicalizeToolSet(this.tools, this.invalidTool);
return {
providerTools: canonical.providerTools,
activeTools: canonical.activeTools,
currentRepairToolNames: () => canonical.activeTools,
diagnostics: () => undefined,
};
}
const connector = this.buildSearchConnector(activeTools);
const allTools = [...this.tools, connector];
const canonical = canonicalizeToolSet(allTools, this.invalidTool);
const knownNames = new Set(canonical.providerTools.map((tool) => tool.name));
const requiredNames = [...requiredToolNames].filter((name) => knownNames.has(name));
const step = { active: new Set<string>() };
const computeActive = (): string[] => {
const names = new Set<string>([...this.directNames, TOOL_SEARCH_NAME]);
for (const name of activeTools.keys()) {
if (knownNames.has(name)) names.add(name);
}
for (const name of requiredNames) names.add(name);
const active = canonicalizeToolSet(allTools, this.invalidTool, names).activeTools;
// Replace, rather than mutate, so an in-flight step retains its own snapshot.
step.active = new Set(active);
return active;
};
return {
providerTools: canonical.providerTools,
activeTools: computeActive(),
projectActiveTools: () => ({ activeTools: computeActive() }),
currentRepairToolNames: () => [...step.active],
gating: { gatedNames: this.searchableNames, activeNames: () => step.active },
diagnostics: (active, chars) => this.buildDiagnostic(allTools, active, chars),
};
}
private buildSearchConnector(
activeTools: Map<string, MakaTool>,
): MakaTool<{ query: string; limit?: number }, ToolSearchResult> {
return {
name: TOOL_SEARCH_NAME,
description: renderInventory(this.groups),
parameters: z.object({
query: z.string().trim().min(1).describe('Search query describing the needed capability.'),
limit: z
.number()
.int()
.min(1)
.max(TOOL_SEARCH_MAX_LIMIT)
.optional()
.describe(`Maximum matches to activate; defaults to ${TOOL_SEARCH_DEFAULT_LIMIT}.`),
}),
nesting: 'direct_only',
impl: ({ query, limit = TOOL_SEARCH_DEFAULT_LIMIT }, context) => {
const normalizedQuery = query.trim();
const ranked = this.searchIndex!.search(normalizedQuery)
.map((result) => String(result.id))
.filter((name) => !activeTools.has(name))
.slice(0, TOOL_SEARCH_MAX_LIMIT)
.filter((name) => this.searchableNames.has(name));
const activated: string[] = [];
let blocked: ToolSearchResult['blocked'];
let schemaChars = 0;
for (const name of ranked) {
if (activated.length >= limit) break;
const tool = this.toolsByName.get(name);
if (!tool) continue;
const chars = toolSchemaCharsForDiagnostics([tool], [tool.name]);
if (chars > TOOL_SEARCH_MAX_SCHEMA_CHARS) {
blocked ??= { name, reason: 'schema_too_large', schemaChars: chars };
continue;
}
if (schemaChars + chars > TOOL_SEARCH_MAX_SCHEMA_CHARS) {
blocked = { name, reason: 'schema_budget_exhausted', schemaChars: chars };
break;
}
activated.push(name);
schemaChars += chars;
}
for (const name of activated) activeTools.set(name, this.toolsByName.get(name)!);
const result: ToolSearchResult = {
activated,
...(blocked ? { blocked } : {}),
};
context.emitRunTrace?.('tool_searched', 'Deferred tools searched', {
query: normalizedQuery,
requestedLimit: limit,
ranked,
activated,
newlyActivated: activated,
schemaChars,
...(blocked ? { blocked } : {}),
});
return result;
},
toModelOutput: ({ output }) => {
const result = output as ToolSearchResult;
return {
type: 'json',
value: {
activated: [...result.activated],
...(result.blocked ? { blocked: { ...result.blocked } } : {}),
},
};
},
};
}
private buildDiagnostic(
allTools: readonly MakaTool[],
active: readonly string[],
visibleToolSchemaChars: number,
): ToolAvailabilityDiagnostic {
const activeSet = new Set(active);
const enabledSourceIds = this.groups
.filter((group) => group.toolNames.every((name) => activeSet.has(name)))
.map((group) => group.id)
.sort(compareExactString);
const availableSourceIds = this.groups
.filter((group) => !group.toolNames.every((name) => activeSet.has(name)))
.map((group) => group.id)
.sort(compareExactString);
const full = canonicalizeToolSet(allTools, this.invalidTool);
const fullToolSchemaChars = toolSchemaCharsForDiagnostics(full.providerTools, full.activeTools);
const toolSchemaCharReduction = Math.max(0, fullToolSchemaChars - visibleToolSchemaChars);
return {
mode: 'search',
enabledSourceIds,
availableSourceIds,
connectorToolName: TOOL_SEARCH_NAME,
visibleToolNamesBySource: groupToolNamesById(this.groups),
visibleToolCount: active.length,
fullToolCount: full.activeTools.length,
hiddenToolCount: Math.max(0, full.activeTools.length - active.length),
visibleToolSchemaChars,
fullToolSchemaChars,
toolSchemaCharReduction,
estimatedToolSchemaTokenReduction: estimateTokens(toolSchemaCharReduction),
};
}
}
function renderInventory(groups: readonly CatalogGroup[]): string {
const lines = groups.flatMap((group) => [
`${group.id}:`,
...group.toolNames.map((name) => `- ${name}`),
]);
return [
'Search the deferred tools bound to this run. A successful search activates the',
'bounded top matches; their complete callable definitions become visible on the',
'next provider step. Search again to expand the active set. A blocked result means',
'the highest remaining match did not fit this search schema budget.',
'',
'Searchable tool inventory (group and canonical name only):',
...lines,
].join('\n');
}
function groupToolNamesById(groups: readonly CatalogGroup[]): Record<string, string[]> {
const out: Record<string, string[]> = {};
for (const group of [...groups].sort((a, b) => compareExactString(a.id, b.id))) {
out[group.id] = [...group.toolNames].sort(compareExactString);
}
return out;
}