diff --git a/docs/api-reference/veryfront/agent.md b/docs/api-reference/veryfront/agent.md index cf1e92a75d..ee1c32629e 100644 --- a/docs/api-reference/veryfront/agent.md +++ b/docs/api-reference/veryfront/agent.md @@ -622,7 +622,7 @@ Input delivered to a hosted agent-service detached execution callback. | `computeOpenToolCalls` | Compute open tool calls. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/streaming/mirrored-tool-chunk-state.ts#L174) | | `containsExactArtifactPathValue` | Contains exact artifact path value helper. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/artifacts/slash-command-artifact-policy.ts#L195) | | `convertAgentRuntimeMessagesToProviderMessages` | Convert agent runtime messages to provider messages. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/runtime/message-adapter.ts#L709) | -| `convertCompactedProviderMessagesToChildForkRuntimeMessages` | Convert compacted provider messages to child fork runtime messages. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/child-fork-step-message-preparation.ts#L101) | +| `convertCompactedProviderMessagesToChildForkRuntimeMessages` | Convert compacted provider messages to child fork runtime messages. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/child-fork-step-message-preparation.ts#L106) | | `convertProviderMessagesToAgentRuntimeMessages` | Convert provider messages to agent runtime messages. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/runtime/message-adapter.ts#L697) | | `createAgentServiceAgUiValidationErrorResponse` | Response payload for create hosted AG-UI validation error. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/ag-ui-chat-request.ts#L156) | | `createAgentServiceAuth` | Create hosted service auth. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/service/auth.ts#L318) | @@ -704,8 +704,8 @@ Input delivered to a hosted agent-service detached execution callback. | `createHostedDurableChildInvokeTraceRecorder` | Create hosted durable child invoke trace recorder. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/durable-child-fork-execution.ts#L312) | | `createHostedFormInputTool` | Create hosted form input tool. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/form-input-tool.ts#L34) | | `createHostedMirroredUiStream` | Create hosted mirrored UI stream. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/streaming/mirrored-tool-chunk-state.ts#L251) | -| `createHostedProjectRemoteToolSource` | Create hosted project remote tool source. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/project-remote-tool-source.ts#L94) | -| `createHostedProjectRemoteToolSources` | Create hosted project remote tool sources. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/project-remote-tool-source.ts#L367) | +| `createHostedProjectRemoteToolSource` | Create hosted project remote tool source. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/project-remote-tool-source.ts#L102) | +| `createHostedProjectRemoteToolSources` | Create hosted project remote tool sources. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/project-remote-tool-source.ts#L375) | | `createHostedProjectSteeringAdapter` | Create hosted project steering adapter. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/project-steering-adapter.ts#L176) | | `createHostedRootRunLifecycleRuntimeAdapter` | Create hosted root run lifecycle runtime adapter. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/agent-run-lifecycle.ts#L162) | | `createHostedRunEventWriterCapability` | Create an exact-run event-writer capability from a credential verified by trusted ingress. General user API tokens are not run-event credentials. The returned frozen object does not expose or serialize the credential. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/child-run-event-writer-token.ts#L315) | @@ -771,7 +771,7 @@ Input delivered to a hosted agent-service detached execution callback. | `fetchDefaultHostedProjectSteering` | Fetch default hosted project steering helper. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/default-project-steering-refresh.ts#L75) | | `fetchLatestConversationUserText` | Fetch latest conversation user text helper. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/artifacts/default-research-artifact-support.ts#L112) | | `filterAgentTraceAttributes` | Filter agent trace attributes. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/trace-attributes.ts#L61) | -| `filterHostedChatRuntimeLocalTools` | Filter hosted chat runtime local tools. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-runtime-tool-assembly.ts#L217) | +| `filterHostedChatRuntimeLocalTools` | Filter hosted chat runtime local tools. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-runtime-tool-assembly.ts#L221) | | `finalizeAgUiBrowserEvents` | Finalize AG-UI browser events helper. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/ag-ui/browser-encoder.ts#L919) | | `finalizeChildRunExecutionResources` | Finalize child run execution resources helper. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/child-run/execution-cleanup.ts#L28) | | `finalizeConversationAgentRun` | Finalize conversation agent run helper. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/conversation/durable.ts#L1326) | @@ -928,8 +928,8 @@ Input delivered to a hosted agent-service detached execution callback. | `prepareHostedChatExecution` | Prepare hosted chat execution. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-preparation.ts#L482) | | `prepareHostedChatRuntimeCreationOptions` | Options accepted by prepare hosted chat runtime creation. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-preparation.ts#L361) | | `prepareHostedChatRuntimeMessages` | Prepare hosted chat runtime messages. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-preparation.ts#L613) | -| `prepareHostedChatRuntimeToolAssembly` | Prepare hosted chat runtime tool assembly. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-runtime-tool-assembly.ts#L253) | -| `prepareHostedChildForkRuntimeStepMessages` | Prepare hosted child fork runtime step messages. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/child-fork-step-message-preparation.ts#L113) | +| `prepareHostedChatRuntimeToolAssembly` | Prepare hosted chat runtime tool assembly. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/chat-runtime-tool-assembly.ts#L257) | +| `prepareHostedChildForkRuntimeStepMessages` | Prepare hosted child fork runtime step messages. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/child-fork-step-message-preparation.ts#L118) | | `prepareHostedConversationRootRunContext` | Context for prepare hosted conversation root run. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/conversation/root-run-lifecycle.ts#L129) | | `prepareVeryfrontCloudAgentServiceChatExecution` | Prepare Veryfront Cloud hosted chat execution. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/cloud-chat-execution-preparation.ts#L76) | | `prepareVeryfrontCloudHostedChatExecution` | Prepare Veryfront Cloud hosted chat execution. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/cloud-chat-execution-preparation.ts#L76) | @@ -1326,7 +1326,7 @@ Input delivered to a hosted agent-service detached execution callback. | `CreateHostedChildInvokeToolOptions` | Options accepted by create hosted child invoke tool. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/child-invoke-tool.ts#L19) | | `CreateHostedMirroredUiStreamInput` | Input payload for create hosted mirrored UI stream. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/streaming/mirrored-tool-chunk-state.ts#L132) | | `CreateHostedProjectRemoteToolSourceInput` | Input payload for create hosted project remote tool source. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/project-remote-tool-source.ts#L64) | -| `CreateHostedProjectRemoteToolSourcesInput` | Input payload for create hosted project remote tool sources. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/project-remote-tool-source.ts#L253) | +| `CreateHostedProjectRemoteToolSourcesInput` | Input payload for create hosted project remote tool sources. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/project-remote-tool-source.ts#L261) | | `CreateHostedRootRunLifecycleRuntimeAdapterInput` | Input payload for create hosted root run lifecycle runtime adapter. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/agent-run-lifecycle.ts#L134) | | `CreateHostedRuntimeStateResolverOptions` | Options accepted by create hosted runtime state resolver. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/runtime-state-resolver.ts#L52) | | `CreateNodeAgentServiceRuntimeInfrastructureOptions` | Options accepted by create node agent service runtime infrastructure. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/service/node-runtime-infrastructure.ts#L26) | @@ -1504,7 +1504,7 @@ Input delivered to a hosted agent-service detached execution callback. | `HostedChildForkRunContext` | Context for hosted child fork run. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/child-fork-run-context.ts#L75) | | `HostedChildForkRunContextInput` | Input payload for hosted child fork run context. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/child-fork-run-context.ts#L90) | | `HostedChildForkRuntimeConfig` | Configuration used by hosted child fork runtime. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/child-tool-input.ts#L83) | -| `HostedChildForkRuntimeStepMessages` | Public API contract for hosted child fork runtime step messages. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/child-fork-step-message-preparation.ts#L42) | +| `HostedChildForkRuntimeStepMessages` | Public API contract for hosted child fork runtime step messages. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/child-fork-step-message-preparation.ts#L47) | | `HostedChildForkRuntimeStepSystemResolver` | Public API contract for hosted child fork runtime step system resolver. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/child-fork-step-message-preparation.ts#L16) | | `HostedChildForkRuntimeToolSelectionResult` | Result returned from hosted child fork runtime tool selection. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/child-requested-tools.ts#L157) | | `HostedChildForkStreamHandlingState` | State for hosted child fork stream handling. | [source](https://github.com/veryfront/veryfront-code/blob/main/src/agent/hosted/child-fork-stream-execution.ts#L60) | diff --git a/docs/architecture/28-model-driven-tool-discovery.md b/docs/architecture/28-model-driven-tool-discovery.md index 748c8e5668..046f697d2f 100644 --- a/docs/architecture/28-model-driven-tool-discovery.md +++ b/docs/architecture/28-model-driven-tool-discovery.md @@ -1,130 +1,137 @@ -# 28 — Model-Driven Tool Discovery and On-Demand Loading +# Model-driven tool discovery and on-demand loading -Status: design accepted, implementation in progress (veryfront-studio#5916). +Status: shipped. Implemented as deferred schema loading around `tool_search`. ## Responsibility -Let an agent run discover authorized MCP capabilities and activate a small, -task-relevant subset mid-run, so the initial model request never has to carry -every tool schema and no tool silently disappears because of provider caps. +Let an agent run reach any authorized tool without carrying every tool schema in +the initial model request, and without a tool silently disappearing because of a +provider cap. -## Problem (current state) +## Problem this solved -`prepareHostedChatRuntimeToolAssembly` unions local, remote (MCP), and -provider-native tool names, sorts them alphabetically, and caps the list with +`prepareHostedChatRuntimeToolAssembly` used to union local, remote (MCP), and +provider-native tool names, sort them alphabetically, and cap the list with `selectProviderCompatibleToolNames` (OpenAI: `OPENAI_MAX_TOOLS = 128`). Local -tools are pinned via `requiredToolNames`; the remaining budget fills with -remote tools **in alphabetical order**. With ~253 discovered MCP tools, every -remote tool past the cut line vanishes deterministically — reads early in the -alphabet survive, later writes (`update_agent`) disappear. This produced the -partial-update incident in veryfront-studio#5906. - -`docs/architecture/21-agent-tool-registration-current-state.md` already flags -the underlying gaps: tool filtering "should be a named policy, not a loose -string array", and list handling should be explicit and bounded. - -## Design - -Two new host tools, siblings of `load_skill` in ergonomics and authorization -posture: - -### `search_tools` — metadata search, side-effect-free - -- Input: `{ query?: string, names?: string[], limit?: number }`. `names` is an - exact-name lookup; `query` is keyword search over name + description. -- Output per result: `{ name, description, source, state }` where `state` is - `active | available | requires_grant`. **No input schemas** are returned. -- Search space: the run's *authorized* catalog only — the same - project/integration gating as `filterProjectScopedRemoteToolDefinitions`. - Hard-unauthorized tools are invisible; grant-recoverable tools surface as - `requires_grant` so the model can tell the user what to connect instead of - concluding the capability does not exist. - -### `load_tools` — activation, capability change - -- Input: `{ names: string[] }`. No prior `search_tools` call is required — - when the model already knows the tool name (from the prompt, a skill - procedure, or an earlier run) activation is a single round-trip. -- Validates every name against the authorized catalog. Unknown or - unauthorized names fail the whole call with a per-name reason — no partial - activation, mirroring the atomicity lesson of veryfront-studio#5906. -- **Refuse, never evict**: if activation would exceed the resolved provider - budget (`getProviderToolProfile(model).maxTools` minus pinned tools), the - call fails with the exact overflow count. Core/bound tools are never - evictable; there is no LRU. Deterministic refusal is debuggable; silent - eviction reintroduces the disappearing-tool bug class. -- On success the activated names join the run's activated set and the - response instructs the model that the tools are callable from the next step. - -## How activation reaches the model (per-step flow) - -1. The activated set lives on the per-run runtime context — the same bag as - `RuntimeLoadSkillToolContext.loadedSkillResponses` — never in - `ProjectScopedRegistryManager` (that is project-scoped; activation is - run-scoped by definition, which also satisfies the no-leak criterion). -2. `prepareHostedChildForkRuntimeStepMessages` already rebuilds instructions - every step; `withRuntimeToolInventory` is idempotent by design. The step - preparation reads `pinned ∪ activated` from the live run context instead of - a fixed `forkToolNames`, so the next model step sees the refreshed - inventory and the new tool schemas. -3. Assembly changes in `prepareHostedChatRuntimeToolAssembly`: remote names no - longer flood the union. Initial inventory = local/configured tools + - provider-native + `search_tools`/`load_tools` (added to the essential set - in `runtime-essential-tools.ts`, so they are never truncated). The - alphabetical `.sort()` before capping remains only as a stable tiebreak for - already-selected names, never as a selection mechanism. - -## Authorization: three independent gates - -1. **Discovery** — `search_tools` searches only the authorized catalog. -2. **Activation** — `load_tools` re-validates names against the catalog. -3. **Execution** — unchanged: `prepareExecution` in - `project-scoped-remote-tools.ts` re-checks allowance at call time. The - activated set feeds `isRemoteToolNameAllowed`, so a tool that was never - activated cannot execute even if a schema leaks into a request. - -## Binding-policy interaction - -Two hardening rules govern how the agent's configured `tools` binding interacts -with discovery and activation. - -**Deny-all excludes discovery tools.** When `allowedToolNames` resolves to an -empty set (explicit `tools: []` binding or a per-run `allowedTools: []` -override), `search_tools` and `load_tools` are not force-added even when -`includeRuntimeEssentialToolsWhenEmpty` is set. Activating new tools is a -broader capability than running pre-configured skills (`load_skill`, which is -still injectable for skill-enabled empty bindings); the two are treated -asymmetrically under deny-all. - -**Activation is bounded by the binding policy.** `load_tools` accepts a -`bindingPolicy` option (`ReadonlySet | null`). When set, the authorized -catalog is intersected with the policy before name validation: names outside the -policy return the same `unknown_tool` reason as genuinely unknown names (no -distinguishability). `null` or omitted means unrestricted (`tools: true`). +tools were pinned; the remaining budget filled with remote tools in +alphabetical order. With a catalog larger than the cap, every remote tool past +the cut line vanished deterministically: reads early in the alphabet survived, +later writes disappeared. + +`docs/architecture/21-agent-tool-registration-current-state.md` records the +related registration-surface gaps. + +## Design as shipped + +One framework-owned model-facing tool, `tool_search`, plus a run-local exposure +state. There is no separate activation call: a search loads the matching schemas +for the next step. + +The contract lives in `src/agent/runtime/tool-exposure.ts`. + +### Loading mode + +`prepareHostedChatRuntimeToolAssembly` resolves a `RuntimeToolLoadingMode` from +`input.allowedToolNames`: an explicit `null` (no binding) selects `deferred`, and +any set selects `eager`. The mode selection is a source excerpt, not a copyable +example. Read it in +[`chat-runtime-tool-assembly.ts`](../../src/agent/hosted/chat-runtime-tool-assembly.ts). + +- **deferred**: the agent has no explicit `tools` binding. The model initially + sees only `tool_search` plus whichever bootstrap tools (`form_input`, + `load_skill`) the run actually authorizes. Bootstrap tools are filtered + against the authorized set, so a run that authorizes neither exposes + `tool_search` alone. That is why the measurement below reports one initially + exposed tool. +- **eager**: the agent declares a binding. The bound set is exposed directly and + `selectProviderCompatibleToolNames` still applies, so a binding larger than + the provider cap is still truncated in alphabetical order after local tools + are pinned. Bindings are normally well under the cap, but nothing enforces + that. The guarantee below is specific to deferred mode. + +### Why the cap no longer truncates + +In deferred mode the authorization catalog is not passed through the provider +cap: + +```ts +const availableToolNames = toolLoadingMode === "deferred" + ? authorizedToolNames + : selectProviderCompatibleToolNames(authorizedToolNames, { ... }); +``` + +`compatibleRemoteToolNames` likewise keeps the full remote set in deferred mode. +The cap governs what is visible to the model on a given step, never what is +searchable or executable. Alphabetical position stops being a selection +mechanism. + +### `tool_search` + +- Schema-free results: `{ name, description, status: "available" | "loaded" }`. + Input schemas are never returned by a search. +- Searches the run's authorized catalog only, under the same project and + integration gating as the eager path. +- Deterministic, case-insensitive matching. Underscores are treated as spaces. + Ranking: exact name, then name substring, then description substring. +- Bounded on every axis: query bytes, candidate count, per-schema depth, node + count and byte size, and total loaded schema budget. See the `TOOL_SEARCH_*` + constants in `tool-exposure.ts`. +- Matching schemas are loaded into `ToolExposureState.loadedToolNames` and are + callable from the next model step. + +## Authorization: two independent gates + +1. **Discovery**: `tool_search` searches only the authorized catalog. +2. **Execution**: unchanged. `prepareExecution` in + `project-scoped-remote-tools.ts` re-checks allowance at call time via + `isRemoteToolNameAllowed`, so a schema reaching a request is not sufficient + to execute. ## Durability and resume -Activation is persisted as a `CUSTOM` conversation run event -(`encodeCustomDataEvent`) with payload `{ kind: "tools_activated", names }` -(and `tools_activation_rejected` with reasons for diagnostics). Resume replays -the event stream, so a resumed run rehydrates its activated set — resumption -never silently downgrades capability. Studio renders these events to explain -why a tool appeared or was refused. +Exposure state is persisted as a private durable checkpoint event, +`AGENT_RUN_TOOL_EXPOSURE_CHECKPOINT`, carrying a versioned +`ToolExposureCheckpoint`. `restoreToolExposureState` rehydrates it, so a resumed +run keeps the schemas it had already loaded instead of starting from an empty +set. Restoration returns exposure, not authorization: execution still re-checks +`isRemoteToolNameAllowed`, so a tool whose permissions changed between steps +becomes unavailable at call time even though its schema was restored. -## Out of scope (unchanged from the issue) +The checkpoint is versioned: v1 names were lexicographically sorted, v2 +preserves oldest-to-newest recency. -- Sending every MCP schema to every request. -- Replacing the curated static binding that mitigates #5906 today — it - becomes the *initial* inventory rather than the *only* inventory. -- Bypassing agent, project, integration, or user capability rules. +Exposure state is run-local by construction. `ToolExposureState` is created per +child run and lives outside any project-scoped registry, so a loaded set cannot +leak across runs. + +## Measured effect + +`docs/evidence/deferred-tool-discovery-hi-anthropic.json`, a committed live +measurement against a 64-tool fixture on Anthropic: + +| Metric | Value | +| ---------------------- | --------------------- | +| authorized tools | 64 | +| initially exposed | 1 (`tool_search`) | +| baseline input tokens | 5276 | +| effective input tokens | 648 | +| reduction | 87.7% (threshold 60%) | -## Risks +`scripts/verify-tool-search-live.ts` reproduces the measurement. + +## Out of scope + +- Sending every schema to every request. +- Bypassing agent, project, integration, or user capability rules. -- The inventory system-message format is asserted verbatim in - `tool-inventory.test.ts`; refresh semantics must not change the message - contract without updating consumers. -- Dynamic input schemas (the `load_skill` enum-narrowing trick) can bloat if - the catalog is huge; `load_tools` therefore validates server-side and keeps - its schema static (`names: string[]`), unlike `load_skill`. -- Per-name failure reasons must not leak other tenants' tool existence: the - unauthorized and nonexistent cases return the same `unknown_tool` reason. +## Notes for future work + +- Deferred mode is conditional on the agent having no explicit binding. A bound + agent whose binding exceeds the provider cap still takes the eager path. +- An earlier design proposed a separate `search_tools` and `load_tools` pair + with its own activation events and per-run activated set. It was superseded by + `tool_search`, and its unwired implementation has been removed. The public + input fields that could still gate a catalog for an external caller + (`activatedRemoteToolNames`, `toolDiscoveryContext`, `pinnedToolNames`, + `getActivatedToolNames`) are retained and marked deprecated, because removing + them would silently widen the catalog for callers that rely on them. diff --git a/src/agent/hosted/chat-runtime-tool-assembly.ts b/src/agent/hosted/chat-runtime-tool-assembly.ts index c9ceb6c378..602c48e84a 100644 --- a/src/agent/hosted/chat-runtime-tool-assembly.ts +++ b/src/agent/hosted/chat-runtime-tool-assembly.ts @@ -35,12 +35,12 @@ import { resolveHostedRuntimeAllowedToolNames, } from "./runtime-essential-tools.ts"; import type { HostedSubmittedFormInputResult } from "./chat-runtime-contract.ts"; -import type { RuntimeToolDiscoveryContext } from "../runtime/tool-discovery-context.ts"; import { applySourceIntegrationPolicy, isIntegrationToolAllowedBySourcePolicy, type SourceIntegrationPolicyManifest, } from "#veryfront/integrations/source-policy.ts"; +import type { RuntimeToolDiscoveryContext } from "../runtime/tool-discovery-context.ts"; import type { RuntimeToolLoadingMode } from "../runtime/runtime-tool-config.ts"; import { TOOL_SEARCH_TOOL_NAME } from "../runtime/tool-exposure.ts"; @@ -114,10 +114,14 @@ export type PrepareHostedChatRuntimeToolAssemblyInput< onStudioProjectSwitch?: HostedProjectRemoteToolSourceProjectSwitchHandler; preloadLatestConversationUserText?: boolean; /** - * Per-run tool discovery context. When provided, its `activatedRemoteToolNames` - * Set is passed (by reference) to every remote tool source as the live - * execution gate. The same Set is mutated by `load_tools`, so newly activated - * tools become executable without re-creating the sources. + * Per-run tool activation context. When its `activatedRemoteToolNames` Set is + * present, it is passed by reference to every remote tool source as the live + * execution gate, so growing the Set exposes tools without re-creating the + * sources. Deprecated: no framework path populates this. It is retained + * because `PrepareHostedChatRuntimeToolAssemblyInput` is public API. + * + * @deprecated Use `tool_search` deferred loading. See + * `docs/architecture/28-model-driven-tool-discovery.md`. */ toolDiscoveryContext?: RuntimeToolDiscoveryContext; /** Exact project-source restriction applied before tool inventory is exposed. */ diff --git a/src/agent/hosted/child-fork-step-message-preparation.ts b/src/agent/hosted/child-fork-step-message-preparation.ts index bbf842b324..e52c04f64f 100644 --- a/src/agent/hosted/child-fork-step-message-preparation.ts +++ b/src/agent/hosted/child-fork-step-message-preparation.ts @@ -25,15 +25,20 @@ export type PrepareHostedChildForkRuntimeStepMessagesInput = { forkToolNames: readonly string[]; resolveSystem?: HostedChildForkRuntimeStepSystemResolver; /** - * Pinned tool names (local/essential) that are always present. - * Required when `getActivatedToolNames` is provided. + * Tool names that are always present. Required when `getActivatedToolNames` + * is supplied. + * + * @deprecated No framework path supplies this. Retained because + * `PrepareHostedChildForkRuntimeStepMessagesInput` is public API. */ pinnedToolNames?: readonly string[]; /** - * When provided, returns the current activated remote tool names from the - * live run context. The result is merged with `pinnedToolNames` and returned - * as `forkToolNames` in the step preparation result, enabling per-step tool - * schema refresh without mutating the caller's fixed forkToolNames array. + * Returns the currently activated tool names. The result is merged with + * `pinnedToolNames` and returned as `forkToolNames`, letting a caller refresh + * the exposed tool set per step without mutating its own fixed array. + * + * @deprecated Use `tool_search` deferred loading. See + * `docs/architecture/28-model-driven-tool-discovery.md`. */ getActivatedToolNames?: () => readonly string[]; }; @@ -43,9 +48,9 @@ export type HostedChildForkRuntimeStepMessages = { messages: AgentMessage[]; system: string; /** - * Present only when `getActivatedToolNames` was provided in the input. - * Contains the live `pinned ∪ activated` set the caller should use for the - * current step instead of its fixed forkToolNames. + * The live `pinned + activated` set for this step. Present only when + * `getActivatedToolNames` was supplied; callers otherwise keep their own + * fixed `forkToolNames`. */ forkToolNames?: readonly string[]; }; @@ -134,9 +139,6 @@ export function prepareHostedChildForkRuntimeStepMessages( compactedMessages, }); - // Compute live forkToolNames = pinned ∪ activated when the caller supplies - // a getActivatedToolNames getter. This allows each step to see newly - // activated tool schemas without the caller mutating its fixed array. const liveForkToolNames = input.getActivatedToolNames ? [ ...new Set([ diff --git a/src/agent/hosted/project-remote-tool-source.ts b/src/agent/hosted/project-remote-tool-source.ts index 171409d267..491b14125f 100644 --- a/src/agent/hosted/project-remote-tool-source.ts +++ b/src/agent/hosted/project-remote-tool-source.ts @@ -67,11 +67,19 @@ export type CreateHostedProjectRemoteToolSourceInput = { getActiveBranchId?: () => string | null | undefined; allowedToolNames?: ReadonlySet | null; /** - * Live activated remote tool names from the discovery context. - * When provided, this Set (passed by reference) is used as the execution - * gate for the remote tool catalog instead of `allowedToolNames`. Because - * the same Set is mutated by `load_tools`, newly activated tools become - * executable without any catalog re-creation. + * Narrower execution gate for the remote tool catalog. When set, only tools + * in this Set can be listed or executed, overriding `allowedToolNames`. The + * Set is held by reference, so growing it exposes tools without re-creating + * the catalog. + * + * `null` is not a fallback: it overrides `allowedToolNames` and disables name + * filtering entirely. Omit the property to keep `allowedToolNames` as the + * gate. + * + * @deprecated No framework path supplies this. It is retained because + * `CreateHostedProjectRemoteToolSourceInput` is public API, and dropping it + * would silently widen the catalog to `allowedToolNames` for any caller that + * relies on it as the gate. */ activatedRemoteToolNames?: ReadonlySet | null; projectScopedRemoteToolOptions?: ProjectScopedRemoteToolOptions; @@ -94,9 +102,9 @@ function resolveActiveBranchId( export function createHostedProjectRemoteToolSource( input: CreateHostedProjectRemoteToolSourceInput, ): RemoteToolSource { - // When `activatedRemoteToolNames` is provided, it acts as the live execution - // gate: only tools in this Set (which grows as load_tools activates them) - // can be listed or executed. Falls back to `allowedToolNames` when absent. + // `activatedRemoteToolNames` is the gate whenever the property is present, + // including when it is `null`, which disables name filtering entirely. Only + // an omitted property falls back to `allowedToolNames`. const catalogAllowedToolNames = input.activatedRemoteToolNames !== undefined ? input.activatedRemoteToolNames : input.allowedToolNames; diff --git a/src/agent/hosted/runtime-essential-tools.test.ts b/src/agent/hosted/runtime-essential-tools.test.ts index b37aa8869f..434b70ec2f 100644 --- a/src/agent/hosted/runtime-essential-tools.test.ts +++ b/src/agent/hosted/runtime-essential-tools.test.ts @@ -21,135 +21,90 @@ describe("resolveHostedRuntimeAllowedToolNames", () => { assertEquals(result?.has("invoke_agent"), false); }); - describe("tool discovery tools are always essential", () => { - it("force-adds search_tools when present in localToolNames and allowedToolNames is restricted", () => { - const result = resolveHostedRuntimeAllowedToolNames({ - allowedToolNames: new Set(["sleep"]), - localToolNames: ["search_tools", "load_tools", "sleep"], - }); - - assertEquals(result?.has("search_tools"), true); - assertEquals(result?.has("load_tools"), true); - assertEquals(result?.has("sleep"), true); + it("returns null (allow-all) when allowedToolNames is null", () => { + const result = resolveHostedRuntimeAllowedToolNames({ + allowedToolNames: null, + localToolNames: ["sleep", "load_skill"], }); - it("does not add discovery tools that are absent from localToolNames", () => { - const result = resolveHostedRuntimeAllowedToolNames({ - allowedToolNames: new Set(["sleep"]), - localToolNames: ["sleep"], - }); + assertEquals(result, null); + }); - assertEquals(result?.has("search_tools"), false); - assertEquals(result?.has("load_tools"), false); + it("removes skill infrastructure from allow-all tools when the known skill manifest is empty", () => { + const result = resolveHostedRuntimeAllowedToolNames({ + allowedToolNames: null, + localToolNames: [ + "sleep", + "load_skill", + "load_skill_reference", + "execute_skill_script", + ], + availableSkillIds: [], }); - it("adds discovery tools even when availableSkillIds is empty", () => { - const result = resolveHostedRuntimeAllowedToolNames({ - allowedToolNames: new Set(["sleep"]), - localToolNames: ["search_tools", "load_tools", "sleep"], - availableSkillIds: [], - }); + assertEquals(result?.has("sleep"), true); + assertEquals(result?.has("load_skill"), false); + assertEquals(result?.has("load_skill_reference"), false); + assertEquals(result?.has("execute_skill_script"), false); + }); - assertEquals(result?.has("search_tools"), true); - assertEquals(result?.has("load_tools"), true); + it("returns empty set unchanged when allowedToolNames is empty", () => { + const result = resolveHostedRuntimeAllowedToolNames({ + allowedToolNames: new Set(), + localToolNames: ["sleep", "load_skill", "load_skill_reference"], }); - it("returns null (allow-all) when allowedToolNames is null", () => { - const result = resolveHostedRuntimeAllowedToolNames({ - allowedToolNames: null, - localToolNames: ["search_tools", "load_tools"], - }); - - assertEquals(result, null); - }); + assertEquals(result?.size, 0); + }); - it("removes skill infrastructure from allow-all tools when the known skill manifest is empty", () => { - const result = resolveHostedRuntimeAllowedToolNames({ - allowedToolNames: null, - localToolNames: [ - "search_tools", - "load_tools", - "sleep", - "load_skill", - "load_skill_reference", - "execute_skill_script", - ], - availableSkillIds: [], - }); - - assertEquals(result?.has("search_tools"), true); - assertEquals(result?.has("load_tools"), true); - assertEquals(result?.has("sleep"), true); - assertEquals(result?.has("load_skill"), false); - assertEquals(result?.has("load_skill_reference"), false); - assertEquals(result?.has("execute_skill_script"), false); + it("removes skill infrastructure for a config-derived empty selector with a known empty skill manifest", () => { + const result = resolveHostedRuntimeAllowedToolNames({ + allowedToolNames: new Set(), + localToolNames: [ + "sleep", + "load_skill", + "load_skill_reference", + ], + includeRuntimeEssentialToolsWhenEmpty: true, + availableSkillIds: [], }); - it("returns empty set unchanged when allowedToolNames is empty", () => { - const result = resolveHostedRuntimeAllowedToolNames({ - allowedToolNames: new Set(), - localToolNames: ["search_tools", "load_tools", "load_skill", "load_skill_reference"], - }); - - assertEquals(result?.size, 0); - }); + assertEquals(result?.has("load_skill"), false); + assertEquals(result?.has("load_skill_reference"), false); + }); - it("removes skill infrastructure for a config-derived empty selector with a known empty skill manifest", () => { - // A sandboxed agent with tools: [] must not gain load_tools/search_tools - // even when the config-derived essential-tools flag is set. Activating tools - // is a broader capability than running pre-configured skills (load_skill), so - // the two are treated asymmetrically under deny-all. - const result = resolveHostedRuntimeAllowedToolNames({ - allowedToolNames: new Set(), - localToolNames: [ - "search_tools", - "load_tools", - "load_skill", - "load_skill_reference", - ], - includeRuntimeEssentialToolsWhenEmpty: true, - availableSkillIds: [], - }); - - assertEquals(result?.has("search_tools"), false); - assertEquals(result?.has("load_tools"), false); - assertEquals(result?.has("load_skill"), false); - assertEquals(result?.has("load_skill_reference"), false); + it("keeps skill loading for legacy unscoped config-derived empty selectors", () => { + const result = resolveHostedRuntimeAllowedToolNames({ + allowedToolNames: new Set(), + localToolNames: ["load_skill", "load_skill_reference"], + includeRuntimeEssentialToolsWhenEmpty: true, }); - it("keeps skill loading for legacy unscoped config-derived empty selectors", () => { - const result = resolveHostedRuntimeAllowedToolNames({ - allowedToolNames: new Set(), - localToolNames: ["load_skill", "load_skill_reference"], - includeRuntimeEssentialToolsWhenEmpty: true, - }); - - assertEquals(result?.has("load_skill"), true); - assertEquals(result?.has("load_skill_reference"), true); - }); + assertEquals(result?.has("load_skill"), true); + assertEquals(result?.has("load_skill_reference"), true); + }); - it("removes only skill-loading and script infrastructure for known empty skill manifests", () => { - const result = resolveHostedRuntimeAllowedToolNames({ - allowedToolNames: new Set(["execute_skill_script", "sleep"]), - localToolNames: ["execute_skill_script", "load_skill", "invoke_agent", "sleep"], - availableSkillIds: [], - }); - - assertEquals(result?.has("execute_skill_script"), false); - assertEquals(result?.has("load_skill"), false); - assertEquals(result?.has("invoke_agent"), false); - assertEquals(result?.has("sleep"), true); + it("removes only skill-loading and script infrastructure for known empty skill manifests", () => { + const result = resolveHostedRuntimeAllowedToolNames({ + allowedToolNames: new Set(["execute_skill_script", "sleep"]), + localToolNames: ["execute_skill_script", "load_skill", "invoke_agent", "sleep"], + availableSkillIds: [], }); - it("preserves explicitly allowed invoke_agent for known empty skill manifests", () => { - const result = resolveHostedRuntimeAllowedToolNames({ - allowedToolNames: new Set(["invoke_agent", "load_skill"]), - localToolNames: ["invoke_agent", "load_skill"], - availableSkillIds: [], - }); + assertEquals(result?.has("execute_skill_script"), false); + assertEquals(result?.has("load_skill"), false); + assertEquals(result?.has("invoke_agent"), false); + assertEquals(result?.has("sleep"), true); + }); - assertEquals(result?.has("invoke_agent"), true); - assertEquals(result?.has("load_skill"), false); + it("preserves explicitly allowed invoke_agent for known empty skill manifests", () => { + const result = resolveHostedRuntimeAllowedToolNames({ + allowedToolNames: new Set(["invoke_agent", "load_skill"]), + localToolNames: ["invoke_agent", "load_skill"], + availableSkillIds: [], }); + + assertEquals(result?.has("invoke_agent"), true); + assertEquals(result?.has("load_skill"), false); }); }); diff --git a/src/agent/hosted/runtime-essential-tools.ts b/src/agent/hosted/runtime-essential-tools.ts index ff5f833fdd..10f709d7ac 100644 --- a/src/agent/hosted/runtime-essential-tools.ts +++ b/src/agent/hosted/runtime-essential-tools.ts @@ -21,12 +21,6 @@ const EMPTY_SKILL_MANIFEST_TOOL_NAMES = [ ...SKILL_SCRIPT_TOOL_NAMES, ] as const; -/** - * Tool discovery tools are unconditionally essential: they must never be - * truncated by the provider cap, regardless of skill availability. - */ -const TOOL_DISCOVERY_TOOL_NAMES = ["search_tools", "load_tools"] as const; - /** Normalize hosted runtime allowed tools. */ export function normalizeHostedRuntimeAllowedToolNames( toolNames: HostedRuntimeAllowedToolNames | undefined, @@ -71,19 +65,6 @@ export function resolveHostedRuntimeAllowedToolNames( } } - // Tool discovery is essential only when the agent already has at least one - // tool in its resolved set. Under deny-all (empty allowedToolNames), discovery - // tools are intentionally excluded: activating new tools is a broader - // capability than running pre-configured skills (load_skill), so the two are - // treated asymmetrically when allowedToolNames is empty. - if (resolvedToolNames.size > 0) { - for (const toolName of TOOL_DISCOVERY_TOOL_NAMES) { - if (localToolNames.has(toolName)) { - resolvedToolNames.add(toolName); - } - } - } - // Preserve request-scoped skill loading tools when the host supplies them. // Hosted cloud supplies load_skill; other adapters may also supply the // reference tool. Explicit request-level empty allowlists return above and diff --git a/src/agent/runtime/load-tools-tool.test.ts b/src/agent/runtime/load-tools-tool.test.ts deleted file mode 100644 index c12d316154..0000000000 --- a/src/agent/runtime/load-tools-tool.test.ts +++ /dev/null @@ -1,374 +0,0 @@ -import "#veryfront/schemas/_test-setup.ts"; -import { assertEquals, assertExists, assertStringIncludes } from "#veryfront/testing/assert.ts"; -import { describe, it } from "#veryfront/testing/bdd.ts"; -import type { RuntimeToolDiscoveryContext } from "./tool-discovery-context.ts"; -import { createLoadToolsTool, type LoadToolsToolOptions } from "./load-tools-tool.ts"; - -const AUTHORIZED_TOOLS = ["read_file", "write_file", "search_code", "update_agent"] as const; - -function makeContext(initial: string[] = []): RuntimeToolDiscoveryContext { - return { - activatedRemoteToolNames: new Set(initial), - }; -} - -function makeOptions( - context: RuntimeToolDiscoveryContext, - overrides: Partial> = {}, -): LoadToolsToolOptions { - return { - context, - pinnedToolNames: ["load_skill", "search_tools", "load_tools"], - model: "anthropic/claude-sonnet-4-6", - getAuthorizedToolNames: () => [...AUTHORIZED_TOOLS], - ...overrides, - }; -} - -describe("load_tools tool", () => { - describe("validation", () => { - it("rejects unknown tool names with unknown_tool reason", async () => { - const context = makeContext(); - const tool = createLoadToolsTool(makeOptions(context)); - const result = await tool.execute({ names: ["nonexistent_tool"] }); - - assertEquals("error" in result, true); - if ("error" in result) { - assertEquals(result.reasons["nonexistent_tool"], "unknown_tool"); - } - }); - - it("rejects unauthorized names with same unknown_tool reason (no info leakage)", async () => { - const context = makeContext(); - const tool = createLoadToolsTool( - makeOptions(context, { - getAuthorizedToolNames: () => ["read_file"], - }), - ); - // write_file exists in some catalog but is not authorized for this run - const result = await tool.execute({ names: ["write_file"] }); - - assertEquals("error" in result, true); - if ("error" in result) { - assertEquals(result.reasons["write_file"], "unknown_tool"); - } - }); - - it("rejects atomically: one bad name fails the whole call", async () => { - const context = makeContext(); - const tool = createLoadToolsTool(makeOptions(context)); - const result = await tool.execute({ names: ["read_file", "bad_tool"] }); - - assertEquals("error" in result, true); - // read_file should NOT be activated (atomic failure) - assertEquals(context.activatedRemoteToolNames?.has("read_file"), false); - }); - - it("returns per-name reasons for each rejected name", async () => { - const context = makeContext(); - const tool = createLoadToolsTool(makeOptions(context)); - const result = await tool.execute({ - names: ["nonexistent_a", "nonexistent_b"], - }); - - assertEquals("error" in result, true); - if ("error" in result) { - assertEquals(result.reasons["nonexistent_a"], "unknown_tool"); - assertEquals(result.reasons["nonexistent_b"], "unknown_tool"); - } - }); - }); - - describe("successful activation", () => { - it("activates valid tools and adds them to the context set", async () => { - const context = makeContext(); - const tool = createLoadToolsTool(makeOptions(context)); - const result = await tool.execute({ names: ["read_file", "write_file"] }); - - assertEquals("activated" in result, true); - if ("activated" in result) { - assertEquals(result.activated.sort(), ["read_file", "write_file"]); - } - assertEquals(context.activatedRemoteToolNames?.has("read_file"), true); - assertEquals(context.activatedRemoteToolNames?.has("write_file"), true); - }); - - it("is idempotent: already-activated tools count as success", async () => { - const context = makeContext(["read_file"]); - const tool = createLoadToolsTool(makeOptions(context)); - const result = await tool.execute({ names: ["read_file"] }); - - assertEquals("activated" in result, true); - if ("activated" in result) { - assertEquals(result.activated, ["read_file"]); - } - }); - - it("only reports newly-activated names (not already-activated duplicates)", async () => { - const context = makeContext(["read_file"]); - const tool = createLoadToolsTool(makeOptions(context)); - const result = await tool.execute({ names: ["read_file", "write_file"] }); - - assertEquals("activated" in result, true); - if ("activated" in result) { - // read_file was already active; only write_file is newly activated - assertEquals(result.newlyActivated, ["write_file"]); - assertEquals(result.activated.sort(), ["read_file", "write_file"]); - } - }); - - it("calls onToolsActivated with newly-activated names only", async () => { - const context = makeContext(["read_file"]); - const activated: string[][] = []; - context.onToolsActivated = (names) => activated.push(names); - - const tool = createLoadToolsTool(makeOptions(context)); - await tool.execute({ names: ["read_file", "write_file"] }); - - assertEquals(activated, [["write_file"]]); - }); - - it("does not call onToolsActivated when all names are already active", async () => { - const context = makeContext(["read_file"]); - let called = false; - context.onToolsActivated = () => { - called = true; - }; - - const tool = createLoadToolsTool(makeOptions(context)); - await tool.execute({ names: ["read_file"] }); - - assertEquals(called, false); - }); - }); - - describe("budget enforcement", () => { - it("refuses when pinned + activated + new would exceed provider budget (OpenAI 128)", async () => { - // Simulate OpenAI 128-tool cap. - // pinned = 3 (load_skill, search_tools, load_tools) - // already activated = 125 - // trying to add 1 more would hit exactly 129 > 128 - const existing = Array.from({ length: 125 }, (_, i) => `tool_${i}`); - const context = makeContext(existing); - // authorized catalog must include those existing + the new one - const authorized = [...existing, "new_tool_a", "new_tool_b"]; - const tool = createLoadToolsTool( - makeOptions(context, { - model: "openai/gpt-4.1", - getAuthorizedToolNames: () => authorized, - }), - ); - const result = await tool.execute({ names: ["new_tool_a"] }); - - assertEquals("error" in result, true); - if ("error" in result) { - assertStringIncludes(result.error, "overflow"); - // overflow = (3 + 125 + 1) - 128 = 1 - assertStringIncludes(result.error, "1"); - } - // not activated - assertEquals(context.activatedRemoteToolNames?.has("new_tool_a"), false); - }); - - it("reports exact overflow count", async () => { - // pinned = 3, activated = 124, adding 3 more = 130 > 128, overflow = 2 - const existing = Array.from({ length: 124 }, (_, i) => `tool_${i}`); - const context = makeContext(existing); - const authorized = [...existing, "tool_a", "tool_b", "tool_c"]; - const tool = createLoadToolsTool( - makeOptions(context, { - model: "openai/gpt-4.1", - getAuthorizedToolNames: () => authorized, - }), - ); - const result = await tool.execute({ names: ["tool_a", "tool_b", "tool_c"] }); - - assertEquals("error" in result, true); - if ("error" in result) { - assertStringIncludes(result.error, "2"); - } - }); - - it("allows activation up to exactly the provider budget", async () => { - // pinned = 3, activated = 124, adding 1 = 128 exactly, no overflow - const existing = Array.from({ length: 124 }, (_, i) => `tool_${i}`); - const context = makeContext(existing); - const authorized = [...existing, "tool_ok"]; - const tool = createLoadToolsTool( - makeOptions(context, { - model: "openai/gpt-4.1", - getAuthorizedToolNames: () => authorized, - }), - ); - const result = await tool.execute({ names: ["tool_ok"] }); - - assertEquals("activated" in result, true); - }); - - it("does not enforce a budget cap for uncapped providers", async () => { - // anthropic has no maxTools - const existing = Array.from({ length: 200 }, (_, i) => `tool_${i}`); - const context = makeContext(existing); - const authorized = [...existing, "extra_tool"]; - const tool = createLoadToolsTool( - makeOptions(context, { - model: "anthropic/claude-sonnet-4-6", - getAuthorizedToolNames: () => authorized, - }), - ); - const result = await tool.execute({ names: ["extra_tool"] }); - - assertEquals("activated" in result, true); - }); - - it("does not evict existing activations on overflow (never-evict policy)", async () => { - const existing = Array.from({ length: 125 }, (_, i) => `tool_${i}`); - const context = makeContext(existing); - const authorized = [...existing, "new_one"]; - const tool = createLoadToolsTool( - makeOptions(context, { - model: "openai/gpt-4.1", - getAuthorizedToolNames: () => authorized, - }), - ); - await tool.execute({ names: ["new_one"] }); - - // All original activations must still be present - for (const name of existing) { - assertEquals(context.activatedRemoteToolNames?.has(name), true); - } - }); - - it("calls onToolsActivationRejected on overflow", async () => { - const existing = Array.from({ length: 126 }, (_, i) => `tool_${i}`); - const context = makeContext(existing); - const rejected: Array<{ names: string[]; reasons: Record }> = []; - context.onToolsActivationRejected = (names, reasons) => rejected.push({ names, reasons }); - const authorized = [...existing, "overflow_tool"]; - const tool = createLoadToolsTool( - makeOptions(context, { - model: "openai/gpt-4.1", - getAuthorizedToolNames: () => authorized, - }), - ); - await tool.execute({ names: ["overflow_tool"] }); - - assertEquals(rejected.length, 1); - const [rejection] = rejected; - assertExists(rejection); - assertEquals(rejection.names, ["overflow_tool"]); - }); - }); - - describe("binding policy", () => { - it("rejects names outside the binding policy with unknown_tool (no distinguishability)", async () => { - const context = makeContext(); - // Binding policy allows only read_file; write_file is in the authorized catalog - // but outside the policy. Both cases must return the same unknown_tool reason. - const tool = createLoadToolsTool( - makeOptions(context, { - bindingPolicy: new Set(["read_file"]), - getAuthorizedToolNames: () => ["read_file", "write_file"], - }), - ); - const result = await tool.execute({ names: ["write_file"] }); - - assertEquals("error" in result, true); - if ("error" in result) { - assertEquals(result.reasons["write_file"], "unknown_tool"); - } - assertEquals(context.activatedRemoteToolNames?.has("write_file"), false); - }); - - it("allows activation when name is in both authorized catalog and binding policy", async () => { - const context = makeContext(); - const tool = createLoadToolsTool( - makeOptions(context, { - bindingPolicy: new Set(["read_file", "write_file"]), - getAuthorizedToolNames: () => ["read_file", "write_file", "delete_file"], - }), - ); - const result = await tool.execute({ names: ["read_file"] }); - - assertEquals("activated" in result, true); - assertEquals(context.activatedRemoteToolNames?.has("read_file"), true); - }); - - it("null binding policy means unrestricted: full authorized catalog can be activated", async () => { - const context = makeContext(); - const tool = createLoadToolsTool( - makeOptions(context, { - bindingPolicy: null, - getAuthorizedToolNames: () => ["read_file", "write_file", "delete_file"], - }), - ); - const result = await tool.execute({ names: ["delete_file"] }); - - assertEquals("activated" in result, true); - assertEquals(context.activatedRemoteToolNames?.has("delete_file"), true); - }); - - it("undefined binding policy means unrestricted (backward-compatible default)", async () => { - const context = makeContext(); - // makeOptions does not set bindingPolicy, so it is undefined - const tool = createLoadToolsTool(makeOptions(context)); - const result = await tool.execute({ names: ["read_file"] }); - - assertEquals("activated" in result, true); - }); - - it("empty binding policy prevents any activation (consistent with deny-all)", async () => { - const context = makeContext(); - const tool = createLoadToolsTool( - makeOptions(context, { - bindingPolicy: new Set([]), - getAuthorizedToolNames: () => ["read_file", "write_file"], - }), - ); - const result = await tool.execute({ names: ["read_file"] }); - - assertEquals("error" in result, true); - if ("error" in result) { - assertEquals(result.reasons["read_file"], "unknown_tool"); - } - }); - - it("policy-blocked names and genuinely unknown names both return unknown_tool (no leakage)", async () => { - const context = makeContext(); - const tool = createLoadToolsTool( - makeOptions(context, { - bindingPolicy: new Set(["read_file"]), - getAuthorizedToolNames: () => ["read_file"], - }), - ); - // write_file is both outside the policy AND not in the authorized catalog - const resultBlocked = await tool.execute({ names: ["write_file"] }); - // completely_unknown is not in the catalog either - const resultUnknown = await tool.execute({ names: ["completely_unknown"] }); - - assertEquals("error" in resultBlocked, true); - assertEquals("error" in resultUnknown, true); - if ("error" in resultBlocked && "error" in resultUnknown) { - assertEquals(resultBlocked.reasons["write_file"], "unknown_tool"); - assertEquals(resultUnknown.reasons["completely_unknown"], "unknown_tool"); - } - }); - }); - - describe("tool metadata", () => { - it("has the correct tool id", () => { - const tool = createLoadToolsTool(makeOptions(makeContext())); - assertEquals(tool.id, "load_tools"); - }); - - it("has a static input schema (no enum narrowing)", () => { - const tool = createLoadToolsTool(makeOptions(makeContext())); - const schema = tool.inputSchemaJson; - // names must be a plain string array, not an enum-constrained list - assertEquals( - (schema as Record).type, - "object", - ); - }); - }); -}); diff --git a/src/agent/runtime/load-tools-tool.ts b/src/agent/runtime/load-tools-tool.ts deleted file mode 100644 index 175ffbd44c..0000000000 --- a/src/agent/runtime/load-tools-tool.ts +++ /dev/null @@ -1,161 +0,0 @@ -import { defineSchema } from "#veryfront/schemas/index.ts"; -import type { InferSchema } from "#veryfront/extensions/schema/index.ts"; -import type { Tool } from "#veryfront/tool/types.ts"; -import { zodToJsonSchema } from "#veryfront/tool/schema/zod-json-schema.ts"; -import { getProviderToolProfile } from "./provider-tool-compat.ts"; -import type { RuntimeToolDiscoveryContext } from "./tool-discovery-context.ts"; - -/** Options accepted by the load_tools tool. */ -export type LoadToolsToolOptions = { - context: RuntimeToolDiscoveryContext; - /** - * Names that are always present in the run (local/essential tools). - * These count toward the provider budget and can never be evicted. - */ - pinnedToolNames: readonly string[]; - /** Model identifier used to resolve the provider tool budget. */ - model?: string; - /** - * Returns the names of every remote tool the current run is authorized to - * use. Unknown and unauthorized tool names are both treated as unknown_tool - * (same reason) to avoid leaking existence of unauthorized tools. - */ - getAuthorizedToolNames: () => readonly string[]; - /** - * When set to a non-null Set, activation candidates are intersected with - * this policy before the authorized-catalog check. Names that are in the - * authorized catalog but outside the binding policy receive the same - * `unknown_tool` reason as genuinely unknown names — no distinguishability. - * null or undefined means unrestricted: the full authorized catalog applies. - */ - bindingPolicy?: ReadonlySet | null; -}; - -/** Input payload for load_tools. */ -const getLoadToolsInputSchema = defineSchema((v) => - v.object({ - names: v.array(v.string().min(1)).min(1).describe( - "Tool names to activate. All names must be valid; the call is atomic.", - ), - }) -); - -export type LoadToolsInput = InferSchema>; - -/** Successful activation output. */ -export type LoadToolsSuccessOutput = { - activated: string[]; - newlyActivated: string[]; - message: string; -}; - -/** Validation or budget-overflow failure output. */ -export type LoadToolsErrorOutput = { - error: string; - reasons: Record; -}; - -/** Output from load_tools. */ -export type LoadToolsOutput = LoadToolsSuccessOutput | LoadToolsErrorOutput; - -/** Create the load_tools host tool. */ -export function createLoadToolsTool( - options: LoadToolsToolOptions, -): Tool { - function getActivatedSet(): Set { - if (!options.context.activatedRemoteToolNames) { - options.context.activatedRemoteToolNames = new Set(); - } - return options.context.activatedRemoteToolNames; - } - - function execute(input: LoadToolsInput): LoadToolsOutput { - // Intersect the full authorized catalog with the agent's binding policy. - // Names outside the policy return the same unknown_tool reason as genuinely - // unknown names so the policy boundary is not distinguishable to the model. - const fullAuthorized = new Set(options.getAuthorizedToolNames()); - const authorized = options.bindingPolicy != null - ? new Set([...fullAuthorized].filter((name) => options.bindingPolicy!.has(name))) - : fullAuthorized; - const activatedSet = getActivatedSet(); - - // --- Validation pass (all-or-nothing) --- - const reasons: Record = {}; - for (const name of input.names) { - if (!authorized.has(name)) { - reasons[name] = "unknown_tool"; - } - } - - if (Object.keys(reasons).length > 0) { - options.context.onToolsActivationRejected?.(input.names, reasons); - return { - error: - `Tool activation failed: one or more names are not in the authorized catalog for this run. ` + - `Provide the per-name reason map to the user so they know which tools to connect.`, - reasons, - }; - } - - // --- Budget check --- - const uniqueRequestedNames = [...new Set(input.names)]; - const newNames = uniqueRequestedNames.filter((n) => !activatedSet.has(n)); - const profile = getProviderToolProfile(options.model); - - if (profile.maxTools !== undefined) { - const pinnedCount = new Set(options.pinnedToolNames).size; - const total = pinnedCount + activatedSet.size + newNames.length; - if (total > profile.maxTools) { - const overflow = total - profile.maxTools; - const rejectionReasons: Record = {}; - for (const name of newNames) { - rejectionReasons[name] = "budget_overflow"; - } - options.context.onToolsActivationRejected?.(newNames, rejectionReasons); - return { - error: - `Tool activation refused: adding ${newNames.length} tool(s) would exceed the provider ` + - `budget of ${profile.maxTools} by ${overflow} (overflow: ${overflow}). ` + - `Remove activated tools or choose a provider with a higher limit. ` + - `Pinned: ${pinnedCount}, currently activated: ${activatedSet.size}, requested new: ${newNames.length}.`, - reasons: rejectionReasons, - }; - } - } - - // --- Atomic activation --- - for (const name of newNames) { - activatedSet.add(name); - } - - if (newNames.length > 0) { - options.context.onToolsActivated?.(newNames); - } - - const allActivated = uniqueRequestedNames; - return { - activated: allActivated, - newlyActivated: newNames, - message: newNames.length > 0 - ? `Activated ${newNames.length} tool(s): ${newNames.join(", ")}. ` + - `These tools are callable from the next step.` - : `All requested tools were already active: ${allActivated.join(", ")}.`, - }; - } - - return { - id: "load_tools", - type: "function", - description: "Activate one or more MCP tools for use in this run. " + - "All names must be valid; the call is atomic (no partial activation). " + - "Use search_tools first to discover available tool names and their current state. " + - "Activated tools are callable from the next step. " + - "The provider budget is enforced: if adding these tools would exceed the limit, the call " + - "is refused with the exact overflow count.", - inputSchema: getLoadToolsInputSchema(), - get inputSchemaJson() { - return zodToJsonSchema(getLoadToolsInputSchema()); - }, - execute: (input: LoadToolsInput) => Promise.resolve(execute(input)), - }; -} diff --git a/src/agent/runtime/search-tools-tool.test.ts b/src/agent/runtime/search-tools-tool.test.ts deleted file mode 100644 index b662a0ad43..0000000000 --- a/src/agent/runtime/search-tools-tool.test.ts +++ /dev/null @@ -1,214 +0,0 @@ -import "#veryfront/schemas/_test-setup.ts"; -import { assertEquals, assertExists } from "#veryfront/testing/assert.ts"; -import { describe, it } from "#veryfront/testing/bdd.ts"; -import type { - RuntimeToolCatalogEntry, - RuntimeToolDiscoveryContext, -} from "./tool-discovery-context.ts"; -import { createSearchToolsTool, type SearchToolsToolOptions } from "./search-tools-tool.ts"; - -const CATALOG: RuntimeToolCatalogEntry[] = [ - { name: "read_file", description: "Read a file from the project", source: "veryfront-api" }, - { name: "write_file", description: "Write content to a file", source: "veryfront-api" }, - { - name: "search_code", - description: "Search for patterns in source code", - source: "veryfront-api", - }, - { name: "update_agent", description: "Update agent configuration", source: "veryfront-api" }, - { - name: "premium_tool", - description: "A grant-required premium capability", - source: "premium-integration", - requiresGrant: true, - }, -]; - -function makeContext(activated: string[] = []): RuntimeToolDiscoveryContext { - return { - activatedRemoteToolNames: new Set(activated), - }; -} - -function makeOptions( - context: RuntimeToolDiscoveryContext, - overrides: Partial> = {}, -): SearchToolsToolOptions { - return { - context, - getAuthorizedCatalog: () => [...CATALOG], - ...overrides, - }; -} - -describe("search_tools tool", () => { - describe("state mapping", () => { - it("returns available for tools not yet activated", async () => { - const context = makeContext(); - const tool = createSearchToolsTool(makeOptions(context)); - const result = await tool.execute({ names: ["read_file"] }); - - assertEquals(result.results.length, 1); - const [entry] = result.results; - assertExists(entry); - assertEquals(entry.name, "read_file"); - assertEquals(entry.state, "available"); - }); - - it("returns active for tools that are in the activated set", async () => { - const context = makeContext(["read_file"]); - const tool = createSearchToolsTool(makeOptions(context)); - const result = await tool.execute({ names: ["read_file"] }); - - const [entry] = result.results; - assertExists(entry); - assertEquals(entry.state, "active"); - }); - - it("returns requires_grant for tools marked requiresGrant", async () => { - const context = makeContext(); - const tool = createSearchToolsTool(makeOptions(context)); - const result = await tool.execute({ names: ["premium_tool"] }); - - const [entry] = result.results; - assertExists(entry); - assertEquals(entry.state, "requires_grant"); - }); - - it("does not return input schemas in results", async () => { - const context = makeContext(); - const tool = createSearchToolsTool(makeOptions(context)); - const result = await tool.execute({ names: ["read_file"] }); - - const [entry] = result.results; - assertExists(entry); - assertEquals("inputSchema" in entry, false); - assertEquals("parameters" in entry, false); - }); - }); - - describe("lookup by names", () => { - it("returns only the named tools when names is provided", async () => { - const context = makeContext(); - const tool = createSearchToolsTool(makeOptions(context)); - const result = await tool.execute({ names: ["read_file", "write_file"] }); - - assertEquals(result.results.length, 2); - assertEquals( - result.results.map((r) => r.name).sort(), - ["read_file", "write_file"], - ); - }); - - it("omits names that are not in the authorized catalog (invisible)", async () => { - const context = makeContext(); - const tool = createSearchToolsTool(makeOptions(context)); - // "mystery_tool" is not in catalog and not unauthorized-but-visible - const result = await tool.execute({ names: ["read_file", "mystery_tool"] }); - - assertEquals(result.results.length, 1); - const [entry] = result.results; - assertExists(entry); - assertEquals(entry.name, "read_file"); - }); - }); - - describe("keyword query", () => { - it("matches query against tool name", async () => { - const context = makeContext(); - const tool = createSearchToolsTool(makeOptions(context)); - const result = await tool.execute({ query: "file" }); - - const names = result.results.map((r) => r.name); - assertEquals(names.includes("read_file"), true); - assertEquals(names.includes("write_file"), true); - assertEquals(names.includes("search_code"), false); - }); - - it("matches query against tool description", async () => { - const context = makeContext(); - const tool = createSearchToolsTool(makeOptions(context)); - const result = await tool.execute({ query: "source code" }); - - const names = result.results.map((r) => r.name); - assertEquals(names.includes("search_code"), true); - }); - - it("is case-insensitive", async () => { - const context = makeContext(); - const tool = createSearchToolsTool(makeOptions(context)); - const result = await tool.execute({ query: "FILE" }); - - const names = result.results.map((r) => r.name); - assertEquals(names.includes("read_file"), true); - }); - - it("returns all catalog tools when no query or names provided", async () => { - const context = makeContext(); - const tool = createSearchToolsTool(makeOptions(context)); - const result = await tool.execute({}); - - assertEquals(result.results.length, CATALOG.length); - }); - }); - - describe("limit", () => { - it("respects the limit parameter", async () => { - const context = makeContext(); - const tool = createSearchToolsTool(makeOptions(context)); - const result = await tool.execute({ limit: 2 }); - - assertEquals(result.results.length, 2); - }); - - it("does not exceed limit even if more results match", async () => { - const context = makeContext(); - const tool = createSearchToolsTool(makeOptions(context)); - const result = await tool.execute({ query: "file", limit: 1 }); - - assertEquals(result.results.length, 1); - }); - }); - - describe("result shape", () => { - it("includes name, description, source, and state in each result", async () => { - const context = makeContext(); - const tool = createSearchToolsTool(makeOptions(context)); - const result = await tool.execute({ names: ["read_file"] }); - - const r = result.results[0]; - assertExists(r); - assertEquals(typeof r.name, "string"); - assertEquals(typeof r.description, "string"); - assertEquals(typeof r.source, "string"); - assertEquals(typeof r.state, "string"); - }); - }); - - describe("tool metadata", () => { - it("has the correct tool id", () => { - const tool = createSearchToolsTool(makeOptions(makeContext())); - assertEquals(tool.id, "search_tools"); - }); - }); - - describe("no cross-run leakage", () => { - it("shows activated state only from the current context", async () => { - const contextA = makeContext(["read_file"]); - const contextB = makeContext(); - - const toolA = createSearchToolsTool(makeOptions(contextA)); - const toolB = createSearchToolsTool(makeOptions(contextB)); - - const resultA = await toolA.execute({ names: ["read_file"] }); - const resultB = await toolB.execute({ names: ["read_file"] }); - - const [entryA] = resultA.results; - const [entryB] = resultB.results; - assertExists(entryA); - assertExists(entryB); - assertEquals(entryA.state, "active"); - assertEquals(entryB.state, "available"); - }); - }); -}); diff --git a/src/agent/runtime/search-tools-tool.ts b/src/agent/runtime/search-tools-tool.ts deleted file mode 100644 index 970198ffdc..0000000000 --- a/src/agent/runtime/search-tools-tool.ts +++ /dev/null @@ -1,121 +0,0 @@ -import { defineSchema } from "#veryfront/schemas/index.ts"; -import type { InferSchema } from "#veryfront/extensions/schema/index.ts"; -import type { Tool } from "#veryfront/tool/types.ts"; -import { zodToJsonSchema } from "#veryfront/tool/schema/zod-json-schema.ts"; -import type { - RuntimeToolCatalogEntry, - RuntimeToolDiscoveryContext, -} from "./tool-discovery-context.ts"; - -export type { RuntimeToolCatalogEntry }; - -/** State of a tool within the current run. */ -export type ToolSearchResultState = "active" | "available" | "requires_grant"; - -/** Single result returned by search_tools. */ -export type ToolSearchResult = { - name: string; - description: string; - source: string; - state: ToolSearchResultState; -}; - -/** Output from search_tools. */ -export type SearchToolsOutput = { - results: ToolSearchResult[]; -}; - -/** Options accepted by the search_tools tool. */ -export type SearchToolsToolOptions = { - context: RuntimeToolDiscoveryContext; - /** - * Returns the full authorized catalog for this run. - * Hard-unauthorized tools must be excluded by the caller before passing. - * Grant-recoverable tools are included with `requiresGrant: true`. - */ - getAuthorizedCatalog: () => readonly RuntimeToolCatalogEntry[]; -}; - -const getSearchToolsInputSchema = defineSchema((v) => - v.object({ - query: v.string().optional().describe( - "Keyword search over tool name and description. Omit to list all available tools.", - ), - names: v.array(v.string()).optional().describe( - "Exact-name lookup. If provided, query is ignored.", - ), - limit: v.number().int().positive().optional().describe( - "Maximum number of results to return.", - ), - }) -); - -export type SearchToolsInput = InferSchema>; - -function resolveState( - entry: RuntimeToolCatalogEntry, - activatedSet: ReadonlySet, -): ToolSearchResultState { - if (activatedSet.has(entry.name)) return "active"; - if (entry.requiresGrant) return "requires_grant"; - return "available"; -} - -function matchesQuery(entry: RuntimeToolCatalogEntry, query: string): boolean { - const q = query.toLowerCase(); - return ( - entry.name.toLowerCase().includes(q) || - entry.description.toLowerCase().includes(q) - ); -} - -/** Create the search_tools host tool. */ -export function createSearchToolsTool( - options: SearchToolsToolOptions, -): Tool { - function execute(input: SearchToolsInput): SearchToolsOutput { - const catalog = options.getAuthorizedCatalog(); - const activatedSet: ReadonlySet = options.context.activatedRemoteToolNames ?? new Set(); - - let entries: readonly RuntimeToolCatalogEntry[]; - - if (input.names && input.names.length > 0) { - // Exact-name lookup: only return catalog entries that match requested names - const nameSet = new Set(input.names); - entries = catalog.filter((e) => nameSet.has(e.name)); - } else if (input.query) { - entries = catalog.filter((e) => matchesQuery(e, input.query!)); - } else { - entries = catalog; - } - - if (input.limit !== undefined && input.limit > 0) { - entries = entries.slice(0, input.limit); - } - - const results: ToolSearchResult[] = entries.map((e) => ({ - name: e.name, - description: e.description, - source: e.source, - state: resolveState(e, activatedSet), - })); - - return { results }; - } - - return { - id: "search_tools", - type: "function", - description: "Search the authorized MCP tool catalog for this run. " + - "Returns name, description, source, and state (active|available|requires_grant) " + - "for each matching tool. Input schemas are not returned. " + - "Hard-unauthorized tools are invisible. " + - "Use names for exact lookup; use query for keyword search over name and description. " + - "After finding the tools you need, call load_tools to activate them.", - inputSchema: getSearchToolsInputSchema(), - get inputSchemaJson() { - return zodToJsonSchema(getSearchToolsInputSchema()); - }, - execute: (input: SearchToolsInput) => Promise.resolve(execute(input)), - }; -} diff --git a/src/agent/runtime/tool-discovery-context.ts b/src/agent/runtime/tool-discovery-context.ts index 693d91116b..2808122d26 100644 --- a/src/agent/runtime/tool-discovery-context.ts +++ b/src/agent/runtime/tool-discovery-context.ts @@ -1,4 +1,4 @@ -/** Public API contract for tool catalog entry in the runtime discovery catalog. */ +/** Public API contract for a tool catalog entry in the runtime discovery catalog. */ export type RuntimeToolCatalogEntry = { name: string; description: string; @@ -7,29 +7,44 @@ export type RuntimeToolCatalogEntry = { }; /** - * Per-run context bag for model-driven tool discovery and on-demand loading. + * Per-run context bag for run-scoped tool activation. * - * Activation is run-scoped by definition. Keep this out of any project-scoped - * registry so a loaded set never leaks across runs. + * The framework never creates this context. A caller that supplies one owns its + * lifetime and must create a fresh `Set` for every run: a `Set` reused across + * runs carries the earlier run's tool names into the later one and overrides + * that run's `allowedToolNames`. + * + * @deprecated Use `tool_search` deferred loading. See + * `docs/architecture/28-model-driven-tool-discovery.md`. This type is retained + * because it is reachable from the public + * `PrepareHostedChatRuntimeToolAssemblyInput.toolDiscoveryContext` field. */ export type RuntimeToolDiscoveryContext = { /** - * The set of remote tool names that have been activated in this run. - * Initialized lazily on first activation. + * Remote tool names activated in this run. When present, this Set is used by + * reference as the execution gate for every remote tool source, so growing it + * widens the catalog without re-creating sources. + * + * `null` disables name filtering entirely and overrides `allowedToolNames`. + * Omit the property to keep `allowedToolNames` as the gate. */ activatedRemoteToolNames?: Set; /** - * Optional callback invoked after successful atomic activation. - * Receives only the *newly* activated names (duplicates excluded). - * The host layer uses this to emit a durable CUSTOM conversation event. + * Invoked after a successful activation with the newly activated names. + * + * @deprecated Never invoked. Its only caller was the `load_tools` tool, which + * was never exported, so no external caller could make this fire. Retained as + * an optional declaration so callers that still construct this context keep + * compiling. */ onToolsActivated?: (names: string[]) => void; /** - * Optional callback invoked when activation is rejected (validation or - * budget overflow). Receives the rejected names and a per-name reason map. - * The host layer uses this to emit a durable CUSTOM conversation event. + * Invoked when an activation is rejected, with the rejected names and a + * per-name reason. + * + * @deprecated Never invoked. See `onToolsActivated`. */ onToolsActivationRejected?: ( names: string[], diff --git a/src/agent/runtime/tool-discovery-events.test.ts b/src/agent/runtime/tool-discovery-events.test.ts deleted file mode 100644 index 9838448c7d..0000000000 --- a/src/agent/runtime/tool-discovery-events.test.ts +++ /dev/null @@ -1,130 +0,0 @@ -import { assertEquals } from "#veryfront/testing/assert.ts"; -import { describe, it } from "#veryfront/testing/bdd.ts"; -import type { ConversationRunEvent } from "../conversation/run-events.ts"; -import { - buildToolsActivatedEvent, - buildToolsActivationRejectedEvent, - hydrateToolDiscoveryFromEvents, -} from "./tool-discovery-events.ts"; -import type { RuntimeToolDiscoveryContext } from "./tool-discovery-context.ts"; - -describe("tool discovery events", () => { - describe("buildToolsActivatedEvent", () => { - it("produces a CUSTOM event with name tools_activated", () => { - const event = buildToolsActivatedEvent(["read_file", "write_file"]); - assertEquals(event.type, "CUSTOM"); - assertEquals(event.name, "tools_activated"); - assertEquals((event.value as { names: string[] }).names, ["read_file", "write_file"]); - }); - }); - - describe("buildToolsActivationRejectedEvent", () => { - it("produces a CUSTOM event with name tools_activation_rejected", () => { - const event = buildToolsActivationRejectedEvent( - ["bad_tool"], - { bad_tool: "unknown_tool" }, - ); - assertEquals(event.type, "CUSTOM"); - assertEquals(event.name, "tools_activation_rejected"); - const value = event.value as { names: string[]; reasons: Record }; - assertEquals(value.names, ["bad_tool"]); - assertEquals(value.reasons["bad_tool"], "unknown_tool"); - }); - }); - - describe("hydrateToolDiscoveryFromEvents", () => { - it("populates activatedRemoteToolNames from tools_activated events", () => { - const context: RuntimeToolDiscoveryContext = {}; - const events: ConversationRunEvent[] = [ - buildToolsActivatedEvent(["read_file", "write_file"]) as ConversationRunEvent, - ]; - - hydrateToolDiscoveryFromEvents(events, context); - - assertEquals(context.activatedRemoteToolNames?.has("read_file"), true); - assertEquals(context.activatedRemoteToolNames?.has("write_file"), true); - }); - - it("merges multiple activation events", () => { - const context: RuntimeToolDiscoveryContext = {}; - const events: ConversationRunEvent[] = [ - buildToolsActivatedEvent(["read_file"]) as ConversationRunEvent, - buildToolsActivatedEvent(["write_file"]) as ConversationRunEvent, - ]; - - hydrateToolDiscoveryFromEvents(events, context); - - assertEquals(context.activatedRemoteToolNames?.has("read_file"), true); - assertEquals(context.activatedRemoteToolNames?.has("write_file"), true); - }); - - it("ignores non-CUSTOM events", () => { - const context: RuntimeToolDiscoveryContext = {}; - const events: ConversationRunEvent[] = [ - { type: "TOOL_CALL_RESULT", toolCallId: "tc-1", content: "ok", role: "tool" }, - buildToolsActivatedEvent(["read_file"]) as ConversationRunEvent, - ]; - - hydrateToolDiscoveryFromEvents(events, context); - - assertEquals(context.activatedRemoteToolNames?.size, 1); - }); - - it("ignores CUSTOM events with unknown names", () => { - const context: RuntimeToolDiscoveryContext = {}; - const events: ConversationRunEvent[] = [ - { type: "CUSTOM", name: "other_event", value: { names: ["read_file"] } }, - ]; - - hydrateToolDiscoveryFromEvents(events, context); - - assertEquals(context.activatedRemoteToolNames, undefined); - }); - - it("ignores tools_activation_rejected events (rejected tools stay inactive)", () => { - const context: RuntimeToolDiscoveryContext = {}; - const events: ConversationRunEvent[] = [ - buildToolsActivationRejectedEvent( - ["bad_tool"], - { bad_tool: "unknown_tool" }, - ) as ConversationRunEvent, - ]; - - hydrateToolDiscoveryFromEvents(events, context); - - assertEquals(context.activatedRemoteToolNames, undefined); - }); - - it("handles a realistic resume sequence (activation followed by rejection)", () => { - const context: RuntimeToolDiscoveryContext = {}; - const events: ConversationRunEvent[] = [ - buildToolsActivatedEvent(["read_file"]) as ConversationRunEvent, - buildToolsActivationRejectedEvent( - ["bad_tool"], - { bad_tool: "unknown_tool" }, - ) as ConversationRunEvent, - buildToolsActivatedEvent(["write_file"]) as ConversationRunEvent, - ]; - - hydrateToolDiscoveryFromEvents(events, context); - - assertEquals(context.activatedRemoteToolNames?.has("read_file"), true); - assertEquals(context.activatedRemoteToolNames?.has("write_file"), true); - assertEquals(context.activatedRemoteToolNames?.has("bad_tool"), false); - }); - - it("does not leak state across separate context objects", () => { - const contextA: RuntimeToolDiscoveryContext = {}; - const contextB: RuntimeToolDiscoveryContext = {}; - const events: ConversationRunEvent[] = [ - buildToolsActivatedEvent(["read_file"]) as ConversationRunEvent, - ]; - - hydrateToolDiscoveryFromEvents(events, contextA); - // contextB was not passed to hydrate - - assertEquals(contextA.activatedRemoteToolNames?.has("read_file"), true); - assertEquals(contextB.activatedRemoteToolNames, undefined); - }); - }); -}); diff --git a/src/agent/runtime/tool-discovery-events.ts b/src/agent/runtime/tool-discovery-events.ts deleted file mode 100644 index 44d12b5256..0000000000 --- a/src/agent/runtime/tool-discovery-events.ts +++ /dev/null @@ -1,97 +0,0 @@ -import type { ConversationRunEvent } from "../conversation/run-events.ts"; -import type { RuntimeToolDiscoveryContext } from "./tool-discovery-context.ts"; - -/** - * CUSTOM event name for successful tool activation. - * Emitted as a durable conversation run event so that a resumed run can - * rehydrate its activated-tool set from the event stream. - */ -export const TOOLS_ACTIVATED_EVENT_NAME = "tools_activated" as const; - -/** - * CUSTOM event name for rejected activation (validation or budget overflow). - * Persisted for diagnostics; rejected tools are NOT added to the activated set - * on replay. - */ -export const TOOLS_ACTIVATION_REJECTED_EVENT_NAME = "tools_activation_rejected" as const; - -/** Payload shape for a tools_activated CUSTOM event. */ -export type ToolsActivatedEventValue = { - kind: "tools_activated"; - names: string[]; -}; - -/** Payload shape for a tools_activation_rejected CUSTOM event. */ -export type ToolsActivationRejectedEventValue = { - kind: "tools_activation_rejected"; - names: string[]; - reasons: Record; -}; - -/** - * Build a CUSTOM conversation run event that records successful activation. - * The host layer should emit this as a `data-tools_activated` stream chunk so - * that `encodeCustomDataEvent` stores it durably. - */ -export function buildToolsActivatedEvent(names: string[]): Omit { - return { - type: "CUSTOM", - name: TOOLS_ACTIVATED_EVENT_NAME, - value: { - kind: "tools_activated", - names, - } satisfies ToolsActivatedEventValue, - }; -} - -/** - * Build a CUSTOM conversation run event that records a rejected activation. - * Stored for diagnostics; replay must not activate the listed tools. - */ -export function buildToolsActivationRejectedEvent( - names: string[], - reasons: Record, -): Omit { - return { - type: "CUSTOM", - name: TOOLS_ACTIVATION_REJECTED_EVENT_NAME, - value: { - kind: "tools_activation_rejected", - names, - reasons, - } satisfies ToolsActivationRejectedEventValue, - }; -} - -/** - * Rehydrate a `RuntimeToolDiscoveryContext` by replaying durable conversation - * run events. Called during run resume so the activated-tool set is restored - * without re-invoking `load_tools`. - * - * Only `tools_activated` events are applied; rejected events are skipped. - */ -export function hydrateToolDiscoveryFromEvents( - events: readonly ConversationRunEvent[], - context: RuntimeToolDiscoveryContext, -): void { - for (const event of events) { - if (event.type !== "CUSTOM" || event.name !== TOOLS_ACTIVATED_EVENT_NAME) { - continue; - } - - const value = event.value as Partial | null; - if (!value || !Array.isArray(value.names)) { - continue; - } - - if (!context.activatedRemoteToolNames) { - context.activatedRemoteToolNames = new Set(); - } - - for (const name of value.names) { - if (typeof name === "string" && name.length > 0) { - context.activatedRemoteToolNames.add(name); - } - } - } -}