Skip to content
5 changes: 5 additions & 0 deletions .changeset/subagent-tool-availability.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": patch
---

Keep subagent tool descriptions aligned with currently available tools.
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import { ISessionToolPolicyGate } from '#/session/sessionToolPolicyGate/sessionT
import { IAgentRuntimeService } from '#/agent/runtimeBinding/agentRuntime';

import { IAgentToolActivationService } from './toolActivation';
import { isToolAvailable } from './toolAvailability';

export class AgentToolActivationService extends Service implements IAgentToolActivationService {
declare readonly _serviceBrand: undefined;
Expand All @@ -36,7 +37,11 @@ export class AgentToolActivationService extends Service implements IAgentToolAct
void this.activate();
}),
);
this._register(this.runtime.onDidChange(() => this.refreshRuntimeRecords()));
this._register(
this.runtime.onDidChange(() => {
this.refreshRuntimeRecords();
}),
);
this._register(
this.contributions.onDidChange((change) => {
this.activateRecords(change.added);
Expand All @@ -63,14 +68,13 @@ export class AgentToolActivationService extends Service implements IAgentToolAct
const { id, options } = record;
const source = options.source ?? 'builtin';
if (this.toolRegistry.resolve(options.name) !== undefined) continue;
if (!this.runtimeAllows(record)) continue;
if (!isToolActive(workspaceVeto, options.name, source)) continue;
const activeByProfile =
options.name === SELECT_TOOLS_TOOL_NAME
? isToolActive(disclosurePolicy, options.name, source)
: isToolActive(policy, options.name, source);
if (!activeByProfile) continue;
if (options.when !== undefined && !options.when(accessor)) continue;
if (!isToolAvailable(options, accessor, this.runtime)) continue;
const tool = accessor.get(id);
const registration = this.toolRegistry.register(tool, {
source: options.source,
Expand All @@ -84,16 +88,14 @@ export class AgentToolActivationService extends Service implements IAgentToolAct

private refreshRuntimeRecords(): void {
for (const record of this.contributions.items) {
if (!this.runtimeAllows(record)) this.deactivateRecord(record);
const required = record.options.requiredRuntimeCapabilities;
if (required !== undefined && !this.runtime.isAvailable(required)) {
this.deactivateRecord(record);
}
}
this.activateRecords(this.contributions.items);
}

private runtimeAllows(record: AgentToolContribution): boolean {
const required = record.options.requiredRuntimeCapabilities;
return required === undefined || this.runtime.isAvailable(required);
}

private deactivateRecord(record: AgentToolContribution): void {
const registration = this.registrations.get(record);
if (registration === undefined) return;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import type { ServicesAccessor } from '#/_base/di/instantiation';
import type { IAgentRuntimeService } from '#/agent/runtimeBinding/agentRuntime';
import type { AgentToolContributionOptions } from '#/agent/toolRegistry/toolContribution';

export function isToolAvailable(
options: AgentToolContributionOptions,
accessor: ServicesAccessor,
runtime: Pick<IAgentRuntimeService, 'isAvailable'>,
): boolean {
const required = options.requiredRuntimeCapabilities;
return (required === undefined || runtime.isAvailable(required)) &&
(options.when?.(accessor) ?? true);
}
72 changes: 40 additions & 32 deletions packages/agent-core-v2/src/agent/tools/agent/agentTool.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { type CollectionView } from '#/_base/di/collection';
import type { IAgentScopeHandle } from '#/_base/di/scope';
import { IInstantiationService } from '#/_base/di/instantiation';
import {
isAbortError,
isUserCancellation,
Expand All @@ -16,12 +17,16 @@ import {
} from '#/agent/task/task';
import { IAgentProfileService } from '#/agent/profile/profile';
import { IModelCatalog } from '#/llm-adapter/model/catalog';
import type { ModelCapability } from '#/llm-adapter/contract/capability';
import {
isToolActive as evaluateToolActive,
resolveActiveToolNames,
} from '#/agent/toolPolicy/evaluate';
import { IAgentToolPolicyService } from '#/agent/toolPolicy/toolPolicy';
import { TOOL_SELECT_FLAG_ID } from '#/agent/toolSelect/flag';
import { SELECT_TOOLS_TOOL_NAME } from '#/agent/toolSelect/toolSelect';
import { IAgentScopeContext } from '#/agent/scopeContext/scopeContext';
import { IAgentRuntimeService } from '#/agent/runtimeBinding/agentRuntime';
import { IAgentLoopService } from '#/agent/loop/loop';
import { IAgentPermissionModeService } from '#/agent/permissionMode/permissionMode';
import {
Expand All @@ -35,6 +40,7 @@ import {
AgentToolContribution,
registerAgentToolService,
} from '#/agent/toolRegistry/toolContribution';
import { isToolAvailable } from '#/agent/toolActivation/toolAvailability';
import { IAgentToolRegistryService, type ToolReference } from '#/agent/toolRegistry/toolRegistry';
import { type AgentProfile } from '#/app/agentProfileCatalog/agentProfileCatalog';
import { ISessionAgentProfileCatalog } from '#/session/sessionAgentProfileCatalog/sessionAgentProfileCatalog';
Expand All @@ -47,8 +53,6 @@ import { ILogService } from '#/_base/log/log';
import { hasPinnedPermissionMode } from '#/features/tower/tower';
import { IConfigService } from '#/app/config/config';
import { IFlagService } from '#/app/flag/flag';
import { ISessionNotify } from '#/features/notify/sessionNotify';
import { NOTIFY_USER_TOOL_NAME } from '#/features/notify/tools/notify-user/notify-user';
import { IAgentLifecycleService } from '#/session/agentLifecycle/agentLifecycle';
import { createAgentAwaitingClose } from '#/session/agentLifecycle/createAwaitingClose';
import {
Expand Down Expand Up @@ -126,6 +130,8 @@ export class SubagentTool implements ISubagentTool {
@ISessionSubagentService private readonly subagents: ISessionSubagentService,
@ISessionAgentProfileCatalog private readonly catalog: ISessionAgentProfileCatalog,
@IAgentScopeContext scopeContext: IAgentScopeContext,
@IInstantiationService private readonly instantiationService: IInstantiationService,
@IAgentRuntimeService private readonly runtime: IAgentRuntimeService,
@IAgentTaskService private readonly tasks: IAgentTaskService,
@IAgentProfileService private readonly profile: IAgentProfileService,
@IModelCatalog private readonly modelCatalog: IModelCatalog,
Expand All @@ -136,7 +142,6 @@ export class SubagentTool implements ISubagentTool {
@ILogService private readonly log: ILogService,
@IConfigService private readonly config: IConfigService,
@IFlagService private readonly flags: IFlagService,
@ISessionNotify private readonly notify: ISessionNotify,
@AgentToolContribution private readonly contributions: CollectionView<AgentToolContribution>,
) {
this.callerAgentId = scopeContext.agentId;
Expand Down Expand Up @@ -164,20 +169,13 @@ export class SubagentTool implements ISubagentTool {
allowlist === undefined
? catalogProfiles
: catalogProfiles.filter((profile) => allowlist.includes(profile.name));
const notifyAvailable = this.notify.enabled;
const knownTools = this.knownToolReferences();
const knownTools = this.availableToolReferences();
const available = new Set(knownTools.map((ref) => ref.name));
const anyMediaModel = this.anyMediaCapableModel();
const typeLines = buildProfileDescriptions(
profiles.map((profile) => ({
...profile,
tools: profile.tools?.filter(
(name) =>
(name !== NOTIFY_USER_TOOL_NAME || notifyAvailable) &&
(isToolNamePattern(name) ||
(name === READ_MEDIA_FILE_TOOL_NAME
? anyMediaModel && this.toolPolicy.isToolActiveForProfile(profile, name, 'builtin')
: available.has(name))),
(name) => isToolNamePattern(name) || available.has(name),
),
})),
knownTools,
Expand Down Expand Up @@ -231,39 +229,55 @@ export class SubagentTool implements ISubagentTool {
return withoutDelegatingTargets(this.catalog, allowlist);
}

private knownToolReferences(): ToolReference[] {
const refs = new Map<string, ToolReference>();
for (const contribution of this.contributions.items) {
refs.set(contribution.options.name, {
name: contribution.options.name,
source: contribution.options.source ?? 'builtin',
});
private availableToolReferences(): ToolReference[] {
const refs = new Map(this.toolRegistry.listReferences().map((ref) => [ref.name, ref]));
this.instantiationService.invokeFunction((accessor) => {
for (const { options } of this.contributions.items) {
if (refs.get(options.name)?.source === 'user') continue;
if (isToolAvailable(options, accessor, this.runtime)) {
refs.set(options.name, { name: options.name, source: options.source ?? 'builtin' });
} else {
refs.delete(options.name);
}
}
});
if (refs.get(READ_MEDIA_FILE_TOOL_NAME)?.source !== 'user') {
if (this.anySubagentModelSupports((capabilities) => capabilities.image_in || capabilities.video_in)) {
refs.set(READ_MEDIA_FILE_TOOL_NAME, { name: READ_MEDIA_FILE_TOOL_NAME, source: 'builtin' });
} else {
refs.delete(READ_MEDIA_FILE_TOOL_NAME);
}
}
for (const ref of this.toolRegistry.listReferences()) {
if (!refs.has(ref.name)) refs.set(ref.name, ref);
if (
!this.flags.enabled(TOOL_SELECT_FLAG_ID) ||
!this.anySubagentModelSupports((capabilities) =>
capabilities.dynamically_loaded_tools === true && capabilities.tool_use,
)
) {
refs.delete(SELECT_TOOLS_TOOL_NAME);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Preserve user overrides of select_tools

When an SDK caller registers a user tool named select_tools and the experimental flag is disabled (the default) or no selectable subagent model supports dynamic tools, this branch removes that live user-tool reference. The preceding loop deliberately preserves user sources, and SessionSubagentService inherits user tools into child agents, so profiles that allow it receive a functioning tool despite the Agent description withholding it. Restrict this deletion to the builtin contribution, as is already done for ReadMediaFile.

AGENTS.md reference: AGENTS.md:L100-L100

Useful? React with 👍 / 👎.

}
return [...refs.values()];
}

private anyMediaCapableModel(): boolean {
private anySubagentModelSupports(supports: (capabilities: ModelCapability) => boolean): boolean {
if (isSubagentModelForced(this.config)) {
const forced = resolveSubagentModelPool(this.config)?.defaultModel;
if (forced === undefined) return false;
try {
const capabilities = this.modelCatalog.get(forced).capabilities;
return capabilities.image_in || capabilities.video_in;
return supports(capabilities);
} catch {
return false;
}
}
const own = this.profile.getModelCapabilities();
if (own.image_in || own.video_in) return true;
if (supports(own)) return true;
const pool = resolveSubagentModelPool(this.config);
if (pool === undefined) return false;
for (const alias of Object.keys(pool.models)) {
try {
const capabilities = this.modelCatalog.get(alias).capabilities;
if (capabilities.image_in || capabilities.video_in) return true;
if (supports(capabilities)) return true;
} catch {
}
}
Expand Down Expand Up @@ -706,7 +720,7 @@ function buildProfileDescriptions(
evaluateToolActive(profile, tool.name, tool.source) &&
!isToolActive(profile, tool.name, tool.source),
);
if (externallyRestricted) {
if (externallyRestricted || activeTools === undefined) {
const effectiveTools = tools
.filter((tool) => isToolActive(profile, tool.name, tool.source))
.map((tool) => tool.name);
Expand All @@ -715,12 +729,6 @@ function buildProfileDescriptions(
}
return `${header}\n Tools: ${effectiveTools.join(', ')}`;
}
if (activeTools === undefined) {
if ((profile.disallowedTools?.length ?? 0) > 0) {
return `${header}\n Tools: all except ${profile.disallowedTools!.join(', ')}`;
}
return `${header}\n Tools: all`;
}
if (activeTools.length === 0) {
return `${header}\n Tools: none`;
}
Expand Down
2 changes: 1 addition & 1 deletion packages/agent-core-v2/src/features/plan/profile/plan.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ const PLAN_ROLE =
'2. What questions remain unanswered that would benefit from explore agent investigation\n' +
'3. Your implementation plan (either preliminary if questions remain, or final if sufficient context exists)\n\n' +
'You are a read-only planning agent: you can read and search files ' +
'and consult the web, but you have no shell and no file-editing tools. ' +
'and, when web tools are available, consult the web, but you have no shell and no file-editing tools. ' +
'Where the general instructions tell you to make changes with tools, that does not apply to you — ' +
'do not attempt to run commands or modify files. Your deliverable is the plan itself, returned as ' +
'your final message.';
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ Guidelines:
- Use Read when you know the specific file path
- Use Bash ONLY for read-only operations (ls, git status, git log, git diff, find)
- NEVER use Bash for any file creation or modification commands
- Use WebSearch or FetchURL when a question needs external context (library documentation, error messages, upstream APIs); the local codebase remains your primary domain
- When WebSearch or FetchURL is available, use it when a question needs external context (library documentation, error messages, upstream APIs); the local codebase remains your primary domain
- Adapt your search depth based on the thoroughness level specified by the caller
- Wherever possible, spawn multiple parallel tool calls for grepping and reading files to maximize speed

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -307,7 +307,7 @@ describe('FullCompaction', () => {
properties: expect.objectContaining({
agent_id: 'main',
source: 'manual',
tokens_before: 6_135,
tokens_before: 6_127,
tokens_after: expect.any(Number),
duration_ms: expect.any(Number),
compacted_count: 6,
Expand Down Expand Up @@ -582,7 +582,7 @@ describe('FullCompaction', () => {
session_id: 'test-session',
cwd: dir,
trigger: 'auto',
token_count: 6_135,
token_count: 6_127,
});
expect(post).toMatchObject({
hook_event_name: 'PostCompact',
Expand Down Expand Up @@ -1950,8 +1950,8 @@ describe('FullCompaction', () => {
event: 'compaction_finished',
properties: expect.objectContaining({
source: 'auto',
tokens_before: 6_142,
tokens_after: 6_159,
tokens_before: 6_134,
tokens_after: 6_151,
compacted_count: 7,
retry_count: 0,
}),
Expand Down
4 changes: 2 additions & 2 deletions packages/agent-core-v2/test/agent/loop/loop.test.ts

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -338,6 +338,33 @@ describe('AgentToolActivationService', () => {
expect(registry.resolve('Beta')).toBeInstanceOf(BetaTool);
});

it('keeps conditional tools registered when a runtime change preserves their requirements', async () => {
let available = true;
registerAgentToolService(IAlphaTool, AlphaTool, {
name: 'Alpha',
requiredRuntimeCapabilities: ['fs'],
when: () => available,
});
registerAgentToolService(IGammaTool, GammaTool, {
name: 'Gamma',
when: () => available,
});
const ix = createActivationHost();
const registry = ix.get(IAgentToolRegistryService);
await ix.get(IAgentToolActivationService).activate();
const alpha = registry.resolve('Alpha');
const gamma = registry.resolve('Gamma');
expect(alpha).toBeInstanceOf(AlphaTool);
expect(gamma).toBeInstanceOf(GammaTool);

available = false;
runtimeData.capabilities.delete('process');
runtimeChangeEmitter.fire();

expect(registry.resolve('Alpha')).toBe(alpha);
expect(registry.resolve('Gamma')).toBe(gamma);
});

it('activates only the tools allowed by the profile allowlist', async () => {
profileData.activeToolNames = ['Alpha'];
registerAgentToolService(IAlphaTool, AlphaTool, { name: 'Alpha' });
Expand Down
Loading
Loading