fix(agent-core): re-register builtin tools when provider resolves late (#1688)

When the model provider becomes resolvable only after the agent starts
(async OAuth / managed free-tokens registration), the builtin tool table
stayed empty: initializeBuiltinTools was gated on hasProvider at fixed
checkpoints and none fired. loopTools now self-heals on read once a
provider resolves, so tool calls no longer fail with "Tool not found".
This commit is contained in:
Haozhe 2026-07-14 16:37:17 +08:00 committed by GitHub
parent d158e0a7ac
commit 94c0ef89d2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 58 additions and 0 deletions

View file

@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": patch
---
Fix built-in tools being unavailable when the model provider becomes ready after the session starts.

View file

@ -830,6 +830,23 @@ export class ToolManager {
get loopTools(): readonly ExecutableTool[] {
if (this.loopToolsOverride !== undefined) return this.loopToolsOverride;
// Self-heal an empty builtin table. The constructor and every config-
// mutation checkpoint gate initializeBuiltinTools() on hasProvider, but a
// provider that becomes resolvable asynchronously (OAuth / managed
// free-tokens model registration) trips none of them — without this the
// agent runs with zero tools while the system prompt still advertises them.
// loopTools is re-read before every step, so the table is populated on the
// first step after the provider resolves. Steady state short-circuits on
// `builtinTools.size === 0`, so hasProvider is not evaluated per read.
if (this.builtinTools.size === 0 && this.agent.config.hasProvider) {
try {
this.initializeBuiltinTools();
} catch (error) {
this.agent.log.warn('lazy initializeBuiltinTools failed; will retry on next read', {
error: error instanceof Error ? error.message : String(error),
});
}
}
const disclosure = this.progressiveDisclosure;
const enabledMcpNames = [...this.mcpTools.keys()].filter((name) =>
this.isMcpToolEnabled(name),

View file

@ -6,7 +6,9 @@ import type { ToolCall } from '@moonshot-ai/kosong';
import { describe, expect, it, vi } from 'vitest';
import { budgetToolResultForModel } from '../../src/agent/turn/tool-result-budget';
import type { KimiConfig } from '../../src/config';
import { HookEngine } from '../../src/session/hooks';
import { ProviderManager } from '../../src/session/provider-manager';
import type { SessionSubagentHost } from '../../src/session/subagent-host';
import { FLAG_DEFINITIONS, FlagResolver } from '../../src/flags';
import { createFakeKaos } from '../tools/fixtures/fake-kaos';
@ -269,6 +271,40 @@ describe('Agent tools', () => {
expect(ctx.agent.tools.loopTools.some((tool) => tool.name === 'AgentSwarm')).toBe(true);
});
it('self-heals the builtin tool table when the provider becomes resolvable after construction', () => {
// The ProviderManager reads this live config; it starts with no model or
// provider, so hasProvider is false at Agent construction and
// initializeBuiltinTools() is skipped — the state the asynchronous
// free-tokens / OAuth model registration produces.
const liveConfig: KimiConfig = { providers: {}, models: {} };
const ctx = testAgent({
providerManager: new ProviderManager({ config: () => liveConfig }),
});
// Aim at a model that cannot resolve yet and enable some tools. Neither call
// runs a gated re-init because hasProvider is still false, so the enabled
// tools have no builtin backing and are not dispatchable.
ctx.agent.config.update({ modelAlias: 'late-model' });
ctx.agent.tools.setActiveTools(['Bash', 'Read', 'Glob']);
expect(ctx.agent.tools.loopTools.some((tool) => tool.name === 'Bash')).toBe(false);
// The provider registers asynchronously: the config the ProviderManager reads
// now resolves the model, but no agent config.update fires, so none of the
// hasProvider-gated checkpoints re-run initializeBuiltinTools().
liveConfig.providers['late-provider'] = { type: 'kimi', apiKey: 'late-key' };
liveConfig.models!['late-model'] = {
provider: 'late-provider',
model: 'late-model',
maxContextSize: 1_000_000,
capabilities: [],
};
// loopTools self-heals on read: the builtin table is populated and the
// enabled tools become dispatchable instead of reporting "Tool not found".
const names = ctx.agent.tools.loopTools.map((tool) => tool.name);
expect(names).toEqual(expect.arrayContaining(['Bash', 'Read', 'Glob']));
});
it('routes registered user tools through tool.call request/response', async () => {
const lookupCall: ToolCall = {
type: 'function',