Skip to content

Commit cf15f0b

Browse files
authored
feat: Tool search and deferred loading (#535)
* Tool search with deferred execution * Frontend implementation * Fixes and error improvements * Adapt tool_search to be a bit more maintainable * PR feedback
1 parent b2a7486 commit cf15f0b

53 files changed

Lines changed: 2828 additions & 134 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎Makefile‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -452,13 +452,15 @@ test-live-openai-responses:
452452
@$(LOAD_DOTENV); \
453453
: "$${OPENAI_API_KEY:?OPENAI_API_KEY is required for live OpenAI Responses tests}"; \
454454
$(GO) test -count=1 -v -tags=live ./internal/model -run '^TestLivePromptCache/openai_responses$$' && \
455+
$(GO) test -count=1 -v -tags=live ./internal/model -run '^TestLiveDeferredToolsLoadAfterSearch/openai_responses' && \
455456
$(SERVICE_E2E_ENV) $(GO) test -count=1 -v -timeout=25m -tags='integration servicee2e live' ./internal/e2e -run '^TestServiceE2ELiveOpenAIResponses(ModelTurn|CompactionRecall|DockerDaemonProcessTools)$$' && \
456457
$(TEST_DB_ENV) $(GO) test -count=1 -v -tags='integration live' ./internal/compaction -run '^TestRunnerLiveOpenAIResponsesCompactionCreatesCheckpoint$$'
457458

458459
test-live-openai-chat-completions:
459460
@$(LOAD_DOTENV); \
460461
: "$${OPENAI_API_KEY:?OPENAI_API_KEY is required for live OpenAI Chat Completions tests}"; \
461462
$(GO) test -count=1 -v -tags=live ./internal/model -run '^TestLivePromptCache/openai_chat$$' && \
463+
$(GO) test -count=1 -v -tags=live ./internal/model -run '^TestLiveDeferredToolsLoadAfterSearch/openai_chat' && \
462464
$(GO) test -count=1 -v -tags=live ./internal/model/openaichatcompletions -run '^TestLiveOpenAIChatCompletionsText$$' && \
463465
$(SERVICE_E2E_ENV) $(GO) test -count=1 -v -timeout=25m -tags='integration servicee2e live' ./internal/e2e -run '^TestServiceE2ELiveOpenAIChatCompletions(ModelTurn|CompactionRecall|DockerDaemonProcessTools)$$' && \
464466
$(TEST_DB_ENV) $(GO) test -count=1 -v -tags='integration live' ./internal/compaction -run '^TestRunnerLiveOpenAIChatCompletionsCompactionCreatesCheckpoint$$'
@@ -467,6 +469,7 @@ test-live-openrouter:
467469
@$(LOAD_DOTENV); \
468470
: "$${OPENROUTER_API_KEY:?OPENROUTER_API_KEY is required for live OpenRouter tests}"; \
469471
$(GO) test -count=1 -v -tags=live ./internal/model -run '^TestLivePromptCache/openrouter_.*$$' && \
472+
$(GO) test -count=1 -v -tags=live ./internal/model -run '^TestLiveDeferredToolsLoadAfterSearch/openrouter_' && \
470473
$(GO) test -count=1 -v -tags=live ./internal/model/openaichatcompletions -run '^TestLiveOpenRouterChatCompletions' && \
471474
$(SERVICE_E2E_ENV) $(GO) test -count=1 -v -timeout=25m -tags='integration servicee2e live' ./internal/e2e -run '^TestServiceE2ELiveOpenRouter(ModelTurn|CompactionRecall|DockerDaemonProcessTools)$$' && \
472475
$(TEST_DB_ENV) $(GO) test -count=1 -v -tags='integration live' ./internal/compaction -run '^TestRunnerLiveOpenRouterCompactionCreatesCheckpoint$$'
@@ -475,6 +478,7 @@ test-live-anthropic:
475478
@$(LOAD_DOTENV); \
476479
: "$${ANTHROPIC_API_KEY:?ANTHROPIC_API_KEY is required for live Anthropic tests}"; \
477480
$(GO) test -count=1 -v -tags=live ./internal/model -run '^TestLivePromptCache/anthropic$$' && \
481+
$(GO) test -count=1 -v -tags=live ./internal/model -run '^TestLiveDeferredToolsLoadAfterSearch/anthropic' && \
478482
$(SERVICE_E2E_ENV) $(GO) test -count=1 -v -timeout=25m -tags='integration servicee2e live' ./internal/e2e -run '^TestServiceE2ELiveAnthropic(ModelTurn|CompactionRecall|DockerDaemonProcessTools)$$' && \
479483
$(TEST_DB_ENV) $(GO) test -count=1 -v -tags='integration live' ./internal/compaction -run '^TestRunnerLiveAnthropicCompactionCreatesCheckpoint$$'
480484

‎docs/agents/configuration.mdx‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,19 @@ Existing machine configs gain missing machine-tool defaults only when saved agai
131131

132132
Slack's `send_integration_message` remains contextual: it is available in Slack conversations without being added to the saved config, unless explicitly disabled. The builder only shows this tool when it is explicitly listed in the config.
133133

134+
### Deferred tools
135+
136+
Set `deferred: true` on any tool (built-in, custom, or MCP) to keep its definition out of the model's context until the model asks for it. Deferring a tool implicitly enables the built-in `tool_search` tool, which the model calls with a regular expression to load matching deferred tools by name, description, and argument names. Keep the tools the agent needs on almost every turn loaded; defer large or rarely used tool sets.
137+
138+
```yaml
139+
tools:
140+
web_search: {} # always loaded
141+
web_fetch:
142+
deferred: true # loaded only after a tool_search match
143+
```
144+
145+
Deferred loading keeps the provider's prompt cache intact on Anthropic Messages and OpenAI Responses. On Chat Completions the model calls loaded tools through `call_deferred_tool` with the tool's name and arguments, so `call_deferred_tool` is a reserved tool name.
146+
134147
### Custom tools
135148

136149
A custom tool describes an operation that your system performs. Its `description` tells the model when to use it, and its `input_schema` defines the arguments.

‎docs/tools/built-in.mdx‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ The tools are grouped by what they do:
2020
| Files | `upload_file`, `download_file`, `read_file`, `search_files` |
2121
| Integrations | `send_integration_message`, `set_integration_target` |
2222
| Skills | `skill` |
23+
| Tool loading | `tool_search` |
2324

2425
A red <span style={{ color: "#c97a7a", fontWeight: 700 }}>*</span> marks required inputs.
2526

@@ -266,6 +267,17 @@ Lists the agent's subagents with their `agent_id`, name, key, state, and last ac
266267

267268
Loads an attached skill. Requires its [skill](/tools/skills) `name`. The tool returns the skill's instructions and, when the agent has machines, installs its supporting files there.
268269

270+
## Tool loading
271+
272+
### `tool_search`
273+
274+
Added automatically when any tool is [deferred](/agents/configuration#deferred-tools). Searches deferred tools by name, description, and argument names, and loads the matches so the model can call them.
275+
276+
| Input | Type | Notes |
277+
| --- | --- | --- |
278+
| `pattern`<span style={{ color: "#c97a7a", fontWeight: 700 }}>*</span> | string | Case-insensitive regular expression, at most 200 characters |
279+
| `max_results` | integer | 1–50; default 5 |
280+
269281
<Info>
270282
Full inputs and defaults, live from your deployment: [Get tool catalog](/api-reference/endpoints/configs-and-profiles/get-tool-catalog).
271283
</Info>

‎docs/tools/mcp.mdx‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ mcp:
4848
| `auth.region` | AWS signing region. Required for `sigv4`. |
4949
| `permission` | Server-wide default mode; per-tool `permission` overrides it. MCP tools default to `always_ask` — third-party code asks first. |
5050
| `default_enabled` | `false` disables tools by default. Set `enabled: true` for each tool you want to expose. |
51+
| `deferred` | `true` keeps the server's tools out of the model's context until `tool_search` loads them; per-tool `deferred` overrides it. See [Deferred tools](/agents/configuration#deferred-tools). |
5152

5253
### How MCP tools are named
5354

‎frontend/apps/web/src/components/agents/AgentConfigMcpServersField.tsx‎

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -209,7 +209,7 @@ export function AgentConfigMcpServersField({
209209
<div className="pb-2">
210210
<OverridesCollapsible title="Tools" keepMounted>
211211
<div className="space-y-4">
212-
<div className="grid gap-4 sm:grid-cols-2">
212+
<div className="grid gap-4 sm:grid-cols-3">
213213
<Field>
214214
<FieldLabel>Default permission</FieldLabel>
215215
<Select
@@ -261,6 +261,29 @@ export function AgentConfigMcpServersField({
261261
</SelectContent>
262262
</Select>
263263
</Field>
264+
<Field>
265+
<FieldLabel>Default loading</FieldLabel>
266+
<Select
267+
value={server.deferred ? 'deferred' : 'loaded'}
268+
onValueChange={(value) => {
269+
updateServer(server.id, {
270+
deferred: value === 'deferred' || undefined,
271+
})
272+
}}
273+
>
274+
<SelectTrigger className="w-full" aria-label="MCP default loading">
275+
<SelectValue>{server.deferred ? 'Deferred' : 'Loaded'}</SelectValue>
276+
</SelectTrigger>
277+
<SelectContent>
278+
<SelectItem value="loaded">Loaded</SelectItem>
279+
<SelectItem value="deferred">Deferred</SelectItem>
280+
</SelectContent>
281+
</Select>
282+
<FieldDescription>
283+
Deferred tools stay out of the model&apos;s context until it finds them
284+
with tool_search.
285+
</FieldDescription>
286+
</Field>
264287
</div>
265288
<AgentConfigMcpServerTools
266289
orgId={orgId}

‎frontend/apps/web/src/components/agents/AgentConfigMcpToolOverrideList.tsx‎

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -170,6 +170,27 @@ function ToolOverrideRow({
170170
))}
171171
</SelectContent>
172172
</Select>
173+
<Select
174+
value={tool.deferred == null ? inheritValue : tool.deferred ? 'deferred' : 'loaded'}
175+
onValueChange={(value) => {
176+
onChange({ deferred: value === inheritValue ? null : value === 'deferred' })
177+
}}
178+
>
179+
<SelectTrigger
180+
size="sm"
181+
className="min-w-0 flex-1 sm:w-32 sm:flex-none"
182+
aria-label={`${tool.name} loading`}
183+
>
184+
<SelectValue>
185+
{tool.deferred == null ? 'Default' : tool.deferred ? 'Deferred' : 'Loaded'}
186+
</SelectValue>
187+
</SelectTrigger>
188+
<SelectContent>
189+
<SelectItem value={inheritValue}>Default</SelectItem>
190+
<SelectItem value="loaded">Loaded</SelectItem>
191+
<SelectItem value="deferred">Deferred</SelectItem>
192+
</SelectContent>
193+
</Select>
173194
<Button
174195
type="button"
175196
size="icon"

‎frontend/apps/web/src/components/agents/AgentConfigToolPreview.test.tsx‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -334,7 +334,7 @@ it('does not offer the Slack tool when it is absent from the source', async () =
334334
{
335335
source_format: 'json',
336336
source: JSON.stringify({
337-
tools: { web_search: {} },
337+
tools: { web_search: { type: 'built_in', permission: { mode: 'always_ask' } } },
338338
mcp: {},
339339
machine_sources: [],
340340
skills: [],
@@ -362,7 +362,9 @@ it('includes configured MCP servers in previews and removes them after deletion'
362362
/>,
363363
)
364364
await vi.waitFor(() => {
365-
expect(requests.at(-1)).toHaveProperty('mcp', { docs: { url: 'https://example.com/mcp' } })
365+
expect(requests.at(-1)).toHaveProperty('mcp', {
366+
docs: { url: 'https://example.com/mcp', default_enabled: false },
367+
})
366368
})
367369
clickLabel('Remove MCP')
368370
await vi.waitFor(() => {

‎frontend/apps/web/src/components/agents/AgentConfigToolsField.tsx‎

Lines changed: 51 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ export interface BasicTool {
2828
name: string
2929
enabled?: boolean
3030
permission: PermissionSelection | null
31+
deferred?: boolean
3132
}
3233

3334
const toolDescriptions = new Map([
@@ -62,7 +63,7 @@ export function AgentConfigToolsField({
6263
}) {
6364
const catalogTools = catalog?.built_in_tools ?? []
6465
const catalogByName = new Map(catalogTools.map((entry) => [entry.name, entry]))
65-
const displayedTools = [
66+
const displayedTools: BasicTool[] = [
6667
...tools,
6768
...(resolvedTools ?? [])
6869
.filter((tool) => !tools.some((configured) => configured.name === tool.name))
@@ -155,6 +156,19 @@ export function AgentConfigToolsField({
155156
)
156157
}}
157158
/>
159+
<ToolLoadingSelect
160+
name={tool.name}
161+
deferred={tool.deferred === true}
162+
onChange={(deferred) => {
163+
onToolsChange(
164+
tools.map((currentTool) =>
165+
currentTool.name === tool.name
166+
? { ...currentTool, deferred: deferred || undefined }
167+
: currentTool,
168+
),
169+
)
170+
}}
171+
/>
158172
<Button
159173
type="button"
160174
size="icon"
@@ -319,6 +333,42 @@ function ToolName({ name, entry }: { name: string; entry?: ToolCatalogEntry }) {
319333
)
320334
}
321335

336+
export function ToolLoadingSelect({
337+
name,
338+
deferred,
339+
onChange,
340+
}: {
341+
name: string
342+
deferred: boolean
343+
onChange: (deferred: boolean) => void
344+
}) {
345+
return (
346+
<Tooltip>
347+
<TooltipTrigger asChild>
348+
<div className="min-w-0 flex-1 sm:w-28 sm:flex-none">
349+
<Select
350+
value={deferred ? 'deferred' : 'loaded'}
351+
onValueChange={(value) => {
352+
onChange(value === 'deferred')
353+
}}
354+
>
355+
<SelectTrigger size="sm" className="w-full" aria-label={`${name} loading`}>
356+
<SelectValue>{deferred ? 'Deferred' : 'Loaded'}</SelectValue>
357+
</SelectTrigger>
358+
<SelectContent>
359+
<SelectItem value="loaded">Loaded</SelectItem>
360+
<SelectItem value="deferred">Deferred</SelectItem>
361+
</SelectContent>
362+
</Select>
363+
</div>
364+
</TooltipTrigger>
365+
<TooltipContent side="top" className="max-w-xs px-3 py-2 text-sm leading-relaxed">
366+
Deferred tools stay out of the model&apos;s context until it finds them with tool_search.
367+
</TooltipContent>
368+
</Tooltip>
369+
)
370+
}
371+
322372
function permissionModeLabel(entry: ToolCatalogEntry | undefined, value: string) {
323373
return entry?.permission_modes.find((mode) => mode.name === value)?.label ?? value
324374
}

‎frontend/apps/web/src/components/agents/agentConfigBasicExtract.ts‎

Lines changed: 29 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { type Document, isAlias, isScalar, visit } from 'yaml'
33
import { z } from 'zod'
44

55
import type { BasicSubagent } from '@/components/agents/agentConfigSubagents'
6+
import type { BasicTool } from '@/components/agents/AgentConfigToolsField'
67
import type {
78
BasicConfig,
89
BasicMachineSource,
@@ -81,6 +82,7 @@ const mcpAuth = z.discriminatedUnion('type', [
8182
const mcpToolEntry = z.strictObject({
8283
enabled: z.boolean().nullable().optional(),
8384
permission: permission.optional(),
85+
deferred: z.boolean().nullable().optional(),
8486
})
8587

8688
export type McpToolEntry = z.infer<typeof mcpToolEntry>
@@ -89,6 +91,7 @@ const mcpEntry = z.strictObject({
8991
url: z.string(),
9092
permission: permission.optional(),
9193
default_enabled: z.boolean().nullable().optional(),
94+
deferred: z.boolean().optional(),
9295
auth: mcpAuth.optional(),
9396
tools: z.record(z.string(), mcpToolEntry).optional(),
9497
})
@@ -99,6 +102,7 @@ const toolEntry = z.strictObject({
99102
type: z.literal('built_in').optional(),
100103
enabled: z.boolean().nullable().optional(),
101104
permission: permission.optional(),
105+
deferred: z.boolean().optional(),
102106
})
103107

104108
export type ToolEntry = z.infer<typeof toolEntry>
@@ -174,11 +178,7 @@ export function extractBasicConfig(document: Document): BasicConfig | null {
174178
providerConfig: doc.model?.provider_config ?? '',
175179
modelName: doc.model?.name ?? '',
176180
machineSources,
177-
tools: Object.entries(doc.tools ?? {}).map(([name, entry]) => ({
178-
name,
179-
enabled: entry.enabled ?? undefined,
180-
permission: permissionDraft(entry.permission),
181-
})),
181+
tools: Object.entries(doc.tools ?? {}).map(([name, entry]) => toolDraft(name, entry)),
182182
mcpServers: Object.entries(doc.mcp ?? {}).map(([name, entry]) => mcpServerDraft(name, entry)),
183183
skillIds: doc.skills ?? [],
184184
subagents: Object.entries(doc.subagents ?? {}).map(([key, entry]) => subagentDraft(key, entry)),
@@ -205,6 +205,16 @@ export function normalizeMultiline(value: string) {
205205
return value.replace(/\r\n?/g, '\n').trimEnd()
206206
}
207207

208+
function toolDraft(name: string, entry: z.infer<typeof toolEntry>): BasicTool {
209+
const draft: BasicTool = {
210+
name,
211+
enabled: entry.enabled ?? undefined,
212+
permission: permissionDraft(entry.permission),
213+
}
214+
if (entry.deferred) draft.deferred = true
215+
return draft
216+
}
217+
208218
function permissionDraft(
209219
value: z.infer<typeof permission> | undefined,
210220
): PermissionSelection | null {
@@ -267,7 +277,7 @@ function countDraft(value?: number): string {
267277

268278
function mcpServerDraft(name: string, entry: z.infer<typeof mcpEntry>): BasicMcpServer {
269279
const auth = entry.auth
270-
return {
280+
const draft: BasicMcpServer = {
271281
id: crypto.randomUUID(),
272282
name,
273283
url: entry.url,
@@ -277,12 +287,18 @@ function mcpServerDraft(name: string, entry: z.infer<typeof mcpEntry>): BasicMcp
277287
secretId: auth?.secret_id ?? '',
278288
service: auth?.type === 'sigv4' ? auth.service : '',
279289
region: auth?.type === 'sigv4' ? auth.region : '',
280-
tools: Object.entries(entry.tools ?? {}).map(
281-
([name, tool]): BasicMcpTool => ({
282-
name,
283-
enabled: tool.enabled ?? null,
284-
permission: permissionDraft(tool.permission),
285-
}),
286-
),
290+
tools: Object.entries(entry.tools ?? {}).map(([name, tool]) => mcpToolDraft(name, tool)),
291+
}
292+
if (entry.deferred) draft.deferred = true
293+
return draft
294+
}
295+
296+
function mcpToolDraft(name: string, tool: z.infer<typeof mcpToolEntry>): BasicMcpTool {
297+
const draft: BasicMcpTool = {
298+
name,
299+
enabled: tool.enabled ?? null,
300+
permission: permissionDraft(tool.permission),
287301
}
302+
if (tool.deferred != null) draft.deferred = tool.deferred
303+
return draft
288304
}

‎frontend/apps/web/src/components/agents/agentConfigMcp.ts‎

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ export interface BasicMcpTool {
1111
name: string
1212
enabled: boolean | null
1313
permission: PermissionSelection | null
14+
deferred?: boolean | null
1415
}
1516

1617
export interface BasicMcpServer {
@@ -19,6 +20,7 @@ export interface BasicMcpServer {
1920
url: string
2021
permission: PermissionSelection | null
2122
defaultEnabled: boolean
23+
deferred?: boolean
2224
authType: McpAuthType
2325
secretId: string
2426
service: string
@@ -30,6 +32,7 @@ export function mcpWire(server: BasicMcpServer): McpEntry {
3032
const wire: McpEntry = { url: server.url.trim() }
3133
if (server.permission != null) wire.permission = permissionWire(server.permission)
3234
wire.default_enabled = server.defaultEnabled
35+
if (server.deferred) wire.deferred = true
3336
if (server.authType !== 'none') {
3437
const secretId = server.secretId.trim()
3538
wire.auth =
@@ -42,7 +45,9 @@ export function mcpWire(server: BasicMcpServer): McpEntry {
4245
}
4346
: { type: server.authType, secret_id: secretId }
4447
}
45-
const tools = server.tools.filter((tool) => tool.enabled != null || tool.permission != null)
48+
const tools = server.tools.filter(
49+
(tool) => tool.enabled != null || tool.permission != null || tool.deferred != null,
50+
)
4651
if (tools.length > 0) {
4752
wire.tools = Object.fromEntries(tools.map((tool) => [tool.name, mcpToolWire(tool)]))
4853
}
@@ -53,6 +58,7 @@ function mcpToolWire(tool: BasicMcpTool): McpToolEntry {
5358
const wire: McpToolEntry = {}
5459
if (tool.enabled != null) wire.enabled = tool.enabled
5560
if (tool.permission != null) wire.permission = permissionWire(tool.permission)
61+
if (tool.deferred != null) wire.deferred = tool.deferred
5662
return wire
5763
}
5864

0 commit comments

Comments
 (0)