mirror of
https://github.com/MoonshotAI/kimi-code.git
synced 2026-07-09 17:29:12 +00:00
* feat(agent-core): record llm request trace in wire.jsonl Add three observability record types so every request sent to the model can be reconstructed from the wire log at the logical-request level: - llm.tools_snapshot: content-addressed snapshot of the top-level tools table as sent (post deferred-strip), written once per unique table - llm.request: one record per outbound request (retries, strict resends, and compaction rounds included) carrying the effective request params and hash links to the system prompt and tools snapshot - mcp.tools_discovered: the server's verbatim tools/list result plus the agent's gating (allow-list, collisions), deduplicated by content hash Observability records never feed state rebuild; replay only restores the write-dedup cursors. The records/types.ts contract now documents the two record classes explicitly (persisted is not the same as replayed). Recording happens at the single Agent.generate choke point. The LLMRequestLogFields side channel gains kind/projection/maxTokens/ droppedCount, chatWithRetry preserves caller-set fields, and compaction tags its requests. The vis wire view renders the new record kinds. * fix(agent-core): record the provider-clamped completion cap in the request trace The llm.request trace recorded the client-requested budget cap, but chat-completions providers tighten the actual wire value inside withMaxCompletionTokens (remaining-context sizing, transport ceilings, model-default resolution) — with the default budget the clamp is active on nearly every non-empty-context request, so the recorded value did not match what was sent. Providers now expose the effective cap they computed as a readonly maxCompletionTokens field on the clone, and the recorder reads it from the effective provider at the Agent.generate choke point. This replaces the side-channel recomputation, which is removed along with the appliedCompletionBudgetCap helper. * fix(agent-core): park pre-replay MCP discovery records and hash the collision outcome Two wire-hygiene fixes for the mcp.tools_discovered trace: Parking: the real Session ordering connects MCP servers concurrently with agent construction, so ToolManager can observe a connected server before agent.resume() has replayed the wire. Recording at that point bypassed the restored dedup cursor (duplicating a 1-50KB record on every resume) and appended a stray metadata record ahead of replay. AgentRecords now exposes a one-shot opened latch — set when replay completes (after the migration rewrite flushes) or when the first live record is logged — and ToolManager parks discoveries until then, re-running the dedup check at drain time. A frozen range-limited replay never opens; those agents are transient previews. Collision hashing: the dedup hash now covers the collision outcome, not just the raw list and allow-list. Collisions depend on which other servers hold a sanitized qualified name at registration time, so a server can re-register with identical tools but a flipped outcome; that gating change must produce a new record instead of being suppressed. * fix(agent-core): skip the request trace for pre-flight-aborted calls Mirror kosong generate()'s pre-flight abort check at the Agent.generate choke point: a call whose signal is already aborted never reaches the wire (generate throws before dispatching), so it must not leave an llm.request/llm.tools_snapshot trace or a diagnostic log line claiming a request was sent. Recording stays before dispatch for every call that passes the gate, preserving the crash-safety of the trace. * chore(agent-core): remove a leftover adaptive-thinking override hook The adaptiveThinkingOverride option was a temporary local hook explicitly marked for removal before commit. Nothing passes it, so resolution falls back to the alias-level adaptiveThinking value in all cases; drop the option and the dead indirection. * fix(kosong): derive the exposed completion cap from generation kwargs maxCompletionTokens was a field stored only by withMaxCompletionTokens, so caps that reach the wire through other paths were invisible to the request trace: with completion budgeting disabled via env, Anthropic still sends the constructor-resolved max_tokens (required by the Messages API), and constructor-level kwargs like OpenAILegacyOptions maxTokens were likewise unreported. Replace the stored field with a getter derived from each provider's generation kwargs — the single source the request body reads — covering constructor defaults, direct withGenerationKwargs configuration, and budget application in one place. Kimi mirrors its request-time legacy max_tokens alias normalization; openai-legacy reuses the same normalizeGenerationKwargs the request path uses. * feat(agent-core): add thinkingKeep passthrough for Kimi providers and update tests
676 lines
28 KiB
TypeScript
676 lines
28 KiB
TypeScript
/**
|
||
* select_tools progressive disclosure — end-to-end agent tests.
|
||
*
|
||
* Uses the scripted-generate harness: real ToolManager/turn loop/context, fake
|
||
* LLM. The three-condition gate (model capability.select_tools ×
|
||
* capability.tool_use × `tool-select` flag) is driven through the alias
|
||
* capability declarations and an injected FlagResolver.
|
||
*
|
||
* The first block pins the gate-closed regression baseline (S0): with any
|
||
* gate closed, the outbound request keeps the inline shape byte-for-byte.
|
||
*/
|
||
|
||
import { describe, expect, it } from 'vitest';
|
||
|
||
import type { ToolCall } from '@moonshot-ai/kosong';
|
||
|
||
import {
|
||
foldAnnouncedToolNames,
|
||
isLoadableToolsAnnouncement,
|
||
} from '../../src/agent/context/dynamic-tools';
|
||
import { ToolManager } from '../../src/agent/tool';
|
||
import type { Agent, AgentRecord } from '../../src/agent';
|
||
import { InMemoryAgentRecordPersistence } from '../../src/agent/records';
|
||
import { FLAG_DEFINITIONS, FlagResolver } from '../../src/flags';
|
||
import type { MCPClient } from '../../src/mcp/types';
|
||
import { estimateTokensForMessage } from '../../src/utils/tokens';
|
||
import { testAgent, type TestAgentContext } from './harness/agent';
|
||
|
||
const DISCLOSURE_PROVIDER = { type: 'kimi', apiKey: 'test-key', model: 'select-capable-model' } as const;
|
||
const DISCLOSURE_CAPABILITIES = {
|
||
image_in: false,
|
||
video_in: false,
|
||
audio_in: false,
|
||
thinking: false,
|
||
tool_use: true,
|
||
max_context_tokens: 256_000,
|
||
select_tools: true,
|
||
} as const;
|
||
|
||
const INLINE_PROVIDER = { type: 'kimi', apiKey: 'test-key', model: 'inline-model' } as const;
|
||
const INLINE_CAPABILITIES = {
|
||
image_in: false,
|
||
video_in: false,
|
||
audio_in: false,
|
||
thinking: false,
|
||
tool_use: true,
|
||
max_context_tokens: 256_000,
|
||
} as const;
|
||
|
||
const GRAFANA_TOOL = 'mcp__grafana__query_range';
|
||
|
||
function toolSelectFlagOn(): FlagResolver {
|
||
return new FlagResolver({}, FLAG_DEFINITIONS, { 'tool-select': true });
|
||
}
|
||
|
||
/** Empty env so an ambient KIMI_CODE_EXPERIMENTAL_FLAG cannot force flags on. */
|
||
function toolSelectFlagOff(): FlagResolver {
|
||
return new FlagResolver({}, FLAG_DEFINITIONS, {});
|
||
}
|
||
|
||
function grafanaClient(callLog: Array<[string, unknown]> = []): MCPClient {
|
||
return {
|
||
async listTools() {
|
||
return [
|
||
{
|
||
name: 'query_range',
|
||
description: 'Query a metrics range',
|
||
inputSchema: {
|
||
type: 'object',
|
||
properties: { query: { type: 'string' } },
|
||
required: ['query'],
|
||
},
|
||
},
|
||
];
|
||
},
|
||
async callTool(name, args) {
|
||
callLog.push([name, args]);
|
||
return { content: [{ type: 'text', text: 'error_rate=0.02' }], isError: false };
|
||
},
|
||
};
|
||
}
|
||
|
||
async function registerGrafana(
|
||
ctx: TestAgentContext,
|
||
callLog: Array<[string, unknown]> = [],
|
||
): Promise<void> {
|
||
const client = grafanaClient(callLog);
|
||
const defs = await client.listTools();
|
||
ctx.agent.tools.registerMcpServer(
|
||
'grafana',
|
||
client,
|
||
defs.map((d) => ({
|
||
name: d.name,
|
||
description: d.description,
|
||
parameters: d.inputSchema as Record<string, unknown>,
|
||
})),
|
||
);
|
||
}
|
||
|
||
async function disclosureAgent(
|
||
callLog: Array<[string, unknown]> = [],
|
||
): Promise<TestAgentContext> {
|
||
const ctx = testAgent({ experimentalFlags: toolSelectFlagOn() });
|
||
ctx.configure({
|
||
tools: ['Read', 'mcp__*'],
|
||
provider: DISCLOSURE_PROVIDER,
|
||
modelCapabilities: DISCLOSURE_CAPABILITIES,
|
||
});
|
||
await registerGrafana(ctx, callLog);
|
||
return ctx;
|
||
}
|
||
|
||
function selectCall(id: string, names: readonly string[]): ToolCall {
|
||
return {
|
||
type: 'function',
|
||
id,
|
||
name: 'select_tools',
|
||
arguments: JSON.stringify({ names }),
|
||
};
|
||
}
|
||
|
||
function mcpCall(id: string, query: string): ToolCall {
|
||
return {
|
||
type: 'function',
|
||
id,
|
||
name: GRAFANA_TOOL,
|
||
arguments: JSON.stringify({ query }),
|
||
};
|
||
}
|
||
|
||
async function runTurn(ctx: TestAgentContext, prompt: string): Promise<void> {
|
||
await ctx.rpc.prompt({ input: [{ type: 'text', text: prompt }] });
|
||
await ctx.untilTurnEnd();
|
||
}
|
||
|
||
function historyText(ctx: TestAgentContext): string {
|
||
return ctx.agent.context.history
|
||
.flatMap((m) => m.content)
|
||
.map((part) => (part.type === 'text' ? part.text : ''))
|
||
.join('\n');
|
||
}
|
||
|
||
function toolResultTexts(ctx: TestAgentContext): string[] {
|
||
return ctx.agent.context.history
|
||
.filter((m) => m.role === 'tool')
|
||
.map((m) => m.content.map((p) => (p.type === 'text' ? p.text : '')).join(''));
|
||
}
|
||
|
||
function schemaMessages(ctx: TestAgentContext) {
|
||
return ctx.agent.context.history.filter((m) => m.tools !== undefined && m.tools.length > 0);
|
||
}
|
||
|
||
describe('gate closed — inline regression baseline (S0)', () => {
|
||
it('without the flag, MCP tools stay inline and nothing about disclosure appears', async () => {
|
||
const ctx = testAgent({ experimentalFlags: toolSelectFlagOff() });
|
||
ctx.configure({
|
||
tools: ['Read', 'mcp__*'],
|
||
provider: DISCLOSURE_PROVIDER,
|
||
modelCapabilities: DISCLOSURE_CAPABILITIES,
|
||
});
|
||
await registerGrafana(ctx);
|
||
|
||
const loopNames = ctx.agent.tools.loopTools.map((t) => t.name);
|
||
expect(loopNames).toContain(GRAFANA_TOOL);
|
||
expect(loopNames).not.toContain('select_tools');
|
||
expect(ctx.agent.tools.loopTools.every((t) => t.deferred !== true)).toBe(true);
|
||
|
||
ctx.mockNextResponse({ type: 'text', text: 'hello' });
|
||
await runTurn(ctx, 'hi');
|
||
|
||
const call = ctx.llmCalls[0]!;
|
||
expect(call.tools.map((t) => t.name)).toContain(GRAFANA_TOOL);
|
||
expect(call.tools.map((t) => t.name)).not.toContain('select_tools');
|
||
expect(historyText(ctx)).not.toContain('<tools_added>');
|
||
});
|
||
|
||
it('without the model capability, the flag alone changes nothing', async () => {
|
||
const ctx = testAgent({ experimentalFlags: toolSelectFlagOn() });
|
||
ctx.configure({
|
||
tools: ['Read', 'mcp__*'],
|
||
provider: INLINE_PROVIDER,
|
||
modelCapabilities: INLINE_CAPABILITIES,
|
||
});
|
||
await registerGrafana(ctx);
|
||
|
||
const loopNames = ctx.agent.tools.loopTools.map((t) => t.name);
|
||
expect(loopNames).toContain(GRAFANA_TOOL);
|
||
expect(loopNames).not.toContain('select_tools');
|
||
|
||
ctx.mockNextResponse({ type: 'text', text: 'hello' });
|
||
await runTurn(ctx, 'hi');
|
||
expect(ctx.llmCalls[0]!.tools.map((t) => t.name)).toContain(GRAFANA_TOOL);
|
||
expect(historyText(ctx)).not.toContain('<tools_added>');
|
||
});
|
||
});
|
||
|
||
describe('disclosure mode — top-level convergence and announcements', () => {
|
||
it('keeps MCP tools out of the top level, registers select_tools, and announces the manifest', async () => {
|
||
const ctx = await disclosureAgent();
|
||
|
||
// Executable table before any select: core + select_tools, no MCP names.
|
||
const loopNames = ctx.agent.tools.loopTools.map((t) => t.name);
|
||
expect(loopNames).toContain('select_tools');
|
||
expect(loopNames).toContain('Read');
|
||
expect(loopNames.some((n) => n.startsWith('mcp__'))).toBe(false);
|
||
|
||
ctx.mockNextResponse({ type: 'text', text: 'hello' });
|
||
await runTurn(ctx, 'hi');
|
||
|
||
// Wire top-level: no MCP schema, select_tools present.
|
||
const call = ctx.llmCalls[0]!;
|
||
const wireNames = call.tools.map((t) => t.name);
|
||
expect(wireNames).toContain('select_tools');
|
||
expect(wireNames.some((n) => n.startsWith('mcp__'))).toBe(false);
|
||
|
||
// First boundary announces the full loadable list; the request saw it.
|
||
const announcements = ctx.agent.context.history.filter(isLoadableToolsAnnouncement);
|
||
expect(announcements).toHaveLength(1);
|
||
expect(historyText(ctx)).toContain(`<tools_added>\n${GRAFANA_TOOL}\n</tools_added>`);
|
||
expect(JSON.stringify(call.history)).toContain(GRAFANA_TOOL);
|
||
});
|
||
|
||
it('does not re-announce when the loadable set is unchanged', async () => {
|
||
const ctx = await disclosureAgent();
|
||
ctx.mockNextResponse({ type: 'text', text: 'one' });
|
||
await runTurn(ctx, 'first');
|
||
ctx.mockNextResponse({ type: 'text', text: 'two' });
|
||
await runTurn(ctx, 'second');
|
||
|
||
expect(ctx.agent.context.history.filter(isLoadableToolsAnnouncement)).toHaveLength(1);
|
||
});
|
||
|
||
it('announces tools_removed at the next boundary after a server disconnects', async () => {
|
||
const ctx = await disclosureAgent();
|
||
ctx.mockNextResponse({ type: 'text', text: 'one' });
|
||
await runTurn(ctx, 'first');
|
||
|
||
ctx.agent.tools.unregisterMcpServer('grafana');
|
||
ctx.mockNextResponse({ type: 'text', text: 'two' });
|
||
await runTurn(ctx, 'second');
|
||
|
||
expect(historyText(ctx)).toContain(`<tools_removed>\n${GRAFANA_TOOL}\n</tools_removed>`);
|
||
expect(foldAnnouncedToolNames(ctx.agent.context.history).size).toBe(0);
|
||
});
|
||
});
|
||
|
||
describe('disclosure mode — select_tools three branches and dispatch', () => {
|
||
it('loads a schema, makes it dispatchable on the next step of the same turn', async () => {
|
||
const callLog: Array<[string, unknown]> = [];
|
||
const ctx = await disclosureAgent(callLog);
|
||
await ctx.rpc.setPermission({ mode: 'yolo' });
|
||
|
||
ctx.mockNextResponse({ type: 'text', text: 'loading' }, selectCall('call-1', [GRAFANA_TOOL]));
|
||
ctx.mockNextResponse({ type: 'text', text: 'querying' }, mcpCall('call-2', 'errors'));
|
||
ctx.mockNextResponse({ type: 'text', text: 'done' });
|
||
await runTurn(ctx, 'check the error rate');
|
||
|
||
// Three-branch result: loaded.
|
||
expect(toolResultTexts(ctx)).toContainEqual(`Loaded: ${GRAFANA_TOOL}`);
|
||
|
||
// The schema message landed after the closed select exchange, carrying the
|
||
// registry schema.
|
||
const schemas = schemaMessages(ctx);
|
||
expect(schemas).toHaveLength(1);
|
||
expect(schemas[0]!.tools!.map((t) => t.name)).toEqual([GRAFANA_TOOL]);
|
||
expect(schemas[0]!.content).toEqual([]);
|
||
|
||
// Step 2 dispatched the freshly loaded tool through the real MCP client.
|
||
expect(callLog).toEqual([['query_range', { query: 'errors' }]]);
|
||
expect(toolResultTexts(ctx)).toContainEqual('error_rate=0.02');
|
||
|
||
// The step-2 request carried the schema message but kept the top level clean.
|
||
const step2 = ctx.llmCalls[1]!;
|
||
expect(step2.tools.map((t) => t.name).some((n) => n.startsWith('mcp__'))).toBe(false);
|
||
expect(step2.history.some((m) => m.tools !== undefined && m.tools.length > 0)).toBe(true);
|
||
});
|
||
|
||
it('keeps the top-level tools snapshot stable across select_tools loads', async () => {
|
||
const persistence = new InMemoryAgentRecordPersistence();
|
||
const ctx = testAgent({ experimentalFlags: toolSelectFlagOn(), persistence });
|
||
ctx.configure({
|
||
tools: ['Read', 'mcp__*'],
|
||
provider: DISCLOSURE_PROVIDER,
|
||
modelCapabilities: DISCLOSURE_CAPABILITIES,
|
||
});
|
||
await registerGrafana(ctx);
|
||
|
||
ctx.mockNextResponse({ type: 'text', text: 'loading' }, selectCall('call-1', [GRAFANA_TOOL]));
|
||
ctx.mockNextResponse({ type: 'text', text: 'ok' });
|
||
await runTurn(ctx, 'load it');
|
||
ctx.mockNextResponse({ type: 'text', text: 'two' });
|
||
await runTurn(ctx, 'again');
|
||
|
||
// Loaded schemas travel via message-level tools declarations (recorded as
|
||
// context messages); the byte-stable top level stays one snapshot.
|
||
const snapshots = persistence.records.filter(
|
||
(record): record is Extract<AgentRecord, { type: 'llm.tools_snapshot' }> =>
|
||
record.type === 'llm.tools_snapshot',
|
||
);
|
||
expect(snapshots).toHaveLength(1);
|
||
const snapshotNames = snapshots[0]!.tools.map((tool) => tool.name);
|
||
expect(snapshotNames).toContain('select_tools');
|
||
expect(snapshotNames).toContain('Read');
|
||
expect(snapshotNames.some((name) => name.startsWith('mcp__'))).toBe(false);
|
||
|
||
const requests = persistence.records.filter(
|
||
(record): record is Extract<AgentRecord, { type: 'llm.request' }> =>
|
||
record.type === 'llm.request',
|
||
);
|
||
expect(requests).toHaveLength(3);
|
||
for (const request of requests) {
|
||
expect(request.toolsHash).toBe(snapshots[0]!.hash);
|
||
expect(request.toolSelect).toBe(true);
|
||
}
|
||
});
|
||
|
||
it('reports Already available without re-injecting, and Unknown per name', async () => {
|
||
const ctx = await disclosureAgent();
|
||
|
||
ctx.mockNextResponse({ type: 'text', text: 'loading' }, selectCall('call-1', [GRAFANA_TOOL]));
|
||
ctx.mockNextResponse({ type: 'text', text: 'ok' });
|
||
await runTurn(ctx, 'load it');
|
||
|
||
// Mixed input: one already loaded, one unknown — settled per name.
|
||
ctx.mockNextResponse(
|
||
{ type: 'text', text: 'again' },
|
||
selectCall('call-2', [GRAFANA_TOOL, 'mcp__nope__missing']),
|
||
);
|
||
ctx.mockNextResponse({ type: 'text', text: 'ok' });
|
||
await runTurn(ctx, 'load again');
|
||
|
||
const results = toolResultTexts(ctx);
|
||
expect(results).toContainEqual(
|
||
`Already available: ${GRAFANA_TOOL}\n` +
|
||
'Unknown tool: mcp__nope__missing. Pick from the latest announced tools list.',
|
||
);
|
||
// No duplicate schema injection.
|
||
expect(schemaMessages(ctx)).toHaveLength(1);
|
||
});
|
||
|
||
it('errors when every requested name is unknown', async () => {
|
||
const ctx = await disclosureAgent();
|
||
ctx.mockNextResponse(
|
||
{ type: 'text', text: 'try' },
|
||
selectCall('call-1', ['mcp__ghost__tool']),
|
||
);
|
||
ctx.mockNextResponse({ type: 'text', text: 'ok' });
|
||
await runTurn(ctx, 'load ghost');
|
||
|
||
const errorResult = ctx.agent.context.history.find(
|
||
(m) => m.role === 'tool' && m.isError === true,
|
||
);
|
||
expect(errorResult).toBeDefined();
|
||
expect(schemaMessages(ctx)).toHaveLength(0);
|
||
});
|
||
});
|
||
|
||
describe('disclosure mode — preflight wording', () => {
|
||
it('distinguishes not-loaded from loaded-but-disconnected', async () => {
|
||
const ctx = await disclosureAgent();
|
||
await ctx.rpc.setPermission({ mode: 'yolo' });
|
||
|
||
// Call without selecting first → guidance to select.
|
||
ctx.mockNextResponse({ type: 'text', text: 'call' }, mcpCall('call-1', 'errors'));
|
||
ctx.mockNextResponse({ type: 'text', text: 'ok' });
|
||
await runTurn(ctx, 'query directly');
|
||
expect(toolResultTexts(ctx).join('\n')).toContain(
|
||
`Tool "${GRAFANA_TOOL}" is available but not loaded.`,
|
||
);
|
||
|
||
// Load it, then disconnect the server → disconnected wording, not "not found".
|
||
ctx.mockNextResponse({ type: 'text', text: 'load' }, selectCall('call-2', [GRAFANA_TOOL]));
|
||
ctx.mockNextResponse({ type: 'text', text: 'ok' });
|
||
await runTurn(ctx, 'load it');
|
||
ctx.agent.tools.unregisterMcpServer('grafana');
|
||
|
||
ctx.mockNextResponse({ type: 'text', text: 'call again' }, mcpCall('call-3', 'errors'));
|
||
ctx.mockNextResponse({ type: 'text', text: 'ok' });
|
||
await runTurn(ctx, 'query again');
|
||
expect(toolResultTexts(ctx).join('\n')).toContain(
|
||
`Tool "${GRAFANA_TOOL}" was loaded but its MCP server is currently disconnected.`,
|
||
);
|
||
});
|
||
});
|
||
|
||
describe('disclosure mode — undo semantics', () => {
|
||
it('keeps schema messages across undo, drops announcements, and self-heals', async () => {
|
||
const ctx = await disclosureAgent();
|
||
|
||
ctx.mockNextResponse({ type: 'text', text: 'load' }, selectCall('call-1', [GRAFANA_TOOL]));
|
||
ctx.mockNextResponse({ type: 'text', text: 'ok' });
|
||
await runTurn(ctx, 'load it');
|
||
expect(schemaMessages(ctx)).toHaveLength(1);
|
||
expect(ctx.agent.context.history.filter(isLoadableToolsAnnouncement)).toHaveLength(1);
|
||
|
||
await ctx.rpc.undoHistory({ count: 1 });
|
||
|
||
// Schema injection survives (injection origin); the announcement and the
|
||
// select exchange are gone.
|
||
expect(schemaMessages(ctx)).toHaveLength(1);
|
||
expect(ctx.agent.context.history.filter(isLoadableToolsAnnouncement)).toHaveLength(0);
|
||
expect(ctx.agent.tools.loadedDynamicToolNames().has(GRAFANA_TOOL)).toBe(true);
|
||
|
||
// Next turn re-announces (diff against the rolled-back fold) and a
|
||
// re-select reports Already available instead of re-injecting.
|
||
ctx.mockNextResponse({ type: 'text', text: 'again' }, selectCall('call-2', [GRAFANA_TOOL]));
|
||
ctx.mockNextResponse({ type: 'text', text: 'ok' });
|
||
await runTurn(ctx, 'load again');
|
||
expect(ctx.agent.context.history.filter(isLoadableToolsAnnouncement)).toHaveLength(1);
|
||
expect(toolResultTexts(ctx)).toContainEqual(`Already available: ${GRAFANA_TOOL}`);
|
||
expect(schemaMessages(ctx)).toHaveLength(1);
|
||
});
|
||
});
|
||
|
||
describe('disclosure mode — model switch projection', () => {
|
||
it('strips dynamic-tool context for a non-supporting model and restores it on switch-back', async () => {
|
||
const ctx = await disclosureAgent();
|
||
|
||
ctx.mockNextResponse({ type: 'text', text: 'load' }, selectCall('call-1', [GRAFANA_TOOL]));
|
||
ctx.mockNextResponse({ type: 'text', text: 'ok' });
|
||
await runTurn(ctx, 'load it');
|
||
|
||
// Canonical history holds the protocol context.
|
||
expect(schemaMessages(ctx)).toHaveLength(1);
|
||
|
||
// Switch to a model without select_tools: the outgoing view drops the
|
||
// schema message and the announcements; the tool table inlines MCP again.
|
||
ctx.configureRuntimeModel(INLINE_PROVIDER, INLINE_CAPABILITIES);
|
||
expect(ctx.agent.toolSelectEnabled).toBe(false);
|
||
const projected = ctx.agent.context.messages;
|
||
expect(projected.some((m) => m.tools !== undefined)).toBe(false);
|
||
expect(
|
||
projected.some((m) =>
|
||
m.content.some((p) => p.type === 'text' && p.text.includes('<tools_added>')),
|
||
),
|
||
).toBe(false);
|
||
const inlineNames = ctx.agent.tools.loopTools.map((t) => t.name);
|
||
expect(inlineNames).toContain(GRAFANA_TOOL);
|
||
expect(inlineNames).not.toContain('select_tools');
|
||
expect(ctx.agent.tools.loopTools.every((t) => t.deferred !== true)).toBe(true);
|
||
|
||
// Switch back: history was never rewritten, the ledger re-scan picks the
|
||
// loaded tool back up as a deferred extra and projection restores.
|
||
ctx.configureRuntimeModel(DISCLOSURE_PROVIDER, DISCLOSURE_CAPABILITIES);
|
||
expect(ctx.agent.toolSelectEnabled).toBe(true);
|
||
expect(ctx.agent.context.messages.some((m) => m.tools !== undefined)).toBe(true);
|
||
const backNames = ctx.agent.tools.loopTools.map((t) => t.name);
|
||
expect(backNames).toContain('select_tools');
|
||
expect(backNames).toContain(GRAFANA_TOOL);
|
||
const extra = ctx.agent.tools.loopTools.find((t) => t.name === GRAFANA_TOOL);
|
||
expect(extra?.deferred).toBe(true);
|
||
});
|
||
});
|
||
|
||
describe('disclosure mode — executable table freshness', () => {
|
||
it('reflects goal-state tool visibility without waiting for a new turn snapshot', async () => {
|
||
// The loop re-reads loopTools per step (buildTools); the same mechanism
|
||
// that makes a selected tool dispatchable mid-turn also makes goal-gated
|
||
// mutation tools appear as soon as a goal exists.
|
||
const ctx = await disclosureAgent();
|
||
ctx.configure({
|
||
tools: ['Read', 'UpdateGoal', 'SetGoalBudget', 'mcp__*'],
|
||
provider: DISCLOSURE_PROVIDER,
|
||
modelCapabilities: DISCLOSURE_CAPABILITIES,
|
||
});
|
||
expect(ctx.agent.tools.loopTools.map((t) => t.name)).not.toContain('UpdateGoal');
|
||
await ctx.agent.goal.createGoal({ objective: 'ship the feature' });
|
||
expect(ctx.agent.tools.loopTools.map((t) => t.name)).toContain('UpdateGoal');
|
||
});
|
||
|
||
it('rebuilds the ledger from a replayed history with no in-memory state (resume path)', () => {
|
||
// Resume replays records into the context history; the ledger must come
|
||
// back from the history scan alone — there is no persisted ledger state.
|
||
const schemaMessage = {
|
||
role: 'system',
|
||
content: [],
|
||
toolCalls: [],
|
||
tools: [{ name: GRAFANA_TOOL, description: 'replayed', parameters: {} }],
|
||
origin: { kind: 'injection', variant: 'dynamic_tool_schema' },
|
||
} as const;
|
||
const agent = {
|
||
toolSelectEnabled: true,
|
||
context: { history: [schemaMessage] },
|
||
config: { hasProvider: false },
|
||
goal: { getGoal: () => ({ goal: null }) },
|
||
} as unknown as Agent;
|
||
const manager = new ToolManager(agent);
|
||
expect(manager.loadedDynamicToolNames().has(GRAFANA_TOOL)).toBe(true);
|
||
});
|
||
});
|
||
|
||
describe('disclosure mode — compaction', () => {
|
||
it('filters protocol context from the summarizer input and rebuilds schemas after compaction', async () => {
|
||
const ctx = await disclosureAgent();
|
||
|
||
ctx.mockNextResponse({ type: 'text', text: 'load' }, selectCall('call-1', [GRAFANA_TOOL]));
|
||
ctx.mockNextResponse({ type: 'text', text: 'ok' });
|
||
await runTurn(ctx, 'load it');
|
||
|
||
const compacted = new Promise<{ tokensAfter: number }>((resolve) => {
|
||
ctx.emitter.once('context.apply_compaction', (entry: { args: { tokensAfter: number } }) => {
|
||
resolve({ tokensAfter: entry.args.tokensAfter });
|
||
});
|
||
});
|
||
const completed = ctx.once('compaction.completed');
|
||
ctx.mockNextResponse({ type: 'text', text: 'Compacted summary.' });
|
||
await ctx.rpc.beginCompaction({});
|
||
const { tokensAfter } = await compacted;
|
||
await completed;
|
||
|
||
// Summarizer input: no schema messages, no announcements.
|
||
const summarizerCall = ctx.llmCalls.at(-1)!;
|
||
expect(summarizerCall.history.some((m) => m.tools !== undefined)).toBe(false);
|
||
expect(JSON.stringify(summarizerCall.history)).not.toContain('<tools_added>');
|
||
|
||
// Post-compaction context: one rebuild message with the registry schema,
|
||
// plus a fresh full announcement — no re-select needed.
|
||
const rebuilt = schemaMessages(ctx);
|
||
expect(rebuilt).toHaveLength(1);
|
||
expect(rebuilt[0]!.tools!.map((t) => t.name)).toEqual([GRAFANA_TOOL]);
|
||
expect(rebuilt[0]!.origin).toEqual({ kind: 'injection', variant: 'dynamic_tool_schema' });
|
||
expect(ctx.agent.context.history.filter(isLoadableToolsAnnouncement)).toHaveLength(1);
|
||
expect(ctx.agent.tools.loadedDynamicToolNames().has(GRAFANA_TOOL)).toBe(true);
|
||
expect(ctx.agent.tools.loopTools.map((t) => t.name)).toContain(GRAFANA_TOOL);
|
||
|
||
// The "nothing new since compaction" guard must be baselined on the
|
||
// true post-compaction floor: summary + rebuild message + the reinjected
|
||
// announcement. result.tokensAfter predates all of it, and a baseline
|
||
// that misses any re-appended piece would let auto-compaction re-trigger
|
||
// against a floor that cannot shrink (each round strips and re-appends
|
||
// the same reminders).
|
||
const internals = ctx.agent.fullCompaction as unknown as {
|
||
lastCompactedTokenCount: number | null;
|
||
};
|
||
const reAnnouncement = ctx.agent.context.history.filter(isLoadableToolsAnnouncement).at(-1)!;
|
||
expect(internals.lastCompactedTokenCount).toBe(
|
||
tokensAfter + estimateTokensForMessage(rebuilt[0]!) + estimateTokensForMessage(reAnnouncement),
|
||
);
|
||
|
||
// The baseline lives strictly within one turn: runOneTurn re-arms it at
|
||
// every turn boundary, which is what makes cross-turn staleness (undo,
|
||
// model switches, /clear while idle) structurally impossible. If this
|
||
// reset ever moves, the guard's staleness analysis must be redone.
|
||
ctx.mockNextResponse({ type: 'text', text: 'next turn' });
|
||
await runTurn(ctx, 'anything new');
|
||
expect(internals.lastCompactedTokenCount).toBeNull();
|
||
});
|
||
|
||
it('survives a runtime tool-select flag flip without a builtin refresh', async () => {
|
||
// Config reload calls FlagResolver.setConfigOverrides on the live
|
||
// resolver; initializeBuiltinTools does NOT re-run. select_tools must
|
||
// still be fully usable the moment the gate opens (it is registered
|
||
// unconditionally; only its exposure is gated), and flipping back off
|
||
// must restore the inline shape.
|
||
const callLog: Array<[string, unknown]> = [];
|
||
const resolver = toolSelectFlagOff();
|
||
const ctx = testAgent({ experimentalFlags: resolver });
|
||
ctx.configure({
|
||
tools: ['Read', 'mcp__*'],
|
||
provider: DISCLOSURE_PROVIDER,
|
||
modelCapabilities: DISCLOSURE_CAPABILITIES,
|
||
});
|
||
await registerGrafana(ctx, callLog);
|
||
await ctx.rpc.setPermission({ mode: 'yolo' });
|
||
|
||
// Flag off: inline.
|
||
ctx.mockNextResponse({ type: 'text', text: 'inline' });
|
||
await runTurn(ctx, 'first');
|
||
const inlineCall = ctx.llmCalls.at(-1)!;
|
||
expect(inlineCall.tools.map((t) => t.name)).toContain(GRAFANA_TOOL);
|
||
expect(inlineCall.tools.map((t) => t.name)).not.toContain('select_tools');
|
||
|
||
// Flip on at runtime: the full select → dispatch chain must work.
|
||
resolver.setConfigOverrides({ 'tool-select': true });
|
||
ctx.mockNextResponse({ type: 'text', text: 'loading' }, selectCall('call-1', [GRAFANA_TOOL]));
|
||
ctx.mockNextResponse({ type: 'text', text: 'querying' }, mcpCall('call-2', 'errors'));
|
||
ctx.mockNextResponse({ type: 'text', text: 'done' });
|
||
await runTurn(ctx, 'now use the tool');
|
||
const disclosureCall = ctx.llmCalls.at(-3)!;
|
||
expect(disclosureCall.tools.map((t) => t.name)).toContain('select_tools');
|
||
expect(disclosureCall.tools.map((t) => t.name).some((n) => n.startsWith('mcp__'))).toBe(false);
|
||
expect(callLog).toEqual([['query_range', { query: 'errors' }]]);
|
||
|
||
// Flip back off: inline again, select_tools gone from the wire.
|
||
resolver.setConfigOverrides({});
|
||
ctx.mockNextResponse({ type: 'text', text: 'inline again' });
|
||
await runTurn(ctx, 'back');
|
||
const backCall = ctx.llmCalls.at(-1)!;
|
||
expect(backCall.tools.map((t) => t.name)).toContain(GRAFANA_TOOL);
|
||
expect(backCall.tools.map((t) => t.name)).not.toContain('select_tools');
|
||
});
|
||
|
||
it('trims the schema rebuild instead of re-entering the compaction trigger band', async () => {
|
||
// A trigger far below one fat schema: without the rebuild budget guard the
|
||
// post-compaction floor (users + summary + schema) would sit permanently
|
||
// above the trigger, and every later step would re-compact and rebuild in
|
||
// a loop (with the default Infinity per-turn cap, forever).
|
||
const trigger = 2_000;
|
||
const ctx = testAgent({
|
||
experimentalFlags: toolSelectFlagOn(),
|
||
compactionStrategy: {
|
||
shouldCompact: (used: number) => used >= trigger,
|
||
shouldBlock: (used: number) => used >= trigger,
|
||
checkAfterStep: false,
|
||
maxCompactionPerTurn: 3,
|
||
maxOverflowCompactionAttempts: 3,
|
||
},
|
||
});
|
||
ctx.configure({
|
||
tools: ['Read', 'mcp__*'],
|
||
provider: DISCLOSURE_PROVIDER,
|
||
modelCapabilities: DISCLOSURE_CAPABILITIES,
|
||
});
|
||
const fatClient: MCPClient = {
|
||
async listTools() {
|
||
return [
|
||
{
|
||
name: 'query_range',
|
||
// ~3k estimated tokens — alone far past the 2k trigger budget.
|
||
description: 'x'.repeat(12_000),
|
||
inputSchema: { type: 'object', properties: {} },
|
||
},
|
||
];
|
||
},
|
||
async callTool() {
|
||
return { content: [{ type: 'text', text: 'ok' }], isError: false };
|
||
},
|
||
};
|
||
ctx.agent.tools.registerMcpServer(
|
||
'grafana',
|
||
fatClient,
|
||
(await fatClient.listTools()).map((d) => ({
|
||
name: d.name,
|
||
description: d.description,
|
||
parameters: d.inputSchema as Record<string, unknown>,
|
||
})),
|
||
);
|
||
await ctx.rpc.setPermission({ mode: 'yolo' });
|
||
|
||
// Step 1 loads the fat schema; step 2's boundary trips the trigger and
|
||
// blocks on auto-compaction (consuming the summary mock), which trims the
|
||
// rebuild. Step 2 then calls the MCP tool directly — the executable table
|
||
// is resolved AFTER the compaction (same state as the messages), so the
|
||
// now-unloaded tool must be rejected by preflight, not dispatched.
|
||
const fatCallLog: unknown[] = [];
|
||
(fatClient as { callTool: unknown }).callTool = async (...args: unknown[]) => {
|
||
fatCallLog.push(args);
|
||
return { content: [{ type: 'text', text: 'ok' }], isError: false };
|
||
};
|
||
ctx.mockNextResponse({ type: 'text', text: 'loading' }, selectCall('call-1', [GRAFANA_TOOL]));
|
||
ctx.mockNextResponse({ type: 'text', text: 'Compacted summary.' });
|
||
ctx.mockNextResponse({ type: 'text', text: 'querying' }, mcpCall('call-2', 'errors'));
|
||
ctx.mockNextResponse({ type: 'text', text: 'done' });
|
||
await runTurn(ctx, 'load the fat tool');
|
||
|
||
// The rebuild was trimmed away: no schema message survives, the ledger is
|
||
// empty again, and the tool is simply re-selectable on demand.
|
||
expect(schemaMessages(ctx)).toHaveLength(0);
|
||
expect(ctx.agent.tools.loadedDynamicToolNames().has(GRAFANA_TOOL)).toBe(false);
|
||
|
||
// The direct call after the trim was rejected with select guidance and
|
||
// never reached the MCP client.
|
||
expect(fatCallLog).toHaveLength(0);
|
||
expect(toolResultTexts(ctx).join('\n')).toContain(
|
||
`Tool "${GRAFANA_TOOL}" is available but not loaded.`,
|
||
);
|
||
|
||
// Regression: the next turn must not re-compact. (No summary mock is
|
||
// queued — an unexpected compaction would fail the scripted generate.)
|
||
const started: unknown[] = [];
|
||
ctx.emitter.on('compaction.started', (event) => started.push(event));
|
||
ctx.mockNextResponse({ type: 'text', text: 'quiet turn' });
|
||
await runTurn(ctx, 'still fine?');
|
||
expect(started).toHaveLength(0);
|
||
});
|
||
});
|