kimi-code/packages/klient/examples/kimi-select-tools.ts
liruifengv 40172c7ca9
feat: unify the host identity across OAuth, telemetry, and kap-server (#2382)
* refactor(oauth): make X-Msh-Platform an explicit host identity field

X-Msh-Platform was hardcoded to kimi_code_cli in createKimiDeviceHeaders,
so non-CLI hosts could not state their own platform and the desktop had
to patch the header after the fact. KimiHostIdentity now carries a
required platform (every host declares its own value; the CLI constant
stays the fallback only for direct createKimiDeviceHeaders callers), and
userAgentProduct is renamed to productName so the transport identity
uses one name everywhere.

All in-repo identity constructions pass platform explicitly; the wire
value for CLI and VS Code hosts is unchanged (kimi_code_cli).

* feat(agent-core-v2): carry the host identity in the bootstrap snapshot

Replace the flat clientVersion field with a required clientIdentity
(KimiHostIdentity) so every consumer reads the same host identity
object: OAuthToolkitService now passes it to the OAuth toolkit, which
means the OAuth device-flow endpoints (device authorization, token
polling, refresh) on the kap-server path finally send the full X-Msh-*
device headers instead of none, and the telemetry cloud appender reads
client_version from the same source. A built-in CLI fallback keeps bare
bootstrap() calls in tests working; composition roots must pass their
own identity.

The session export manifest grows an optional desktopVersion field
(payload plumbed through; filled by kap-server in a follow-up).

* feat(agent-core): thread the host identity into the managed auth facades

The v1 managed auth facade constructed its OAuth toolkit without an
identity, so token refreshes from inside the core went out without any
X-Msh-* device headers. createManagedAuthFacade now takes an optional
KimiHostIdentity and every call site supplies one:
CoreProcessService._defaultOAuthTokenResolver forwards the core
process's options.identity (the same source _defaultKimiRequestHeaders
uses), and the DI-held services (oauth / auth summary / model catalog)
read it from a new optional identity field on IEnvironmentService. The
library-level "no identity, no device headers" contract is unchanged.

* feat(kap-server)!: require the host identity and derive request headers from it

ServerStartOptions.hostIdentity is now a required ServerHostIdentity
(KimiHostIdentity + optional prompt display fields), replacing both the
old optional HostIdentityOverrides (renamed to PromptIdentityOverrides,
its productName field now displayName) and the version option (renamed
to serverVersion — it is the engine version reported as server_version,
while the host product version travels in hostIdentity.version).

The server now feeds bootstrap's clientIdentity from hostIdentity and
derives the default outbound headers (User-Agent + X-Msh-*) from it via
createKimiDefaultHeaders, so kap-server-hosted OAuth flows and model /
WebSearch requests carry the real host identity instead of a hardcoded
kimi-code-cli fallback UA. Explicit header seeds still win as an escape
hatch.

Session export manifests record the host product version: kimiCodeVersion
now carries hostIdentity.version (the engine version no longer appears),
and desktop exports (desktop: true) are additionally stamped with a
desktopVersion field. The instance registry keeps its host_version wire
field for compatibility (kimi-inspect reads it); only the in-memory name
changed to serverVersion.

* feat(cli): wire the CLI host identity into the kimi web server

kimi web now passes createKimiCodeHostIdentity(version) as the server's
hostIdentity, so web-UI OAuth flows and the engine's outbound requests
carry the explicit CLI identity (productName + version + platform). The
explicit hostRequestHeadersSeed is dropped — kap-server derives the same
headers from hostIdentity — and buildKimiDefaultHeaders goes away with
its only consumer.

* test(klient): drop clientVersion from the bootstrap contract parity list

* chore: add changesets for the host identity unification

* feat(cli): tag kimi web requests with a (web) User-Agent suffix

kimi web shares the CLI product token and platform, so its outbound
requests were indistinguishable from direct CLI runs upstream. Its host
identity now carries userAgentSuffix 'web', putting web-UI traffic at
kimi-code-cli/<version> (web) while X-Msh-Platform stays kimi_code_cli.

* fix(klient): keep the env() clientVersion wire field after the bootstrap identity switch

The bootstrap snapshot replaced the flat clientVersion scalar with
clientIdentity, which broke klient's env() fan-out (RPCError: method not
found). The wire surface keeps clientVersion — now sourced from
clientIdentity.version — and bootstrapService gains a clientIdentity
read (registered in envContract with an object schema) for consumers
that want the full identity.

* feat(oauth): send the product User-Agent on OAuth requests

The OAuth endpoints used to receive only the X-Msh-* device headers
(undici's default UA otherwise), which left the OAuth host unable to
distinguish runtime surfaces — notably kimi web, whose platform matches
the CLI and whose only distinguishing mark is the (web) UA suffix. The
toolkit now feeds the full identity headers (User-Agent + X-Msh-*) into
every device authorization, token polling, and refresh request; the
request-header type widens from DeviceHeaders to OAuthRequestHeaders.

* feat(vscode): report kimi_code_vscode as the extension's platform

The VS Code extension inherited the CLI's hardcoded X-Msh-Platform value;
with platform now an explicit identity field it declares its own, so the
managed endpoints and OAuth host can tell extension traffic apart from
CLI runs.

* refactor(agent-core-v2)!: require the client identity at the composition root

The bootstrap fallback identity fabricated a kimi-code-cli/unknown host
for any caller that forgot to pass one — the same silent-misreport
pattern this series set out to remove, and it made "required" a lie.
BootstrapInput.clientIdentity is now required, so a missing identity
fails at compile time instead of being papered over. Test and example
callers pass a shared fixture (klient examples and test engines get one
each); the node-sdk v2 client asserts its host identity with the oauth
helper. Also folds DeviceHeaders from an interface into a type alias so
it stays assignable to the widened OAuthRequestHeaders record.

* feat(oauth)!: require and validate the platform in device headers

Drops the quiet CLI fallback in createKimiDeviceHeaders (the same
silent-misreport pattern removed from the bootstrap identity): platform
is now a required option, validated with the same required-ASCII rule as
the version — empty or all-non-ASCII values throw instead of emitting a
blank X-Msh-Platform, and header-unsafe characters are stripped rather
than sent raw.

* fix(node-sdk): seed the host request headers on the v2 client path

The interactive v2 engine path (experimental flag) bootstrapped without
a hostRequestHeaders seed, so managed vendor calls went out with the
SDK's default User-Agent (OpenAI/JS) and no X-Msh-* at all — v1 passes
the full identity headers on the same requests. The v2 client now seeds
the headers from its asserted host identity, and a test pins the seed.

* chore: simplify the CLI changeset wording
2026-07-30 13:45:41 +08:00

739 lines
28 KiB
TypeScript

/**
* Probe the `select_tools` (progressive tool disclosure) capability of the
* kimi-type providers, based on the real providers/auth in
* `~/.kimi-code/config.toml`.
*
* Feature recap (from the Tool Select guide): with the `tool-select`
* experimental flag + `tool_use` + `dynamically_loaded_tools` capabilities,
* MCP tool schemas no longer ride the top-level `tools[]`; the model calls
* the builtin `select_tools` with exact names (announced in
* `<tools_added>`/`<tools_removed>` system reminders), the engine injects
* the loaded schemas as a `role: 'system'` message whose `tools` field
* carries full definitions, and the model then calls the loaded tool.
*
* The wire fact that makes this kimi-specific: only the kimi vendor trait
* serializes `message.tools` into the outbound request
* (`kimiOpenAITrait.convertMessage`); every other base SKIPS
* tool-declaration-only messages entirely. So the whole mechanism can only
* work on `(kimi, openai)` — this example proves that on a stub, then
* behaviorally tests each real kimi model with a simulated "kimi computer
* use" tool pool.
*
* Part A — deterministic, local stub (OpenAI Chat Completions wire):
* 1. kimi providerType: the tool-declaration message reaches the wire with
* an embedded `tools` array.
* 2. no providerType (plain openai): the same message is dropped — the
* boundary non-kimi providers cannot cross.
* 3. `deferred: true` top-level tools are filtered out of the wire
* `tools[]` by the contract's generate() (their schema travels via the
* declaration message instead).
*
* Part B — live, per kimi model from the real config: a two-step flow that
* mirrors exactly what the agent loop produces.
* step 1: announcement + `select_tools` available, user asks for a
* screenshot — does the model call `select_tools`, with names from
* the announced list?
* step 2: history += the select_tools call, its "Loaded: ..." result, and
* the schema-injection system message — does the model now call
* the loaded computer-use tool?
*
* Run:
* pnpm -C packages/klient exec tsx --tsconfig ./tsconfig.examples.json \
* --import ../../build/register-raw-text-loader.mjs examples/kimi-select-tools.ts
*
* Env:
* KIMI_CODE_HOME — default `~/.kimi-code`
* KIMI_SELECT_TOOLS_MODELS — comma-separated model ids for the live parts (default: all kimi-type)
* KIMI_SELECT_TOOLS_SKIP_LIVE — set to `1` to skip part B (no real API calls)
* KIMI_SELECT_TOOLS_TAP — set to `1` to run part C instead of B: route
* the flow through a logging proxy and dump
* the actual wire Context of each request.
*/
import { createServer, type IncomingMessage, type ServerResponse } from 'node:http';
import { homedir } from 'node:os';
import { join } from 'node:path';
import { EXAMPLE_CLIENT_IDENTITY } from './identity.js';
import type { AddressInfo } from 'node:net';
import { bootstrap, logSeed, resolveLoggingConfig } from '@moonshot-ai/agent-core-v2';
import { IConfigService } from '@moonshot-ai/agent-core-v2/app/config/config';
import { renderLoadableToolsAnnouncement } from '@moonshot-ai/agent-core-v2/agent/toolSelect/dynamicTools';
import { UNKNOWN_CAPABILITY } from '@moonshot-ai/agent-core-v2/kosong/contract/capability';
import type { Message } from '@moonshot-ai/agent-core-v2/kosong/contract/message';
import type { Tool } from '@moonshot-ai/agent-core-v2/kosong/contract/tool';
import type { AuthProvider, Model } from '@moonshot-ai/agent-core-v2/kosong/model/catalog';
import { IModelCatalog } from '@moonshot-ai/agent-core-v2/kosong/model/catalog';
import type {
ModelRequestInput,
ModelRequester,
} from '@moonshot-ai/agent-core-v2/kosong/model/modelRequester';
import { ModelRequesterImpl } from '@moonshot-ai/agent-core-v2/kosong/model/modelRequesterImpl';
import { IProtocolAdapterRegistry } from '@moonshot-ai/agent-core-v2/kosong/protocol/protocol';
import { ProtocolAdapterRegistry } from '@moonshot-ai/agent-core-v2/kosong/provider/protocolAdapterRegistry';
function assert(cond: boolean, message: string): asserts cond {
if (!cond) throw new Error(`assertion failed: ${message}`);
}
// ---------------------------------------------------------------------------
// The simulated "kimi computer use" MCP tool pool.
// ---------------------------------------------------------------------------
const COMPUTER_USE_TOOLS: readonly Tool[] = [
{
name: 'computer_screenshot',
description: 'Take a screenshot of the current screen and return it as an image.',
parameters: { type: 'object', properties: {}, additionalProperties: false },
},
{
name: 'computer_click',
description: 'Click at a screen coordinate.',
parameters: {
type: 'object',
properties: {
x: { type: 'number', description: 'Screen x coordinate.' },
y: { type: 'number', description: 'Screen y coordinate.' },
button: { type: 'string', enum: ['left', 'right', 'middle'] },
},
required: ['x', 'y'],
additionalProperties: false,
},
},
{
name: 'computer_move',
description: 'Move the mouse to a screen coordinate.',
parameters: {
type: 'object',
properties: {
x: { type: 'number' },
y: { type: 'number' },
},
required: ['x', 'y'],
additionalProperties: false,
},
},
{
name: 'computer_type',
description: 'Type text at the current focus.',
parameters: {
type: 'object',
properties: { text: { type: 'string' } },
required: ['text'],
additionalProperties: false,
},
},
{
name: 'computer_scroll',
description: 'Scroll at a screen coordinate.',
parameters: {
type: 'object',
properties: {
x: { type: 'number' },
y: { type: 'number' },
delta_y: { type: 'number' },
},
required: ['x', 'y', 'delta_y'],
additionalProperties: false,
},
},
];
const COMPUTER_USE_NAMES = COMPUTER_USE_TOOLS.map((t) => t.name);
/** The builtin select_tools schema, mirroring `SelectToolsInputSchema`. */
const SELECT_TOOLS: Tool = {
name: 'select_tools',
description:
'Load one or more tools by name so you can call them. ' +
'All available tool names are listed in the <tools_added>/<tools_removed> announcements ' +
'in the system context — fold them in order to get the current list. ' +
'Pass the exact name(s) you need; their full definitions become available immediately, ' +
'so you can call them directly in your next tool call.',
parameters: {
type: 'object',
properties: {
names: {
type: 'array',
items: { type: 'string' },
minItems: 1,
description: 'Exact tool names to load, taken from the latest announced tool list.',
},
},
required: ['names'],
additionalProperties: false,
},
};
const SYSTEM_PROMPT =
'You are a computer-use agent operating the user\u2019s computer through tool calls. ' +
'Only a core tool set is available up front; additional tools are announced by name in ' +
'<tools_added> blocks in the system context. To use an announced tool, first call ' +
'select_tools with its exact name to load its definition, then call the tool itself.';
const userMessage = (text: string): Message => ({
role: 'user',
content: [{ type: 'text', text }],
toolCalls: [],
});
/** The schema-injection message the engine appends after a successful load. */
const toolDeclarationMessage = (tools: readonly Tool[]): Message => ({
role: 'system',
content: [],
toolCalls: [],
tools,
});
const announcementMessage = (names: readonly string[]): Message => ({
role: 'system',
content: [
{ type: 'text', text: renderLoadableToolsAnnouncement(names, []) },
],
toolCalls: [],
});
// ---------------------------------------------------------------------------
// Shared request draining.
// ---------------------------------------------------------------------------
interface Collected {
readonly text: string;
readonly toolCalls: readonly { id: string; name: string; arguments: string | null }[];
readonly finishReason?: string | undefined;
}
async function collect(
requester: ModelRequester,
input: ModelRequestInput,
signal?: AbortSignal,
): Promise<Collected> {
let text = '';
let toolCalls: Collected['toolCalls'] = [];
let finishReason: string | undefined;
for await (const event of requester.request(input, signal)) {
if (event.type === 'part' && event.part.type === 'text') text += event.part.text;
if (event.type === 'finish') {
finishReason = event.providerFinishReason ?? event.rawFinishReason;
toolCalls = event.message.toolCalls.map((t) => ({
id: t.id,
name: t.name,
arguments: t.arguments,
}));
}
}
return { text, toolCalls, finishReason };
}
// ---------------------------------------------------------------------------
// Part A — deterministic wire-encoding probes against a local stub.
// ---------------------------------------------------------------------------
const sseChunk = (delta: object, finishReason: string | null): string =>
JSON.stringify({
id: 'chatcmpl-probe',
object: 'chat.completion.chunk',
created: 0,
model: 'probe-model',
choices: [{ index: 0, delta, finish_reason: finishReason }],
});
function writePong(res: ServerResponse): void {
res.writeHead(200, { 'content-type': 'text/event-stream' });
res.write(`data: ${sseChunk({ role: 'assistant' }, null)}\n\n`);
res.write(`data: ${sseChunk({ content: 'pong' }, null)}\n\n`);
res.write(`data: ${sseChunk({}, 'stop')}\n\n`);
res.end('data: [DONE]\n\n');
}
interface WireMessage {
readonly role?: string;
readonly tools?: readonly { function?: { name?: string } }[];
}
interface WireBody {
readonly messages?: readonly WireMessage[];
readonly tools?: readonly { function?: { name?: string } }[];
}
async function probeWireEncoding(): Promise<void> {
console.log('\n=== part A: wire-encoding boundary (local stub) ===');
let handler: (req: IncomingMessage, res: ServerResponse) => void = (_req, res) =>
writePong(res);
let lastBody: WireBody | undefined;
const server = createServer((req, res) => {
const chunks: Buffer[] = [];
req.on('data', (chunk: Buffer) => chunks.push(chunk));
req.on('end', () => {
try {
lastBody = JSON.parse(Buffer.concat(chunks).toString('utf8')) as WireBody;
} catch {
lastBody = undefined;
}
handler(req, res);
});
});
await new Promise<void>((resolve) => {
server.listen(0, '127.0.0.1', () => resolve());
});
const port = (server.address() as AddressInfo).port;
const registry = new ProtocolAdapterRegistry();
const staticKey: AuthProvider = {
canRefresh: false,
getAuth: () => Promise.resolve({ apiKey: 'sk-probe' }),
};
const makeRequester = (providerType?: string): ModelRequester => {
const model: Model = {
id: 'probe',
name: 'probe-model',
aliases: [],
protocol: 'openai',
baseUrl: `http://127.0.0.1:${String(port)}`,
headers: {},
capabilities: { ...UNKNOWN_CAPABILITY, max_context_tokens: 8192 },
maxContextSize: 8192,
alwaysThinking: false,
providerType,
providerName: providerType ?? 'probe',
authProvider: staticKey,
};
return new ModelRequesterImpl(model, registry);
};
const input: ModelRequestInput = {
systemPrompt: SYSTEM_PROMPT,
tools: [SELECT_TOOLS, { ...COMPUTER_USE_TOOLS[0]!, deferred: true }],
messages: [
announcementMessage(COMPUTER_USE_NAMES),
userMessage('take a screenshot'),
toolDeclarationMessage([COMPUTER_USE_TOOLS[0]!]),
],
};
try {
// 1) kimi providerType: the declaration message reaches the wire, with the
// loaded schema embedded in the message — and the deferred top-level tool
// does NOT duplicate into the wire tools[].
await collect(makeRequester('kimi'), input);
const kimiBody = lastBody;
const declaration = kimiBody?.messages?.find((m) => Array.isArray(m.tools));
assert(declaration !== undefined, 'kimi: a wire message carries the loaded tool schemas');
assert(
declaration.tools?.some((t) => t.function?.name === 'computer_screenshot') === true,
'kimi: declaration embeds computer_screenshot',
);
assert(
kimiBody?.tools?.some((t) => t.function?.name === 'select_tools') === true,
'kimi: top-level tools[] keeps select_tools',
);
assert(
kimiBody?.tools?.some((t) => t.function?.name === 'computer_screenshot') !== true,
'kimi: deferred tool stays OUT of top-level tools[]',
);
console.log(
`[ok] kimi: declaration on wire (role=${String(declaration.role)}), ` +
`top-level tools=[${(kimiBody?.tools ?? []).map((t) => t.function?.name ?? '?').join(', ')}]`,
);
// 2) plain openai: the same declaration message is dropped outright — the
// dynamic-tool schema can never reach a non-kimi wire. (3 wire messages =
// system prompt + announcement + user; the declaration is gone.)
await collect(makeRequester(), input);
const plainBody = lastBody;
assert(
plainBody?.messages?.every((m) => !Array.isArray(m.tools)) === true,
'openai: declaration message dropped (no wire message carries tools)',
);
assert(
plainBody?.messages?.length === 3,
`openai: declaration removed from history (got ${String(plainBody?.messages?.length)} messages)`,
);
console.log(
'[ok] openai: declaration message dropped — dynamic tools cannot cross a non-kimi wire',
);
} finally {
server.closeAllConnections();
await new Promise<void>((resolve) => {
server.close(() => resolve());
});
}
}
// ---------------------------------------------------------------------------
// Part B — live two-step select_tools flow per real kimi model.
// ---------------------------------------------------------------------------
type Step1Outcome =
| { readonly kind: 'selected'; readonly names: readonly string[]; readonly callId: string; readonly argumentsJson: string }
| { readonly kind: 'no-call'; readonly text: string }
| { readonly kind: 'error'; readonly message: string };
type Step2Outcome =
| { readonly kind: 'called'; readonly name: string; readonly argumentsJson: string | null }
| { readonly kind: 'other'; readonly description: string }
| { readonly kind: 'error'; readonly message: string };
async function withTimeout<T>(promise: Promise<T>, ms: number, label: string): Promise<T> {
let timer: ReturnType<typeof setTimeout> | undefined;
try {
return await Promise.race([
promise,
new Promise<never>((_resolve, reject) => {
timer = setTimeout(() => reject(new Error(`${label} timed out after ${String(ms)}ms`)), ms);
}),
]);
} finally {
if (timer !== undefined) clearTimeout(timer);
}
}
interface Scenario {
readonly prompt: string;
/** The tool a correct model is expected to select and call. */
readonly expectTool: string;
}
const SCENARIOS: readonly Scenario[] = [
{ prompt: 'Take a screenshot of my screen right now.', expectTool: 'computer_screenshot' },
{
prompt: 'Click at the exact center of my screen. The screen resolution is 1920x1080.',
expectTool: 'computer_click',
},
];
async function step1Select(requester: ModelRequester, scenario: Scenario): Promise<Step1Outcome> {
try {
const result = await withTimeout(
collect(requester, {
systemPrompt: SYSTEM_PROMPT,
tools: [SELECT_TOOLS],
messages: [
announcementMessage(COMPUTER_USE_NAMES),
userMessage(scenario.prompt),
],
}),
60_000,
'step1',
);
const call = result.toolCalls.find((t) => t.name === 'select_tools');
if (call === undefined) {
const other = result.toolCalls.map((t) => t.name).join(', ');
return {
kind: 'no-call',
text:
result.toolCalls.length > 0
? `called [${other}] instead`
: `answered text: ${result.text.slice(0, 80)}`,
};
}
let names: string[] = [];
try {
const parsed = JSON.parse(call.arguments ?? '{}') as { names?: unknown };
if (Array.isArray(parsed.names)) names = parsed.names.filter((n): n is string => typeof n === 'string');
} catch {
// keep names empty — reported below
}
return { kind: 'selected', names, callId: call.id, argumentsJson: call.arguments ?? '' };
} catch (error) {
return { kind: 'error', message: error instanceof Error ? error.message.split('\n')[0] ?? '' : String(error) };
}
}
async function step2UseLoadedTool(
requester: ModelRequester,
step1: Extract<Step1Outcome, { kind: 'selected' }>,
scenario: Scenario,
): Promise<Step2Outcome> {
const validNames = step1.names.filter((n) => COMPUTER_USE_NAMES.includes(n));
const loadName = validNames.includes(scenario.expectTool)
? scenario.expectTool
: validNames[0];
if (loadName === undefined) {
return { kind: 'other', description: `selected names not in announced list: [${step1.names.join(', ')}]` };
}
const loadedTool = COMPUTER_USE_TOOLS.find((t) => t.name === loadName)!;
const messages = (withDeclaration: boolean): ModelRequestInput => ({
systemPrompt: SYSTEM_PROMPT,
tools: [SELECT_TOOLS, { ...loadedTool, deferred: true }],
messages: [
announcementMessage(COMPUTER_USE_NAMES),
userMessage(scenario.prompt),
{
role: 'assistant',
content: [],
toolCalls: [
{
type: 'function',
id: step1.callId,
name: 'select_tools',
arguments: JSON.stringify({ names: [loadName] }),
},
],
},
{
role: 'tool',
toolCallId: step1.callId,
content: [{ type: 'text', text: `Loaded: ${loadName}` }],
toolCalls: [],
},
// The schema-injection message — the exact wire shape whose acceptance
// this probe measures. Dropped entirely in the isolation retry below.
...(withDeclaration ? [toolDeclarationMessage([loadedTool])] : []),
],
});
try {
const result = await withTimeout(collect(requester, messages(true)), 60_000, 'step2');
const call = result.toolCalls.find((t) => t.name === loadName);
if (call !== undefined) {
return { kind: 'called', name: call.name, argumentsJson: call.arguments };
}
const other = result.toolCalls.map((t) => t.name).join(', ');
return {
kind: 'other',
description:
result.toolCalls.length > 0
? `called [${other}] instead of ${loadName}`
: `answered text: ${result.text.slice(0, 80)}`,
};
} catch (error) {
const message = error instanceof Error ? error.message.split('\n')[0] ?? '' : String(error);
// Differential: retry the SAME request minus the schema-injection message.
// If it then succeeds, the backend rejects the dynamic-schema wire shape
// specifically; if it fails too, the problem is elsewhere in the flow.
try {
await withTimeout(collect(requester, messages(false)), 60_000, 'step2-isolation');
return {
kind: 'error',
message: `${message} (isolation: same request WITHOUT the tools-in-message injection succeeds — backend rejects the dynamic-schema wire shape)`,
};
} catch {
return { kind: 'error', message: `${message} (isolation: also fails without the injection — not caused by the declaration message)` };
}
}
}
async function probeLiveKimiProviders(): Promise<void> {
const homeDir = process.env['KIMI_CODE_HOME'] ?? join(homedir(), '.kimi-code');
console.log(`\n=== part B: live select_tools flow on real kimi providers (${homeDir}) ===`);
const { app } = bootstrap({ homeDir, clientIdentity: EXAMPLE_CLIENT_IDENTITY }, [
...logSeed(resolveLoggingConfig({ homeDir, env: process.env })),
]);
try {
await app.accessor.get(IConfigService).ready;
const catalog = app.accessor.get(IModelCatalog);
const filter = process.env['KIMI_SELECT_TOOLS_MODELS']?.split(',').map((s) => s.trim());
const models = await catalog.listModels();
const targets = models.filter((m) => {
if (filter !== undefined && !filter.includes(m.model)) return false;
try {
return catalog.get(m.model).providerType === 'kimi';
} catch {
return false;
}
});
assert(targets.length > 0, 'at least one kimi-type model configured');
const summary: string[] = [];
for (const m of targets) {
const model = catalog.get(m.model);
const declared = model.capabilities.dynamically_loaded_tools === true;
const requester = catalog.getRequester(m.model);
for (const scenario of SCENARIOS) {
const startedAt = Date.now();
const step1 = await step1Select(requester, scenario);
let row: string;
if (step1.kind === 'selected') {
const step2 = await step2UseLoadedTool(requester, step1, scenario);
const elapsed = `${String(Date.now() - startedAt)}ms`;
if (step2.kind === 'called') {
row = `PASS select=[${step1.names.join(', ')}] then called ${step2.name}(${step2.argumentsJson ?? ''})`;
} else if (step2.kind === 'other') {
row = `PARTIAL select=[${step1.names.join(', ')}] but step2: ${step2.description}`;
} else {
row = `PARTIAL select=[${step1.names.join(', ')}] but step2 error: ${step2.message}`;
}
console.log(`[${m.model}] ${scenario.expectTool} ${elapsed} declared=${String(declared)} ${row}`);
} else if (step1.kind === 'no-call') {
row = `FAIL no select_tools call: ${step1.text}`;
console.log(`[${m.model}] ${scenario.expectTool} ${String(Date.now() - startedAt)}ms declared=${String(declared)} ${row}`);
} else {
row = `ERROR ${step1.message}`;
console.log(`[${m.model}] ${scenario.expectTool} ${String(Date.now() - startedAt)}ms declared=${String(declared)} ${row}`);
}
summary.push(`${m.model.padEnd(40)} ${scenario.expectTool.padEnd(22)} ${row}`);
}
}
console.log('\n=== live summary (declared = config declares dynamically_loaded_tools) ===');
for (const line of summary) console.log(line);
} finally {
app.dispose();
}
}
// ---------------------------------------------------------------------------
// Part C — TAP mode: sit a logging proxy between the requester and the real
// endpoint, and dump the actual wire Context (structure digest only — auth
// headers are forwarded untouched, never printed).
// ---------------------------------------------------------------------------
interface TapWireTool {
readonly type?: string;
readonly function?: { readonly name?: string };
}
interface TapWireMessage {
readonly role?: string;
readonly content?: unknown;
readonly tools?: readonly TapWireTool[];
readonly tool_calls?: readonly { readonly function?: { readonly name?: string } }[];
readonly tool_call_id?: string;
}
function describeWireBody(raw: Buffer): string[] {
const lines: string[] = [];
let body: { tools?: TapWireTool[]; messages?: TapWireMessage[]; model?: string };
try {
body = JSON.parse(raw.toString('utf8')) as typeof body;
} catch {
return [' (unparseable body)'];
}
const toolName = (t: TapWireTool): string => t.function?.name ?? t.type ?? '?';
lines.push(` model=${String(body.model)}`);
lines.push(` top-level tools[] = [${(body.tools ?? []).map(toolName).join(', ')}]`);
for (const [i, m] of (body.messages ?? []).entries()) {
const parts: string[] = [`#${String(i)} role=${String(m.role)}`];
if (Array.isArray(m.tools)) {
parts.push(`>>> tools=[${m.tools.map(toolName).join(', ')}] (dynamic schema injected HERE)`);
}
if (Array.isArray(m.tool_calls)) {
parts.push(`tool_calls=[${m.tool_calls.map((tc) => tc.function?.name ?? '?').join(', ')}]`);
}
if (m.tool_call_id !== undefined) parts.push(`tool_call_id=${m.tool_call_id}`);
const content =
typeof m.content === 'string' ? m.content : m.content === undefined ? '' : JSON.stringify(m.content);
if (content.length > 0) parts.push(`content=${content.slice(0, 60).replaceAll('\n', ' ')}`);
lines.push(` ${parts.join(' ')}`);
}
return lines;
}
async function probeTappedContext(): Promise<void> {
const homeDir = process.env['KIMI_CODE_HOME'] ?? join(homedir(), '.kimi-code');
console.log(`\n=== part C: tapped wire context (${homeDir}) ===`);
const { app } = bootstrap({ homeDir, clientIdentity: EXAMPLE_CLIENT_IDENTITY }, [
...logSeed(resolveLoggingConfig({ homeDir, env: process.env })),
]);
try {
await app.accessor.get(IConfigService).ready;
const catalog = app.accessor.get(IModelCatalog);
const registry = app.accessor.get(IProtocolAdapterRegistry);
const filter = process.env['KIMI_SELECT_TOOLS_MODELS']?.split(',').map((s) => s.trim());
const models = await catalog.listModels();
const targets = models.filter((m) => {
if (filter !== undefined && !filter.includes(m.model)) return false;
try {
return catalog.get(m.model).providerType === 'kimi';
} catch {
return false;
}
});
assert(targets.length > 0, 'at least one kimi-type model configured');
for (const m of targets) {
const model = catalog.get(m.model);
if (model.baseUrl === undefined) {
console.log(`[${m.model}] skip tap: model has no resolved baseUrl`);
continue;
}
const upstream = model.baseUrl;
let tapCount = 0;
const server = createServer((req, res) => {
const chunks: Buffer[] = [];
req.on('data', (chunk: Buffer) => chunks.push(chunk));
req.on('end', () => {
void (async () => {
const raw = Buffer.concat(chunks);
tapCount += 1;
console.log(`\n[${m.model}] ── request #${String(tapCount)}${upstream}${req.url ?? ''}`);
for (const line of describeWireBody(raw)) console.log(line);
const headers = { ...(req.headers as Record<string, string>) };
delete headers['host'];
delete headers['content-length'];
delete headers['connection'];
const response = await fetch(`${upstream}${req.url ?? ''}`, {
method: req.method,
headers,
body: raw.length > 0 ? raw : undefined,
});
const passthrough: Record<string, string> = {};
response.headers.forEach((value, key) => {
if (key !== 'content-length' && key !== 'content-encoding' && key !== 'transfer-encoding') {
passthrough[key] = value;
}
});
res.writeHead(response.status, passthrough);
res.end(Buffer.from(await response.arrayBuffer()));
})().catch((error: unknown) => {
res.writeHead(502);
res.end(String(error));
});
});
});
await new Promise<void>((resolve) => {
server.listen(0, '127.0.0.1', () => resolve());
});
const port = (server.address() as AddressInfo).port;
try {
const tapped = new ModelRequesterImpl(
{ ...model, baseUrl: `http://127.0.0.1:${String(port)}` },
registry,
);
// One scenario is enough to show the mechanism: click (has arguments).
const scenario = SCENARIOS[1]!;
const step1 = await step1Select(tapped, scenario);
if (step1.kind === 'selected') {
const step2 = await step2UseLoadedTool(tapped, step1, scenario);
console.log(
`\n[${m.model}] outcome: select=[${step1.names.join(', ')}] -> ${step2.kind === 'called' ? `called ${step2.name}(${step2.argumentsJson ?? ''})` : JSON.stringify(step2)}`,
);
} else {
console.log(`\n[${m.model}] step1 outcome: ${JSON.stringify(step1)}`);
}
} finally {
server.closeAllConnections();
await new Promise<void>((resolve) => {
server.close(() => resolve());
});
}
}
} finally {
app.dispose();
}
}
// ---------------------------------------------------------------------------
async function main(): Promise<void> {
await probeWireEncoding();
if (process.env['KIMI_SELECT_TOOLS_TAP'] === '1') {
await probeTappedContext();
} else if (process.env['KIMI_SELECT_TOOLS_SKIP_LIVE'] !== '1') {
await probeLiveKimiProviders();
}
console.log('\nselect-tools: OK');
}
try {
await main();
} catch (error) {
console.error(error);
process.exit(1);
}