feat(agent-core): record llm request trace in wire.jsonl (#1448)

* 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
This commit is contained in:
Kai 2026-07-07 14:09:19 +08:00 committed by GitHub
parent 9b18dc46cf
commit 65d30177ad
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
39 changed files with 1307 additions and 77 deletions

View file

@ -0,0 +1,7 @@
---
"@moonshot-ai/agent-core": minor
"@moonshot-ai/kimi-code": minor
"@moonshot-ai/kosong": minor
---
Record a request trace in each agent's `wire.jsonl`: content-addressed snapshots of the tool schemas sent to the model, one record per model request (including retries, strict resends, and compaction rounds) with the effective request parameters, and the raw MCP tool listing per server, so sessions carry enough data to reconstruct every model request for debugging. Chat providers now expose the effective completion-token cap they send on the wire, so the trace records the provider-clamped value rather than the requested budget.

View file

@ -495,7 +495,9 @@ export function projectContext(
case 'swarm_mode.exit':
swarm = { active: false };
break;
// Kinds that don't affect the projected timeline / derived state:
// Kinds that don't affect the projected timeline / derived state,
// including the observability records (request trace — `llm.*`,
// `mcp.tools_discovered`), which are never part of context state:
case 'metadata':
case 'forked':
case 'turn.prompt':
@ -509,6 +511,9 @@ export function projectContext(
case 'tools.unregister_user_tool':
case 'tools.set_active_tools':
case 'tools.update_store':
case 'llm.tools_snapshot':
case 'llm.request':
case 'mcp.tools_discovered':
break;
default: {
const _exhaustive: never = rec;

View file

@ -592,6 +592,153 @@ export const WIRE_RENDERERS: RendererMap = {
label: 'goal×',
headline: () => ({ main: <Dim>goal cleared</Dim> }),
},
// Observability records — the request trace (see agent-core records/types.ts).
'llm.tools_snapshot': {
tone: 'tools',
label: 'req·tools',
headline: (r) => ({
main: (
<span className="flex items-center gap-2 min-w-0">
<Mono>
{r.tools.length} tool{r.tools.length === 1 ? '' : 's'}
</Mono>
<Dim className="truncate">
{r.tools
.slice(0, 4)
.map((tool) => tool.name)
.join(', ')}
{r.tools.length > 4 ? ` +${r.tools.length - 4} more` : ''}
</Dim>
</span>
),
right: <Mono>#{r.hash.slice(0, 8)}</Mono>,
}),
},
'llm.request': {
tone: 'meta',
label: 'llm→',
headline: (r) => {
const parts: string[] = [];
if (r.turnStep !== undefined) parts.push(`step ${r.turnStep}`);
if (r.attempt !== undefined) parts.push(`attempt ${r.attempt}`);
parts.push(`${r.messageCount} msgs`);
if (r.maxTokens !== undefined) parts.push(`max ${r.maxTokens} tok`);
return {
main: (
<span className="flex items-center gap-2 min-w-0">
<Pill tone={r.kind === 'compaction' ? 'compaction' : 'turn'} variant="soft">
{r.kind}
</Pill>
<Mono>{r.model}</Mono>
<Dim className="truncate">{parts.join(' · ')}</Dim>
</span>
),
right:
r.projection !== undefined ? (
<Pill tone="warning" variant="soft">
{r.projection}
</Pill>
) : undefined,
};
},
detail: (r) => (
<div className="grid grid-cols-[140px_1fr] gap-x-3 gap-y-[2px]">
<FieldRow label="provider">
<Mono>{r.provider}</Mono>
</FieldRow>
<FieldRow label="model">
<Mono>{r.model}</Mono>
</FieldRow>
{r.modelAlias !== undefined ? (
<FieldRow label="modelAlias">
<Mono>{r.modelAlias}</Mono>
</FieldRow>
) : null}
{r.thinkingEffort !== undefined ? (
<FieldRow label="thinkingEffort">
<Mono>{r.thinkingEffort}</Mono>
</FieldRow>
) : null}
{r.thinkingKeep !== undefined ? (
<FieldRow label="thinkingKeep">
<Mono>{r.thinkingKeep}</Mono>
</FieldRow>
) : null}
{r.temperature !== undefined ? (
<FieldRow label="temperature">
<span className="text-[var(--color-sev-info)]">{r.temperature}</span>
</FieldRow>
) : null}
{r.topP !== undefined ? (
<FieldRow label="topP">
<span className="text-[var(--color-sev-info)]">{r.topP}</span>
</FieldRow>
) : null}
{r.maxTokens !== undefined ? (
<FieldRow label="maxTokens">
<span className="text-[var(--color-sev-info)]">{r.maxTokens}</span>
</FieldRow>
) : null}
{r.betaApi !== undefined ? (
<FieldRow label="betaApi">
<Mono>{String(r.betaApi)}</Mono>
</FieldRow>
) : null}
<FieldRow label="toolSelect">
<Mono>{String(r.toolSelect)}</Mono>
</FieldRow>
<FieldRow label="toolsHash" wide>
<Mono className="break-all">{r.toolsHash}</Mono>
</FieldRow>
<FieldRow label="systemPromptHash" wide>
<Mono className="break-all">{r.systemPromptHash}</Mono>
</FieldRow>
{r.systemPrompt !== undefined ? (
<FieldRow label="systemPrompt" wide>
<SizePreview
label="systemPrompt"
sizeBytes={r.systemPrompt.length}
preview={r.systemPrompt}
>
<pre className="whitespace-pre-wrap break-words text-fg-1">{r.systemPrompt}</pre>
</SizePreview>
</FieldRow>
) : null}
{r.droppedCount !== undefined ? (
<FieldRow label="droppedCount">
<span className="text-[var(--color-sev-info)]">{r.droppedCount}</span>
</FieldRow>
) : null}
</div>
),
},
'mcp.tools_discovered': {
tone: 'tools',
label: 'mcp·list',
headline: (r) => ({
main: (
<span className="flex items-center gap-2 min-w-0">
<Mono>{r.serverName}</Mono>
<Dim>
{r.tools.length} tool{r.tools.length === 1 ? '' : 's'} · {r.enabledNames.length}{' '}
enabled
</Dim>
</span>
),
right:
r.collisions !== undefined && r.collisions.length > 0 ? (
<Pill tone="warning" variant="soft">
{r.collisions.length} collision{r.collisions.length === 1 ? '' : 's'}
</Pill>
) : (
<Mono>#{r.hash.slice(0, 8)}</Mono>
),
}),
},
};
/** Look up a renderer by a runtime `type` string. Returns `undefined` for kinds

View file

@ -22,7 +22,7 @@ All sessions are saved under `$KIMI_CODE_HOME/sessions/` (default: `~/.kimi-code
```
- `state.json`: session metadata such as title and creation time.
- `agents/*/wire.jsonl`: the agent event stream, used for session recovery and replay.
- `agents/*/wire.jsonl`: the agent event stream, used for session recovery and replay. It also carries a request trace — the tool schemas, request parameters, and MCP tool listings sent to the model — for debugging.
::: warning
Do not manually edit files inside the `sessions/` directory — doing so may prevent sessions from being restored correctly.

View file

@ -22,7 +22,7 @@ Kimi Code CLI 把每次对话持久化为一个「会话」,保留消息历史
```
- `state.json`:会话标题、创建时间等元数据。
- `agents/*/wire.jsonl`Agent 事件流,用于会话恢复和回放。
- `agents/*/wire.jsonl`Agent 事件流,用于会话恢复和回放;同时记录发给模型的请求轨迹(工具 schema、请求参数、MCP 工具清单),便于调试
::: warning 注意
`sessions/` 目录下的文件请勿手动编辑,否则可能导致会话无法正常恢复。

View file

@ -18,6 +18,7 @@ import {
} from '@moonshot-ai/kosong';
import type { Agent } from '..';
import type { GenerateOptionsWithRequestLogFields } from '../llm-request-logger';
import type { ContextMessage } from '../context/types';
import {
collectLoadedDynamicToolNames,
@ -43,8 +44,7 @@ import {
import {
applyCompletionBudget,
resolveCompletionBudget,
} from '../../utils/completion-budget';
import { renderPrompt } from '../../utils/render-prompt';
} from '../../utils/completion-budget';import { renderPrompt } from '../../utils/render-prompt';
import compactionInstructionTemplate from './compaction-instruction.md?raw';
import type { CompactionBeginData, CompactionResult } from './types';
import {
@ -514,13 +514,17 @@ export class FullCompaction {
];
const estimatedCompactionRequestTokens = this.estimateRequestTokens(messages);
try {
const generateOptions: GenerateOptionsWithRequestLogFields = {
signal,
requestLogFields: { kind: 'compaction', droppedCount },
};
const response = await this.agent.generate(
provider,
this.agent.config.systemPrompt,
[...this.agent.tools.loopTools],
messages,
undefined,
{ signal },
generateOptions,
);
if (response.finishReason === 'truncated') {
throw new CompactionTruncatedError();

View file

@ -55,6 +55,7 @@ import { TurnFlow } from './turn';
import { KosongLLM } from './turn/kosong-llm';
import { UsageRecorder } from './usage';
import { LlmRequestLogger, splitGenerateOptions } from './llm-request-logger';
import { LlmRequestRecorder } from './llm-request-recorder';
import { resolveCompletionBudget } from '../utils/completion-budget';
import type { Kaos } from '@moonshot-ai/kaos';
import type { ToolServices } from '../tools/support/services';
@ -125,6 +126,7 @@ export class Agent {
readonly experimentalFlags: ExperimentalFlagResolver;
readonly llmRequestLogger: LlmRequestLogger;
readonly llmRequestRecorder: LlmRequestRecorder;
readonly blobStore: BlobStore | undefined;
readonly records: AgentRecords;
readonly fullCompaction: FullCompaction;
@ -180,6 +182,7 @@ export class Agent {
this.systemPromptContextProvider = options.systemPromptContextProvider;
this.llmRequestLogger = new LlmRequestLogger(this.log);
this.llmRequestRecorder = new LlmRequestRecorder(this);
this.blobStore = options.homedir
? new BlobStore({ blobsDir: join(options.homedir, 'blobs') })
: undefined;
@ -255,14 +258,27 @@ export class Agent {
const { requestLogFields, generateOptions } = splitGenerateOptions(options);
const modelAlias = this.config.modelAlias;
const run = (requestOptions: Parameters<typeof generate>[5]) => {
this.llmRequestLogger.logRequest({
provider,
modelAlias,
systemPrompt,
tools,
messages: history,
fields: requestLogFields,
});
// Mirror kosong generate()'s pre-flight abort check: a call whose
// signal is already aborted never reaches the wire (generate throws
// before dispatching), so it must not leave a request trace or a
// diagnostic log line claiming a request was sent.
if (requestOptions?.signal?.aborted !== true) {
this.llmRequestLogger.logRequest({
provider,
modelAlias,
systemPrompt,
tools,
messages: history,
fields: requestLogFields,
});
this.llmRequestRecorder.record({
provider,
systemPrompt,
tools,
messages: history,
fields: requestLogFields,
});
}
return this.rawGenerate(provider, systemPrompt, tools, history, callbacks, requestOptions);
};
if (generateOptions?.auth !== undefined) {

View file

@ -68,10 +68,10 @@ export function splitGenerateOptions(options: GenerateOptionsWithRequestLogField
return { requestLogFields, generateOptions };
}
function toolSignature(tools: readonly Tool[]) {
export function toolSignature(tools: readonly Tool[]) {
return tools.map(({ name, description, parameters }) => ({ name, description, parameters }));
}
function fingerprint(content: string): string {
export function fingerprint(content: string): string {
return createHash('sha256').update(content).digest('hex');
}

View file

@ -0,0 +1,140 @@
/**
* Durable request-trace recorder: writes the observability records
* (`llm.tools_snapshot`, `llm.request`) that make every outbound model
* request reconstructable from the wire log. Called from the single
* `Agent.generate` choke point, so loop steps, retry attempts, strict
* resends, and compaction rounds all leave a trace.
*
* Sibling of `LlmRequestLogger` (diagnostic log lines, hashes only); this
* class owns the wire-record side. See the observability-records note in
* `records/types.ts` for the persistence contract.
*/
import { KimiChatProvider, type ChatProvider, type Message, type Tool } from '@moonshot-ai/kosong';
import { parseFloatEnv } from '#/config/resolve';
import { resolveThinkingKeep } from '#/config/kimi-env-params';
import type { Agent } from '.';
import type { LLMRequestLogFields } from '../loop';
import { fingerprint, toolSignature } from './llm-request-logger';
export class LlmRequestRecorder {
/** Hashes of tool tables already durable in this wire log. */
private readonly seenToolsHashes = new Set<string>();
/**
* Identity cache over the last wire tool table. Tool instances are treated
* as immutable and are stable across steps (rebuilt only by
* `initializeBuiltinTools` / MCP re-registration), so element-wise identity
* implies content equality the common per-step path costs no hashing.
*/
private lastWireTools: readonly Tool[] | undefined;
private lastToolsHash: string | undefined;
private lastSystemPrompt: string | undefined;
private lastSystemPromptHash: string | undefined;
constructor(private readonly agent: Agent) {}
/** Replay: a snapshot with this hash is already durable; never re-log it. */
restoreToolsSnapshot(hash: string): void {
this.seenToolsHashes.add(hash);
}
record(input: {
readonly provider: ChatProvider;
readonly systemPrompt: string;
readonly tools: readonly Tool[];
readonly messages: readonly Message[];
readonly fields: LLMRequestLogFields | undefined;
}): void {
const { provider, systemPrompt, messages } = input;
const fields = input.fields ?? {};
// Deferred tools are stripped by kosong generate() before the provider
// sees them; snapshot what actually goes on the wire. In disclosure mode
// this keeps the snapshot byte-stable across select_tools loads.
const wireTools = input.tools.filter((tool) => tool.deferred !== true);
const toolsHash = this.toolsHashFor(wireTools);
if (!this.seenToolsHashes.has(toolsHash)) {
this.seenToolsHashes.add(toolsHash);
this.agent.records.logRecord({
type: 'llm.tools_snapshot',
hash: toolsHash,
tools: toolSignature(wireTools),
});
}
const modelAlias = this.agent.config.modelAlias;
// Mirror the ConfigState.provider pipeline for Kimi-only request params:
// env sampling overrides and the preserved-thinking keep passthrough
// reach the wire only for Kimi providers, resolved by the same exported
// helpers used at construction. thinkingEffort needs no mirroring — the
// Kimi provider derives it from the request body's thinking payload, so
// env effort overrides are already reflected in the read value.
const isKimiProvider = provider instanceof KimiChatProvider;
this.agent.records.logRecord({
type: 'llm.request',
kind: fields.kind ?? 'loop',
provider: provider.name,
model: provider.modelName,
modelAlias,
thinkingEffort: provider.thinkingEffort ?? undefined,
thinkingKeep: isKimiProvider
? resolveThinkingKeep(
process.env,
this.agent.kimiConfig?.thinking?.keep,
provider.thinkingEffort ?? 'off',
)
: undefined,
temperature: isKimiProvider
? parseFloatEnv(process.env['KIMI_MODEL_TEMPERATURE'], 'KIMI_MODEL_TEMPERATURE')
: undefined,
topP: isKimiProvider
? parseFloatEnv(process.env['KIMI_MODEL_TOP_P'], 'KIMI_MODEL_TOP_P')
: undefined,
maxTokens: provider.maxCompletionTokens,
betaApi:
modelAlias === undefined
? undefined
: this.agent.kimiConfig?.models?.[modelAlias]?.betaApi,
toolSelect: this.agent.toolSelectEnabled,
systemPromptHash: this.systemPromptHashFor(systemPrompt),
systemPrompt:
systemPrompt === this.agent.config.systemPrompt ? undefined : systemPrompt,
toolsHash,
messageCount: messages.length,
turnStep: fields.turnStep,
attempt: fields.attempt,
projection: fields.projection,
droppedCount: fields.droppedCount,
});
}
private toolsHashFor(wireTools: readonly Tool[]): string {
if (this.lastToolsHash !== undefined && sameToolInstances(this.lastWireTools, wireTools)) {
return this.lastToolsHash;
}
const hash = fingerprint(JSON.stringify(toolSignature(wireTools)));
this.lastWireTools = wireTools;
this.lastToolsHash = hash;
return hash;
}
private systemPromptHashFor(systemPrompt: string): string {
if (this.lastSystemPromptHash === undefined || systemPrompt !== this.lastSystemPrompt) {
this.lastSystemPrompt = systemPrompt;
this.lastSystemPromptHash = fingerprint(systemPrompt);
}
return this.lastSystemPromptHash;
}
}
function sameToolInstances(
previous: readonly Tool[] | undefined,
current: readonly Tool[],
): boolean {
if (previous === undefined || previous.length !== current.length) return false;
for (let i = 0; i < current.length; i++) {
if (previous[i] !== current[i]) return false;
}
return true;
}

View file

@ -129,6 +129,17 @@ function restoreAgentRecord(agent: Agent, input: AgentRecord): void {
case 'goal.clear':
agent.goal.restoreClear(input);
return;
// Observability records: no state to rebuild; only restore the
// write-dedup cursors so a resumed session does not re-log snapshots
// that are already durable in this wire log.
case 'llm.tools_snapshot':
agent.llmRequestRecorder.restoreToolsSnapshot(input.hash);
return;
case 'llm.request':
return;
case 'mcp.tools_discovered':
agent.tools.restoreMcpDiscovery(input.serverName, input.hash);
return;
}
}
@ -143,6 +154,17 @@ export interface AgentRecordsReplayOptions {
export class AgentRecords {
private _restoring: RestoringContext | null = null;
private metadataInitialized = false;
private _replaying = false;
/**
* One-shot latch: the durable log is "open" once replay has completed (the
* write-dedup cursors of observability records are restored) or, for agents
* that never resume, once the first record has been logged live. Producers
* of observability records (MCP discovery) park their writes until then
* logging earlier would both duplicate records that replay is about to
* dedupe and append a stray metadata record ahead of replay.
*/
private _opened = false;
private readonly onOpenedCallbacks: Array<() => void> = [];
constructor(
private readonly agent: Agent,
@ -153,6 +175,38 @@ export class AgentRecords {
return this._restoring;
}
/**
* Whether observability records may be written directly. False before the
* log is opened (see `_opened`); producers should park and re-attempt from
* an `onOpened` callback. Always true without persistence there is no
* durable log to protect.
*/
get observabilityReady(): boolean {
return this.persistence === undefined || this._opened;
}
/**
* Register a callback fired once, when the log opens. Not fired for a
* range-limited (frozen) replay those agents are transient previews and
* must not append new records.
*/
onOpened(callback: () => void): void {
if (this._opened) {
callback();
return;
}
this.onOpenedCallbacks.push(callback);
}
private markOpened(): void {
if (this._opened) return;
this._opened = true;
const callbacks = this.onOpenedCallbacks.splice(0);
for (const callback of callbacks) {
callback();
}
}
logRecord(record: AgentRecord): void {
if (this._restoring !== null) return;
const stamped: AgentRecord =
@ -173,6 +227,13 @@ export class AgentRecords {
this.metadataInitialized = true;
}
this.persistence?.append(stamped);
// A live record was durably logged, so this agent is not waiting on a
// replay: open the log for observability producers. Guarded against the
// (currently hypothetical) mid-replay logRecord — opening there would
// let observability writes race the dedup-cursor restore.
if (!this._replaying) {
this.markOpened();
}
}
restore(record: AgentRecord): boolean {
@ -194,46 +255,57 @@ export class AgentRecords {
let warning: string | undefined;
const replayedRecords: AgentRecord[] | undefined = rewriteMigratedRecords ? [] : undefined;
let completed = true;
for await (const record of this.persistence.read()) {
if (!hasMetadata) {
if (record.type !== 'metadata') {
throw new Error('AgentRecords replay expected metadata as the first record');
this._replaying = true;
try {
for await (const record of this.persistence.read()) {
if (!hasMetadata) {
if (record.type !== 'metadata') {
throw new Error('AgentRecords replay expected metadata as the first record');
}
hasMetadata = true;
this.metadataInitialized = true;
const readVersion = record.protocol_version;
if (isNewerWireVersion(readVersion)) {
warning = `Session wire protocol version ${readVersion} is newer than the current version ${AGENT_WIRE_PROTOCOL_VERSION}. Records will be replayed without migration.`;
shouldRewrite = false;
} else {
migrations = resolveWireMigrations(readVersion);
shouldRewrite = readVersion !== AGENT_WIRE_PROTOCOL_VERSION;
}
}
hasMetadata = true;
this.metadataInitialized = true;
const readVersion = record.protocol_version;
if (isNewerWireVersion(readVersion)) {
warning = `Session wire protocol version ${readVersion} is newer than the current version ${AGENT_WIRE_PROTOCOL_VERSION}. Records will be replayed without migration.`;
shouldRewrite = false;
} else {
migrations = resolveWireMigrations(readVersion);
shouldRewrite = readVersion !== AGENT_WIRE_PROTOCOL_VERSION;
let migratedRecord = migrateWireRecord(
record as WireMigrationRecord,
migrations,
) as AgentRecord;
if (migratedRecord.type === 'metadata') {
migratedRecord = {
...migratedRecord,
protocol_version: AGENT_WIRE_PROTOCOL_VERSION,
};
}
replayedRecords?.push(migratedRecord);
if (this.restore(migratedRecord)) {
completed = false;
break;
}
}
let migratedRecord = migrateWireRecord(
record as WireMigrationRecord,
migrations,
) as AgentRecord;
if (migratedRecord.type === 'metadata') {
migratedRecord = {
...migratedRecord,
protocol_version: AGENT_WIRE_PROTOCOL_VERSION,
};
if (completed && shouldRewrite && replayedRecords !== undefined) {
this.persistence.rewrite(replayedRecords);
await this.persistence.flush();
}
replayedRecords?.push(migratedRecord);
if (this.restore(migratedRecord)) {
completed = false;
break;
if (completed && this.agent.blobStore !== undefined) {
for (const msg of this.agent.context.history) {
await this.agent.blobStore.rehydrateParts(msg.content);
}
}
} finally {
this._replaying = false;
}
if (completed && shouldRewrite && replayedRecords !== undefined) {
this.persistence.rewrite(replayedRecords);
await this.persistence.flush();
}
if (completed && this.agent.blobStore !== undefined) {
for (const msg of this.agent.context.history) {
await this.agent.blobStore.rehydrateParts(msg.content);
}
// Open only AFTER the migration rewrite has flushed — records appended by
// onOpened callbacks before the rewrite would be wiped by it. A frozen
// (range-limited) replay never opens: see onOpened.
if (completed) {
this.markOpened();
}
return { warning };
}

View file

@ -1,20 +1,38 @@
import type { ContentPart, TokenUsage } from '@moonshot-ai/kosong';
import type { ContentPart, ThinkingEffort, TokenUsage } from '@moonshot-ai/kosong';
import type { LoopRecordedEvent } from '../../loop';
import type { GoalActor, GoalBudgetLimits, GoalStatus } from '../goal';
import type { MCPToolDefinition } from '../../mcp/types';
import type { ToolStoreUpdate } from '../../tools/store';
import type { CompactionBeginData, CompactionResult } from '../compaction';
import type { AgentConfigUpdateData } from '../config';
import type { ContextMessage, PromptOrigin } from '../context';
import type { PermissionApprovalResultRecord, PermissionMode } from '../permission';
import type { UserToolRegistration } from '../tool';
import type { McpToolCollision, UserToolRegistration } from '../tool';
import type { UsageRecordScope } from '../usage';
import type { SwarmModeTrigger } from '../swarm';
/** One entry of a tools table as sent in a request's top-level `tools[]`. */
export interface LlmRequestToolSchema {
name: string;
description: string;
parameters: Record<string, unknown>;
}
// Agent records are the ordered event log used to rebuild agent state on resume.
// Use records, not state.json, when correctness depends on the order in which
// state transitions happened. Each persisted record type must have explicit
// resume semantics in restoreAgentRecord; a write-only record is not persistence.
// state transitions happened.
//
// Two record classes exist, and being persisted is not the same as being
// replayed:
// - State records (the default): each type must have explicit state-rebuild
// semantics in restoreAgentRecord; a write-only state record is not
// persistence.
// - Observability records (`llm.tools_snapshot`, `llm.request`,
// `mcp.tools_discovered`): a durable trace of the data sent to the model,
// for debugging and trajectory replay. They never feed state rebuild;
// their only resume semantics is restoring the write-dedup cursors so a
// resumed session does not re-log snapshots it already persisted.
export interface AgentRecordEvents {
metadata: {
protocol_version: string;
@ -98,6 +116,85 @@ export interface AgentRecordEvents {
actor?: GoalActor;
};
'goal.clear': {};
// Observability records (see the header note): request-trace data, not
// state. Resume only restores the write-dedup cursors.
/**
* Content-addressed snapshot of a request's top-level `tools[]` (after the
* `deferred` strip exactly what the provider receives). Written once per
* unique table; `llm.request.toolsHash` points here.
*/
'llm.tools_snapshot': {
hash: string;
tools: readonly LlmRequestToolSchema[];
};
/**
* One record per outbound model request (every retry attempt, strict
* resend, and compaction round included). Together with `config.update`
* (system prompt full text), context records (messages), and
* `llm.tools_snapshot` (tool schemas), this makes each request
* reconstructable from the wire log at the logical-request level.
*/
'llm.request': {
kind: 'loop' | 'compaction';
provider: string;
model: string;
modelAlias?: string;
/**
* Provider-effective thinking effort for Kimi providers this is derived
* from the request body's thinking payload, so env overrides
* (`KIMI_MODEL_THINKING_EFFORT`) are already reflected.
*/
thinkingEffort?: ThinkingEffort;
/**
* Kimi preserved-thinking passthrough (`thinking.keep`) in effect for
* this request resolved from env, config, and the default, none of
* which are otherwise recorded.
*/
thinkingKeep?: string;
/** Effective env-driven sampling overrides (Kimi provider only). */
temperature?: number;
topP?: number;
/**
* Effective completion-token cap the provider sends on the wire read
* from the effective provider, so provider-side clamping (remaining
* context window, transport ceilings) and provider-level defaults (e.g.
* Anthropic's required `max_tokens`) are included.
*/
maxTokens?: number;
betaApi?: boolean;
/** Progressive tool disclosure in effect (env flag × model capability). */
toolSelect: boolean;
systemPromptHash: string;
/**
* Inlined only when the request's system prompt differs from the current
* `config.update` value (no such caller today; defensive for future ones).
*/
systemPrompt?: string;
toolsHash: string;
messageCount: number;
turnStep?: string;
attempt?: string;
/** Set when this request is the strict wire-compliant rebuild resend. */
projection?: 'strict';
/** Compaction only: messages dropped so far by overflow/empty shrinking. */
droppedCount?: number;
};
/**
* Raw MCP `tools/list` result as advertised by the server, plus how this
* agent gated it (allow-list, name collisions). Written on registration,
* deduplicated per server by content hash.
*/
'mcp.tools_discovered': {
serverName: string;
hash: string;
tools: readonly MCPToolDefinition[];
enabledNames: readonly string[];
collisions?: readonly McpToolCollision[];
};
}
export type AgentRecord = {

View file

@ -12,9 +12,10 @@ import { createMcpAuthTool } from '../../mcp/auth-tool';
import type { McpConnectionManager, McpServerEntry } from '../../mcp';
import { mcpResultToExecutableOutput } from '../../mcp/output';
import { isMcpToolName, qualifyMcpToolName } from '../../mcp/tool-naming';
import type { MCPClient } from '../../mcp/types';
import type { MCPClient, MCPToolDefinition } from '../../mcp/types';
import { DEFAULT_AGENT_PROFILES } from '../../profile';
import { extendWorkspaceWithSkillRoots } from '../../skill';
import { fingerprint } from '../llm-request-logger';
import * as b from '../../tools/builtin';
import type { ToolStore, ToolStoreData, ToolStoreKey } from '../../tools/store';
import type {
@ -35,6 +36,13 @@ interface McpToolEntry {
readonly serverName: string;
}
interface PendingMcpDiscovery {
readonly serverName: string;
readonly rawTools: readonly MCPToolDefinition[];
readonly enabledNames: readonly string[];
readonly collisions: readonly McpToolCollision[];
}
export class ToolManager {
protected builtinTools: Map<string, BuiltinTool> = new Map();
protected readonly userTools: Map<string, ExecutableTool> = new Map();
@ -55,6 +63,20 @@ export class ToolManager {
private readonly pendingLoadedDynamicTools = new Set<string>();
protected readonly store: Partial<ToolStoreData> = {};
private mcpToolStatusUnsubscribe: (() => void) | undefined;
/**
* `serverName\nhash` keys of `mcp.tools_discovered` records already durable
* in this wire log. Restored on replay; reconnects with an unchanged raw
* tool list, allow-list, and collision outcome do not re-log.
*/
private readonly seenMcpDiscoveries = new Set<string>();
/**
* Discoveries observed before the record log opened (constructor-time
* attach can run before `agent.resume()` replays the wire see
* `AgentRecords.observabilityReady`). The dedup decision must be re-made at
* drain time, after replay has restored `seenMcpDiscoveries`.
*/
private readonly pendingMcpDiscoveries: PendingMcpDiscovery[] = [];
private mcpDiscoveryDrainSubscribed = false;
/** Abort controllers for in-flight `!` shell commands, keyed by commandId so
* the TUI can cancel (Esc / Ctrl+C) a running command. */
@ -386,6 +408,12 @@ export class ToolManager {
resolved.tools,
resolved.enabledNames,
);
this.recordMcpToolsDiscovered(
entry.name,
resolved.rawTools,
resolved.enabledNames,
result.collisions,
);
this.emitMcpToolCollisions(entry.name, result.collisions);
this.agent.emitEvent({
type: 'tool.list.updated',
@ -394,6 +422,71 @@ export class ToolManager {
});
}
/** Replay: a discovery with this hash is already durable; never re-log it. */
restoreMcpDiscovery(serverName: string, hash: string): void {
this.seenMcpDiscoveries.add(`${serverName}\n${hash}`);
}
/**
* Observability record: the server's verbatim `tools/list` result plus how
* this agent gated it (allow-list, collisions). See `records/types.ts`.
* Parked while the record log has not opened yet (pre-replay window).
*/
private recordMcpToolsDiscovered(
serverName: string,
rawTools: readonly MCPToolDefinition[],
enabledNames: ReadonlySet<string>,
collisions: readonly McpToolCollision[],
): void {
const discovery: PendingMcpDiscovery = {
serverName,
rawTools,
enabledNames: [...enabledNames].toSorted((a, b) => a.localeCompare(b)),
collisions,
};
if (!this.agent.records.observabilityReady) {
this.pendingMcpDiscoveries.push(discovery);
// Lazy one-shot subscription: only agents that actually parked need
// the drain callback, and at park time the log is guaranteed unopened.
if (!this.mcpDiscoveryDrainSubscribed) {
this.mcpDiscoveryDrainSubscribed = true;
this.agent.records.onOpened(() => {
this.drainPendingMcpDiscoveries();
});
}
return;
}
this.writeMcpDiscovery(discovery);
}
private drainPendingMcpDiscoveries(): void {
const pending = this.pendingMcpDiscoveries.splice(0);
for (const discovery of pending) {
this.writeMcpDiscovery(discovery);
}
}
private writeMcpDiscovery(discovery: PendingMcpDiscovery): void {
const { serverName, rawTools, enabledNames, collisions } = discovery;
// The hash covers everything the record captures — the raw list, the
// allow-list, AND the collision outcome. Collisions depend on which
// other servers hold a qualified name at registration time, so the same
// server can re-register with identical tools but a different outcome;
// that change must produce a new record.
const hash = fingerprint(JSON.stringify({ tools: rawTools, enabledNames, collisions }));
const key = `${serverName}\n${hash}`;
if (this.seenMcpDiscoveries.has(key)) return;
this.seenMcpDiscoveries.add(key);
this.agent.records.logRecord({
type: 'mcp.tools_discovered',
serverName,
hash,
tools: rawTools,
enabledNames,
collisions: collisions.length > 0 ? collisions : undefined,
});
}
private emitMcpToolCollisions(serverName: string, collisions: readonly McpToolCollision[]): void {
if (collisions.length === 0) return;
const summary = collisions

View file

@ -23,9 +23,20 @@ export interface ToolCallDelta {
readonly argumentsPart?: string | undefined;
}
/**
* Request-scoped side channel from the host layers (loop, LLM adapter,
* compaction) down to the `Agent.generate` choke point, consumed there by the
* diagnostic logger and the wire-record request trace.
*/
export interface LLMRequestLogFields {
readonly turnStep: string;
readonly turnStep?: string;
readonly attempt?: string;
/** Request purpose; absent means a regular loop step. */
readonly kind?: 'loop' | 'compaction';
/** Set when the messages are the strict wire-compliant rebuild resend. */
readonly projection?: 'strict';
/** Compaction only: messages dropped so far by overflow/empty shrinking. */
readonly droppedCount?: number;
}
export interface LLMStreamTiming {

View file

@ -88,12 +88,18 @@ function paramsForAttempt(
maxAttempts: number,
): LLMChatParams {
const turnStep = `${input.turnId}.${String(input.currentStep)}`;
// Preserve caller-set fields (e.g. the strict-resend projection marker);
// only the per-attempt turnStep/attempt pair is owned here.
return {
...input.params,
requestLogFields:
attempt === 1
? { turnStep }
: { turnStep, attempt: `${String(attempt)}/${String(maxAttempts)}` },
? { ...input.params.requestLogFields, turnStep }
: {
...input.params.requestLogFields,
turnStep,
attempt: `${String(attempt)}/${String(maxAttempts)}`,
},
};
}

View file

@ -162,7 +162,11 @@ export async function executeLoopStep(deps: ExecuteLoopStepDeps): Promise<{
try {
response = await chatWithRetry({
...retryInput,
params: { ...chatParams, messages: strictMessages },
params: {
...chatParams,
messages: strictMessages,
requestLogFields: { projection: 'strict' },
},
});
} catch (strictError) {
// The strictly-sanitized rebuild was still rejected — our wire-compliance

View file

@ -11,7 +11,7 @@ import { SseMcpClient } from './client-sse';
import type { UnexpectedCloseReason } from './client-shared';
import { StdioMcpClient } from './client-stdio';
import type { McpOAuthService } from './oauth';
import { assertMcpInputSchema, type MCPClient } from './types';
import { assertMcpInputSchema, type MCPClient, type MCPToolDefinition } from './types';
export type McpServerStatus = 'pending' | 'connected' | 'failed' | 'disabled' | 'needs-auth';
@ -29,6 +29,8 @@ interface InternalEntry {
attemptId: number;
status: McpServerStatus;
tools?: readonly Tool[];
/** Verbatim `tools/list` result the converted {@link tools} came from. */
rawTools?: readonly MCPToolDefinition[];
enabledNames?: ReadonlySet<string>;
error?: string;
client?: RuntimeMcpClient;
@ -136,12 +138,18 @@ export class McpConnectionManager {
resolved(
name: string,
):
| { client: MCPClient; tools: readonly Tool[]; enabledNames: ReadonlySet<string> }
| {
client: MCPClient;
tools: readonly Tool[];
rawTools: readonly MCPToolDefinition[];
enabledNames: ReadonlySet<string>;
}
| undefined {
const entry = this.entries.get(name);
if (
entry?.status !== 'connected' ||
entry.tools === undefined ||
entry.rawTools === undefined ||
entry.client === undefined
) {
return undefined;
@ -149,6 +157,7 @@ export class McpConnectionManager {
return {
client: entry.client,
tools: entry.tools,
rawTools: entry.rawTools,
enabledNames: entry.enabledNames ?? new Set(entry.tools.map((t) => t.name)),
};
}
@ -191,6 +200,7 @@ export class McpConnectionManager {
await this.closeClient(entry);
entry.status = 'disabled';
entry.tools = undefined;
entry.rawTools = undefined;
entry.enabledNames = undefined;
entry.error = undefined;
this.emit(entry);
@ -242,6 +252,7 @@ export class McpConnectionManager {
if (!this.isCurrent(entry, attemptId)) return;
entry.status = 'pending';
entry.tools = undefined;
entry.rawTools = undefined;
entry.enabledNames = undefined;
entry.error = undefined;
this.emit(entry);
@ -263,7 +274,7 @@ export class McpConnectionManager {
const startupClient = this.createClient(entry.config, entry.name);
client = startupClient;
entry.client = startupClient;
const tools = await withTimeout(
const discovered = await withTimeout(
this.connectAndDiscoverTools(startupClient),
timeoutMs,
() => {
@ -275,8 +286,9 @@ export class McpConnectionManager {
await this.closeRuntimeClient(startupClient);
return;
}
entry.tools = tools;
entry.enabledNames = computeEnabledNames(entry.config, tools);
entry.tools = discovered.tools;
entry.rawTools = discovered.rawTools;
entry.enabledNames = computeEnabledNames(entry.config, discovered.tools);
entry.status = 'connected';
this.watchForUnexpectedClose(entry, startupClient, attemptId);
} catch (error) {
@ -294,6 +306,7 @@ export class McpConnectionManager {
entry.error = formatStartupError(error, client);
}
entry.tools = undefined;
entry.rawTools = undefined;
entry.enabledNames = undefined;
// Drop the client reference so a later reconnect builds a fresh one.
await this.closeClient(entry);
@ -315,6 +328,7 @@ export class McpConnectionManager {
entry.status = 'failed';
entry.error = formatUnexpectedCloseError(entry.name, reason);
entry.tools = undefined;
entry.rawTools = undefined;
entry.enabledNames = undefined;
entry.client = undefined;
// Best-effort close; the transport is already gone, but this lets the
@ -376,14 +390,19 @@ export class McpConnectionManager {
return isUnauthorizedLikeError(error);
}
private async connectAndDiscoverTools(client: RuntimeMcpClient): Promise<Tool[]> {
private async connectAndDiscoverTools(
client: RuntimeMcpClient,
): Promise<{ tools: Tool[]; rawTools: MCPToolDefinition[] }> {
await client.connect();
const mcpTools = await client.listTools();
return mcpTools.map((mcpTool) => ({
name: mcpTool.name,
description: mcpTool.description,
parameters: assertMcpInputSchema(mcpTool.name, mcpTool.inputSchema),
}));
return {
rawTools: mcpTools,
tools: mcpTools.map((mcpTool) => ({
name: mcpTool.name,
description: mcpTool.description,
parameters: assertMcpInputSchema(mcpTool.name, mcpTool.inputSchema),
})),
};
}
private async closeClient(entry: InternalEntry): Promise<void> {

View file

@ -39,8 +39,6 @@ interface ProviderManagerOptions {
readonly kimiRequestHeaders?: Record<string, string>;
readonly resolveOAuthTokenProvider?: OAuthTokenProviderResolver;
readonly promptCacheKey?: string;
// remove before commit
readonly adaptiveThinkingOverride?: () => boolean | undefined;
}
type AuthorizedRequest = <T>(
@ -121,9 +119,6 @@ export class ProviderManager implements ModelProvider {
);
}
// remove before commit
const adaptiveThinkingOverride = this.options.adaptiveThinkingOverride?.();
const effectiveAdaptiveThinking = adaptiveThinkingOverride ?? effectiveAlias.adaptiveThinking;
const provider = toKosongProviderConfig(
providerConfig,
alias.model,
@ -132,7 +127,7 @@ export class ProviderManager implements ModelProvider {
effectiveAlias.maxOutputSize,
effectiveAlias.reasoningKey,
this.options.promptCacheKey,
effectiveAdaptiveThinking,
effectiveAlias.adaptiveThinking,
alias.betaApi,
effectiveAlias.supportEfforts,
);

File diff suppressed because one or more lines are too long

View file

@ -76,6 +76,8 @@ describe('FullCompaction', () => {
[wire] context.append_message { "message": { "role": "user", "content": [ { "type": "text", "text": "recent user three" } ], "toolCalls": [], "origin": { "kind": "user" } }, "time": "<time>" }
[wire] full_compaction.begin { "source": "manual", "instruction": "Keep the important test facts.", "time": "<time>" }
[emit] compaction.started { "trigger": "manual", "instruction": "Keep the important test facts." }
[wire] llm.tools_snapshot { "hash": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945", "tools": [], "time": "<time>" }
[wire] llm.request { "kind": "compaction", "provider": "kimi", "model": "kimi-code", "modelAlias": "kimi-code", "thinkingEffort": "off", "maxTokens": 131072, "toolSelect": false, "systemPromptHash": "ec9c34379c88babbc468ef2f3e0e08cd2f422c8c4a910664fb8bb394d703a575", "toolsHash": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945", "messageCount": 7, "droppedCount": 0, "time": "<time>" }
[wire] usage.record { "model": "kimi-code", "usage": { "inputOther": 1181, "output": 8, "inputCacheRead": 0, "inputCacheCreation": 0 }, "usageScope": "session", "time": "<time>" }
[emit] agent.status.updated { "model": "kimi-code", "contextTokens": 120, "maxContextTokens": 256000, "contextUsage": 0.00046875, "planMode": false, "swarmMode": false, "permission": "manual", "usage": { "byModel": { "kimi-code": { "inputOther": 1181, "output": 8, "inputCacheRead": 0, "inputCacheCreation": 0 } }, "total": { "inputOther": 1181, "output": 8, "inputCacheRead": 0, "inputCacheCreation": 0 } } }
[wire] context.apply_compaction { "summary": "Compacted summary.", "contextSummary": "The conversation so far has been compacted to free up context. What follows is your own working summary of this task — use it to continue your train of thought rather than starting over. Treat it as notes, not proof: where it says a step was done, tests passed, or a fix worked, verify that yourself before relying on it. Any user messages earlier in this context are preserved verbatim from the compacted conversation; where a system-reminder note among them marks an omitted middle section, the user messages it replaced are covered by this summary.\\nCompacted summary.", "compactedCount": 6, "tokensBefore": 39, "tokensAfter": 158, "keptUserMessageCount": 3, "time": "<time>" }
@ -1030,6 +1032,8 @@ describe('FullCompaction', () => {
[wire] full_compaction.begin { "source": "manual", "time": "<time>" }
[emit] compaction.started { "trigger": "manual" }
[wire] context.append_message { "message": { "role": "user", "content": [ { "type": "text", "text": "new user while compacting" } ], "toolCalls": [], "origin": { "kind": "user" } }, "time": "<time>" }
[wire] llm.tools_snapshot { "hash": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945", "tools": [], "time": "<time>" }
[wire] llm.request { "kind": "compaction", "provider": "kimi", "model": "kimi-code", "modelAlias": "kimi-code", "thinkingEffort": "off", "maxTokens": 131072, "toolSelect": false, "systemPromptHash": "ec9c34379c88babbc468ef2f3e0e08cd2f422c8c4a910664fb8bb394d703a575", "toolsHash": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945", "messageCount": 5, "droppedCount": 0, "time": "<time>" }
[wire] usage.record { "model": "kimi-code", "usage": { "inputOther": 1152, "output": 8, "inputCacheRead": 0, "inputCacheCreation": 0 }, "usageScope": "session", "time": "<time>" }
[emit] agent.status.updated { "model": "kimi-code", "contextTokens": 80, "maxContextTokens": 256000, "contextUsage": 0.0003125, "planMode": false, "swarmMode": false, "permission": "manual", "usage": { "byModel": { "kimi-code": { "inputOther": 1152, "output": 8, "inputCacheRead": 0, "inputCacheCreation": 0 } }, "total": { "inputOther": 1152, "output": 8, "inputCacheRead": 0, "inputCacheCreation": 0 } } }
[wire] context.apply_compaction { "summary": "Compacted prefix.", "contextSummary": "The conversation so far has been compacted to free up context. What follows is your own working summary of this task — use it to continue your train of thought rather than starting over. Treat it as notes, not proof: where it says a step was done, tests passed, or a fix worked, verify that yourself before relying on it. Any user messages earlier in this context are preserved verbatim from the compacted conversation; where a system-reminder note among them marks an omitted middle section, the user messages it replaced are covered by this summary.\\nCompacted prefix.", "compactedCount": 4, "tokensBefore": 25, "tokensAfter": 160, "keptUserMessageCount": 3, "time": "<time>" }
@ -1094,6 +1098,8 @@ describe('FullCompaction', () => {
[emit] compaction.started { "trigger": "manual" }
[wire] context.clear { "time": "<time>" }
[emit] agent.status.updated { "model": "kimi-code", "contextTokens": 0, "maxContextTokens": 256000, "contextUsage": 0, "planMode": false, "swarmMode": false, "permission": "manual" }
[wire] llm.tools_snapshot { "hash": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945", "tools": [], "time": "<time>" }
[wire] llm.request { "kind": "compaction", "provider": "kimi", "model": "kimi-code", "modelAlias": "kimi-code", "thinkingEffort": "off", "maxTokens": 131072, "toolSelect": false, "systemPromptHash": "ec9c34379c88babbc468ef2f3e0e08cd2f422c8c4a910664fb8bb394d703a575", "toolsHash": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945", "messageCount": 5, "droppedCount": 0, "time": "<time>" }
[wire] usage.record { "model": "kimi-code", "usage": { "inputOther": 1152, "output": 7, "inputCacheRead": 0, "inputCacheCreation": 0 }, "usageScope": "session", "time": "<time>" }
[emit] agent.status.updated { "model": "kimi-code", "contextTokens": 0, "maxContextTokens": 256000, "contextUsage": 0, "planMode": false, "swarmMode": false, "permission": "manual", "usage": { "byModel": { "kimi-code": { "inputOther": 1152, "output": 7, "inputCacheRead": 0, "inputCacheCreation": 0 } }, "total": { "inputOther": 1152, "output": 7, "inputCacheRead": 0, "inputCacheCreation": 0 } } }
[wire] full_compaction.cancel { "time": "<time>" }
@ -1138,6 +1144,8 @@ describe('FullCompaction', () => {
[wire] full_compaction.begin { "source": "auto", "time": "<time>" }
[emit] compaction.started { "trigger": "auto" }
[emit] compaction.blocked { "turnId": 0 }
[wire] llm.tools_snapshot { "hash": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945", "tools": [], "time": "<time>" }
[wire] llm.request { "kind": "compaction", "provider": "kimi", "model": "kimi-code", "modelAlias": "kimi-code", "thinkingEffort": "off", "maxTokens": 131072, "toolSelect": false, "systemPromptHash": "ec9c34379c88babbc468ef2f3e0e08cd2f422c8c4a910664fb8bb394d703a575", "toolsHash": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945", "messageCount": 8, "droppedCount": 0, "time": "<time>" }
[wire] usage.record { "model": "kimi-code", "usage": { "inputOther": 1173, "output": 9, "inputCacheRead": 0, "inputCacheCreation": 0 }, "usageScope": "session", "time": "<time>" }
[emit] agent.status.updated { "model": "kimi-code", "contextTokens": 950000, "maxContextTokens": 256000, "contextUsage": 3.7109375, "planMode": false, "swarmMode": false, "permission": "manual", "usage": { "byModel": { "kimi-code": { "inputOther": 1173, "output": 9, "inputCacheRead": 0, "inputCacheCreation": 0 } }, "total": { "inputOther": 1173, "output": 9, "inputCacheRead": 0, "inputCacheCreation": 0 } } }
[wire] context.apply_compaction { "summary": "Auto compacted summary.", "contextSummary": "The conversation so far has been compacted to free up context. What follows is your own working summary of this task — use it to continue your train of thought rather than starting over. Treat it as notes, not proof: where it says a step was done, tests passed, or a fix worked, verify that yourself before relying on it. Any user messages earlier in this context are preserved verbatim from the compacted conversation; where a system-reminder note among them marks an omitted middle section, the user messages it replaced are covered by this summary.\\nAuto compacted summary.", "compactedCount": 7, "tokensBefore": 46, "tokensAfter": 166, "keptUserMessageCount": 4, "time": "<time>" }
@ -1146,6 +1154,7 @@ describe('FullCompaction', () => {
[emit] compaction.completed { "result": { "summary": "Auto compacted summary.", "compactedCount": 7, "tokensBefore": 46, "tokensAfter": 166, "keptUserMessageCount": 4 } }
[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>" }
[wire] llm.request { "kind": "loop", "provider": "kimi", "model": "kimi-code", "modelAlias": "kimi-code", "thinkingEffort": "off", "maxTokens": 255834, "toolSelect": false, "systemPromptHash": "ec9c34379c88babbc468ef2f3e0e08cd2f422c8c4a910664fb8bb394d703a575", "toolsHash": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945", "messageCount": 2, "turnStep": "0.1", "time": "<time>" }
[emit] assistant.delta { "turnId": 0, "delta": "I can answer after compaction." }
[wire] context.append_loop_event { "event": { "type": "content.part", "uuid": "<uuid-2>", "turnId": "0", "step": 1, "stepUuid": "<uuid-1>", "part": { "type": "text", "text": "I can answer after compaction." } }, "time": "<time>" }
[wire] context.append_loop_event { "event": { "type": "step.end", "uuid": "<uuid-1>", "turnId": "0", "step": 1, "usage": { "inputOther": 165, "output": 11, "inputCacheRead": 0, "inputCacheCreation": 0 }, "finishReason": "end_turn", "messageId": "mock-2" }, "time": "<time>" }
@ -2175,6 +2184,8 @@ describe('FullCompaction', () => {
[wire] full_compaction.begin { "source": "auto", "time": "<time>" }
[emit] compaction.started { "trigger": "auto" }
[emit] compaction.blocked { "turnId": 0 }
[wire] llm.tools_snapshot { "hash": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945", "tools": [], "time": "<time>" }
[wire] llm.request { "kind": "compaction", "provider": "kimi", "model": "mock-model", "modelAlias": "mock-model", "thinkingEffort": "off", "maxTokens": 131072, "toolSelect": false, "systemPromptHash": "ec9c34379c88babbc468ef2f3e0e08cd2f422c8c4a910664fb8bb394d703a575", "toolsHash": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945", "messageCount": 2, "droppedCount": 0, "time": "<time>" }
[wire] usage.record { "model": "mock-model", "usage": { "inputOther": 1135, "output": 9, "inputCacheRead": 0, "inputCacheCreation": 0 }, "usageScope": "session", "time": "<time>" }
[emit] agent.status.updated { "model": "mock-model", "contextTokens": 0, "maxContextTokens": 1000000, "contextUsage": 0, "planMode": false, "swarmMode": false, "permission": "manual", "usage": { "byModel": { "mock-model": { "inputOther": 1135, "output": 9, "inputCacheRead": 0, "inputCacheCreation": 0 } }, "total": { "inputOther": 1135, "output": 9, "inputCacheRead": 0, "inputCacheCreation": 0 } } }
[wire] context.apply_compaction { "summary": "First compacted summary.", "contextSummary": "The conversation so far has been compacted to free up context. What follows is your own working summary of this task — use it to continue your train of thought rather than starting over. Treat it as notes, not proof: where it says a step was done, tests passed, or a fix worked, verify that yourself before relying on it. Any user messages earlier in this context are preserved verbatim from the compacted conversation; where a system-reminder note among them marks an omitted middle section, the user messages it replaced are covered by this summary.\\nFirst compacted summary.", "compactedCount": 1, "tokensBefore": 8, "tokensAfter": 153, "keptUserMessageCount": 1, "time": "<time>" }
@ -2183,6 +2194,7 @@ describe('FullCompaction', () => {
[emit] compaction.completed { "result": { "summary": "First compacted summary.", "compactedCount": 1, "tokensBefore": 8, "tokensAfter": 153, "keptUserMessageCount": 1 } }
[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>" }
[wire] llm.request { "kind": "loop", "provider": "kimi", "model": "mock-model", "modelAlias": "mock-model", "thinkingEffort": "off", "maxTokens": 999847, "toolSelect": false, "systemPromptHash": "ec9c34379c88babbc468ef2f3e0e08cd2f422c8c4a910664fb8bb394d703a575", "toolsHash": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945", "messageCount": 2, "turnStep": "0.1", "time": "<time>" }
[emit] assistant.delta { "turnId": 0, "delta": "I need a tool." }
[emit] tool.call.delta { "turnId": 0, "toolCallId": "call_missing", "name": "MissingTool", "argumentsPart": "{}" }
[wire] context.append_loop_event { "event": { "type": "content.part", "uuid": "<uuid-2>", "turnId": "0", "step": 1, "stepUuid": "<uuid-1>", "part": { "type": "text", "text": "I need a tool." } }, "time": "<time>" }

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,398 @@
import { describe, expect, it, vi } from 'vitest';
import type { AgentRecord } from '../../src/agent';
import {
InMemoryAgentRecordPersistence,
type AgentRecordOf,
} from '../../src/agent/records';
import type { McpConnectionManager, McpServerEntry, McpStatusListener } from '../../src/mcp';
import type { MCPClient, MCPToolDefinition } from '../../src/mcp/types';
import { testAgent, type TestAgentContext } from './harness/agent';
function recordsOf<T extends AgentRecord['type']>(
persistence: InMemoryAgentRecordPersistence,
type: T,
): AgentRecordOf<T>[] {
return persistence.records.filter(
(record): record is AgentRecordOf<T> => record.type === type,
);
}
async function runTurn(ctx: TestAgentContext, prompt: string): Promise<void> {
await ctx.rpc.prompt({ input: [{ type: 'text', text: prompt }] });
await ctx.untilTurnEnd();
}
describe('llm request trace records', () => {
it('writes one tools snapshot per unique table and one llm.request per request', async () => {
const persistence = new InMemoryAgentRecordPersistence();
const ctx = testAgent({ persistence });
ctx.configure({ tools: ['Read'] });
ctx.mockNextResponse({ type: 'text', text: 'one' });
await runTurn(ctx, 'first');
ctx.mockNextResponse({ type: 'text', text: 'two' });
await runTurn(ctx, 'second');
const snapshots = recordsOf(persistence, 'llm.tools_snapshot');
expect(snapshots).toHaveLength(1);
const snapshot = snapshots[0]!;
expect(snapshot.tools.map((tool) => tool.name)).toEqual(['Read']);
expect(snapshot.tools[0]!.description.length).toBeGreaterThan(0);
expect(snapshot.tools[0]!.parameters).toMatchObject({ type: 'object' });
const requests = recordsOf(persistence, 'llm.request');
expect(requests).toHaveLength(2);
expect(requests.map((request) => request.turnStep)).toEqual(['0.1', '1.1']);
for (const request of requests) {
expect(request.kind).toBe('loop');
expect(request.toolsHash).toBe(snapshot.hash);
expect(request.systemPromptHash).toMatch(/^[0-9a-f]{64}$/);
// The request used the config system prompt, so no inline copy.
expect(request.systemPrompt).toBeUndefined();
expect(request.messageCount).toBeGreaterThan(0);
expect(request.model).toBe('mock-model');
expect(request.toolSelect).toBe(false);
// Thinking is off, so no keep passthrough is sent or recorded.
expect(request.thinkingKeep).toBeUndefined();
}
// maxTokens is the provider-clamped wire value: the second request has
// consumed context, so its remaining-context cap is strictly smaller.
expect(requests[0]!.maxTokens).toBe(1_000_000);
expect(requests[1]!.maxTokens!).toBeLessThan(requests[0]!.maxTokens!);
});
it('writes a new snapshot when the active tool table changes', async () => {
const persistence = new InMemoryAgentRecordPersistence();
const ctx = testAgent({ persistence });
ctx.configure({ tools: ['Read'] });
ctx.mockNextResponse({ type: 'text', text: 'one' });
await runTurn(ctx, 'first');
await ctx.rpc.setActiveTools({ names: ['Read', 'Glob'] });
ctx.mockNextResponse({ type: 'text', text: 'two' });
await runTurn(ctx, 'second');
const snapshots = recordsOf(persistence, 'llm.tools_snapshot');
expect(snapshots).toHaveLength(2);
expect(snapshots[0]!.hash).not.toBe(snapshots[1]!.hash);
expect(snapshots[1]!.tools.map((tool) => tool.name)).toEqual(['Glob', 'Read']);
const requests = recordsOf(persistence, 'llm.request');
expect(requests.map((request) => request.toolsHash)).toEqual([
snapshots[0]!.hash,
snapshots[1]!.hash,
]);
});
it('does not re-log a durable snapshot after resume', async () => {
const persistence = new InMemoryAgentRecordPersistence();
const ctx = testAgent({ persistence });
ctx.configure({ tools: ['Read'] });
ctx.mockNextResponse({ type: 'text', text: 'one' });
await runTurn(ctx, 'first');
const resumedPersistence = new InMemoryAgentRecordPersistence(
structuredClone(persistence.records),
);
const resumed = testAgent({ persistence: resumedPersistence });
await resumed.agent.resume();
resumed.mockNextResponse({ type: 'text', text: 'after resume' });
await runTurn(resumed, 'again');
const snapshots = recordsOf(resumedPersistence, 'llm.tools_snapshot');
expect(snapshots).toHaveLength(1);
const requests = recordsOf(resumedPersistence, 'llm.request');
expect(requests).toHaveLength(2);
expect(requests[1]!.toolsHash).toBe(requests[0]!.toolsHash);
});
it('inlines the system prompt when a request bypasses the config prompt', async () => {
const persistence = new InMemoryAgentRecordPersistence();
const ctx = testAgent({ persistence });
ctx.configure();
ctx.mockNextResponse({ type: 'text', text: 'ok' });
await ctx.agent.generate(
ctx.agent.config.provider,
'summarizer prompt',
[],
[{ role: 'user', content: [{ type: 'text', text: 'hi' }], toolCalls: [] }],
undefined,
{ signal: new AbortController().signal },
);
const request = recordsOf(persistence, 'llm.request').at(-1)!;
expect(request.systemPrompt).toBe('summarizer prompt');
expect(request.messageCount).toBe(1);
});
it('records the effective kimi thinking effort and keep passthrough', async () => {
vi.stubEnv('KIMI_MODEL_THINKING_EFFORT', 'max');
try {
const persistence = new InMemoryAgentRecordPersistence();
const ctx = testAgent({ persistence });
ctx.configure();
ctx.agent.config.update({ thinkingEffort: 'high' });
ctx.mockNextResponse({ type: 'text', text: 'ok' });
await runTurn(ctx, 'think about it');
const request = recordsOf(persistence, 'llm.request').at(-1)!;
// The Kimi provider derives thinkingEffort from the request body's
// thinking payload, so the env override is the recorded wire value.
expect(request.thinkingEffort).toBe('max');
// Default preserved-thinking passthrough while thinking is on.
expect(request.thinkingKeep).toBe('all');
} finally {
vi.unstubAllEnvs();
}
});
it('does not record a call that fails the pre-flight abort check', async () => {
const persistence = new InMemoryAgentRecordPersistence();
const ctx = testAgent({ persistence });
ctx.configure();
// Already-aborted signal: kosong generate() throws before dispatching,
// so the call never reaches the wire and must leave no request trace.
const controller = new AbortController();
controller.abort();
const recordCountBefore = persistence.records.length;
await expect(
ctx.agent.generate(
ctx.agent.config.provider,
'prompt',
[],
[{ role: 'user', content: [{ type: 'text', text: 'hi' }], toolCalls: [] }],
undefined,
{ signal: controller.signal },
),
).rejects.toMatchObject({ name: 'AbortError' });
expect(persistence.records).toHaveLength(recordCountBefore);
expect(recordsOf(persistence, 'llm.request')).toHaveLength(0);
expect(recordsOf(persistence, 'llm.tools_snapshot')).toHaveLength(0);
});
});
describe('mcp.tools_discovered records', () => {
const RAW_TOOLS: MCPToolDefinition[] = [
{
name: 'query_range',
description: 'Query a metrics range',
inputSchema: {
type: 'object',
properties: { query: { type: 'string' } },
required: ['query'],
},
},
];
function fakeMcp(input: {
readonly serverName?: string;
readonly rawTools: readonly MCPToolDefinition[];
readonly enabledNames: () => ReadonlySet<string>;
readonly onListener?: (listener: McpStatusListener) => void;
}): { mcp: McpConnectionManager; entry: McpServerEntry } {
const client: MCPClient = {
async listTools() {
return [...input.rawTools];
},
async callTool() {
return { content: [{ type: 'text', text: 'ok' }], isError: false };
},
};
const entry: McpServerEntry = {
name: input.serverName ?? 'grafana',
transport: 'stdio',
status: 'connected',
toolCount: input.rawTools.length,
};
const mcp = {
list: () => [entry],
onStatusChange: (listener: McpStatusListener) => {
input.onListener?.(listener);
return () => {};
},
resolved: () => ({
client,
tools: input.rawTools.map((definition) => ({
name: definition.name,
description: definition.description,
parameters: definition.inputSchema as Record<string, unknown>,
})),
rawTools: input.rawTools,
enabledNames: input.enabledNames(),
}),
oauthService: undefined,
getRemoteServerUrl: () => undefined,
} as unknown as McpConnectionManager;
return { mcp, entry };
}
function attachFakeMcp(ctx: TestAgentContext, mcp: McpConnectionManager): void {
(ctx.agent as { mcp?: McpConnectionManager }).mcp = mcp;
ctx.agent.tools.attachMcpTools();
}
it('records the raw tools/list once and dedups unchanged re-registrations', async () => {
const persistence = new InMemoryAgentRecordPersistence();
const ctx = testAgent({ persistence });
ctx.configure({ tools: ['mcp__*'] });
let statusListener: McpStatusListener | undefined;
let enabled: ReadonlySet<string> = new Set(['query_range']);
const { mcp, entry } = fakeMcp({
rawTools: RAW_TOOLS,
enabledNames: () => enabled,
onListener: (listener) => {
statusListener = listener;
},
});
attachFakeMcp(ctx, mcp);
// Reconnect with identical content: no second record.
statusListener?.(entry);
const discoveries = recordsOf(persistence, 'mcp.tools_discovered');
expect(discoveries).toHaveLength(1);
expect(discoveries[0]).toMatchObject({
serverName: 'grafana',
tools: RAW_TOOLS,
enabledNames: ['query_range'],
});
expect(discoveries[0]!.collisions).toBeUndefined();
// An allow-list change is a different gating decision — record it.
enabled = new Set();
statusListener?.(entry);
expect(recordsOf(persistence, 'mcp.tools_discovered')).toHaveLength(2);
});
it('does not re-log a durable discovery after resume', async () => {
const persistence = new InMemoryAgentRecordPersistence();
const ctx = testAgent({ persistence });
ctx.configure({ tools: ['mcp__*'] });
const first = fakeMcp({
rawTools: RAW_TOOLS,
enabledNames: () => new Set(['query_range']),
});
attachFakeMcp(ctx, first.mcp);
expect(recordsOf(persistence, 'mcp.tools_discovered')).toHaveLength(1);
const resumedPersistence = new InMemoryAgentRecordPersistence(
structuredClone(persistence.records),
);
const resumed = testAgent({ persistence: resumedPersistence });
await resumed.agent.resume();
const second = fakeMcp({
rawTools: RAW_TOOLS,
enabledNames: () => new Set(['query_range']),
});
attachFakeMcp(resumed, second.mcp);
expect(recordsOf(resumedPersistence, 'mcp.tools_discovered')).toHaveLength(1);
});
it('parks a pre-resume discovery and dedups it against the replayed record', async () => {
const persistence = new InMemoryAgentRecordPersistence();
const ctx = testAgent({ persistence });
ctx.configure({ tools: ['mcp__*'] });
const first = fakeMcp({
rawTools: RAW_TOOLS,
enabledNames: () => new Set(['query_range']),
});
attachFakeMcp(ctx, first.mcp);
expect(recordsOf(persistence, 'mcp.tools_discovered')).toHaveLength(1);
// Real Session ordering: MCP servers are already connected when the
// resumed agent is constructed, so ToolManager attaches (and observes the
// discovery) BEFORE agent.resume() replays the wire.
const resumedPersistence = new InMemoryAgentRecordPersistence(
structuredClone(persistence.records),
);
const recordCountBeforeResume = resumedPersistence.records.length;
const resumed = testAgent({ persistence: resumedPersistence });
const second = fakeMcp({
rawTools: RAW_TOOLS,
enabledNames: () => new Set(['query_range']),
});
attachFakeMcp(resumed, second.mcp);
// Parked: nothing may be appended before replay — in particular no
// duplicate discovery and no stray metadata record.
expect(resumedPersistence.records).toHaveLength(recordCountBeforeResume);
await resumed.agent.resume();
expect(recordsOf(resumedPersistence, 'mcp.tools_discovered')).toHaveLength(1);
expect(
resumedPersistence.records.filter((record) => record.type === 'metadata'),
).toHaveLength(1);
});
it('parks a discovery observed before the log opens and writes it after the first record', async () => {
const persistence = new InMemoryAgentRecordPersistence();
const ctx = testAgent({ persistence });
// Attach BEFORE configure: nothing durable exists yet, so the discovery
// must park instead of opening the log with an observability record.
const { mcp } = fakeMcp({
rawTools: RAW_TOOLS,
enabledNames: () => new Set(['query_range']),
});
attachFakeMcp(ctx, mcp);
expect(persistence.records).toHaveLength(0);
ctx.configure({ tools: ['mcp__*'] });
expect(recordsOf(persistence, 'mcp.tools_discovered')).toHaveLength(1);
expect(persistence.records[0]!.type).toBe('metadata');
expect(
persistence.records.filter((record) => record.type === 'metadata'),
).toHaveLength(1);
});
it('re-records when only the collision outcome changes', async () => {
const persistence = new InMemoryAgentRecordPersistence();
const ctx = testAgent({ persistence });
ctx.configure({ tools: ['mcp__*'] });
// "graf.ana" sanitizes to "graf_ana", so both servers qualify their tool
// as mcp__graf_ana__query_range; whoever registers first wins the name.
const occupant: MCPClient = {
async listTools() {
return [];
},
async callTool() {
return { content: [], isError: false };
},
};
ctx.agent.tools.registerMcpServer('graf.ana', occupant, [
{ name: 'query_range', description: 'occupies the qualified name', parameters: {} },
]);
let statusListener: McpStatusListener | undefined;
const { mcp, entry } = fakeMcp({
serverName: 'graf_ana',
rawTools: RAW_TOOLS,
enabledNames: () => new Set(['query_range']),
onListener: (listener) => {
statusListener = listener;
},
});
attachFakeMcp(ctx, mcp);
const first = recordsOf(persistence, 'mcp.tools_discovered');
expect(first).toHaveLength(1);
expect(first[0]!.collisions).toHaveLength(1);
// Same rawTools and allow-list, but the colliding server is gone: the
// outcome flips, so a new record must be written.
ctx.agent.tools.unregisterMcpServer('graf.ana');
statusListener?.(entry);
const all = recordsOf(persistence, 'mcp.tools_discovered');
expect(all).toHaveLength(2);
expect(all[1]!.collisions).toBeUndefined();
});
});

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -19,7 +19,8 @@ import {
isLoadableToolsAnnouncement,
} from '../../src/agent/context/dynamic-tools';
import { ToolManager } from '../../src/agent/tool';
import type { Agent } from '../../src/agent';
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';
@ -274,6 +275,45 @@ describe('disclosure mode — select_tools three branches and dispatch', () => {
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();

View file

@ -309,6 +309,8 @@ describe('Agent tools', () => {
[wire] context.append_message { "message": { "role": "user", "content": [ { "type": "text", "text": "<auto-mode-enter-reminder>" } ], "toolCalls": [], "origin": { "kind": "injection", "variant": "permission_mode" } }, "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>" }
[wire] llm.tools_snapshot { "hash": "3bfeb22e61431247933e79f6ab94e7ca14a127f899bc87e7bbd22594ba9cdb66", "tools": [ { "name": "Lookup", "description": "Look up a short test value.", "parameters": { "type": "object", "properties": { "query": { "type": "string" } }, "required": [ "query" ], "additionalProperties": false } } ], "time": "<time>" }
[wire] llm.request { "kind": "loop", "provider": "kimi", "model": "mock-model", "modelAlias": "mock-model", "thinkingEffort": "off", "maxTokens": 1000000, "toolSelect": false, "systemPromptHash": "ec9c34379c88babbc468ef2f3e0e08cd2f422c8c4a910664fb8bb394d703a575", "toolsHash": "3bfeb22e61431247933e79f6ab94e7ca14a127f899bc87e7bbd22594ba9cdb66", "messageCount": 2, "turnStep": "0.1", "time": "<time>" }
[emit] assistant.delta { "turnId": 0, "delta": "I will look it up." }
[emit] tool.call.delta { "turnId": 0, "toolCallId": "call_lookup", "name": "Lookup", "argumentsPart": "{\\"query\\":\\"moon\\"}" }
[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 look it up." } }, "time": "<time>" }
@ -334,6 +336,7 @@ describe('Agent tools', () => {
[emit] agent.status.updated { "model": "mock-model", "contextTokens": 104, "maxContextTokens": 1000000, "contextUsage": 0.000104, "planMode": false, "swarmMode": false, "permission": "auto", "usage": { "byModel": { "mock-model": { "inputOther": 88, "output": 16, "inputCacheRead": 0, "inputCacheCreation": 0 } }, "total": { "inputOther": 88, "output": 16, "inputCacheRead": 0, "inputCacheCreation": 0 }, "currentTurn": { "inputOther": 88, "output": 16, "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>" }
[wire] llm.request { "kind": "loop", "provider": "kimi", "model": "mock-model", "modelAlias": "mock-model", "thinkingEffort": "off", "maxTokens": 999896, "toolSelect": false, "systemPromptHash": "ec9c34379c88babbc468ef2f3e0e08cd2f422c8c4a910664fb8bb394d703a575", "toolsHash": "3bfeb22e61431247933e79f6ab94e7ca14a127f899bc87e7bbd22594ba9cdb66", "messageCount": 4, "turnStep": "0.2", "time": "<time>" }
[emit] assistant.delta { "turnId": 0, "delta": "The lookup result is moon-result." }
[wire] context.append_loop_event { "event": { "type": "content.part", "uuid": "<uuid-4>", "turnId": "0", "step": 2, "stepUuid": "<uuid-3>", "part": { "type": "text", "text": "The lookup result is moon-result." } }, "time": "<time>" }
[wire] context.append_loop_event { "event": { "type": "step.end", "uuid": "<uuid-3>", "turnId": "0", "step": 2, "usage": { "inputOther": 108, "output": 12, "inputCacheRead": 0, "inputCacheCreation": 0 }, "finishReason": "end_turn", "messageId": "mock-2" }, "time": "<time>" }
@ -360,6 +363,8 @@ describe('Agent tools', () => {
[wire] context.append_message { "message": { "role": "user", "content": [ { "type": "text", "text": "Can you still use Lookup?" } ], "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>" }
[wire] llm.tools_snapshot { "hash": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945", "tools": [], "time": "<time>" }
[wire] llm.request { "kind": "loop", "provider": "kimi", "model": "mock-model", "modelAlias": "mock-model", "thinkingEffort": "off", "maxTokens": 999880, "toolSelect": false, "systemPromptHash": "ec9c34379c88babbc468ef2f3e0e08cd2f422c8c4a910664fb8bb394d703a575", "toolsHash": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945", "messageCount": 6, "turnStep": "1.1", "time": "<time>" }
[emit] assistant.delta { "turnId": 1, "delta": "No lookup tool is available." }
[wire] context.append_loop_event { "event": { "type": "content.part", "uuid": "<uuid-6>", "turnId": "1", "step": 1, "stepUuid": "<uuid-5>", "part": { "type": "text", "text": "No lookup tool is available." } }, "time": "<time>" }
[wire] context.append_loop_event { "event": { "type": "step.end", "uuid": "<uuid-5>", "turnId": "1", "step": 1, "usage": { "inputOther": 128, "output": 10, "inputCacheRead": 0, "inputCacheCreation": 0 }, "finishReason": "end_turn", "messageId": "mock-3" }, "time": "<time>" }

File diff suppressed because one or more lines are too long

View file

@ -26,6 +26,36 @@ function makeInput(
}
describe('chatWithRetry: terminated stream drops', () => {
it('preserves caller-set requestLogFields across attempts while owning turnStep/attempt', async () => {
// The strict-resend path marks its params with `projection: 'strict'`;
// the per-attempt rebuild must merge that marker instead of replacing
// the whole fields object.
let calls = 0;
const seenFields: Array<LLMChatParams['requestLogFields']> = [];
const llm: LLM = {
systemPrompt: '',
modelName: 'mock',
isRetryableError: (e) => isRetryableGenerateError(e),
async chat(params: LLMChatParams): Promise<LLMChatResponse> {
calls += 1;
seenFields.push(params.requestLogFields);
if (calls === 1) throw new APIConnectionError('terminated');
return okResponse();
},
};
const input = makeInput(llm, new AbortController().signal);
await chatWithRetry({
...input,
params: { ...input.params, requestLogFields: { projection: 'strict' } },
});
expect(seenFields).toEqual([
{ projection: 'strict', turnStep: 't.1' },
{ projection: 'strict', turnStep: 't.1', attempt: '2/3' },
]);
});
it('retries an APIConnectionError("terminated") and succeeds on a later attempt', async () => {
// A mid-stream `terminated` is classified as a retryable APIConnectionError,
// so an intermittent connection drop should be recovered transparently.

View file

@ -161,6 +161,14 @@ describe('McpConnectionManager', () => {
const resolved = cm.resolved('filtered');
expect(resolved).toBeDefined();
expect([...(resolved?.enabledNames ?? [])]).toEqual(['echo']);
// The raw tools/list result stays verbatim and unfiltered — the
// allow-list only gates registration, not the discovery trace.
const rawNames = resolved?.rawTools.map((tool) => tool.name) ?? [];
expect(rawNames).toContain('echo');
expect(rawNames).toContain('boom');
for (const rawTool of resolved?.rawTools ?? []) {
expect(rawTool.inputSchema).toBeDefined();
}
const entry = cm.get('filtered');
expect(entry?.toolCount).toBe(1);
} finally {

View file

@ -185,6 +185,17 @@ export interface ChatProvider {
readonly modelName: string;
/** Current thinking effort, or `null` if thinking is not configured. */
readonly thinkingEffort: ThinkingEffort | null;
/**
* The effective completion-token cap this instance will send on the wire,
* derived from its generation kwargs covering constructor defaults (e.g.
* Anthropic's required `max_tokens`), direct kwargs configuration, and
* {@link withMaxCompletionTokens} (after any implementation-side clamping:
* remaining context window, transport ceilings, model-default resolution).
* `undefined` when the instance sends no cap. Read by hosts that record
* the outbound request, so the recorded value matches what the provider
* actually sends.
*/
readonly maxCompletionTokens?: number;
/**
* Send a conversation to the LLM and return a streamed response.
*

View file

@ -940,6 +940,15 @@ class AnthropicStreamedMessage implements StreamedMessage {
export class AnthropicChatProvider implements ChatProvider {
readonly name: string = 'anthropic';
/**
* See {@link ChatProvider.maxCompletionTokens}. `max_tokens` is required by
* the Messages API and is initialized in the constructor, so this reflects
* the wire value even when no completion budget was applied.
*/
get maxCompletionTokens(): number | undefined {
return this._generationKwargs.max_tokens;
}
private _model: string;
private _stream: boolean;
private _client: Anthropic | undefined;

View file

@ -694,6 +694,11 @@ export function convertGoogleGenAIError(error: unknown): ChatProviderError {
export class GoogleGenAIChatProvider implements ChatProvider {
readonly name: string = 'google_genai';
/** See {@link ChatProvider.maxCompletionTokens}. */
get maxCompletionTokens(): number | undefined {
return this._generationKwargs.maxOutputTokens;
}
private _model: string;
private _client: GenAIClient | undefined;
private _generationKwargs: GoogleGenAIGenerationKwargs;

View file

@ -374,6 +374,15 @@ class KimiStreamedMessage implements StreamedMessage {
export class KimiChatProvider implements ChatProvider {
readonly name: string = 'kimi';
/**
* See {@link ChatProvider.maxCompletionTokens}. Mirrors the request-time
* normalization: `max_completion_tokens` wins over the legacy `max_tokens`
* alias.
*/
get maxCompletionTokens(): number | undefined {
return this._generationKwargs.max_completion_tokens ?? this._generationKwargs.max_tokens;
}
private _model: string;
private _stream: boolean;
private _apiKey: string | undefined;

View file

@ -450,6 +450,16 @@ export class OpenAILegacyStreamedMessage implements StreamedMessage {
export class OpenAILegacyChatProvider implements ChatProvider {
readonly name: string = 'openai';
/**
* See {@link ChatProvider.maxCompletionTokens}. Reuses the request-time
* kwargs normalization so the model-dependent `max_tokens` /
* `max_completion_tokens` aliasing is mirrored exactly.
*/
get maxCompletionTokens(): number | undefined {
const kwargs = normalizeGenerationKwargs(this._model, this._generationKwargs);
return kwargs.max_completion_tokens ?? kwargs.max_tokens;
}
private _model: string;
private _stream: boolean;
private _apiKey: string | undefined;

View file

@ -983,6 +983,11 @@ export class OpenAIResponsesStreamedMessage implements StreamedMessage {
export class OpenAIResponsesChatProvider implements ChatProvider {
readonly name: string = 'openai-responses';
/** See {@link ChatProvider.maxCompletionTokens}. */
get maxCompletionTokens(): number | undefined {
return this._generationKwargs.max_output_tokens;
}
private _model: string;
private _stream: boolean;
private _apiKey: string | undefined;

View file

@ -2774,6 +2774,25 @@ describe('AnthropicChatProvider constructor max_tokens', () => {
expect(provider).not.toBe(original);
expect(body['max_tokens']).toBe(2048);
expect(provider.maxCompletionTokens).toBe(2048);
});
it('exposes the constructor-resolved max_tokens without any budget application', async () => {
// max_tokens is required by the Messages API, so even when completion
// budgeting is disabled the wire carries the constructor default; the
// exposed cap must reflect it for the request trace.
const provider = new AnthropicChatProvider({
model: 'claude-opus-4-7',
apiKey: 'test-key',
stream: false,
});
const history: Message[] = [
{ role: 'user', content: [{ type: 'text', text: 'hi' }], toolCalls: [] },
];
const body = await captureRequestBody(provider, '', [], history);
expect(provider.maxCompletionTokens).toBeDefined();
expect(provider.maxCompletionTokens).toBe(body['max_tokens']);
});
it('withMaxCompletionTokens lowers the inferred model default cap', async () => {
@ -2803,6 +2822,9 @@ describe('AnthropicChatProvider constructor max_tokens', () => {
const body = await captureRequestBody(provider, '', [], history);
expect(body['max_tokens']).toBe(1024);
// The exposed effective cap tracks the preserved existing value, not the
// requested budget — the request trace records this field.
expect(provider.maxCompletionTokens).toBe(1024);
});
it('withMaxCompletionTokens preserves an existing higher max_tokens cap', async () => {

View file

@ -663,6 +663,7 @@ describe('GoogleGenAIChatProvider', () => {
const config = body['config'] as Record<string, unknown>;
expect(provider).not.toBe(original);
expect(config['maxOutputTokens']).toBe(1024);
expect(provider.maxCompletionTokens).toBe(1024);
});
});

View file

@ -642,6 +642,10 @@ describe('KimiChatProvider', () => {
expect(body['max_completion_tokens']).toBe(1024);
expect(body['max_tokens']).toBeUndefined();
expect(provider.maxCompletionTokens).toBe(1024);
// Without any budget application no cap goes on the wire, and the
// exposed value says so.
expect(createProvider().maxCompletionTokens).toBeUndefined();
});
it('withMaxCompletionTokens sizes the cap to the remaining context window', async () => {
@ -655,6 +659,9 @@ describe('KimiChatProvider', () => {
const body = await captureRequestBody(provider, '', [], history);
expect(body['max_completion_tokens']).toBe(70000);
// The exposed effective cap matches the clamped wire value, not the
// requested budget — the request trace records this field.
expect(provider.maxCompletionTokens).toBe(70000);
});
it('passes constructor generation kwargs into the request body', async () => {

View file

@ -654,6 +654,9 @@ describe('OpenAILegacyChatProvider', () => {
// 1000000 - 30000 = 970000, clamped to 131072
expect(body['max_tokens']).toBe(131072);
// The exposed effective cap matches the ceiling-clamped wire value —
// the request trace records this field.
expect(provider.maxCompletionTokens).toBe(131072);
});
});
@ -670,6 +673,9 @@ describe('OpenAILegacyChatProvider', () => {
];
const body = await captureRequestBody(provider, '', [], history);
expect(body['max_tokens']).toBe(1024);
// The constructor-level cap is on the wire without any budget
// application, so the exposed cap must reflect it too.
expect(provider.maxCompletionTokens).toBe(1024);
});
it('does not inject max_tokens when maxTokens option is omitted', async () => {

View file

@ -908,6 +908,7 @@ describe('OpenAIResponsesChatProvider', () => {
expect(provider).not.toBe(original);
expect(body['max_output_tokens']).toBe(1024);
expect(provider.maxCompletionTokens).toBe(1024);
});
});