blob: 1dbe437037cf2cf9d80c7d6cf098f33e560d170f [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 { ToolCategory } from '@maka/core/permission';
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 { toolActivationKey } from './tool-activation-identity.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;
/** Tools that remain visible whenever they are bound. */
const DIRECT_TOOL_NAMES: ReadonlySet<string> = new Set([
'Bash',
'Read',
'Write',
'Edit',
'Glob',
'Grep',
'WebFetch',
'AskUserQuestion',
'StopBackgroundTask',
// Existing carve-out pending the separate skill-discovery decision.
'Skill',
'SkillSearch',
// Provider-routed equivalent of the direct Write/Edit surface.
'apply_patch',
]);
/**
* Discovery capability-family derived from a tool's permission `categoryHint`.
*
* This reuses the existing permission taxonomy (`ToolCategory`) purely for
* *presentation* in the deferred-tool search inventory: it never loads a tool
* schema and never affects permission classification. Several permission
* categories intentionally collapse into one browsing family (e.g. every shell
* bucket → `shell`). `custom_tool` is deliberately `null` so our own
* session-scoped tools without a stronger hint keep falling back to `other`.
*
* The map is TOTAL over `ToolCategory` on purpose: adding a new category to the
* union forces an explicit decision here (family or `null`) at compile time,
* instead of silently collapsing the new category into `other`.
*/
const CATEGORY_FAMILY: Record<ToolCategory, { id: string; label: string } | null> = {
read: { id: 'filesystem', label: 'Filesystem & search' },
file_write: { id: 'filesystem', label: 'Filesystem & search' },
fs_destructive: { id: 'filesystem', label: 'Filesystem & search' },
shell_safe: { id: 'shell', label: 'Shell & processes' },
shell_unsafe: { id: 'shell', label: 'Shell & processes' },
privileged: { id: 'shell', label: 'Shell & processes' },
git_destructive: { id: 'shell', label: 'Shell & processes' },
web_read: { id: 'web', label: 'Web & network' },
network_send: { id: 'web', label: 'Web & network' },
browser: { id: 'browser', label: 'Browser automation' },
computer_use: { id: 'computer_use', label: 'Computer use' },
client_capability: { id: 'client_capability', label: 'Client capabilities' },
subagent: { id: 'agents', label: 'Agent orchestration' },
// null = intentionally ungrouped; falls back to the `other` bucket.
custom_tool: null,
};
/** Optional search metadata for a subset of the bound deferred tools. */
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.
* Supplying this config enables default deferral; omitting it keeps every
* bound tool direct for an explicit wire-schema ceiling.
*/
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 | undefined,
): `sha256:${string}` {
return stableHash({
mode: config === undefined ? 'full' : 'search',
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 SearchGroup {
id: string;
toolNames: string[];
label?: string;
description?: string;
}
interface SearchDocument {
id: string;
name: string;
searchText: string;
}
/**
* Immutable, backend-scoped bound-tool inventory 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 activationKeysByName: ReadonlyMap<string, `sha256:${string}`>;
private readonly groups: readonly SearchGroup[];
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]));
this.activationKeysByName = new Map(tools.map((tool) => [tool.name, toolActivationKey(tool)]));
const known = new Set(this.toolsByName.keys());
const searchable =
config === undefined
? new Set<string>()
: new Set([...known].filter((name) => !DIRECT_TOOL_NAMES.has(name)));
const claimed = new Set<string>();
const groups: SearchGroup[] = [];
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 (!searchable.has(name) || claimed.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 } : {}),
});
}
const ungrouped = [...searchable].filter((name) => !claimed.has(name)).sort(compareExactString);
if (ungrouped.length > 0) {
// Bucket ungrouped native tools by their permission `categoryHint` so the
// search inventory advertises a compact capability-family map instead of a
// single opaque `other` group. This reads metadata already on the bound
// tool (no schema is loaded) and never affects permission classification.
// A caller-supplied group with a colliding id keeps precedence: family
// members merge into it. Tools with no hint (or `custom_tool`) still fall
// back to `other`.
const familyMembers = new Map<string, { label?: string; names: string[] }>();
const otherNames: string[] = [];
for (const name of ungrouped) {
const hint = this.toolsByName.get(name)?.categoryHint;
// A mapped-but-`null` entry (e.g. custom_tool) and an absent hint both
// route to `other`; only a non-null family is bucketed.
const family = hint ? CATEGORY_FAMILY[hint] : null;
if (!family) {
otherNames.push(name);
continue;
}
const bucket = familyMembers.get(family.id) ?? { label: family.label, names: [] };
bucket.names.push(name);
familyMembers.set(family.id, bucket);
}
for (const id of [...familyMembers.keys()].sort(compareExactString)) {
const bucket = familyMembers.get(id)!;
const existing = groups.find((group) => group.id === id);
if (existing) {
existing.toolNames = [...existing.toolNames, ...bucket.names].sort(compareExactString);
} else {
groups.push({
id,
toolNames: [...bucket.names].sort(compareExactString),
...(bucket.label !== undefined ? { label: bucket.label } : {}),
});
}
}
if (otherNames.length > 0) {
const fallback = groups.find((group) => group.id === 'other');
if (fallback) {
fallback.toolNames = [...fallback.toolNames, ...otherNames].sort(compareExactString);
} else {
groups.push({ id: 'other', toolNames: otherNames.sort(compareExactString) });
}
}
}
this.groups = groups;
this.searchableNames = searchable;
this.directNames = new Set([...known].filter((name) => !searchable.has(name)));
if (searchable.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(
[...searchable].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, string>,
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));
// Activation belongs to a stable logical contribution, not a temporary
// wrapper object or merely its name. Equivalent Host wrappers survive
// per-step rebuilding; a replaced Plugin generation does not.
for (const [name, activatedKey] of activeTools) {
if (this.activationKeysByName.get(name) !== activatedKey) activeTools.delete(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, string>,
): 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}.`),
}),
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.activationKeysByName.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 SearchGroup[]): 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 SearchGroup[]): 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;
}