kimi-code/packages/agent-core/test/utils/tokens.test.ts
STAR-QUAKE f0896a53b0
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 (#1369)
* 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>
2026-07-06 15:51:08 +08:00

101 lines
3.8 KiB
TypeScript

import type { ContentPart } from '@moonshot-ai/kosong';
import { describe, expect, it } from 'vitest';
import {
estimateTokens,
estimateTokensForContentPart,
estimateTokensForMessage,
estimateTokensForTools,
MEDIA_TOKEN_ESTIMATE,
} from '../../src/utils/tokens';
// Regression coverage for CMP-03: media content parts (image/audio/video) must
// NOT estimate to 0 tokens. When they did, compaction triggers, the
// overflow-shrink budget, the kept-user 20k budget, and the reported
// `tokensAfter` all went blind to the single largest context contributor (a
// base64 image data URL), so a vision-heavy session could overflow the provider
// while the estimator reported a near-empty context.
describe('estimateTokensForContentPart — media parts', () => {
const imagePart: ContentPart = {
type: 'image_url',
imageUrl: { url: 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAAB' },
};
const audioPart: ContentPart = {
type: 'audio_url',
audioUrl: { url: 'data:audio/mp3;base64,AAAA' },
};
const videoPart: ContentPart = {
type: 'video_url',
videoUrl: { url: 'data:video/mp4;base64,AAAA' },
};
it('estimates an image part as a substantial, non-zero token cost', () => {
expect(estimateTokensForContentPart(imagePart)).toBe(MEDIA_TOKEN_ESTIMATE);
expect(MEDIA_TOKEN_ESTIMATE).toBeGreaterThan(100);
});
it('estimates audio and video parts as non-zero', () => {
expect(estimateTokensForContentPart(audioPart)).toBeGreaterThan(0);
expect(estimateTokensForContentPart(videoPart)).toBeGreaterThan(0);
});
it('uses a bounded fixed estimate, not the base64 payload length', () => {
// A ~4 MB base64 data URL must not be counted as text (which would yield
// ~1M "tokens"); the estimate must stay a small bounded value.
const huge = 'A'.repeat(4_000_000);
const bigImage: ContentPart = {
type: 'image_url',
imageUrl: { url: `data:image/png;base64,${huge}` },
};
const estimate = estimateTokensForContentPart(bigImage);
expect(estimate).toBeGreaterThan(0);
expect(estimate).toBeLessThan(50_000);
});
it('includes media when estimating a whole message', () => {
const message = {
role: 'user',
content: [{ type: 'text', text: 'see screenshot' }, imagePart] satisfies ContentPart[],
};
// The image must dominate the ~4-token text, not be free.
expect(estimateTokensForMessage(message)).toBeGreaterThan(100);
});
});
// Dynamic tool schema messages (select_tools progressive disclosure) carry
// full tool definitions in `message.tools`. If the estimator ignores them,
// injected schemas are invisible to every compaction budget and the context
// overflows before compaction triggers.
describe('estimateTokensForMessage — message.tools', () => {
const tool = {
name: 'mcp__grafana__query_range',
description: 'Query a Prometheus-compatible range endpoint.',
parameters: {
type: 'object',
properties: { query: { type: 'string' }, minutes: { type: 'number' } },
required: ['query'],
},
};
it('counts injected tool schemas', () => {
const bare = { role: 'system', content: [] } as const;
const withTools = { role: 'system', content: [], tools: [tool] } as const;
expect(estimateTokensForMessage(withTools)).toBe(
estimateTokensForMessage(bare) + estimateTokensForTools([tool]),
);
});
it('leaves messages without tools byte-identical to the old estimate', () => {
const message = {
role: 'user',
content: [{ type: 'text', text: 'hello world' }] satisfies ContentPart[],
toolCalls: [{ name: 'Read', arguments: { file: 'a.ts' } }],
};
const expected =
estimateTokens('user') +
estimateTokens('hello world') +
estimateTokens('Read') +
estimateTokens(JSON.stringify({ file: 'a.ts' }));
expect(estimateTokensForMessage(message)).toBe(expected);
});
});