kimi-code/packages/agent-core/test/loop/retry.test.ts
Kai 65d30177ad
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
2026-07-07 14:09:19 +08:00

159 lines
5.2 KiB
TypeScript

import { APIConnectionError, emptyUsage, isRetryableGenerateError } from '@moonshot-ai/kosong';
import { describe, expect, it } from 'vitest';
import type { KimiConfig } from '#/config';
import { ErrorCodes, KimiError } from '#/errors';
import type { LLM, LLMChatParams, LLMChatResponse } from '#/loop/llm';
import { chatWithRetry } from '#/loop/retry';
import { ProviderManager } from '#/session/provider-manager';
function okResponse(): LLMChatResponse {
return { toolCalls: [], usage: emptyUsage() };
}
function makeInput(
llm: LLM,
signal: AbortSignal,
): Parameters<typeof chatWithRetry>[0] {
return {
llm,
params: { messages: [], tools: [], signal },
dispatchEvent: async () => {},
turnId: 't',
currentStep: 1,
stepUuid: 'u',
};
}
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.
let calls = 0;
const llm: LLM = {
systemPrompt: '',
modelName: 'mock',
isRetryableError: (e) => isRetryableGenerateError(e),
async chat(_params: LLMChatParams): Promise<LLMChatResponse> {
calls += 1;
if (calls === 1) throw new APIConnectionError('terminated');
return okResponse();
},
};
const response = await chatWithRetry(makeInput(llm, new AbortController().signal));
expect(calls).toBe(2);
expect(response).toEqual(okResponse());
});
it('does NOT retry when the signal is aborted (user ESC), surfacing a clean AbortError', async () => {
// Even though `terminated` is retryable, a user-aborted request must never
// be retried: the abort signal is checked before any retry, so it surfaces
// as an AbortError rather than a provider error.
let calls = 0;
const ac = new AbortController();
ac.abort();
const llm: LLM = {
systemPrompt: '',
modelName: 'mock',
isRetryableError: (e) => isRetryableGenerateError(e),
async chat(_params: LLMChatParams): Promise<LLMChatResponse> {
calls += 1;
throw new APIConnectionError('terminated');
},
};
await expect(chatWithRetry(makeInput(llm, ac.signal))).rejects.toMatchObject({
name: 'AbortError',
});
expect(calls).toBe(1);
});
it('does not retry OAuth token fetch connection errors (already retried internally)', async () => {
let tokenCalls = 0;
const manager = new ProviderManager({
config: oauthConfig(),
resolveOAuthTokenProvider: () => ({
async getAccessToken() {
tokenCalls += 1;
throw new KimiError(
ErrorCodes.PROVIDER_CONNECTION_ERROR,
'OAuth provider "managed:kimi-code" failed to fetch an access token: fetch failed',
);
},
}),
});
const resolveAuth = manager.resolveAuth('kimi-code/kimi-for-coding');
if (resolveAuth === undefined) throw new Error('expected OAuth auth resolver');
let chatCalls = 0;
const llm: LLM = {
systemPrompt: '',
modelName: 'mock',
isRetryableError: (e) => isRetryableGenerateError(e),
async chat(_params: LLMChatParams): Promise<LLMChatResponse> {
chatCalls += 1;
return resolveAuth(async () => okResponse());
},
};
await expect(chatWithRetry(makeInput(llm, new AbortController().signal))).rejects.toMatchObject({
code: ErrorCodes.PROVIDER_CONNECTION_ERROR,
});
expect(chatCalls).toBe(1);
expect(tokenCalls).toBe(1);
});
});
function oauthConfig(): KimiConfig {
return {
defaultModel: 'kimi-code/kimi-for-coding',
providers: {
'managed:kimi-code': {
type: 'kimi',
apiKey: '',
baseUrl: 'https://api.example/v1',
oauth: { storage: 'file', key: 'oauth/kimi-code' },
},
},
models: {
'kimi-code/kimi-for-coding': {
provider: 'managed:kimi-code',
model: 'kimi-for-coding',
maxContextSize: 1_000_000,
},
},
};
}