Merge branch 'kimi-code-v2' of https://github.com/MoonshotAI/kimi-code into kimi-code-v2

This commit is contained in:
_Kerman 2026-07-07 20:51:56 +08:00
commit d8f54b8530
16 changed files with 175 additions and 86 deletions

View file

@ -5,7 +5,13 @@
* (`ContextMessage[]`): reads through `wire.getModel`, writes through the
* wire-protocol 1.4 Ops (`append` / `clear` / `undo` / `applyCompaction`), with
* `splice` retained for protocol 1.5 replay and the rare internal single-delete.
* Every mutation still fires `onSpliced` from the live path only (replay rebuilds
* As the sole live mutation gateway for the history, it also cascades a
* `context_size.measured` Op alongside every mutation that changes the measured
* prefix `clear` resets it, `applyCompaction` adopts `tokensAfter`, and
* `undo` / `splice` rebase it (to an estimate when the measured aggregate is
* truncated); `append` leaves the measured prefix untouched since new messages
* are the unmeasured tail (see `contextSizeService`). Every mutation still fires
* `onSpliced` from the live path only (replay rebuilds
* the Model silently and never invokes these methods), so existing subscribers
* (micro-compaction, context-injector, task-notification) observe the same
* splice-shaped change events regardless of which 1.4 Op was persisted. Message
@ -17,8 +23,11 @@
import { Disposable } from '#/_base/di/lifecycle';
import { InstantiationType } from '#/_base/di/extensions';
import { LifecycleScope, registerScopedService } from '#/_base/di/scope';
import { estimateTokensForMessages } from '#/_base/utils/tokens';
import { IEventBus } from '#/app/event/eventBus';
import { ContextSizeModel, contextSizeMeasured } from '#/agent/contextSize/contextSizeOps';
import { IAgentWireService } from '#/wire/tokens';
import type { Op } from '#/wire/op';
import type { IWireService } from '#/wire/wireService';
import {
@ -91,7 +100,7 @@ export class AgentContextMemoryService extends Disposable implements IAgentConte
clear(): void {
const deleteCount = this.get().length;
if (deleteCount === 0) return;
this.wire.dispatch(contextClear({}));
this.wire.dispatch(contextClear({}), contextSizeMeasured({ length: 0, tokens: 0 }));
this.eventBus.publish({ type: 'context.spliced', start: 0, deleteCount, messages: [] });
}
@ -99,7 +108,7 @@ export class AgentContextMemoryService extends Disposable implements IAgentConte
const history = this.get();
const cut = computeUndoCut(history, count);
if (cut.cutIndex >= 0 && cut.removedCount >= count) {
this.wire.dispatch(contextUndo({ count }));
this.wire.dispatch(contextUndo({ count }), ...this.sizeOpsForCut(cut.cutIndex, history));
this.eventBus.publish({ type: 'context.spliced',
start: cut.cutIndex,
deleteCount: history.length - cut.cutIndex,
@ -123,6 +132,7 @@ export class AgentContextMemoryService extends Disposable implements IAgentConte
keptHeadUserMessageCount: result.keptHeadUserMessageCount,
droppedCount: result.droppedCount,
}),
contextSizeMeasured({ length: result.messages.length, tokens: result.tokensAfter }),
);
this.eventBus.publish({ type: 'context.spliced',
start: 0,
@ -142,7 +152,10 @@ export class AgentContextMemoryService extends Disposable implements IAgentConte
tokens?: number,
): void {
const stamped = messages.map(ensureMessageId);
this.wire.dispatch(contextSplice({ start, deleteCount, messages: stamped, tokens }));
this.wire.dispatch(
contextSplice({ start, deleteCount, messages: stamped, tokens }),
...this.sizeOpsForSplice(start, deleteCount, stamped, tokens),
);
this.eventBus.publish({ type: 'context.spliced',
start,
deleteCount,
@ -150,6 +163,49 @@ export class AgentContextMemoryService extends Disposable implements IAgentConte
tokens,
});
}
/**
* Cascade a `context_size.measured` Op when an undo truncates the measured
* prefix (`ContextSizeModel.length`). If the surviving context still covers
* the measured prefix, the measurement stays valid and nothing is emitted;
* otherwise the prefix is rebased to an estimate of the surviving messages
* (an aggregate measured count can't be truncated without per-message data).
*/
private sizeOpsForCut(cutIndex: number, history: readonly ContextMessage[]): Op[] {
const model = this.wire.getModel(ContextSizeModel);
if (model.length <= cutIndex) return [];
return [
contextSizeMeasured({
length: cutIndex,
tokens: estimateTokensForMessages(history.slice(0, cutIndex)),
}),
];
}
/**
* Cascade a `context_size.measured` Op when a splice touches the measured
* prefix. A splice confined to the unmeasured tail leaves the prefix intact
* and emits nothing; a splice that reaches into the prefix invalidates the
* measured aggregate, so the whole surviving context is rebased to an
* estimate (caller-provided `tokens` win when present).
*/
private sizeOpsForSplice(
start: number,
deleteCount: number,
inserted: readonly ContextMessage[],
tokens?: number,
): Op[] {
const model = this.wire.getModel(ContextSizeModel);
if (start >= model.length) return [];
const next = this.get().slice();
next.splice(start, deleteCount, ...inserted);
return [
contextSizeMeasured({
length: next.length,
tokens: tokens ?? estimateTokensForMessages(next),
}),
];
}
}
registerScopedService(

View file

@ -5,17 +5,23 @@
*
* Declares the deterministic measured prefix as `{ length, tokens }` (initial
* `{ 0, 0 }`): the length (in messages) and total token count of the most
* recent `context_size.measured` record. `apply` is pure it normalizes the
* recent `context_size.measured` record. That record is written from two live
* paths: `llmRequester` after each measured exchange (a true LLM-reported
* count), and `contextMemoryService` cascading alongside every context mutation
* that changes the measured prefix (`clear` resets, `applyCompaction` adopts
* `tokensAfter`, `undo` / `splice` rebase to an estimate when the aggregate is
* truncated); `append` is intentionally not cascaded because new messages are
* the unmeasured tail. Because both writes go through the same Op and the
* cascaded values are persisted on the record, `wire.dispatch` and `wire.replay`
* produce identical state. `apply` is pure it normalizes the
* payload and returns the SAME reference on a no-op so the wire's
* reference-equality gate stays quiet and carries no non-determinism, so
* `wire.dispatch(contextSizeMeasured(...))` and `wire.replay` produce identical
* state (the last measured record wins). The sparse `measuredPrefixTokens`
* array and the per-message live `estimates` (including the compaction-provided
* `context.tokens`) are intentionally NOT in the Model: they are inherently
* live estimates, recomputed on the live read path from the surviving context
* and never persisted or replayed mirroring the `goal` domain's
* `wallClockMs` split (deterministic in the Model, live-only out). Consumed by
* the Agent-scope `contextSizeService`.
* reference-equality gate stays quiet and carries no non-determinism (the
* last measured record wins). The sparse `measuredPrefixTokens` array and the
* per-message live `estimates` are intentionally NOT in the Model: only the
* aggregate prefix is persisted, while sub-range estimates are recomputed on
* the live read path from the surviving context mirroring the `goal`
* domain's `wallClockMs` split (deterministic in the Model, live-only out).
* Consumed by the Agent-scope `contextSizeService`.
*/
import { defineModel } from '#/wire/model';

View file

@ -72,10 +72,7 @@ export class AgentPlanService extends Disposable implements IAgentPlanService {
return generateHeroSlug(randomUUID(), new Set());
}
async enter(
id = this.createPlanId(),
createFile = false,
): Promise<void> {
async enter(id = this.createPlanId(), createFile = false): Promise<void> {
if (this.isActive) {
throw new Error('Already in plan mode');
}

View file

@ -10,7 +10,6 @@
import { createDecorator } from '#/_base/di/instantiation';
import type { ITaskHandle } from '#/app/task/task';
import type { Hooks } from '#/hooks';
import type {
AgentTask,
AgentTaskInfo,

View file

@ -54,9 +54,9 @@ import {
import { LEGACY_BACKGROUND_SECTION, TASK_SECTION, type AgentTaskConfig } from './configSection';
import { AgentTaskPersistence } from './persist';
import { TaskModel, taskStarted, taskTerminated } from './taskOps';
import { TaskListTool } from '#/agent/task/tools/task-list';
import { TaskOutputTool } from '#/agent/task/tools/task-output';
import { TaskStopTool } from '#/agent/task/tools/task-stop';
import '#/agent/task/tools/task-list';
import '#/agent/task/tools/task-output';
import '#/agent/task/tools/task-stop';
interface ForegroundRelease {
readonly promise: Promise<ForegroundTaskReleaseReason>;

View file

@ -149,11 +149,10 @@ export class SessionLegacyService implements ISessionLegacyService {
const handle = await this.lifecycle.fork({
sourceSessionId: sessionId,
title: body.title ?? `Child: ${parentTitle || sessionId}`,
metadata: {
...(body.metadata ?? {}),
metadata: Object.assign({}, body.metadata, {
parent_session_id: sessionId,
child_session_kind: CHILD_SESSION_KIND,
},
}),
});
const meta = await handle.accessor.get(ISessionMetadata).read();
const ctx = handle.accessor.get(ISessionContext);

View file

@ -12,11 +12,7 @@
import { promises as fs } from 'node:fs';
import path from 'pathe';
import {
SkillParseError,
UnsupportedSkillTypeError,
parseSkillText,
} from './parser';
import { UnsupportedSkillTypeError, parseSkillText } from './parser';
import type { SkillDiscoveryResult, ISkillDiscovery } from './skillDiscovery';
import type { SkillDefinition, SkillRoot, SkippedSkill } from './types';
import { normalizeSkillName } from './types';

View file

@ -1,7 +1,7 @@
import { Readability } from '@mozilla/readability';
import { parseHTML as rawParseHTML } from 'linkedom';
import { HttpFetchError, type UrlFetcher, type UrlFetchResult } from '../tools/fetch-url';
import { HttpFetchError, type UrlFetcher, type UrlFetchResult } from '../tools/fetch-url-types';
// Readability's .d.ts references the global `Document` type, but this
// package compiles with `lib: ES2023` (no DOM). Extracting the

View file

@ -1,4 +1,4 @@
import { HttpFetchError, type UrlFetcher, type UrlFetchResult } from '../tools/fetch-url';
import { HttpFetchError, type UrlFetcher, type UrlFetchResult } from '../tools/fetch-url-types';
export interface BearerTokenProvider {
getAccessToken(options?: { readonly force?: boolean | undefined }): Promise<string>;

View file

@ -0,0 +1,42 @@
/**
* `web` domain (L4) host-injected `UrlFetcher` contract.
*/
/**
* How the returned content relates to the original response body.
*
* - `passthrough` the body was already plain text / markdown and is
* returned verbatim, in full.
* - `extracted` the body was an HTML page; only the main article text
* was extracted and returned.
*/
export type UrlFetchKind = 'passthrough' | 'extracted';
export interface UrlFetchResult {
/** The text handed to the LLM. */
readonly content: string;
/** Whether `content` is a verbatim passthrough or extracted main text. */
readonly kind: UrlFetchKind;
}
export interface UrlFetcher {
fetch(
url: string,
options?: { toolCallId?: string; signal?: AbortSignal },
): Promise<UrlFetchResult>;
}
/**
* Thrown by a `UrlFetcher` when the upstream HTTP request completed but
* returned a non-success status. The tool branches on this to surface
* `Status: N` in the error message; non-HTTP failures (DNS, timeout,
* connection reset, ) keep flowing through as plain `Error`.
*/
export class HttpFetchError extends Error {
override readonly name = 'HttpFetchError';
readonly status: number;
constructor(status: number, message: string) {
super(message);
this.status = status;
}
}

View file

@ -1,12 +1,11 @@
/**
* `web` domain (L4) `FetchURL` builtin tool and its `UrlFetcher` contract.
* `web` domain (L4) `FetchURL` builtin tool.
*
* Defines the `FetchURL` tool and the host-injected `UrlFetcher` interface
* (plus `UrlFetchResult` / `HttpFetchError`). The tool reads its fetcher from
* the App-scope `IWebFetchService` at registry-construction time and
* self-registers via `registerTool(...)` at module load; the default service
* falls back to the built-in `LocalFetchURLProvider`, so `FetchURL` is always
* available without OAuth.
* Defines the `FetchURL` tool. The host-injected `UrlFetcher` contract lives
* in `fetch-url-types`; the tool reads its fetcher from the App-scope
* `IWebFetchService` at registry-construction time and self-registers via
* `registerTool(...)` at module load. The default service falls back to the
* built-in `LocalFetchURLProvider`, so `FetchURL` is always available without OAuth.
*/
import { z } from 'zod';
@ -24,49 +23,9 @@ import { ToolResultBuilder } from '#/agent/tool/result-builder';
import { registerTool } from '#/agent/toolRegistry/toolContribution';
import { IWebFetchService } from '../web';
import { HttpFetchError, type UrlFetcher } from './fetch-url-types';
import DESCRIPTION from './fetch-url.md?raw';
// ── Provider interface (host-injected) ───────────────────────────────
/**
* How the returned content relates to the original response body.
*
* - `passthrough` the body was already plain text / markdown and is
* returned verbatim, in full.
* - `extracted` the body was an HTML page; only the main article text
* was extracted and returned.
*/
export type UrlFetchKind = 'passthrough' | 'extracted';
export interface UrlFetchResult {
/** The text handed to the LLM. */
readonly content: string;
/** Whether `content` is a verbatim passthrough or extracted main text. */
readonly kind: UrlFetchKind;
}
export interface UrlFetcher {
fetch(
url: string,
options?: { toolCallId?: string; signal?: AbortSignal },
): Promise<UrlFetchResult>;
}
/**
* Thrown by a `UrlFetcher` when the upstream HTTP request completed but
* returned a non-success status. The tool branches on this to surface
* `Status: N` in the error message; non-HTTP failures (DNS, timeout,
* connection reset, ) keep flowing through as plain `Error`.
*/
export class HttpFetchError extends Error {
override readonly name = 'HttpFetchError';
readonly status: number;
constructor(status: number, message: string) {
super(message);
this.status = status;
}
}
// ── Input schema ─────────────────────────────────────────────────────
export const FetchURLInputSchema = z.object({

View file

@ -11,10 +11,10 @@
import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation';
import type { UrlFetcher } from './tools/fetch-url';
import type { UrlFetcher } from './tools/fetch-url-types';
export type { UrlFetcher, UrlFetchKind, UrlFetchResult } from './tools/fetch-url';
export { HttpFetchError } from './tools/fetch-url';
export type { UrlFetcher, UrlFetchKind, UrlFetchResult } from './tools/fetch-url-types';
export { HttpFetchError } from './tools/fetch-url-types';
export interface WebFetchServiceOptions {
/** URL fetch backend. Defaults to the built-in `LocalFetchURLProvider`. */

View file

@ -12,7 +12,7 @@ import { InstantiationType } from '#/_base/di/extensions';
import { LifecycleScope, registerScopedService } from '#/_base/di/scope';
import { LocalFetchURLProvider } from './providers/local-fetch-url';
import type { UrlFetcher } from './tools/fetch-url';
import type { UrlFetcher } from './tools/fetch-url-types';
import { IWebFetchService, type WebFetchServiceOptions } from './web';
export class WebFetchService implements IWebFetchService {

View file

@ -496,7 +496,7 @@ function parseRgJsonOutput(
}
}
for (const p of [...fileBuf.keys()]) {
for (const p of Array.from(fileBuf.keys())) {
finalize(p);
}

View file

@ -69,7 +69,7 @@ export class SessionTodoService extends Disposable implements ISessionTodoServic
this._register(
toDisposable(() => {
for (const agentId of [...this.agentBindings.keys()]) {
for (const agentId of Array.from(this.agentBindings.keys())) {
this.disposeAgentBindings(agentId);
}
this.todos = [];

View file

@ -662,6 +662,41 @@ describe('Agent context', () => {
expect(contextSize.get(-1, -3)).toEqual({ size: 0, measured: 0, estimated: 0 });
});
it('resets the measured context size when the context is cleared', () => {
ctx.appendAssistantTextWithUsage(1, 'answer', 1_000);
expect(contextSize.get().measured).toBe(1_000);
context.clear();
expect(contextSize.get()).toEqual({ size: 0, measured: 0, estimated: 0 });
});
it('rebases the measured prefix to an estimate when undo truncates it', () => {
ctx.appendAssistantTextWithUsage(1, 'a1', 1_000);
ctx.appendAssistantTextWithUsage(2, 'a2', 2_000);
// The measured prefix covers the full four-message context.
expect(contextSize.get().measured).toBe(2_000);
ctx.undoHistory(1);
const surviving = context.get();
expect(surviving.map((m) => m.role)).toEqual(['user', 'assistant']);
const estimate = estimateTokensForMessages(surviving);
// The truncated prefix is rebased to an estimate of the surviving context.
expect(contextSize.get()).toEqual({ size: estimate, measured: estimate, estimated: 0 });
});
it('keeps the measured prefix when undo removes only the unmeasured tail', () => {
ctx.appendAssistantTextWithUsage(1, 'a1', 1_000);
ctx.appendUserMessage([{ type: 'text', text: 'unmeasured follow up' }]);
expect(contextSize.get().measured).toBe(1_000);
ctx.undoHistory(1);
expect(context.get().map((m) => m.role)).toEqual(['user', 'assistant']);
expect(contextSize.get()).toEqual({ size: 1_000, measured: 1_000, estimated: 0 });
});
it('undo only counts real user prompts, skipping task notifications', () => {
ctx.appendAssistantText(1, 'first response');
ctx.appendAssistantText(2, 'second response');