mirror of
https://github.com/MoonshotAI/kimi-code.git
synced 2026-08-22 23:26:12 +00:00
Merge branch 'kimi-code-v2' of https://github.com/MoonshotAI/kimi-code into kimi-code-v2
This commit is contained in:
commit
d8f54b8530
16 changed files with 175 additions and 86 deletions
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
|
|
|
|||
42
packages/agent-core-v2/src/app/web/tools/fetch-url-types.ts
Normal file
42
packages/agent-core-v2/src/app/web/tools/fetch-url-types.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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`. */
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -496,7 +496,7 @@ function parseRgJsonOutput(
|
|||
}
|
||||
}
|
||||
|
||||
for (const p of [...fileBuf.keys()]) {
|
||||
for (const p of Array.from(fileBuf.keys())) {
|
||||
finalize(p);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 = [];
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue