Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/tool-websearch-not-found-filter.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@moonshot-ai/agent-core": patch
"@moonshot-ai/kimi-code": patch
---

Filter unregistered profile tools from active set and warn when they require configuration.
50 changes: 49 additions & 1 deletion packages/agent-core/src/agent/tool/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,13 @@ export class ToolManager {
protected enabledTools: Set<string> = new Set();
/** Glob patterns (e.g. `mcp__*`, `mcp__github__*`) gating which MCP tools the profile exposes. */
private mcpAccessPatterns: string[] = [];
/**
* Profile-requested builtin tool names that could not be resolved because
* `initializeBuiltinTools()` had not run yet. Replayed once builtins are
* available so tools such as `WebSearch` — which require a service that may
* be present at init time — are not silently dropped on first profile apply.
*/
private pendingBuiltinToolNames: string[] = [];
protected readonly store: Partial<ToolStoreData> = {};
private mcpToolStatusUnsubscribe: (() => void) | undefined;

Expand Down Expand Up @@ -297,7 +304,26 @@ export class ToolManager {
});
// MCP entries are glob patterns gated separately; the rest are exact
// builtin/user tool names. The split keeps every caller on one string[].
this.enabledTools = new Set(names.filter((name) => !isMcpToolName(name)));
const nonMcpNames = names.filter((name) => !isMcpToolName(name));
const availableNames = nonMcpNames.filter(
(name) => this.builtinTools.has(name) || this.userTools.has(name),
);
const missingTools = nonMcpNames.filter((name) => !availableNames.includes(name));
if (missingTools.length > 0) {
if (this.builtinTools.size > 0) {
// Builtins are fully initialized — missing tools are genuinely unavailable.
this.agent.log.warn(
`The following tools listed in the active profile are not available and will be omitted: ${missingTools.join(', ')}. ` +
`They may require additional service configuration.`,
);
}
// Save pending builtin names so they can be re-applied once builtins
// are initialized (e.g. when the model is configured after profile apply).
this.pendingBuiltinToolNames = missingTools;
} else {
this.pendingBuiltinToolNames = [];
}
this.enabledTools = new Set(availableNames);
Comment thread
LifeJiggy marked this conversation as resolved.
this.mcpAccessPatterns = names.filter((name) => isMcpToolName(name));
}

Expand Down Expand Up @@ -399,6 +425,28 @@ export class ToolManager {
.filter((tool) => !!tool)
.map((tool) => [tool.name, tool] as const),
);
// Re-apply pending profile tool names that were deferred because builtins
// had not been initialized when `setActiveTools` was first called.
if (this.pendingBuiltinToolNames.length > 0) {
const nowAvailable = this.pendingBuiltinToolNames.filter(
(name) => this.builtinTools.has(name) || this.userTools.has(name),
);
if (nowAvailable.length > 0) {
for (const name of nowAvailable) {
this.enabledTools.add(name);
Comment on lines +430 to +436
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Replay pending tools before constructing background-capable tools

When a profile is applied before builtins are initialized, setActiveTools now leaves enabledTools empty and stores TaskList/TaskOutput/TaskStop in pendingBuiltinToolNames. This replay happens only here, after initializeBuiltinTools() has already computed allowBackground and constructed BashTool/AgentTool, so fresh sessions that configure the model after applying the default profile still expose the task tools but permanently reject run_in_background=true as unavailable. Move the replay (or compute allowBackground from pending names too) before constructing those tools.

Useful? React with 👍 / 👎.

}
}
const stillMissing = this.pendingBuiltinToolNames.filter(
(name) => !nowAvailable.includes(name),
);
if (stillMissing.length > 0) {
this.agent.log.warn(
`The following tools listed in the active profile are not available and will be omitted: ${stillMissing.join(', ')}. ` +
`They may require additional service configuration.`,
);
}
this.pendingBuiltinToolNames = [];
}
}

private createVideoUploader(provider: ChatProvider): b.VideoUploader | undefined {
Expand Down
2 changes: 1 addition & 1 deletion packages/agent-core/src/agent/turn/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -803,7 +803,7 @@ function telemetryToolOutcome(result: ToolTelemetryResult): 'success' | 'error'

function telemetryToolErrorType(result: ToolTelemetryResult): string {
const text = toolResultText(result);
if (text.startsWith('Tool "') && text.includes('" not found')) return 'ToolNotFound';
if (text.startsWith('Tool "') && (text.includes('" not found') || text.includes('is not available'))) return 'ToolNotFound';
if (text.startsWith('Invalid args for tool "')) return 'ToolInputError';
if (text.includes('prepareToolExecution hook failed')) return 'HookError';
if (text.includes('finalizeToolResult hook failed')) return 'HookError';
Expand Down
5 changes: 4 additions & 1 deletion packages/agent-core/src/loop/tool-call.ts
Original file line number Diff line number Diff line change
Expand Up @@ -167,7 +167,10 @@ function preflightToolCall(
toolCall,
toolName,
args,
output: `Tool "${toolName}" not found`,
output:
`Tool "${toolName}" is not available. The tool was not found in the current session's tool list. ` +
`It may require configuration or have been removed from the active profile. ` +
`Use the available tools listed in your tool list instead.`,
};
}
if (!parsedArgs.success) {
Expand Down
4 changes: 2 additions & 2 deletions packages/agent-core/test/agent/compaction.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1499,8 +1499,8 @@ describe('Agent compaction', () => {
[wire] context.append_loop_event { "event": { "type": "content.part", "uuid": "<uuid-2>", "turnId": "0", "step": 1, "stepUuid": "<uuid-1>", "part": { "type": "text", "text": "I need a tool." } }, "time": "<time>" }
[wire] context.append_loop_event { "event": { "type": "tool.call", "uuid": "call_missing", "turnId": "0", "step": 1, "stepUuid": "<uuid-1>", "toolCallId": "call_missing", "name": "MissingTool", "args": {} }, "time": "<time>" }
[emit] tool.call.started { "turnId": 0, "toolCallId": "call_missing", "name": "MissingTool", "args": {} }
[wire] context.append_loop_event { "event": { "type": "tool.result", "parentUuid": "call_missing", "toolCallId": "call_missing", "result": { "output": "Tool \\"MissingTool\\" not found", "isError": true } }, "time": "<time>" }
[emit] tool.result { "turnId": 0, "toolCallId": "call_missing", "output": "Tool \\"MissingTool\\" not found", "isError": true }
[wire] context.append_loop_event { "event": { "type": "tool.result", "parentUuid": "call_missing", "toolCallId": "call_missing", "result": { "output": "Tool \\"MissingTool\\" is not available. The tool was not found in the current session's tool list. It may require configuration or have been removed from the active profile. Use the available tools listed in your tool list instead.", "isError": true } }, "time": "<time>" }
[emit] tool.result { "turnId": 0, "toolCallId": "call_missing", "output": "Tool \\"MissingTool\\" is not available. The tool was not found in the current session's tool list. It may require configuration or have been removed from the active profile. Use the available tools listed in your tool list instead.", "isError": true }
[wire] context.append_loop_event { "event": { "type": "step.end", "uuid": "<uuid-1>", "turnId": "0", "step": 1, "usage": { "inputOther": 9, "output": 11, "inputCacheRead": 0, "inputCacheCreation": 0 }, "finishReason": "tool_use" }, "time": "<time>" }
[emit] turn.step.completed { "turnId": 0, "step": 1, "stepId": "<uuid-1>", "usage": { "inputOther": 9, "output": 11, "inputCacheRead": 0, "inputCacheCreation": 0 }, "finishReason": "tool_use" }
[wire] usage.record { "model": "mock-model", "usage": { "inputOther": 9, "output": 11, "inputCacheRead": 0, "inputCacheCreation": 0 }, "usageScope": "turn", "time": "<time>" }
Expand Down
173 changes: 173 additions & 0 deletions packages/agent-core/test/agent/tool.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type { ToolCall } from '@moonshot-ai/kosong';
import { describe, expect, it, vi } from 'vitest';

import { ToolManager } from '../../src/agent/tool';
import { HookEngine } from '../../src/session/hooks';
import type { SessionSubagentHost } from '../../src/session/subagent-host';
import { createFakeKaos } from '../tools/fixtures/fake-kaos';
Expand All @@ -9,6 +10,178 @@ import { executeTool } from '../tools/fixtures/execute-tool';

const signal = new AbortController().signal;

describe('ToolManager setActiveTools filtering', () => {
it('filters out unregistered profile tools from the active set', () => {
const warnings: string[] = [];
const agent = {
records: { logRecord: vi.fn() },
config: { hasProvider: false },
log: { warn: (msg: string) => { warnings.push(msg); } },
mcp: undefined,
emitEvent: vi.fn(),
} as unknown as import('../../src/agent').Agent;

const tm = new ToolManager(agent);
// Populate builtinTools map directly to simulate registered tools
(tm as any).builtinTools.set('Read', { name: 'Read', description: '', parameters: {}, resolveExecution: vi.fn() });
(tm as any).builtinTools.set('Write', { name: 'Write', description: '', parameters: {}, resolveExecution: vi.fn() });
(tm as any).builtinTools.set('Bash', { name: 'Bash', description: '', parameters: {}, resolveExecution: vi.fn() });

// Set active tools — WebSearch and NonExistentTool are not registered
tm.setActiveTools(['Read', 'Write', 'Bash', 'WebSearch', 'NonExistentTool']);

// Check active tools via toolInfos
const activeNames = [...tm.toolInfos()]
.filter((i) => i.active)
.map((i) => i.name)
.toSorted();
expect(activeNames).toEqual(['Bash', 'Read', 'Write']);

// Check warning was logged with missing tool names
expect(warnings.length).toBe(1);
expect(warnings[0]).toContain('are not available');
expect(warnings[0]).toContain('WebSearch');
expect(warnings[0]).toContain('NonExistentTool');
// Read should not be mentioned as missing
expect(warnings[0]).not.toContain('Read');
});

it('keeps all tools when all profile names are registered', () => {
const warnings: string[] = [];
const agent = {
records: { logRecord: vi.fn() },
config: { hasProvider: false },
log: { warn: (msg: string) => { warnings.push(msg); } },
mcp: undefined,
emitEvent: vi.fn(),
} as unknown as import('../../src/agent').Agent;

const tm = new ToolManager(agent);
(tm as any).builtinTools.set('Read', { name: 'Read', description: '', parameters: {}, resolveExecution: vi.fn() });
(tm as any).builtinTools.set('Write', { name: 'Write', description: '', parameters: {}, resolveExecution: vi.fn() });
(tm as any).builtinTools.set('Bash', { name: 'Bash', description: '', parameters: {}, resolveExecution: vi.fn() });
(tm as any).builtinTools.set('Grep', { name: 'Grep', description: '', parameters: {}, resolveExecution: vi.fn() });
(tm as any).builtinTools.set('Glob', { name: 'Glob', description: '', parameters: {}, resolveExecution: vi.fn() });

tm.setActiveTools(['Read', 'Write', 'Bash', 'Grep', 'Glob']);

const activeNames = [...tm.toolInfos()]
.filter((i) => i.active)
.map((i) => i.name)
.toSorted();
expect(activeNames).toEqual(['Bash', 'Glob', 'Grep', 'Read', 'Write']);
expect(warnings.length).toBe(0);
});

it('does not warn when all tools are available', () => {
const warnings: string[] = [];
const agent = {
records: { logRecord: vi.fn() },
config: { hasProvider: false },
log: { warn: (msg: string) => { warnings.push(msg); } },
mcp: undefined,
emitEvent: vi.fn(),
} as unknown as import('../../src/agent').Agent;

const tm = new ToolManager(agent);
(tm as any).builtinTools.set('Read', { name: 'Read', description: '', parameters: {}, resolveExecution: vi.fn() });

tm.setActiveTools(['Read']);
expect(warnings.length).toBe(0);
});

it('defers builtin tool names as pending when builtins not yet initialized', () => {
const warnings: string[] = [];
const agent = {
records: { logRecord: vi.fn() },
config: { hasProvider: false },
log: { warn: (msg: string) => { warnings.push(msg); } },
mcp: undefined,
emitEvent: vi.fn(),
} as unknown as import('../../src/agent').Agent;

const tm = new ToolManager(agent);
// builtinTools map is empty — simulate pre-initialization state

tm.setActiveTools(['Read', 'Write', 'Bash']);

// No warning because builtins are not yet initialized
expect(warnings.length).toBe(0);

// Active set is empty since no builtins are registered
const activeNames = [...tm.toolInfos()]
.filter((i) => i.active)
.map((i) => i.name);
expect(activeNames).toEqual([]);

// Names should be stored as pending
expect((tm as any).pendingBuiltinToolNames).toEqual(['Read', 'Write', 'Bash']);
});

it('resolves previously deferred tools when setActiveTools is recalled after builtin init', () => {
const makeTool = (name: string) => ({ name, description: '', parameters: {}, resolveExecution: vi.fn() });
const agent = {
records: { logRecord: vi.fn() },
config: { hasProvider: false },
log: { warn: vi.fn() },
mcp: undefined,
emitEvent: vi.fn(),
} as unknown as import('../../src/agent').Agent;

const tm = new ToolManager(agent);
// builtinTools empty — pre-init state

// First call: builtins not initialized — names deferred
tm.setActiveTools(['Read', 'Write']);
expect((tm as any).pendingBuiltinToolNames).toEqual(['Read', 'Write']);

// Builtins become available
(tm as any).builtinTools.set('Read', makeTool('Read'));
(tm as any).builtinTools.set('Write', makeTool('Write'));

// Second call: builtins populated — names resolve
tm.setActiveTools(['Read', 'Write']);
expect((tm as any).pendingBuiltinToolNames).toEqual([]);

const activeNames = [...tm.toolInfos()]
.filter((i) => i.active)
.map((i) => i.name)
.toSorted();
expect(activeNames).toEqual(['Read', 'Write']);
// No warning because all tools resolved
expect(agent.log.warn).not.toHaveBeenCalled();
});

it('warns about tools still missing when builtins were already initialized', () => {
const warnings: string[] = [];
const agent = {
records: { logRecord: vi.fn() },
config: { hasProvider: false },
log: { warn: (msg: string) => { warnings.push(msg); } },
mcp: undefined,
emitEvent: vi.fn(),
} as unknown as import('../../src/agent').Agent;

const tm = new ToolManager(agent);
// Populate builtins BEFORE setActiveTools (simulates initialized state)
(tm as any).builtinTools.set('Read', { name: 'Read', description: '', parameters: {}, resolveExecution: vi.fn() });

// Call setActiveTools with a genuinely missing tool
tm.setActiveTools(['Read', 'BogusTool']);

// Warning should fire immediately (builtins already initialized)
expect(warnings.length).toBe(1);
expect(warnings[0]).toContain('BogusTool');
expect(warnings[0]).toContain('not available');

// Read should be active, BogusTool should not
const activeNames = [...tm.toolInfos()]
.filter((i) => i.active)
.map((i) => i.name);
expect(activeNames).toEqual(['Read']);
});
});

describe('Agent tools', () => {
it('blocks tools through PreToolUse before permission and emits PostToolUseFailure', async () => {
const execWithEnv = vi.fn().mockRejectedValue(new Error('Bash should not execute'));
Expand Down