From 4d23a80e1d32dd1818c1ee227c7afb3f3187c473 Mon Sep 17 00:00:00 2001 From: "haozhe.yang" Date: Tue, 7 Jul 2026 20:27:20 +0800 Subject: [PATCH] feat(agent-core-v2): cascade context size measurement - cascade context_size.measured when context memory clears, undoes, compacts, or splices the measured prefix - add context memory tests for measured prefix reset and rebase behavior - fix lint issues around task tool side-effect imports, web fetch type cycle, and unused imports --- .../contextMemory/contextMemoryService.ts | 64 +++++++++++++++++-- .../src/agent/contextSize/contextSizeOps.ts | 26 +++++--- .../src/agent/plan/planService.ts | 6 +- packages/agent-core-v2/src/agent/task/task.ts | 1 - .../src/agent/task/taskService.ts | 6 +- .../app/sessionLegacy/sessionLegacyService.ts | 5 +- .../app/skillCatalog/fileSkillDiscovery.ts | 6 +- .../src/app/web/providers/local-fetch-url.ts | 2 +- .../app/web/providers/moonshot-fetch-url.ts | 2 +- .../src/app/web/tools/fetch-url-types.ts | 42 ++++++++++++ .../src/app/web/tools/fetch-url.ts | 55 ++-------------- packages/agent-core-v2/src/app/web/web.ts | 6 +- .../agent-core-v2/src/app/web/webService.ts | 2 +- .../backends/node-local/tools/grepSearch.ts | 2 +- .../src/session/todo/sessionTodoService.ts | 2 +- .../test/contextMemory/context.test.ts | 35 ++++++++++ 16 files changed, 175 insertions(+), 87 deletions(-) create mode 100644 packages/agent-core-v2/src/app/web/tools/fetch-url-types.ts diff --git a/packages/agent-core-v2/src/agent/contextMemory/contextMemoryService.ts b/packages/agent-core-v2/src/agent/contextMemory/contextMemoryService.ts index 68f2024da..0183e5e07 100644 --- a/packages/agent-core-v2/src/agent/contextMemory/contextMemoryService.ts +++ b/packages/agent-core-v2/src/agent/contextMemory/contextMemoryService.ts @@ -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( diff --git a/packages/agent-core-v2/src/agent/contextSize/contextSizeOps.ts b/packages/agent-core-v2/src/agent/contextSize/contextSizeOps.ts index 046b0064e..05dde1600 100644 --- a/packages/agent-core-v2/src/agent/contextSize/contextSizeOps.ts +++ b/packages/agent-core-v2/src/agent/contextSize/contextSizeOps.ts @@ -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'; diff --git a/packages/agent-core-v2/src/agent/plan/planService.ts b/packages/agent-core-v2/src/agent/plan/planService.ts index 6906721c8..6dee4932e 100644 --- a/packages/agent-core-v2/src/agent/plan/planService.ts +++ b/packages/agent-core-v2/src/agent/plan/planService.ts @@ -102,11 +102,7 @@ export class AgentPlanService extends Disposable implements IAgentPlanService { return generateHeroSlug(randomUUID(), new Set()); } - async enter( - id = this.createPlanId(), - createFile = false, - emitStatus = true, - ): Promise { + async enter(id = this.createPlanId(), createFile = false): Promise { if (this.isActive) { throw new Error('Already in plan mode'); } diff --git a/packages/agent-core-v2/src/agent/task/task.ts b/packages/agent-core-v2/src/agent/task/task.ts index 7f59ee35e..e0d4da581 100644 --- a/packages/agent-core-v2/src/agent/task/task.ts +++ b/packages/agent-core-v2/src/agent/task/task.ts @@ -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, diff --git a/packages/agent-core-v2/src/agent/task/taskService.ts b/packages/agent-core-v2/src/agent/task/taskService.ts index 506174c5b..20c31b4c8 100644 --- a/packages/agent-core-v2/src/agent/task/taskService.ts +++ b/packages/agent-core-v2/src/agent/task/taskService.ts @@ -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; diff --git a/packages/agent-core-v2/src/app/sessionLegacy/sessionLegacyService.ts b/packages/agent-core-v2/src/app/sessionLegacy/sessionLegacyService.ts index a103db5a1..099f6ed61 100644 --- a/packages/agent-core-v2/src/app/sessionLegacy/sessionLegacyService.ts +++ b/packages/agent-core-v2/src/app/sessionLegacy/sessionLegacyService.ts @@ -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); diff --git a/packages/agent-core-v2/src/app/skillCatalog/fileSkillDiscovery.ts b/packages/agent-core-v2/src/app/skillCatalog/fileSkillDiscovery.ts index d9c92397d..030b90e7c 100644 --- a/packages/agent-core-v2/src/app/skillCatalog/fileSkillDiscovery.ts +++ b/packages/agent-core-v2/src/app/skillCatalog/fileSkillDiscovery.ts @@ -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'; diff --git a/packages/agent-core-v2/src/app/web/providers/local-fetch-url.ts b/packages/agent-core-v2/src/app/web/providers/local-fetch-url.ts index a2cb3cefe..cbbd361c2 100644 --- a/packages/agent-core-v2/src/app/web/providers/local-fetch-url.ts +++ b/packages/agent-core-v2/src/app/web/providers/local-fetch-url.ts @@ -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 diff --git a/packages/agent-core-v2/src/app/web/providers/moonshot-fetch-url.ts b/packages/agent-core-v2/src/app/web/providers/moonshot-fetch-url.ts index 340ee41c3..63d6bf5a0 100644 --- a/packages/agent-core-v2/src/app/web/providers/moonshot-fetch-url.ts +++ b/packages/agent-core-v2/src/app/web/providers/moonshot-fetch-url.ts @@ -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; diff --git a/packages/agent-core-v2/src/app/web/tools/fetch-url-types.ts b/packages/agent-core-v2/src/app/web/tools/fetch-url-types.ts new file mode 100644 index 000000000..a6ba94654 --- /dev/null +++ b/packages/agent-core-v2/src/app/web/tools/fetch-url-types.ts @@ -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; +} + +/** + * 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; + } +} diff --git a/packages/agent-core-v2/src/app/web/tools/fetch-url.ts b/packages/agent-core-v2/src/app/web/tools/fetch-url.ts index 5c268c724..0c67db348 100644 --- a/packages/agent-core-v2/src/app/web/tools/fetch-url.ts +++ b/packages/agent-core-v2/src/app/web/tools/fetch-url.ts @@ -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; -} - -/** - * 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({ diff --git a/packages/agent-core-v2/src/app/web/web.ts b/packages/agent-core-v2/src/app/web/web.ts index fce26174a..7a8193fc2 100644 --- a/packages/agent-core-v2/src/app/web/web.ts +++ b/packages/agent-core-v2/src/app/web/web.ts @@ -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`. */ diff --git a/packages/agent-core-v2/src/app/web/webService.ts b/packages/agent-core-v2/src/app/web/webService.ts index b3512b470..6d52d6a68 100644 --- a/packages/agent-core-v2/src/app/web/webService.ts +++ b/packages/agent-core-v2/src/app/web/webService.ts @@ -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 { diff --git a/packages/agent-core-v2/src/os/backends/node-local/tools/grepSearch.ts b/packages/agent-core-v2/src/os/backends/node-local/tools/grepSearch.ts index 18a5675d4..ffeb2c9c9 100644 --- a/packages/agent-core-v2/src/os/backends/node-local/tools/grepSearch.ts +++ b/packages/agent-core-v2/src/os/backends/node-local/tools/grepSearch.ts @@ -496,7 +496,7 @@ function parseRgJsonOutput( } } - for (const p of [...fileBuf.keys()]) { + for (const p of Array.from(fileBuf.keys())) { finalize(p); } diff --git a/packages/agent-core-v2/src/session/todo/sessionTodoService.ts b/packages/agent-core-v2/src/session/todo/sessionTodoService.ts index fa91b8e1f..625d765eb 100644 --- a/packages/agent-core-v2/src/session/todo/sessionTodoService.ts +++ b/packages/agent-core-v2/src/session/todo/sessionTodoService.ts @@ -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 = []; diff --git a/packages/agent-core-v2/test/contextMemory/context.test.ts b/packages/agent-core-v2/test/contextMemory/context.test.ts index f88f33f15..86cfd2020 100644 --- a/packages/agent-core-v2/test/contextMemory/context.test.ts +++ b/packages/agent-core-v2/test/contextMemory/context.test.ts @@ -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');