mirror of
https://github.com/MoonshotAI/kimi-code.git
synced 2026-07-09 17:29:12 +00:00
Some checks are pending
CI / build (push) Waiting to run
CI / test (push) Waiting to run
CI / test-pi-tui (push) Waiting to run
CI / test-windows (push) Waiting to run
CI / lint (push) Waiting to run
CI / typecheck (push) Waiting to run
Nix Build / Check flake.nix workspace sync (push) Waiting to run
Nix Build / nix build .#kimi-code (push) Blocked by required conditions
Release / Release (push) Waiting to run
Release / Deploy docs (push) Blocked by required conditions
Release / Native release artifact (push) Blocked by required conditions
Release / Desktop release artifact (push) Blocked by required conditions
Release / Publish native release assets (push) Blocked by required conditions
* feat(agent-core): progressive tool disclosure via select_tools Keep MCP tool schemas out of the immutable top-level tools[] and let the model load them on demand, preserving the provider prompt cache: - kosong: Message.tools (append-only load primitive, serialized as Kimi messages[].tools with type:function wrapping and no content), Tool.deferred (stripped once in generate() so loaded tools stay executable without re-entering the top level), select_tools capability bit (UNKNOWN/catalog default false). - select_tools builtin: load-by-exact-name, three-branch semantics settled per name (Loaded / Already available / Unknown), schemas read from the live registry, injection-origin schema messages survive undo. - ToolsDiffInjector: <tools_added>/<tools_removed> announcements at turn boundaries and post-compaction, folded from history (undo/compaction/ resume self-heal), appended only when the loadable set changes. - Loaded-tools ledger = history scan + defer-window pending set (cleared on /clear); loop re-reads the executable table per step so a selected tool dispatches on the next step of the same turn; preflight distinguishes not-loaded from loaded-but-disconnected. - Cross-cuts: projection strips protocol context for non-select_tools models (lossless mid-session model switch both ways), compaction filters it from the summarizer input and rebuilds loaded schemas keep-all after folding, token estimation counts message.tools, request logging reflects the post-strip wire tools. - Three-condition gate: capability.select_tools x capability.tool_use x tool-select experimental flag (KIMI_CODE_EXPERIMENTAL_TOOL_SELECT). Any gate closed reproduces the inline request byte-for-byte; all current models keep the capability off, so behavior is unchanged until a supporting model is catalogued. The SDK catalog-to-alias mapping forwards the capability so catalog-driven setups can enable it. * feat(kosong): skip tool-declaration-only messages in non-Kimi providers Message-level tool declarations (messages[].tools) are a Kimi wire feature. The other providers' explicit field construction already keeps the tools field off the wire, but the content-free leftover message would be rejected (OpenAI: system message without content) or serialize as a garbage <system></system> turn (Anthropic/Google system-to-user wrapping). Skip such messages entirely via a shared predicate; a message that also carries content only loses the tools field, as before. Unreachable in kimi-code (the projection gate strips dynamic-tool context for models without the select_tools capability before any provider sees it) — defense-in-depth for direct kosong consumers. * fix(agent-core): survive runtime flag flips and align tool table with post-compaction state Two fixes from PR review: - Register select_tools unconditionally and gate only its exposure in loopTools. The tool-select flag can flip at runtime (config reload calls setConfigOverrides on the live resolver) without initializeBuiltinTools re-running; previously the disclosure shape activated while the tool itself was unregistered, cutting the session off from MCP entirely until a model/cwd change rebuilt the builtins. A profile listing the name explicitly still never surfaces it in inline mode, and execution guards the flip race defensively. - Resolve the per-step tool table AFTER beforeStep, next to buildMessages. beforeStep can run full compaction, which trims loaded schemas and rewrites the ledger; a table captured before it could still dispatch a tool whose schema the model no longer has. The executable table and the request messages now always reflect the same state, so a trimmed tool is rejected with select guidance instead of executed. * fix(agent-core): drop unused Tool import in dynamic-tools * fix(agent-core): baseline compaction guard after post-compaction reinjection The reinjected reminders (loadable-tools manifest, goal) are re-appended after every compaction, but the nothing-new-since-compaction baseline was captured before injectAfterCompaction. With a large manifest the guard could re-trigger auto-compaction against a floor that cannot shrink. Raise the baseline to the true post-compaction floor once reinjection completes; the earlier capture stays as a fallback when reinjection throws. --------- Co-authored-by: fengchenchen <fengchenchen@moonshot.ai>
238 lines
16 KiB
TypeScript
238 lines
16 KiB
TypeScript
import type { ModelCapability, ProviderConfig, ToolCall } from '@moonshot-ai/kosong';
|
|
import { describe, expect, it } from 'vitest';
|
|
|
|
import type { ResolvedAgentProfile } from '../../src/profile';
|
|
import { createCommandKaos, testAgent } from './harness/agent';
|
|
import { DEFAULT_TEST_SYSTEM_PROMPT } from './harness/snapshots';
|
|
|
|
describe('Agent config', () => {
|
|
it('exposes provider, system prompt, thinking effort, and model capability updates', async () => {
|
|
const ctx = testAgent();
|
|
const initialProvider: ProviderConfig = {
|
|
type: 'openai',
|
|
apiKey: 'sk-initial',
|
|
baseUrl: 'https://initial.example/v1',
|
|
model: 'gpt-initial',
|
|
};
|
|
const initialCapability: ModelCapability = {
|
|
image_in: true,
|
|
video_in: false,
|
|
audio_in: false,
|
|
thinking: false,
|
|
tool_use: true,
|
|
max_context_tokens: 128000,
|
|
};
|
|
ctx.configure({
|
|
provider: initialProvider,
|
|
modelCapabilities: initialCapability,
|
|
});
|
|
|
|
await expect(ctx.rpc.getConfig({})).resolves.toMatchObject({
|
|
provider: initialProvider,
|
|
systemPrompt: DEFAULT_TEST_SYSTEM_PROMPT,
|
|
thinkingEffort: 'off',
|
|
modelCapabilities: initialCapability,
|
|
});
|
|
|
|
const nextProvider: ProviderConfig = {
|
|
type: 'kimi',
|
|
apiKey: 'sk-next',
|
|
baseUrl: 'https://next.example/v1',
|
|
model: 'kimi-next',
|
|
};
|
|
const nextCapability: ModelCapability = {
|
|
image_in: true,
|
|
video_in: true,
|
|
audio_in: false,
|
|
thinking: true,
|
|
tool_use: true,
|
|
max_context_tokens: 262144,
|
|
};
|
|
ctx.configureRuntimeModel(nextProvider, nextCapability);
|
|
ctx.agent.config.update({
|
|
systemPrompt: 'Changed profile prompt.',
|
|
thinkingEffort: 'high',
|
|
});
|
|
|
|
await expect(ctx.rpc.getConfig({})).resolves.toMatchObject({
|
|
provider: nextProvider,
|
|
systemPrompt: 'Changed profile prompt.',
|
|
thinkingEffort: 'high',
|
|
modelCapabilities: nextCapability,
|
|
});
|
|
await ctx.expectResumeMatches();
|
|
});
|
|
|
|
it('useProfile emits the rendered system prompt and active tools', async () => {
|
|
const ctx = testAgent();
|
|
ctx.configure();
|
|
const profile: ResolvedAgentProfile = {
|
|
name: 'test-profile',
|
|
systemPrompt: () => 'Profile system prompt.',
|
|
tools: ['Bash'],
|
|
};
|
|
|
|
ctx.agent.useProfile(profile);
|
|
|
|
expect(ctx.newEvents()).toMatchInlineSnapshot(`
|
|
[wire] config.update { "profileName": "test-profile", "systemPrompt": "Profile system prompt.", "time": "<time>" }
|
|
[emit] agent.status.updated { "model": "mock-model", "contextTokens": 0, "maxContextTokens": 1000000, "contextUsage": 0, "planMode": false, "swarmMode": false, "permission": "manual" }
|
|
[wire] tools.set_active_tools { "names": [ "Bash" ], "time": "<time>" }
|
|
`);
|
|
await ctx.expectResumeMatches();
|
|
});
|
|
|
|
it('useProfile passes additionalDirsInfo to profile system prompts', async () => {
|
|
const ctx = testAgent();
|
|
ctx.configure();
|
|
const profile: ResolvedAgentProfile = {
|
|
name: 'context-profile',
|
|
systemPrompt: (context) =>
|
|
`Prompt with additional dirs: ${context.additionalDirsInfo ?? 'none'}`,
|
|
tools: ['Bash'],
|
|
};
|
|
|
|
ctx.agent.useProfile(profile, {
|
|
cwdListing: 'cwd listing',
|
|
agentsMd: 'agents md',
|
|
additionalDirsInfo: '### /extra\nextra-file.txt',
|
|
});
|
|
|
|
expect(ctx.agent.config.systemPrompt).toBe(
|
|
'Prompt with additional dirs: ### /extra\nextra-file.txt',
|
|
);
|
|
|
|
ctx.agent.useProfile(profile);
|
|
|
|
expect(ctx.agent.config.systemPrompt).toBe('Prompt with additional dirs: none');
|
|
});
|
|
|
|
it('config.update with cwd initializes builtin tools', async () => {
|
|
const ctx = testAgent();
|
|
ctx.configure();
|
|
|
|
const tools = await ctx.rpc.getTools({});
|
|
|
|
expect(toolNames(tools)).toEqual(
|
|
expect.arrayContaining(['Bash', 'Read', 'Write', 'Edit', 'Grep', 'Glob']),
|
|
);
|
|
await ctx.expectResumeMatches();
|
|
});
|
|
|
|
it('keeps turn-start config for later steps and applies updates to the next turn', async () => {
|
|
const bashCall: ToolCall = {
|
|
type: 'function',
|
|
id: 'call_bash',
|
|
name: 'Bash',
|
|
arguments: '{"command":"printf original-result","timeout":60}',
|
|
};
|
|
const ctx = testAgent({ kaos: createCommandKaos('original-result') });
|
|
ctx.configure({ tools: ['Bash'] });
|
|
|
|
ctx.mockNextResponse({ type: 'text', text: 'I will run Bash.' }, bashCall);
|
|
await ctx.rpc.prompt({
|
|
input: [{ type: 'text', text: 'Run Bash before config changes' }],
|
|
});
|
|
expect(await ctx.untilApproval(true)).toMatchInlineSnapshot(`
|
|
[wire] turn.prompt { "input": [ { "type": "text", "text": "Run Bash before config changes" } ], "origin": { "kind": "user" }, "time": "<time>" }
|
|
[emit] turn.started { "turnId": 0, "origin": { "kind": "user" } }
|
|
[wire] context.append_message { "message": { "role": "user", "content": [ { "type": "text", "text": "Run Bash before config changes" } ], "toolCalls": [], "origin": { "kind": "user" } }, "time": "<time>" }
|
|
[wire] context.append_loop_event { "event": { "type": "step.begin", "uuid": "<uuid-1>", "turnId": "0", "step": 1 }, "time": "<time>" }
|
|
[emit] turn.step.started { "turnId": 0, "step": 1, "stepId": "<uuid-1>" }
|
|
[emit] assistant.delta { "turnId": 0, "delta": "I will run Bash." }
|
|
[emit] tool.call.delta { "turnId": 0, "toolCallId": "call_bash", "name": "Bash", "argumentsPart": "{\\"command\\":\\"printf original-result\\",\\"timeout\\":60}" }
|
|
[wire] context.append_loop_event { "event": { "type": "content.part", "uuid": "<uuid-2>", "turnId": "0", "step": 1, "stepUuid": "<uuid-1>", "part": { "type": "text", "text": "I will run Bash." } }, "time": "<time>" }
|
|
[emit] requestApproval { "turnId": 0, "toolCallId": "call_bash", "toolName": "Bash", "action": "Running: printf original-result", "display": { "kind": "command", "command": "printf original-result", "cwd": "<cwd>", "language": "bash" } }
|
|
`);
|
|
expect(ctx.lastLlmInput()).toMatchInlineSnapshot(`
|
|
system: <system-prompt>
|
|
tools: Bash
|
|
messages:
|
|
user: text "Run Bash before config changes"
|
|
`);
|
|
|
|
ctx.configureRuntimeModel({
|
|
type: 'kimi',
|
|
apiKey: 'test-key',
|
|
model: 'changed-model',
|
|
});
|
|
ctx.agent.config.update({ systemPrompt: 'Changed system prompt.' });
|
|
await ctx.rpc.setActiveTools({ names: [] });
|
|
|
|
ctx.mockNextResponse({ type: 'text', text: 'Still using the original turn config.' });
|
|
expect(await ctx.untilTurnEnd()).toMatchInlineSnapshot(`
|
|
[wire] permission.record_approval_result { "turnId": 0, "toolCallId": "call_bash", "toolName": "Bash", "action": "Running: printf original-result", "result": { "decision": "approved", "selectedLabel": "approve" }, "time": "<time>" }
|
|
[wire] config.update { "modelAlias": "changed-model", "time": "<time>" }
|
|
[emit] agent.status.updated { "model": "changed-model", "contextTokens": 0, "maxContextTokens": 1000000, "contextUsage": 0, "planMode": false, "swarmMode": false, "permission": "manual" }
|
|
[wire] config.update { "systemPrompt": "Changed system prompt.", "time": "<time>" }
|
|
[emit] agent.status.updated { "model": "changed-model", "contextTokens": 0, "maxContextTokens": 1000000, "contextUsage": 0, "planMode": false, "swarmMode": false, "permission": "manual" }
|
|
[wire] tools.set_active_tools { "names": [], "time": "<time>" }
|
|
[wire] context.append_loop_event { "event": { "type": "tool.call", "uuid": "call_bash", "turnId": "0", "step": 1, "stepUuid": "<uuid-1>", "toolCallId": "call_bash", "name": "Bash", "args": { "command": "printf original-result", "timeout": 60 }, "description": "Running: printf original-result", "display": { "kind": "command", "command": "printf original-result", "cwd": "<cwd>", "language": "bash" } }, "time": "<time>" }
|
|
[emit] tool.call.started { "turnId": 0, "toolCallId": "call_bash", "name": "Bash", "args": { "command": "printf original-result", "timeout": 60 }, "description": "Running: printf original-result", "display": { "kind": "command", "command": "printf original-result", "cwd": "<cwd>", "language": "bash" } }
|
|
[emit] tool.progress { "turnId": 0, "toolCallId": "call_bash", "update": { "kind": "stdout", "text": "original-result" } }
|
|
[wire] context.append_loop_event { "event": { "type": "tool.result", "parentUuid": "call_bash", "toolCallId": "call_bash", "result": { "output": "original-result" } }, "time": "<time>" }
|
|
[emit] tool.result { "turnId": 0, "toolCallId": "call_bash", "output": "original-result" }
|
|
[wire] context.append_loop_event { "event": { "type": "step.end", "uuid": "<uuid-1>", "turnId": "0", "step": 1, "usage": { "inputOther": 9, "output": 23, "inputCacheRead": 0, "inputCacheCreation": 0 }, "finishReason": "tool_use", "messageId": "mock-1" }, "time": "<time>" }
|
|
[emit] turn.step.completed { "turnId": 0, "step": 1, "stepId": "<uuid-1>", "usage": { "inputOther": 9, "output": 23, "inputCacheRead": 0, "inputCacheCreation": 0 }, "finishReason": "tool_use" }
|
|
[wire] usage.record { "model": "mock-model", "usage": { "inputOther": 9, "output": 23, "inputCacheRead": 0, "inputCacheCreation": 0 }, "usageScope": "turn", "time": "<time>" }
|
|
[emit] agent.status.updated { "model": "changed-model", "contextTokens": 32, "maxContextTokens": 1000000, "contextUsage": 0.000032, "planMode": false, "swarmMode": false, "permission": "manual", "usage": { "byModel": { "mock-model": { "inputOther": 9, "output": 23, "inputCacheRead": 0, "inputCacheCreation": 0 } }, "total": { "inputOther": 9, "output": 23, "inputCacheRead": 0, "inputCacheCreation": 0 }, "currentTurn": { "inputOther": 9, "output": 23, "inputCacheRead": 0, "inputCacheCreation": 0 } } }
|
|
[wire] context.append_loop_event { "event": { "type": "step.begin", "uuid": "<uuid-3>", "turnId": "0", "step": 2 }, "time": "<time>" }
|
|
[emit] turn.step.started { "turnId": 0, "step": 2, "stepId": "<uuid-3>" }
|
|
[emit] assistant.delta { "turnId": 0, "delta": "Still using the original turn config." }
|
|
[wire] context.append_loop_event { "event": { "type": "content.part", "uuid": "<uuid-4>", "turnId": "0", "step": 2, "stepUuid": "<uuid-3>", "part": { "type": "text", "text": "Still using the original turn config." } }, "time": "<time>" }
|
|
[wire] context.append_loop_event { "event": { "type": "step.end", "uuid": "<uuid-3>", "turnId": "0", "step": 2, "usage": { "inputOther": 37, "output": 13, "inputCacheRead": 0, "inputCacheCreation": 0 }, "finishReason": "end_turn", "messageId": "mock-2" }, "time": "<time>" }
|
|
[emit] turn.step.completed { "turnId": 0, "step": 2, "stepId": "<uuid-3>", "usage": { "inputOther": 37, "output": 13, "inputCacheRead": 0, "inputCacheCreation": 0 }, "finishReason": "end_turn" }
|
|
[wire] usage.record { "model": "mock-model", "usage": { "inputOther": 37, "output": 13, "inputCacheRead": 0, "inputCacheCreation": 0 }, "usageScope": "turn", "time": "<time>" }
|
|
[emit] agent.status.updated { "model": "changed-model", "contextTokens": 50, "maxContextTokens": 1000000, "contextUsage": 0.00005, "planMode": false, "swarmMode": false, "permission": "manual", "usage": { "byModel": { "mock-model": { "inputOther": 46, "output": 36, "inputCacheRead": 0, "inputCacheCreation": 0 } }, "total": { "inputOther": 46, "output": 36, "inputCacheRead": 0, "inputCacheCreation": 0 }, "currentTurn": { "inputOther": 46, "output": 36, "inputCacheRead": 0, "inputCacheCreation": 0 } } }
|
|
[emit] turn.ended { "turnId": 0, "reason": "completed" }
|
|
`);
|
|
// Model and system prompt keep the turn-start snapshot for the rest of the
|
|
// turn. The tool table is deliberately different: it is re-read per step
|
|
// (so select_tools loads and goal-state visibility apply mid-turn), which
|
|
// makes the mid-turn setActiveTools([]) visible from step 2 on.
|
|
expect(ctx.lastLlmInput()).toMatchInlineSnapshot(`
|
|
tools: []
|
|
messages:
|
|
<last>
|
|
assistant: text "I will run Bash." calls call_bash:Bash { "command": "printf original-result", "timeout": 60 }
|
|
tool[call_bash]: text "original-result"
|
|
`);
|
|
|
|
ctx.mockNextResponse({ type: 'text', text: 'Now the changed config is active.' });
|
|
await ctx.rpc.prompt({ input: [{ type: 'text', text: 'Start a fresh turn' }] });
|
|
|
|
expect(await ctx.untilTurnEnd()).toMatchInlineSnapshot(`
|
|
[wire] turn.prompt { "input": [ { "type": "text", "text": "Start a fresh turn" } ], "origin": { "kind": "user" }, "time": "<time>" }
|
|
[emit] turn.started { "turnId": 1, "origin": { "kind": "user" } }
|
|
[wire] context.append_message { "message": { "role": "user", "content": [ { "type": "text", "text": "Start a fresh turn" } ], "toolCalls": [], "origin": { "kind": "user" } }, "time": "<time>" }
|
|
[wire] context.append_loop_event { "event": { "type": "step.begin", "uuid": "<uuid-5>", "turnId": "1", "step": 1 }, "time": "<time>" }
|
|
[emit] turn.step.started { "turnId": 1, "step": 1, "stepId": "<uuid-5>" }
|
|
[emit] assistant.delta { "turnId": 1, "delta": "Now the changed config is active." }
|
|
[wire] context.append_loop_event { "event": { "type": "content.part", "uuid": "<uuid-6>", "turnId": "1", "step": 1, "stepUuid": "<uuid-5>", "part": { "type": "text", "text": "Now the changed config is active." } }, "time": "<time>" }
|
|
[wire] context.append_loop_event { "event": { "type": "step.end", "uuid": "<uuid-5>", "turnId": "1", "step": 1, "usage": { "inputOther": 56, "output": 12, "inputCacheRead": 0, "inputCacheCreation": 0 }, "finishReason": "end_turn", "messageId": "mock-3" }, "time": "<time>" }
|
|
[emit] turn.step.completed { "turnId": 1, "step": 1, "stepId": "<uuid-5>", "usage": { "inputOther": 56, "output": 12, "inputCacheRead": 0, "inputCacheCreation": 0 }, "finishReason": "end_turn" }
|
|
[wire] usage.record { "model": "changed-model", "usage": { "inputOther": 56, "output": 12, "inputCacheRead": 0, "inputCacheCreation": 0 }, "usageScope": "turn", "time": "<time>" }
|
|
[emit] agent.status.updated { "model": "changed-model", "contextTokens": 68, "maxContextTokens": 1000000, "contextUsage": 0.000068, "planMode": false, "swarmMode": false, "permission": "manual", "usage": { "byModel": { "mock-model": { "inputOther": 46, "output": 36, "inputCacheRead": 0, "inputCacheCreation": 0 }, "changed-model": { "inputOther": 56, "output": 12, "inputCacheRead": 0, "inputCacheCreation": 0 } }, "total": { "inputOther": 102, "output": 48, "inputCacheRead": 0, "inputCacheCreation": 0 }, "currentTurn": { "inputOther": 56, "output": 12, "inputCacheRead": 0, "inputCacheCreation": 0 } } }
|
|
[emit] turn.ended { "turnId": 1, "reason": "completed" }
|
|
`);
|
|
expect(ctx.lastLlmInput()).toMatchInlineSnapshot(`
|
|
system: "Changed system prompt."
|
|
messages:
|
|
<last>
|
|
assistant: text "Still using the original turn config."
|
|
user: text "Start a fresh turn"
|
|
`);
|
|
await ctx.expectResumeMatches();
|
|
});
|
|
});
|
|
|
|
function toolNames(value: unknown): string[] {
|
|
if (!Array.isArray(value)) return [];
|
|
return value
|
|
.map((item) => {
|
|
if (item === null || typeof item !== 'object') return null;
|
|
const record = item as Record<string, unknown>;
|
|
return typeof record['name'] === 'string' ? record['name'] : null;
|
|
})
|
|
.filter((name): name is string => name !== null);
|
|
}
|