From d6d528d3d860d159fdd34e60a9abe19bd904bbf3 Mon Sep 17 00:00:00 2001 From: AstroHan Date: Fri, 28 Aug 2026 22:32:37 +0800 Subject: [PATCH] feat(runtime): defer non-direct tools by default Make the final executable binding the tool-availability authority. - defer every bound tool outside the direct baseline when search availability is enabled - keep explicit bound and profile ceilings fully visible by omitting search availability - retain Client Capability groups only as optional search metadata - remove the shared static tool catalog, Host projection, and Runtime memory-group special case - keep Skill, SkillSearch, and provider-routed apply_patch direct Closes #4091 Generated-by: Codex --- packages/core/package.json | 1 - packages/core/src/tool-catalog.ts | 233 ------------------ .../execution-model-composition.test.ts | 27 +- .../src/server/execution-composition.ts | 2 +- .../src/server/host-run-composer.ts | 2 +- .../src/server/interactive-run-composer.ts | 59 ++--- packages/runtime/package.json | 1 - .../src/__tests__/ai-sdk-backend.test.ts | 207 +++++++++------- .../__tests__/deferred-tools-backend.test.ts | 7 +- .../src/__tests__/tool-availability.test.ts | 43 +++- .../src/__tests__/tool-catalog-derive.test.ts | 112 --------- packages/runtime/src/request-shape.ts | 2 +- packages/runtime/src/skills-context.ts | 6 + packages/runtime/src/skills.ts | 1 + packages/runtime/src/tool-availability.ts | 57 +++-- packages/runtime/src/tool-catalog-derive.ts | 192 --------------- 16 files changed, 244 insertions(+), 708 deletions(-) delete mode 100644 packages/core/src/tool-catalog.ts delete mode 100644 packages/runtime/src/__tests__/tool-catalog-derive.test.ts delete mode 100644 packages/runtime/src/tool-catalog-derive.ts diff --git a/packages/core/package.json b/packages/core/package.json index f0086d4c2a..f7ca67c273 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -109,7 +109,6 @@ "./usage-record-schema": "./dist/usage-record-schema.js", "./session-send-projection": "./dist/session-send-projection.js", "./session-name": "./dist/session-name.js", - "./tool-catalog": "./dist/tool-catalog.js", "./thread-search": "./dist/thread-search.js", "./agent-graph-timeline": "./dist/agent-graph-timeline.js", "./agent-swarm": "./dist/agent-swarm.js", diff --git a/packages/core/src/tool-catalog.ts b/packages/core/src/tool-catalog.ts deleted file mode 100644 index c95e2170b4..0000000000 --- a/packages/core/src/tool-catalog.ts +++ /dev/null @@ -1,233 +0,0 @@ -/* - * 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. - */ - -/** - * Shared product tool vocabulary (#1099). - * - * Tool is the catalog atom. Surface is optional and only for jointly governed - * searchable groups and/or a shared host product boundary. - * Hosts own implementations; this module owns names and metadata. Derive - * HostCapabilities / ToolAvailability groups from catalog ∩ host binding. - * - * Public catalog tables are deeply frozen. Consumers must not mutate them; - * each surface owns an independent hosts record so affinity edits cannot bleed. - */ - -export const TOOL_HOST_IDS = ['desktop', 'cli', 'runtime-host'] as const; -export type ToolHostId = (typeof TOOL_HOST_IDS)[number]; - -/** Whether a host product surface may bind the pack. Not a runtime enable flag. */ -export type ToolHostSupport = 'supported' | 'unsupported'; - -/** - * Reserved for future policy projections (e.g. read-only). v1 does not consume - * these; hosts and permission stay unchanged. - */ -export type ToolEffect = 'read' | 'write' | 'shell' | 'network' | 'ui' | 'agent'; - -export interface CatalogToolDef { - readonly name: string; - /** Optional future policy tags; unused by v1 product paths. */ - readonly effects?: readonly ToolEffect[]; - /** Feeds HostCapabilities.capabilities when the tool is bound. */ - readonly capabilityTags?: readonly string[]; -} - -export interface CatalogSurfaceDef { - readonly id: string; - readonly label: string; - readonly description: string; - readonly toolNames: readonly string[]; - readonly hosts: Readonly>; -} - -function desktopOnlyHosts(): Readonly> { - return Object.freeze({ - desktop: 'supported', - cli: 'unsupported', - 'runtime-host': 'unsupported', - } satisfies Record); -} - -function allHosts(): Readonly> { - return Object.freeze({ - desktop: 'supported', - cli: 'supported', - 'runtime-host': 'supported', - } satisfies Record); -} - -function freezeTool(tool: CatalogToolDef): CatalogToolDef { - return Object.freeze({ - name: tool.name, - ...(tool.effects ? { effects: Object.freeze([...tool.effects]) } : {}), - ...(tool.capabilityTags ? { capabilityTags: Object.freeze([...tool.capabilityTags]) } : {}), - }); -} - -function freezeSurface(surface: CatalogSurfaceDef): CatalogSurfaceDef { - return Object.freeze({ - id: surface.id, - label: surface.label, - description: surface.description, - toolNames: Object.freeze([...surface.toolNames]), - hosts: Object.freeze({ ...surface.hosts }), - }); -} - -/** Always-on product tools (no surface) plus every surface member. */ -export const MAKA_CATALOG_TOOLS: readonly CatalogToolDef[] = Object.freeze( - [ - // Core file / shell - { name: 'Bash' }, - { name: 'Read' }, - { name: 'ArchiveRead' }, - { name: 'Write' }, - { name: 'Edit' }, - { name: 'apply_patch' }, - { name: 'FormatJson' }, - { name: 'Glob' }, - { name: 'Grep' }, - { name: 'StopBackgroundTask' }, - { name: 'WriteStdin' }, - // Host product always-on - { name: 'AskUserQuestion' }, - { name: 'request_sandbox_boundary' }, - { name: 'Skill' }, - { name: 'SkillSearch' }, - { name: 'WebFetch', effects: ['network'] as const }, - { name: 'WebSearch' }, - { name: 'SearchHistory', effects: ['read'] as const }, - { name: 'ReadHistory', effects: ['read'] as const }, - { name: 'MakaSettingsGet', effects: ['read'] as const }, - { name: 'MakaSettingsUpdate', effects: ['write'] as const }, - { name: 'ExploreAgent' }, - { name: 'ScheduledTask' }, - { name: 'GoalSet' }, - { name: 'GoalClear' }, - { name: 'GoalStatus' }, - { name: 'GoalPause' }, - { name: 'GoalResume' }, - { name: 'task_create' }, - { name: 'task_update' }, - { name: 'task_list' }, - { name: 'task_get' }, - { name: 'memory_remember' }, - { name: 'memory_extract' }, - // browser surface - { name: 'browser_navigate' }, - { name: 'browser_snapshot' }, - { name: 'browser_click' }, - { name: 'browser_type' }, - { name: 'browser_wait' }, - { name: 'browser_extract' }, - // computer_use surface - { name: 'maka_computer' }, - // rive surface - { name: 'RiveWorkflow' }, - // agent surface (id matches AGENT_TOOL_GROUP_ID) - { name: 'agent_spawn' }, - { name: 'agent_list' }, - { name: 'agent_output' }, - { name: 'agent_swarm_status' }, - // Host-managed agent graph supervisor surface - { name: 'view_agent_graph' }, - { name: 'update_agent_graph' }, - { name: 'yield_agent_graph' }, - ].map(freezeTool), -); - -/** - * Jointly governed searchable packs. Id `agent` matches the runtime - * ToolAvailability group id (AGENT_TOOL_GROUP_ID), not a separate "subagent" id. - * Each surface gets its own hosts object so affinity cannot cross-contaminate. - */ -export const MAKA_CATALOG_SURFACES: readonly CatalogSurfaceDef[] = Object.freeze( - [ - { - id: 'rive', - label: 'Rive', - description: - 'Durable multi-agent Rive workflows: validate/import/run/status, scheduler, retries.', - toolNames: ['RiveWorkflow'], - hosts: desktopOnlyHosts(), - }, - { - id: 'browser', - label: 'Browser', - description: 'Drive the embedded browser: navigate, snapshot, click, type, wait, extract.', - toolNames: [ - 'browser_navigate', - 'browser_snapshot', - 'browser_click', - 'browser_type', - 'browser_wait', - 'browser_extract', - ], - hosts: desktopOnlyHosts(), - }, - { - id: 'computer_use', - label: 'Computer', - description: 'Observe and operate an explicitly approved local application.', - toolNames: ['maka_computer'], - hosts: desktopOnlyHosts(), - }, - { - id: 'agent', - label: 'Agent', - description: 'Spawn, fan out, and inspect foreground child agents.', - toolNames: [ - 'agent_spawn', - 'agent_list', - 'agent_output', - 'agent_swarm_status', - 'view_agent_graph', - 'update_agent_graph', - 'yield_agent_graph', - ], - hosts: allHosts(), - }, - ].map(freezeSurface), -); - -const TOOL_BY_NAME = new Map(MAKA_CATALOG_TOOLS.map((tool) => [tool.name, tool])); -const TOOL_NAME_SET: ReadonlySet = new Set(TOOL_BY_NAME.keys()); - -export function catalogToolByName(name: string): CatalogToolDef | undefined { - return TOOL_BY_NAME.get(name); -} - -/** Isolated snapshot of catalog tool names (mutations do not affect the catalog). */ -export function catalogToolNameSet(): ReadonlySet { - return new Set(TOOL_NAME_SET); -} - -/** Bound names that are not catalog rows (sorted). Empty means the binding is catalog-clean. */ -export function unknownBoundToolNames(boundToolNames: Iterable): string[] { - const unknown: string[] = []; - for (const name of boundToolNames) { - if (!TOOL_BY_NAME.has(name)) unknown.push(name); - } - return unknown.sort(); -} - -export function catalogSurfaceById(id: string): CatalogSurfaceDef | undefined { - return MAKA_CATALOG_SURFACES.find((surface) => surface.id === id); -} diff --git a/packages/runtime-host/src/__tests__/execution-model-composition.test.ts b/packages/runtime-host/src/__tests__/execution-model-composition.test.ts index ecfba7fad1..53d3260a59 100644 --- a/packages/runtime-host/src/__tests__/execution-model-composition.test.ts +++ b/packages/runtime-host/src/__tests__/execution-model-composition.test.ts @@ -1484,40 +1484,21 @@ test('production Host executes a canonical ai-sdk Session against a real provide assert.match(requestText, /HOSTED_MEMORY_SENTINEL/); assert.match(JSON.stringify(mainRequests[1]?.body), /HOSTED_SKILL_BODY_MUST_STAY_LAZY/); // Tavily is selected but no web-search credential exists, so the provider - // must never see WebSearch in the effective root tool surface. + // must never see WebSearch in the effective root tool surface. Non-direct + // bound tools stay deferred behind tool_search until activated. assert.deepEqual(toolNames(request?.body), [ 'ArchiveRead', 'AskUserQuestion', 'Bash', 'Edit', - 'ExploreAgent', - 'FormatJson', 'Glob', - 'GoalClear', - 'GoalPause', - 'GoalResume', - 'GoalSet', - 'GoalStatus', 'Grep', - 'MakaSettingsGet', - 'MakaSettingsUpdate', 'Read', - 'ReadHistory', - 'ScheduledTask', - 'SearchHistory', 'Skill', 'SkillSearch', 'StopBackgroundTask', 'WebFetch', 'Write', - 'WriteStdin', - 'memory_extract', - 'memory_remember', - 'request_sandbox_boundary', - 'task_create', - 'task_get', - 'task_list', - 'task_update', 'tool_search', ]); assert.match(JSON.stringify(compactRequests[0]?.body), /context summarization assistant/); @@ -3257,7 +3238,7 @@ test('a bound tool ceiling excludes dynamic Client Capability tools', () => { assert.deepEqual(composition.tools, [boundTool]); assert.equal( - composition.toolAvailability.groups?.some((group) => group.id === 'client_fixture'), + composition.toolAvailability?.groups?.some((group) => group.id === 'client_fixture') ?? false, false, ); }); @@ -3291,7 +3272,7 @@ test('the headless coding profile freezes the Eval prompt and tool ceiling', asy composition.tools.map(({ name }) => name), ['Bash', 'Read', 'Write', 'Edit', 'Glob', 'Grep', 'apply_patch'], ); - assert.deepEqual(composition.toolAvailability.groups, []); + assert.equal(composition.toolAvailability, undefined); assert.equal( ( await composition.resolveSystemPrompt({ diff --git a/packages/runtime-host/src/server/execution-composition.ts b/packages/runtime-host/src/server/execution-composition.ts index d8d09a8da3..a3c9180abb 100644 --- a/packages/runtime-host/src/server/execution-composition.ts +++ b/packages/runtime-host/src/server/execution-composition.ts @@ -41,10 +41,10 @@ import { type BackendFactory, } from '@maka/runtime/session-manager'; import { buildToolsForAgentDefinition } from '@maka/runtime/agent-catalog'; -import { buildHostCapabilitiesFromBinding } from '@maka/runtime/tool-catalog-derive'; import { buildHistoryTools } from '@maka/runtime/history-tools'; import { createLocalContinuationSafetyInspector } from '@maka/runtime/continuation-safety'; import { createConfiguredSubagentCatalog } from '@maka/runtime/configured-subagent-catalog'; +import { buildHostCapabilitiesFromBinding } from '@maka/runtime/skills'; import { createBuiltinSandboxManager, createSandboxDiagnosticsProvider, diff --git a/packages/runtime-host/src/server/host-run-composer.ts b/packages/runtime-host/src/server/host-run-composer.ts index 5323cf4564..b302075597 100644 --- a/packages/runtime-host/src/server/host-run-composer.ts +++ b/packages/runtime-host/src/server/host-run-composer.ts @@ -46,7 +46,7 @@ export interface HostRunComposer { readonly composerId: string; readonly composerRevision: string; readonly tools: readonly MakaTool[]; - readonly toolAvailability: ToolAvailabilityConfig; + readonly toolAvailability?: ToolAvailabilityConfig; readonly resolveSystemPrompt: (context: HostModelPromptContext) => Promise; readonly turnTailPrompt: (context: HostModelPromptContext) => Promise; readonly planTraceContext?: AiSdkBackendInput['planTraceContext']; diff --git a/packages/runtime-host/src/server/interactive-run-composer.ts b/packages/runtime-host/src/server/interactive-run-composer.ts index 177b345f06..047745ac58 100644 --- a/packages/runtime-host/src/server/interactive-run-composer.ts +++ b/packages/runtime-host/src/server/interactive-run-composer.ts @@ -45,15 +45,12 @@ import { buildUpdatePlanTool, } from '@maka/runtime/plan-tools'; import { buildExploreAgentTool } from '@maka/runtime/explore-agent-tool'; -import { - buildHostCapabilitiesFromBinding, - projectEffectiveProductToolSurface, -} from '@maka/runtime/tool-catalog-derive'; import { buildParentAgentTools } from '@maka/runtime/subagent-tools'; import { buildPersonalizationPromptFragment } from '@maka/runtime/system-prompt/personalization-prompt'; import { buildRequestSandboxBoundaryTool } from '@maka/runtime/sandbox-boundary-tool'; import { buildSessionEnvironmentPromptFragment } from '@maka/runtime/system-prompt/session-environment-prompt'; import { + buildHostCapabilitiesFromBinding, buildSkillAgentToolFromInventory, buildSkillSearchAgentToolFromInventory, buildSkillsPromptFragmentFromInventoryWithReport, @@ -75,7 +72,7 @@ import { resolveProjectGitInfo } from '@maka/runtime/system-prompt/project-conte import { routeWebFetchTools } from '@maka/runtime/web-fetch-tool'; import { routeWebSearchTools } from '@maka/runtime/native-web-search-tool'; import { type MakaTool } from '@maka/runtime/tool-runtime'; -import { type ToolAvailabilityConfig, type ToolGroup } from '@maka/runtime/tool-availability'; +import { type ToolGroup } from '@maka/runtime/tool-availability'; import { resolveTurnShellPlan, type TurnShellPlan, @@ -183,23 +180,21 @@ export function createInteractiveRunComposer(input: InteractiveRunComposerInput) fullAccess: input.plan.permissionMode === 'bypass', }) : candidateTools; - const productSurface = projectEffectiveProductToolSurface({ - host: 'runtime-host', - tools: selectedTools, - }); // A bound tool list is an exact child/local activation ceiling. Dynamic - // capabilities must be included by the authority that constructs that list. - const tools = [...productSurface.tools]; + // capabilities must be included by the authority that constructs that + // list. The ceiling is also an exact wire contract: no deferred search + // groups inside it, so the bound tools stay fully visible. + const tools = [...selectedTools]; assertUniqueToolNames(tools); - const toolAvailability = mergeToolAvailability( - productSurface.toolAvailability, - hasToolCeiling - ? [] - : filterToolGroups( + const hostCapabilities = buildHostCapabilitiesFromBinding(tools.map(({ name }) => name)); + const toolAvailability = hasToolCeiling + ? undefined + : { + groups: filterToolGroups( input.clientCapabilities?.groups ?? [], new Set(tools.map(({ name }) => name)), ), - ); + }; const childInstruction = input.childInstruction?.trim(); const runProfile = hostedExecutionRunProfile(input.toolProfile); const resolvedSystemPrompts = new Map>(); @@ -222,7 +217,7 @@ export function createInteractiveRunComposer(input: InteractiveRunComposerInput) .then(async ([promptState, inventory]) => { const skills = buildSkillsPromptFragmentFromInventoryWithReport( inventory.inventory, - productSurface.hostCapabilities, + hostCapabilities, input.skillBudget, ); context.emitSkillCatalogTrace?.('Skill catalog selection completed', { @@ -491,23 +486,6 @@ export function createInteractiveRunComposerFactory( }; } -function mergeToolAvailability( - product: ToolAvailabilityConfig, - clientGroups: readonly ToolGroup[], -): ToolAvailabilityConfig { - if (clientGroups.length === 0) return product; - const groupIds = new Set((product.groups ?? []).map((group) => group.id)); - for (const group of clientGroups) { - if (groupIds.has(group.id)) { - throw new Error(`Client Capability tool group collision: ${group.id}`); - } - groupIds.add(group.id); - } - return { - groups: [...(product.groups ?? []), ...clientGroups], - }; -} - function assertUniqueToolNames(tools: readonly MakaTool[]): void { const names = new Set(); for (const tool of tools) { @@ -609,7 +587,12 @@ function renderPlanTail( } function filterToolGroups(groups: readonly ToolGroup[], names: ReadonlySet): ToolGroup[] { + const seenIds = new Set(); return groups.flatMap((group) => { + if (seenIds.has(group.id)) { + throw new Error(`Client Capability tool group collision: ${group.id}`); + } + seenIds.add(group.id); const toolNames = group.toolNames.filter((name) => names.has(name)); return toolNames.length > 0 ? [{ ...group, toolNames }] : []; }); @@ -725,11 +708,13 @@ function renderTaskLedgerTail( const rendered = renderTaskLedgerPromptText(tasks); if (!rendered.text) return undefined; return [ - 'Current task ledger (current-turn context only; maintain it with task_create, task_update, task_list, and task_get):', + 'Current task ledger (current-turn context only; maintain it with task_create, task_update, task_list, and task_get — activate them via tool_search first when they are not already visible):', '', rendered.text, ...(rendered.omittedCount > 0 - ? [`omitted=${rendered.omittedCount} (use task_list/task_get for the complete ledger)`] + ? [ + `omitted=${rendered.omittedCount} (use task_list/task_get via tool_search for the complete ledger)`, + ] : []), '', ].join('\n'); diff --git a/packages/runtime/package.json b/packages/runtime/package.json index c4d2a6bad5..e6d02965d2 100644 --- a/packages/runtime/package.json +++ b/packages/runtime/package.json @@ -106,7 +106,6 @@ "./tavily-search": "./dist/tavily-search.js", "./terminal-run-commit": "./dist/terminal-run-commit.js", "./tool-availability": "./dist/tool-availability.js", - "./tool-catalog-derive": "./dist/tool-catalog-derive.js", "./tool-free-model-call": "./dist/tool-free-model-call.js", "./tool-result-archive": "./dist/tool-result-archive.js", "./tool-result-archive-capability": "./dist/tool-result-archive-capability.js", diff --git a/packages/runtime/src/__tests__/ai-sdk-backend.test.ts b/packages/runtime/src/__tests__/ai-sdk-backend.test.ts index 9c4b2a1d20..500876e063 100644 --- a/packages/runtime/src/__tests__/ai-sdk-backend.test.ts +++ b/packages/runtime/src/__tests__/ai-sdk-backend.test.ts @@ -530,6 +530,38 @@ describe('AiSdkBackend ApplyPatch routing', () => { }); }); +/** Deferred memory triggers need one tool_search step before the model may call them. */ +function memorySearchChunks(searchToolName: string): LanguageModelV4StreamPart[] { + return [ + { type: 'stream-start', warnings: [] }, + { + type: 'tool-call', + toolCallId: 'memory-search', + toolName: searchToolName, + input: JSON.stringify({ query: 'memory' }), + }, + { + type: 'finish', + finishReason: { unified: 'tool-calls', raw: 'tool_calls' }, + usage: emptyUsage(), + }, + ]; +} + +function memoryFinishTextChunks(delta: string): LanguageModelV4StreamPart[] { + return [ + { type: 'stream-start', warnings: [] }, + { type: 'text-start', id: 'text-1' }, + { type: 'text-delta', id: 'text-1', delta }, + { type: 'text-end', id: 'text-1' }, + { + type: 'finish', + finishReason: { unified: 'stop', raw: 'stop' }, + usage: emptyUsage(), + }, + ]; +} + describe('AiSdkBackend Memory Extraction triggers', () => { test('dispatches a pre-turn Compaction recipe without projecting history or awaiting it', async () => { const model = completionModel(); @@ -832,8 +864,30 @@ describe('AiSdkBackend Memory Extraction triggers', () => { }); test('exposes explicitly unsupported Memory triggers on the native OpenAI Responses lane', async () => { - const model = completionModel(); + let modelCalls = 0; let memoryCalled = false; + const model = new MockLanguageModelV4({ + doStream: async () => { + modelCalls += 1; + return { + stream: simulateReadableStream({ + chunks: (modelCalls === 1 + ? memorySearchChunks('maka_tool_search') + : [ + { type: 'stream-start', warnings: [] }, + { + type: 'finish', + finishReason: { unified: 'stop', raw: 'stop' }, + usage: emptyUsage(), + }, + ]) as LanguageModelV4StreamPart[], + initialDelayInMs: null, + chunkDelayInMs: null, + }), + }; + }, + }); + const durable = durableTurnHarness('turn-1', 'hello'); const backend = createTestAiSdkBackend({ sessionId: 'session-1', header: header(), @@ -843,6 +897,8 @@ describe('AiSdkBackend Memory Extraction triggers', () => { modelId: 'gpt-5.4', modelFactory: () => model, tools: [], + toolAvailability: {}, + loadTurnRuntimeEvents: durable.loadTurnRuntimeEvents, memoryExtraction: { gate: async () => ({ allowed: true }), remember: async () => { @@ -857,14 +913,20 @@ describe('AiSdkBackend Memory Extraction triggers', () => { now: monotonicClock(), }); - await drain(backend.send({ turnId: 'turn-1', text: 'hello', context: [] })); + await drainDurably( + backend.send(durable.input({ runId: 'run-1', invocationId: 'invocation-1' })), + durable, + ); + const stepZeroToolNames = model.doStreamCalls[0]?.tools?.map((tool) => tool.name) ?? []; assert.equal( - model.doStreamCalls[0]?.tools?.some( - (tool) => tool.name === 'memory_remember' || tool.name === 'memory_extract', - ) ?? false, - true, + stepZeroToolNames.some((name) => name === 'memory_remember' || name === 'memory_extract'), + false, ); + assert.ok(stepZeroToolNames.includes('maka_tool_search')); + const searchedToolNames = model.doStreamCalls[1]?.tools?.map((tool) => tool.name) ?? []; + assert.ok(searchedToolNames.includes('memory_remember')); + assert.ok(searchedToolNames.includes('memory_extract')); assert.equal(memoryCalled, false); }); @@ -877,31 +939,23 @@ describe('AiSdkBackend Memory Extraction triggers', () => { return { stream: simulateReadableStream({ chunks: (modelCalls === 1 - ? [ - { type: 'stream-start', warnings: [] }, - { - type: 'tool-call', - toolCallId: 'remember-call', - toolName: 'memory_remember', - input: '{}', - }, - { - type: 'finish', - finishReason: { unified: 'tool-calls', raw: 'tool_calls' }, - usage: emptyUsage(), - }, - ] - : [ - { type: 'stream-start', warnings: [] }, - { type: 'text-start', id: 'text-1' }, - { type: 'text-delta', id: 'text-1', delta: 'Remembered.' }, - { type: 'text-end', id: 'text-1' }, - { - type: 'finish', - finishReason: { unified: 'stop', raw: 'stop' }, - usage: emptyUsage(), - }, - ]) as LanguageModelV4StreamPart[], + ? memorySearchChunks(TOOL_SEARCH_NAME) + : modelCalls === 2 + ? [ + { type: 'stream-start', warnings: [] }, + { + type: 'tool-call', + toolCallId: 'remember-call', + toolName: 'memory_remember', + input: '{}', + }, + { + type: 'finish', + finishReason: { unified: 'tool-calls', raw: 'tool_calls' }, + usage: emptyUsage(), + }, + ] + : memoryFinishTextChunks('Remembered.')) as LanguageModelV4StreamPart[], initialDelayInMs: null, chunkDelayInMs: null, }), @@ -918,6 +972,7 @@ describe('AiSdkBackend Memory Extraction triggers', () => { modelId: 'mock-model-id', modelFactory: () => model, tools: [], + toolAvailability: {}, loadTurnRuntimeEvents: durable.loadTurnRuntimeEvents, memoryExtraction: { gate: async () => ({ allowed: true }), @@ -946,7 +1001,7 @@ describe('AiSdkBackend Memory Extraction triggers', () => { ); assert.ok(sourceUserEvent); assert.deepEqual(snapshot?.sourceEventMessagePositions?.[sourceUserEvent.id], [0]); - assert.match(JSON.stringify(model.doStreamCalls[1]?.prompt), /User prefers concise Chinese/); + assert.match(JSON.stringify(model.doStreamCalls[2]?.prompt), /User prefers concise Chinese/); }); test('keeps the complete frozen provider context while evidence authority remains user-only', async () => { @@ -972,31 +1027,23 @@ describe('AiSdkBackend Memory Extraction triggers', () => { }, ] : modelCalls === 2 - ? [ - { type: 'stream-start', warnings: [] }, - { - type: 'tool-call', - toolCallId: 'remember-call', - toolName: 'memory_remember', - input: '{}', - }, - { - type: 'finish', - finishReason: { unified: 'tool-calls', raw: 'tool_calls' }, - usage: emptyUsage(), - }, - ] - : [ - { type: 'stream-start', warnings: [] }, - { type: 'text-start', id: 'text-1' }, - { type: 'text-delta', id: 'text-1', delta: 'Remembered.' }, - { type: 'text-end', id: 'text-1' }, - { - type: 'finish', - finishReason: { unified: 'stop', raw: 'stop' }, - usage: emptyUsage(), - }, - ]; + ? memorySearchChunks(TOOL_SEARCH_NAME) + : modelCalls === 3 + ? [ + { type: 'stream-start', warnings: [] }, + { + type: 'tool-call', + toolCallId: 'remember-call', + toolName: 'memory_remember', + input: '{}', + }, + { + type: 'finish', + finishReason: { unified: 'tool-calls', raw: 'tool_calls' }, + usage: emptyUsage(), + }, + ] + : memoryFinishTextChunks('Remembered.'); return { stream: simulateReadableStream({ chunks, @@ -1023,6 +1070,7 @@ describe('AiSdkBackend Memory Extraction triggers', () => { impl: async () => ({ value: 'TOOL-ONLY-SECRET' }), }, ], + toolAvailability: {}, loadTurnRuntimeEvents: durable.loadTurnRuntimeEvents, memoryExtraction: { gate: async () => ({ allowed: true }), @@ -1065,31 +1113,23 @@ describe('AiSdkBackend Memory Extraction triggers', () => { return { stream: simulateReadableStream({ chunks: (modelCalls === 1 - ? [ - { type: 'stream-start', warnings: [] }, - { - type: 'tool-call', - toolCallId: 'extract-call', - toolName: 'memory_extract', - input: '{}', - }, - { - type: 'finish', - finishReason: { unified: 'tool-calls', raw: 'tool_calls' }, - usage: emptyUsage(), - }, - ] - : [ - { type: 'stream-start', warnings: [] }, - { type: 'text-start', id: 'text-1' }, - { type: 'text-delta', id: 'text-1', delta: 'Done.' }, - { type: 'text-end', id: 'text-1' }, - { - type: 'finish', - finishReason: { unified: 'stop', raw: 'stop' }, - usage: emptyUsage(), - }, - ]) as LanguageModelV4StreamPart[], + ? memorySearchChunks(TOOL_SEARCH_NAME) + : modelCalls === 2 + ? [ + { type: 'stream-start', warnings: [] }, + { + type: 'tool-call', + toolCallId: 'extract-call', + toolName: 'memory_extract', + input: '{}', + }, + { + type: 'finish', + finishReason: { unified: 'tool-calls', raw: 'tool_calls' }, + usage: emptyUsage(), + }, + ] + : memoryFinishTextChunks('Done.')) as LanguageModelV4StreamPart[], initialDelayInMs: null, chunkDelayInMs: null, }), @@ -1106,6 +1146,7 @@ describe('AiSdkBackend Memory Extraction triggers', () => { modelId: 'mock-model-id', modelFactory: () => model, tools: [], + toolAvailability: {}, loadTurnRuntimeEvents: durable.loadTurnRuntimeEvents, memoryExtraction: { gate: async () => ({ allowed: true }), @@ -1124,7 +1165,7 @@ describe('AiSdkBackend Memory Extraction triggers', () => { ); await new Promise((resolve) => setImmediate(resolve)); - assert.equal(modelCalls, 2); + assert.equal(modelCalls, 3); assert.ok( durable.ledger.some( (event) => diff --git a/packages/runtime/src/__tests__/deferred-tools-backend.test.ts b/packages/runtime/src/__tests__/deferred-tools-backend.test.ts index fa479b3b14..086f717a27 100644 --- a/packages/runtime/src/__tests__/deferred-tools-backend.test.ts +++ b/packages/runtime/src/__tests__/deferred-tools-backend.test.ts @@ -79,6 +79,7 @@ function backend(input: { durable?: ReturnType; traces?: RunTraceEvent[]; toolAvailability?: ToolAvailabilityConfig; + fullSurface?: boolean; }): AiSdkBackend { let id = 0; return createTestAiSdkBackend({ @@ -90,7 +91,7 @@ function backend(input: { modelId: 'mock-model-id', modelFactory: () => input.model, tools: boundTools(input.calls), - toolAvailability: input.toolAvailability ?? availability, + ...(input.fullSurface ? {} : { toolAvailability: input.toolAvailability ?? availability }), ...(input.durable ? { loadTurnRuntimeEvents: input.durable.loadTurnRuntimeEvents } : {}), ...(input.traces ? { recordRunTrace: (event) => input.traces!.push(event) } : {}), newId: () => `id-${++id}`, @@ -195,10 +196,10 @@ describe('AiSdkBackend tool_search activation', () => { assert.ok(!captured[0]?.includes('browser_click')); }); - test('an empty search-space config keeps the complete bound surface direct', async () => { + test('omitting search availability keeps the complete bound surface direct', async () => { const captured: string[][] = []; await drain( - backend({ model: capturingModel(captured), calls: [], toolAvailability: {} }).send({ + backend({ model: capturingModel(captured), calls: [], fullSurface: true }).send({ turnId: 'turn-1', text: 'hi', context: [], diff --git a/packages/runtime/src/__tests__/tool-availability.test.ts b/packages/runtime/src/__tests__/tool-availability.test.ts index 3b51ec3cf9..25fdb924e6 100644 --- a/packages/runtime/src/__tests__/tool-availability.test.ts +++ b/packages/runtime/src/__tests__/tool-availability.test.ts @@ -60,6 +60,10 @@ test('tool availability hash canonicalizes group members', () => { assert.equal(grouped, reordered); }); +test('tool availability hash distinguishes full and search-enabled bindings', () => { + assert.notEqual(toolAvailabilityHash(undefined), toolAvailabilityHash({})); +}); + function runtime() { return new ToolAvailabilityRuntime( [ @@ -106,6 +110,24 @@ describe('ToolAvailabilityRuntime — search activation', () => { assert.doesNotMatch(searchTool(plan).description, /- Read/); }); + test('skill discovery tools stay direct while search is enabled', () => { + const plan = new ToolAvailabilityRuntime( + [tool('Skill'), tool('SkillSearch'), tool('custom')], + {}, + invalid, + ).prepare(new Map()); + assert.deepEqual(plan.activeTools, ['Skill', 'SkillSearch', TOOL_SEARCH_NAME]); + }); + + test('provider-routed apply_patch inherits direct editing visibility', () => { + const plan = new ToolAvailabilityRuntime( + [tool('apply_patch'), tool('custom')], + {}, + invalid, + ).prepare(new Map()); + assert.deepEqual(plan.activeTools, ['apply_patch', TOOL_SEARCH_NAME]); + }); + test('inventory contains group and canonical names without tool descriptions', () => { const connector = searchTool(runtime().prepare(new Map())); assert.match(connector.description, /browser:\n- browser_click/); @@ -303,10 +325,23 @@ describe('ToolAvailabilityRuntime — search activation', () => { assert.ok(!secondPlan.activeTools.includes('browser_click')); }); - test('without searchable groups every bound tool stays directly visible', () => { - const plan = new ToolAvailabilityRuntime([tool('Read'), tool('custom')], {}, invalid).prepare( - new Map(), - ); + test('an ungrouped bound tool is deferred by default', () => { + const plan = new ToolAvailabilityRuntime( + [tool('Read'), tool('future_tool')], + {}, + invalid, + ).prepare(new Map()); + assert.deepEqual(plan.activeTools, ['Read', TOOL_SEARCH_NAME]); + assert.match(searchTool(plan).description, /- future_tool/); + assert.ok(plan.gating?.gatedNames.has('future_tool')); + }); + + test('omitting availability keeps an explicit binding fully visible', () => { + const plan = new ToolAvailabilityRuntime( + [tool('Read'), tool('custom')], + undefined, + invalid, + ).prepare(new Map()); assert.deepEqual(plan.activeTools, ['custom', 'Read']); assert.ok(!plan.providerTools.some((candidate) => candidate.name === TOOL_SEARCH_NAME)); assert.equal(plan.gating, undefined); diff --git a/packages/runtime/src/__tests__/tool-catalog-derive.test.ts b/packages/runtime/src/__tests__/tool-catalog-derive.test.ts deleted file mode 100644 index 04c10b7c95..0000000000 --- a/packages/runtime/src/__tests__/tool-catalog-derive.test.ts +++ /dev/null @@ -1,112 +0,0 @@ -/* - * 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 assert from 'node:assert/strict'; -import { describe, it } from 'node:test'; -import { - assertProductBindingCatalogClean, - buildSearchableToolGroupsFromCatalog, - projectEffectiveProductToolSurface, -} from '../tool-catalog-derive.js'; -import type { MakaTool } from '../tool-runtime.js'; - -function tool(name: string): MakaTool { - return { - name, - description: name, - parameters: { parse: (value: unknown) => value }, - impl: async () => null, - }; -} - -describe('projectEffectiveProductToolSurface', () => { - it('removes a catalog surface that is unsupported on the selected host', () => { - const surface = projectEffectiveProductToolSurface({ - host: 'cli', - tools: [tool('Read'), tool('browser_navigate'), tool('mcp__server__tool')], - }); - - assert.deepEqual( - surface.tools.map((candidate) => candidate.name), - ['Read', 'mcp__server__tool'], - ); - }); - - it('treats a scoped child binding as a hard ceiling', () => { - const surface = projectEffectiveProductToolSurface({ - host: 'desktop', - tools: [tool('Read'), tool('Grep')], - }); - - assert.deepEqual( - surface.tools.map((candidate) => candidate.name), - ['Read', 'Grep'], - ); - assert.deepEqual(surface.boundSurfaceIds, []); - assert.deepEqual(surface.toolAvailability.groups, []); - assert.deepEqual(surface.identity.productToolNames, ['Grep', 'Read']); - }); -}); - -describe('buildSearchableToolGroupsFromCatalog', () => { - it('includes only supported deferred surfaces that have bound members', () => { - const groups = buildSearchableToolGroupsFromCatalog('desktop', [ - 'Read', - 'maka_computer', - 'agent_spawn', - 'agent_list', - 'RiveWorkflow', - ]); - assert.deepEqual(groups.map((group) => group.id).sort(), ['agent', 'computer_use', 'rive']); - const computerUse = groups.find((group) => group.id === 'computer_use'); - assert.deepEqual(computerUse?.toolNames, ['maka_computer']); - assert.equal(computerUse?.label, 'Computer'); - const agent = groups.find((group) => group.id === 'agent'); - assert.deepEqual(agent?.toolNames, ['agent_spawn', 'agent_list']); - }); - - it('never advertises desktop-only packs on cli', () => { - const bound = [ - 'browser_navigate', - 'maka_computer', - 'RiveWorkflow', - 'agent_spawn', - 'agent_list', - 'agent_output', - ]; - const groups = buildSearchableToolGroupsFromCatalog('cli', bound); - assert.deepEqual( - groups.map((group) => group.id), - ['agent'], - ); - assert.equal( - groups.some((group) => ['browser', 'computer_use', 'rive'].includes(group.id)), - false, - ); - }); -}); - -describe('assertProductBindingCatalogClean', () => { - it('throws when a product name is missing from the catalog', () => { - assert.throws( - () => assertProductBindingCatalogClean('cli', ['Read', 'NotARealTool']), - /cli: bound product tools missing from catalog: NotARealTool/, - ); - }); -}); diff --git a/packages/runtime/src/request-shape.ts b/packages/runtime/src/request-shape.ts index b585963913..fb76ee45e5 100644 --- a/packages/runtime/src/request-shape.ts +++ b/packages/runtime/src/request-shape.ts @@ -125,7 +125,7 @@ export type PreparedRequestSegmentRef = Pick; } +export function buildHostCapabilitiesFromBinding( + boundToolNames: Iterable, +): HostCapabilities { + return Object.freeze({ toolNames: new Set(boundToolNames) }); +} + /** Resolves the capability surface for the session executing a Skill call. */ export type HostCapabilitiesResolver = ( context: Pick, diff --git a/packages/runtime/src/skills.ts b/packages/runtime/src/skills.ts index 9e8b0b05dc..8a4c8937b3 100644 --- a/packages/runtime/src/skills.ts +++ b/packages/runtime/src/skills.ts @@ -145,6 +145,7 @@ export type { // ── From skills-context ──────────────────────────────────────────────────── export { + buildHostCapabilitiesFromBinding, gateSkillsByHostCapabilities, resolveSkillsPromptCharBudget, selectSkillsForContext, diff --git a/packages/runtime/src/tool-availability.ts b/packages/runtime/src/tool-availability.ts index d35b5a4041..cba36581e7 100644 --- a/packages/runtime/src/tool-availability.ts +++ b/packages/runtime/src/tool-availability.ts @@ -33,7 +33,7 @@ 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. */ +/** Tools that remain visible whenever they are bound. */ const DIRECT_TOOL_NAMES: ReadonlySet = new Set([ 'Bash', 'Read', @@ -45,9 +45,14 @@ const DIRECT_TOOL_NAMES: ReadonlySet = new Set([ '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', ]); -/** A discoverable source whose members are searched and activated individually. */ +/** Optional search metadata for a subset of the bound deferred tools. */ export interface ToolGroup { id: string; toolNames: readonly string[]; @@ -56,7 +61,11 @@ export interface ToolGroup { } export interface ToolAvailabilityConfig { - /** Search-space presentation metadata derived from the current bound tools. */ + /** + * 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[]; } @@ -69,9 +78,12 @@ export interface ToolSearchResult { }; } -export function toolAvailabilityHash(config: ToolAvailabilityConfig): `sha256:${string}` { +export function toolAvailabilityHash( + config: ToolAvailabilityConfig | undefined, +): `sha256:${string}` { return stableHash({ - groups: (config.groups ?? []).map((group) => ({ + 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 } : {}), @@ -102,7 +114,7 @@ export interface ToolAvailabilityPlan { ) => ToolAvailabilityDiagnostic | undefined; } -interface CatalogGroup { +interface SearchGroup { id: string; toolNames: string[]; label?: string; @@ -116,7 +128,7 @@ interface SearchDocument { } /** - * Immutable, backend-scoped bound-tool catalog and MiniSearch index. + * 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 @@ -125,7 +137,7 @@ interface SearchDocument { export class ToolAvailabilityRuntime { private readonly tools: readonly MakaTool[]; private readonly toolsByName: ReadonlyMap; - private readonly groups: readonly CatalogGroup[]; + private readonly groups: readonly SearchGroup[]; private readonly searchableNames: ReadonlySet; private readonly directNames: ReadonlySet; private readonly searchIndex?: MiniSearch; @@ -145,14 +157,18 @@ export class ToolAvailabilityRuntime { this.toolsByName = new Map(tools.map((tool) => [tool.name, tool])); const known = new Set(this.toolsByName.keys()); + const searchable = + config === undefined + ? new Set() + : new Set([...known].filter((name) => !DIRECT_TOOL_NAMES.has(name))); const claimed = new Set(); - const groups: CatalogGroup[] = []; + 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 (!known.has(name) || claimed.has(name) || DIRECT_TOOL_NAMES.has(name)) continue; + if (!searchable.has(name) || claimed.has(name)) continue; claimed.add(name); members.push(name); } @@ -165,11 +181,20 @@ export class ToolAvailabilityRuntime { ...(group.description !== undefined ? { description: group.description } : {}), }); } + const ungrouped = [...searchable].filter((name) => !claimed.has(name)).sort(compareExactString); + if (ungrouped.length > 0) { + const fallback = groups.find((group) => group.id === 'other'); + if (fallback) { + fallback.toolNames = [...fallback.toolNames, ...ungrouped].sort(compareExactString); + } else { + groups.push({ id: 'other', toolNames: ungrouped }); + } + } this.groups = groups; - this.searchableNames = claimed; - this.directNames = new Set([...known].filter((name) => !claimed.has(name))); + this.searchableNames = searchable; + this.directNames = new Set([...known].filter((name) => !searchable.has(name))); - if (claimed.size > 0) { + if (searchable.size > 0) { const groupByToolName = new Map( groups.flatMap((group) => group.toolNames.map((name) => [name, group] as const)), ); @@ -185,7 +210,7 @@ export class ToolAvailabilityRuntime { }, }); index.addAll( - [...claimed].map((name) => { + [...searchable].map((name) => { const tool = this.toolsByName.get(name)!; const group = groupByToolName.get(name); return { @@ -356,7 +381,7 @@ export class ToolAvailabilityRuntime { } } -function renderInventory(groups: readonly CatalogGroup[]): string { +function renderInventory(groups: readonly SearchGroup[]): string { const lines = groups.flatMap((group) => [ `${group.id}:`, ...group.toolNames.map((name) => `- ${name}`), @@ -372,7 +397,7 @@ function renderInventory(groups: readonly CatalogGroup[]): string { ].join('\n'); } -function groupToolNamesById(groups: readonly CatalogGroup[]): Record { +function groupToolNamesById(groups: readonly SearchGroup[]): Record { const out: Record = {}; for (const group of [...groups].sort((a, b) => compareExactString(a.id, b.id))) { out[group.id] = [...group.toolNames].sort(compareExactString); diff --git a/packages/runtime/src/tool-catalog-derive.ts b/packages/runtime/src/tool-catalog-derive.ts deleted file mode 100644 index b9e8467e8e..0000000000 --- a/packages/runtime/src/tool-catalog-derive.ts +++ /dev/null @@ -1,192 +0,0 @@ -/* - * 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. - */ - -/** - * Derive HostCapabilities and searchable ToolAvailability groups from the shared - * tool catalog ∩ host binding (#1099). Hosts still construct MakaTool - * instances; this module only projects names and surface metadata. - */ - -import { - MAKA_CATALOG_SURFACES, - catalogToolByName, - unknownBoundToolNames, - type ToolHostId, -} from '@maka/core/tool-catalog'; -import type { HostCapabilities } from './skills-context.js'; -import type { ToolGroup } from './tool-availability.js'; -import type { MakaTool } from './tool-runtime.js'; - -export interface ProductToolSurfaceIdentity { - readonly productToolNames: readonly string[]; -} - -export interface EffectiveProductToolSurface { - readonly tools: readonly MakaTool[]; - readonly toolNames: ReadonlySet; - readonly productToolNames: readonly string[]; - readonly hostCapabilities: HostCapabilities; - readonly toolAvailability: { - readonly groups: readonly ToolGroup[]; - }; - readonly boundSurfaceIds: readonly string[]; - readonly identity: ProductToolSurfaceIdentity; -} - -interface ReadonlySetOperand { - readonly size: number; - has(value: T): boolean; - keys(): Iterator; -} - -interface ReadonlySetAlgebra { - union(other: ReadonlySetOperand): Set; - intersection(other: ReadonlySetOperand): Set; - difference(other: ReadonlySetOperand): Set; - symmetricDifference(other: ReadonlySetOperand): Set; - isSubsetOf(other: ReadonlySetOperand): boolean; - isSupersetOf(other: ReadonlySetOperand): boolean; - isDisjointFrom(other: ReadonlySetOperand): boolean; -} - -function readonlySetSnapshot(values: Iterable): ReadonlySet { - const snapshot = new Set(values); - const algebra = snapshot as Set & ReadonlySetAlgebra; - const view: ReadonlySet = Object.freeze({ - get size() { - return snapshot.size; - }, - has: (value: T) => snapshot.has(value), - entries: () => snapshot.entries(), - keys: () => snapshot.keys(), - values: () => snapshot.values(), - forEach: (callback: (value: T, value2: T, set: ReadonlySet) => void, thisArg?: unknown) => { - snapshot.forEach((value, value2) => callback.call(thisArg, value, value2, view)); - }, - union: (other: ReadonlySetOperand) => algebra.union(other), - intersection: (other: ReadonlySetOperand) => algebra.intersection(other), - difference: (other: ReadonlySetOperand) => algebra.difference(other), - symmetricDifference: (other: ReadonlySetOperand) => algebra.symmetricDifference(other), - isSubsetOf: (other: ReadonlySetOperand) => algebra.isSubsetOf(other), - isSupersetOf: (other: ReadonlySetOperand) => algebra.isSupersetOf(other), - isDisjointFrom: (other: ReadonlySetOperand) => algebra.isDisjointFrom(other), - [Symbol.iterator]: () => snapshot[Symbol.iterator](), - [Symbol.toStringTag]: 'Set', - }); - return view; -} - -export function projectEffectiveProductToolSurface(input: { - host: ToolHostId; - tools: readonly MakaTool[]; -}): EffectiveProductToolSurface { - const excludedToolNames = new Set(); - for (const surface of MAKA_CATALOG_SURFACES) { - if (surface.hosts[input.host] === 'supported') continue; - for (const name of surface.toolNames) excludedToolNames.add(name); - } - const tools = input.tools.filter((tool) => !excludedToolNames.has(tool.name)); - const boundToolNames = new Set(tools.map((tool) => tool.name)); - const toolNames = readonlySetSnapshot(boundToolNames); - const productToolNames = [...boundToolNames].filter((name) => catalogToolByName(name)).sort(); - const groups = buildSearchableToolGroupsFromCatalog(input.host, boundToolNames).map((group) => - Object.freeze({ - ...group, - toolNames: Object.freeze([...group.toolNames]), - }), - ); - const hostCapabilities = buildHostCapabilitiesFromBinding(boundToolNames); - return Object.freeze({ - tools: Object.freeze(tools), - toolNames, - productToolNames: Object.freeze(productToolNames), - hostCapabilities, - toolAvailability: Object.freeze({ - groups: Object.freeze(groups), - }), - boundSurfaceIds: Object.freeze(groups.map((group) => group.id)), - identity: Object.freeze({ - productToolNames: Object.freeze([...productToolNames]), - }), - }); -} - -/** Build skill-host capability surface from the tools this process actually bound. */ -export function buildHostCapabilitiesFromBinding( - boundToolNames: Iterable, -): HostCapabilities { - const toolNames = new Set(); - const capabilities = new Set(); - for (const name of boundToolNames) { - toolNames.add(name); - const tags = catalogToolByName(name)?.capabilityTags; - if (!tags) continue; - for (const tag of tags) capabilities.add(tag); - } - const readonlyToolNames = readonlySetSnapshot(toolNames); - if (capabilities.size === 0) return Object.freeze({ toolNames: readonlyToolNames }); - return Object.freeze({ - toolNames: readonlyToolNames, - capabilities: readonlySetSnapshot(capabilities), - }); -} - -/** - * Searchable tool groups for a host: catalog surfaces that are supported on - * the host and have at least one bound member. Unsupported - * affinity never appears, even if a name were somehow bound. - */ -export function buildSearchableToolGroupsFromCatalog( - host: ToolHostId, - boundToolNames: Iterable, -): ToolGroup[] { - const bound = boundToolNames instanceof Set ? boundToolNames : new Set(boundToolNames); - const groups: ToolGroup[] = []; - for (const surface of MAKA_CATALOG_SURFACES) { - if (surface.hosts[host] !== 'supported') continue; - const toolNames = surface.toolNames.filter((name) => bound.has(name)); - if (toolNames.length === 0) continue; - groups.push({ - id: surface.id, - label: surface.label, - description: surface.description, - toolNames, - }); - } - return groups; -} - -/** - * Product-tool catalog cleanliness for host wiring (#1099 S2). - * - * MCP tools (`mcp__…`) are external and out of product-catalog scope. Harness / - * experiment names may be excluded by the caller before invoking this helper. - * Throws when any remaining bound name is missing from the catalog. - */ -export function assertProductBindingCatalogClean( - hostLabel: string, - boundToolNames: Iterable, -): void { - const productNames = [...boundToolNames].filter((name) => !name.startsWith('mcp__')); - const unknown = unknownBoundToolNames(productNames); - if (unknown.length === 0) return; - throw new Error( - `[tool-catalog] ${hostLabel}: bound product tools missing from catalog: ${unknown.join(', ')}`, - ); -}