From a33bd1a80b06042e3ebbe586cda2a00512bb2548 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Mon, 28 Sep 2026 01:21:32 +0800 Subject: [PATCH] perf(web): bound streamed tool argument work --- .../conversation-fold.bench.client.ts | 42 +++++- .../conversation-fold.worker.client.ts | 122 ++++++++++++++---- benchmarks/package.json | 1 + .../conversation-nodes/process-activity.ts | 12 +- .../tests/process-groups.client.spec.ts | 49 +++++++ packages/util/values/src/partial-json.ts | 12 +- pnpm-lock.yaml | 3 + 7 files changed, 205 insertions(+), 36 deletions(-) diff --git a/benchmarks/conversation-fold/conversation-fold.bench.client.ts b/benchmarks/conversation-fold/conversation-fold.bench.client.ts index cf067aea2f..b0bec50469 100644 --- a/benchmarks/conversation-fold/conversation-fold.bench.client.ts +++ b/benchmarks/conversation-fold/conversation-fold.bench.client.ts @@ -1,4 +1,4 @@ -/** Required performance budget for the compiled cold Client conversation fold. */ +/** Required performance budgets for compiled Client history folding and tool preparation. */ import { join } from 'node:path' import { describe, expect, it } from 'vitest' @@ -10,7 +10,7 @@ import { ciTimeBudget, PERFORMANCE_BUDGET_HEADROOM, } from '../support/calibration.ts' -import type { ConversationFoldWorkerReport } from './conversation-fold.worker.client.ts' +import type { ConversationFoldWorkerReport, PreparingToolWorkerReport } from './conversation-fold.worker.client.ts' /** Replies in the folded window; each carries one reasoning block and one text block. */ const TURNS = 200 @@ -39,6 +39,12 @@ const LARGE_FOLD_BUDGET_MS = ciTimeBudget(EXPECTED_LARGE_FOLD_MS) const EXPECTED_DELTA_SCALING = 2.5 const MAX_DELTA_SCALING = EXPECTED_DELTA_SCALING * PERFORMANCE_BUDGET_HEADROOM +const PREPARING_SAMPLES = 3 +const PREPARING_CASES = [ + { tool: 'write', characters: 512 * 1024, expectedMs: 300, expectedMb: 30 }, + { tool: 'bash', characters: 128 * 1024, expectedMs: 150, expectedMb: 8 }, +] as const + const WORKER = join( import.meta.dirname, '..', @@ -47,9 +53,9 @@ const WORKER = join( 'conversation-fold.worker.js', ) -function requireReport( - run: BuiltBenchmarkWorkerRun, -): ConversationFoldWorkerReport { +function requireReport( + run: BuiltBenchmarkWorkerRun, +): Report { if (run.report !== undefined) return run.report const stderrLines = run.stderr.trim().split('\n') throw new Error( @@ -76,3 +82,29 @@ describe('cold Chat fold of a large v2 history window', () => { expect(report.scaling).toBeLessThanOrEqual(MAX_DELTA_SCALING) }) }) + +describe('preparing tool arguments', () => { + it.each(PREPARING_CASES)('publishes streamed $tool arguments within bounded CPU and heap costs', async (workload) => { + const samples: PreparingToolWorkerReport[] = [] + for (let index = 0; index < PREPARING_SAMPLES; index++) { + const run = await runBuiltBenchmarkWorker({ + worker: WORKER, args: ['preparing', workload.tool, String(workload.characters)], + exposeGc: true, timeoutMs: WORKER_TIMEOUT_MS, + }) + expect(run.timedOut, run.stderr).toBe(false) + expect(run.signal, run.stderr).toBeNull() + expect(run.exitCode, run.stderr).toBe(0) + const report = requireReport(run) + expect(report.characters).toBe(workload.characters) + expect(report.detail).toBe(workload.tool === 'write' ? 'preview.md' : `${'abcdefghijklmno '.repeat(10).slice(0, 159)}…`) + samples.push(report) + } + const medianMs = samples.map(sample => sample.elapsedMs).toSorted((a, b) => a - b)[1]! + const retainedMb = samples.map(sample => sample.retainedMb).toSorted((a, b) => a - b)[1]! + const budgetMs = ciTimeBudget(workload.expectedMs) + const budgetMb = workload.expectedMb * PERFORMANCE_BUDGET_HEADROOM + console.log(JSON.stringify({ benchmark: `conversation-fold/preparing-${workload.tool}`, samples, medianMs, retainedMb, budgetMs, budgetMb })) + expect(medianMs).toBeLessThanOrEqual(budgetMs) + expect(retainedMb).toBeLessThanOrEqual(budgetMb) + }) +}) diff --git a/benchmarks/conversation-fold/conversation-fold.worker.client.ts b/benchmarks/conversation-fold/conversation-fold.worker.client.ts index 707051a4dc..8be52a5afd 100644 --- a/benchmarks/conversation-fold/conversation-fold.worker.client.ts +++ b/benchmarks/conversation-fold/conversation-fold.worker.client.ts @@ -1,10 +1,11 @@ -/** Compiled worker for the cold Client conversation-fold benchmark. */ +/** Compiled worker for Client history folding and live tool preparation. */ import { performance } from 'node:perf_hooks' import { AssistantStreamAccumulator } from '@deepseek-ai/dsh-llm/assistant-stream' +import { LlmAttemptId, ToolCallId } from '@deepseek-ai/dsh-llm/brand' import type { StreamChunk } from '@deepseek-ai/dsh-llm' import type { SessionEvent } from '@deepseek-ai/dsh-session/types' -import type { ChatSnapshot } from '@deepseek-ai/dsh-client-ui-chat/client' +import type { ChatNode, ChatSnapshot } from '@deepseek-ai/dsh-client-ui-chat/client' import type { SessionEventLikeEntry } from '@deepseek-ai/dsh-api-session-controller/client' // These Client-only fold modules have no plain-Node package export and are compiled into this worker. import { ConversationNodeAssembler } from '../../packages/client/ui-conversation/src/client/conversation/assembler.ts' @@ -20,6 +21,7 @@ import { compactionDefinition } from '../../packages/client/ui-chat/src/client/c import { unknownFallbackDefinition } from '../../packages/client/ui-chat/src/client/conversation-nodes/fallback.ts' import { nextStepInboxDefinition } from '../../packages/client/ui-chat/src/client/conversation-nodes/inbox.ts' import { messageDefinition } from '../../packages/client/ui-chat/src/client/conversation-nodes/message.ts' +import { processGroupDefinition } from '../../packages/client/ui-chat/src/client/conversation-nodes/process-groups.ts' import { requestPromptDefinition } from '../../packages/client/ui-chat/src/client/conversation-nodes/request-prompt.ts' import { retryDefinition } from '../../packages/client/ui-chat/src/client/conversation-nodes/retry.ts' import { toolDefinition } from '../../packages/client/ui-chat/src/client/conversation-nodes/tool.ts' @@ -42,6 +44,16 @@ export interface ConversationFoldWorkerReport { readonly scaling: number } +/** Incremental argument folding through the Tool Definition and Chat group publication. */ +export interface PreparingToolWorkerReport { + readonly tool: 'write' | 'bash' + readonly characters: number | undefined + readonly fragments: number + readonly elapsedMs: number + readonly retainedMb: number + readonly detail: string | undefined +} + class BenchEventDefinitions { readonly definitions: readonly ConversationNodeDefinition[] = [ nextStepInboxDefinition, @@ -173,31 +185,95 @@ function positiveInteger(value: string | undefined, label: string): number { return parsed } +function preparingTool(tool: 'write' | 'bash', characters: number): PreparingToolWorkerReport { + if (globalThis.gc === undefined) throw new Error('preparing benchmark requires --expose-gc') + const chunkSize = 16 + const chunksPerPublication = 64 + const payload = 'abcdefghijklmno '.repeat(Math.ceil(characters / chunkSize)).slice(0, characters) + const tail = `${payload}"}` + const callId = ToolCallId('preparing-benchmark') + const attemptId = LlmAttemptId('preparing-benchmark') + const delta = (index: number, argumentsDelta: string): SessionEventLikeEntry => ({ + type: 'transient', + event: { + type: 'assistant/live-chunk', seq: 2 + index / (tail.length + 1), time: TIME_ZERO + index, + data: { turn: 1, step: 1, attemptId, chunk: { + type: 'tool-call-delta', index: 0, id: callId, argumentsDelta, + ...index === 0 ? { name: tool } : {}, + } }, + }, + }) + const chunks: SessionEventLikeEntry[] = [] + for (let at = 0; at < tail.length; at += chunkSize) chunks.push(delta(at + 1, tail.slice(at, at + chunkSize))) + const assembler = new ConversationNodeAssembler( + new BenchEventDefinitions(), new BenchViewDefinitions(), + { entries: () => [processGroupDefinition], forTarget: target => target === 'chat' ? processGroupDefinition : undefined }, + ) + assembler.replaceWindow([ + entry(0, 'turn/start', { turn: 1 }), + entry(1, 'step/start', { turn: 1, step: 1 }), + delta(0, tool === 'write' ? '{"file_path":"preview.md","content":"' : '{"command":"'), + ], false) + assembler.activateTarget('chat') + const snapshot = assembler.get('chat') + const node = snapshot?.nodes.values().find((candidate): candidate is ChatNode<'tool-call'> => candidate.kind === 'tool-call') + if (node === undefined) throw new Error('preparing benchmark did not create its Tool node') + const args = node.data.root.args + if (tool === 'write') args.stringLength('content', { step: 1024 }) + globalThis.gc() + const before = process.memoryUsage().heapUsed + const start = performance.now() + for (let index = 0; index < chunks.length; index++) { + assembler.append(chunks[index]!) + if ((index + 1) % chunksPerPublication === 0) assembler.flush() + } + assembler.flush() + const elapsedMs = performance.now() - start + globalThis.gc() + const retainedMb = (process.memoryUsage().heapUsed - before) / 1048576 + const groups = assembler.grouped('chat') + const group = groups?.entries.find(reference => reference.kind === 'group') + return { + tool, characters: args.stringLength(tool === 'write' ? 'content' : 'command'), fragments: chunks.length, + elapsedMs, retainedMb, + detail: group === undefined ? undefined : groups?.groupSource(group.key).getSnapshot()?.data.summary.runningDetail, + } +} + assertBuiltBenchmarkRuntime(import.meta.url, { + '@deepseek-ai/dsh-brand': import.meta.resolve('@deepseek-ai/dsh-brand'), '@deepseek-ai/dsh-client-store': import.meta.resolve('@deepseek-ai/dsh-client-store'), '@deepseek-ai/dsh-llm/assistant-stream': import.meta.resolve('@deepseek-ai/dsh-llm/assistant-stream'), '@deepseek-ai/dsh-session/surface': import.meta.resolve('@deepseek-ai/dsh-session/surface'), '@deepseek-ai/dsh-token-meter/client': import.meta.resolve('@deepseek-ai/dsh-token-meter/client'), + '@deepseek-ai/dsh-util-values': import.meta.resolve('@deepseek-ai/dsh-util-values'), }) -const [turnsValue, smallDeltasValue, largeDeltasValue, attemptsValue] = process.argv.slice(2) -const turns = positiveInteger(turnsValue, 'turns') -const smallDeltas = positiveInteger(smallDeltasValue, 'small deltas') -const largeDeltas = positiveInteger(largeDeltasValue, 'large deltas') -const attempts = positiveInteger(attemptsValue, 'attempts') -const small = synthesizeWindow(turns, smallDeltas) -const large = synthesizeWindow(turns, largeDeltas) -if (large.entries.length !== small.entries.length || large.records !== small.records) { - throw new Error('conversation-fold workloads must have matching event and compact-record counts') +if (process.argv[2] === 'preparing') { + const tool = process.argv[3] + if (tool !== 'write' && tool !== 'bash') throw new Error('preparing benchmark tool must be write or bash') + const report = preparingTool(tool, positiveInteger(process.argv[4], 'characters')) + process.stdout.write(`${JSON.stringify(report)}\n`) +} else { + const [turnsValue, smallDeltasValue, largeDeltasValue, attemptsValue] = process.argv.slice(2) + const turns = positiveInteger(turnsValue, 'turns') + const smallDeltas = positiveInteger(smallDeltasValue, 'small deltas') + const largeDeltas = positiveInteger(largeDeltasValue, 'large deltas') + const attempts = positiveInteger(attemptsValue, 'attempts') + const small = synthesizeWindow(turns, smallDeltas) + const large = synthesizeWindow(turns, largeDeltas) + if (large.entries.length !== small.entries.length || large.records !== small.records) { + throw new Error('conversation-fold workloads must have matching event and compact-record counts') + } + const smallFold = bestOf(small.entries, attempts) + const largeFold = bestOf(large.entries, attempts) + const report: ConversationFoldWorkerReport = { + events: large.entries.length, + compactRecords: large.records, + streamedDeltas: turns * (largeDeltas + Math.floor(largeDeltas / 4)), + chatNodes: largeFold.nodes, + smallFoldMs: Math.round(smallFold.ms * 10) / 10, + largeFoldMs: Math.round(largeFold.ms * 10) / 10, + scaling: Math.round((largeFold.ms / Math.max(smallFold.ms, 1)) * 100) / 100, + } + process.stdout.write(`${JSON.stringify(report)}\n`) } -const smallFold = bestOf(small.entries, attempts) -const largeFold = bestOf(large.entries, attempts) -const report: ConversationFoldWorkerReport = { - events: large.entries.length, - compactRecords: large.records, - streamedDeltas: turns * (largeDeltas + Math.floor(largeDeltas / 4)), - chatNodes: largeFold.nodes, - smallFoldMs: Math.round(smallFold.ms * 10) / 10, - largeFoldMs: Math.round(largeFold.ms * 10) / 10, - scaling: Math.round((largeFold.ms / Math.max(smallFold.ms, 1)) * 100) / 100, -} -process.stdout.write(`${JSON.stringify(report)}\n`) diff --git a/benchmarks/package.json b/benchmarks/package.json index e496a148b2..0fa57f90e5 100644 --- a/benchmarks/package.json +++ b/benchmarks/package.json @@ -16,6 +16,7 @@ "@deepseek-ai/dsh-agent-loop-testkit": "workspace:*", "@deepseek-ai/dsh-agent-preset-registry": "workspace:*", "@deepseek-ai/dsh-api-session-controller": "workspace:*", + "@deepseek-ai/dsh-brand": "workspace:*", "@deepseek-ai/dsh-client-store": "workspace:*", "@deepseek-ai/dsh-client-ui-chat": "workspace:*", "@deepseek-ai/dsh-deque": "workspace:*", diff --git a/packages/client/ui-chat/src/client/conversation-nodes/process-activity.ts b/packages/client/ui-chat/src/client/conversation-nodes/process-activity.ts index 656b03774f..028ceb3c30 100644 --- a/packages/client/ui-chat/src/client/conversation-nodes/process-activity.ts +++ b/packages/client/ui-chat/src/client/conversation-nodes/process-activity.ts @@ -35,10 +35,14 @@ function normalizeLiveToolDetail(value: unknown): string { ? value.join(', ') : '' const normalized = text.replace(/\s+/g, ' ').trim() - const chars = Array.from(LIVE_TOOL_DETAIL_SEGMENTER.segment(normalized), part => part.segment) - return chars.length <= LIVE_TOOL_DETAIL_MAX_CHARS - ? normalized - : `${chars.slice(0, LIVE_TOOL_DETAIL_MAX_CHARS - 1).join('').trimEnd()}…` + const chars: string[] = [] + for (const { segment } of LIVE_TOOL_DETAIL_SEGMENTER.segment(normalized)) { + if (chars.length === LIVE_TOOL_DETAIL_MAX_CHARS) { + return `${chars.slice(0, LIVE_TOOL_DETAIL_MAX_CHARS - 1).join('').trimEnd()}…` + } + chars.push(segment) + } + return normalized } function questionDetail(value: unknown): string { diff --git a/packages/client/ui-chat/tests/process-groups.client.spec.ts b/packages/client/ui-chat/tests/process-groups.client.spec.ts index fcf52e67b4..e6f3b6ff96 100644 --- a/packages/client/ui-chat/tests/process-groups.client.spec.ts +++ b/packages/client/ui-chat/tests/process-groups.client.spec.ts @@ -6,6 +6,7 @@ import { PartialArguments } from '@deepseek-ai/dsh-util-values' import type { ChatConversationViewNode, ChatNode } from '../src/client/contract/chat-nodes.ts' import { ChatSnapshotBuilder } from '../src/client/conversation-nodes/chat-snapshot-builder.ts' import { ProcessState } from '../src/client/conversation-nodes/process-groups.ts' +import { processActivity } from '../src/client/conversation-nodes/process-activity.ts' import { ConversationGroupStore } from '../../ui-conversation/src/client/conversation/group-store.ts' import type { ProcessGroupData } from '../src/client/contract/process-groups.ts' @@ -80,6 +81,54 @@ function harness(nodes: readonly ChatConversationViewNode[], currentTimeline = t } describe('Definition-owned Chat process groups', () => { + it.each([ + ['whitespace', ' one\t\n two ', 'one two'], + ['empty text', ' \t\n ', 'bash'], + ['exact limit', 'x'.repeat(160), 'x'.repeat(160)], + ['overflow', 'x'.repeat(161), `${'x'.repeat(159)}…`], + ['space at cutoff', `${'x'.repeat(158)} \n yz`, `${'x'.repeat(158)}…`], + ['combining marks', 'e\u0301'.repeat(161), `${'e\u0301'.repeat(159)}…`], + ['emoji at limit', '👩‍💻'.repeat(160), '👩‍💻'.repeat(160)], + ['emoji overflow', '👩‍💻'.repeat(161), `${'👩‍💻'.repeat(159)}…`], + ])('preserves normalized live detail for %s', (_label, command, expected) => { + const node = tool('call', 2) + const argsRaw = JSON.stringify({ command }) + const root = { ...node.data.root, argsRaw, args: PartialArguments.fromText(argsRaw) } + expect(processActivity([{ ...node, data: { root } }]).runningDetail).toBe(expected) + }) + + it('bounds grapheme traversal while allowing a later description to replace a long command', () => { + const args = new PartialArguments() + args.append(`{"command":"${'x'.repeat(1024)}`) + const node: ChatNode<'tool-call'> = { + ...tool('call', 2), + data: { root: { phase: 'preparing', args, callId: 'call', name: 'bash', turn: 1, step: 1, time: 2, subCalls: [] } }, + } + const segmenter = new Intl.Segmenter(undefined, { granularity: 'grapheme' }) + const segment = segmenter.segment.bind(segmenter) + let visited = 0 + const spy = vi.spyOn(Intl.Segmenter.prototype, 'segment').mockImplementation((input) => { + const segments = segment(input) + const iterator = segments[Symbol.iterator]() + segments[Symbol.iterator] = function* () { + for (const part of iterator) { + visited++ + yield part + } + return undefined + } + return segments + }) + try { + expect(processActivity([node]).runningDetail).toBe(`${'x'.repeat(159)}…`) + expect(visited).toBeLessThanOrEqual(161) + expect(args.append('","description":"Run focused tests"}')).toBe(true) + expect(processActivity([node]).runningDetail).toBe('Run focused tests') + } finally { + spy.mockRestore() + } + }) + it('clears preparation from a closed group while retaining its recorded activity', () => { const preparing: ChatNode<'tool-call'> = { ...tool('call', 2, 'write'), diff --git a/packages/util/values/src/partial-json.ts b/packages/util/values/src/partial-json.ts index aa2122db1e..c6c375a3b2 100644 --- a/packages/util/values/src/partial-json.ts +++ b/packages/util/values/src/partial-json.ts @@ -90,7 +90,7 @@ export class PartialArguments { */ static fromText(text: string): PartialArguments { const view = new PartialArguments() - view.raw = text + view.append(text) view.sealed = true return view } @@ -118,6 +118,7 @@ export class PartialArguments { private object: Readonly> | undefined private sealed = false // Scan progress, located fields, and remembered reads are caches over the source. + #pending = '' #consumed = 0 #mode: Mode = 'root' #escape = false @@ -145,6 +146,7 @@ export class PartialArguments { append(fragment: string): boolean { if (this.sealed) throw new Error('PartialArguments: cannot append to a sealed view') this.raw += fragment + this.#pending += fragment if (this.#reads.size === 0) return false this.scan() let changed = false @@ -297,11 +299,13 @@ export class PartialArguments { return entry?.kind === 'value' && entry.end >= 0 ? entry.parsed : undefined } - /** Scan the text not yet consumed; each character is handled once. */ + /** Index only unread text; indexing accumulated raw text repeatedly flattens its prefix. */ private scan(): void { if (this.object !== undefined) return - while (this.#consumed < this.raw.length && this.#mode !== 'invalid') { - this.step(this.raw[this.#consumed] as string, this.#consumed) + const pending = this.#pending + this.#pending = '' + for (let index = 0; index < pending.length && this.#mode !== 'invalid'; index++) { + this.step(pending[index] as string, this.#consumed) this.#consumed++ } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0fa127dea0..2f763ddd4b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -883,6 +883,9 @@ importers: '@deepseek-ai/dsh-api-session-controller': specifier: workspace:* version: link:../packages/api/session-controller + '@deepseek-ai/dsh-brand': + specifier: workspace:* + version: link:../packages/util/brand '@deepseek-ai/dsh-client-store': specifier: workspace:* version: link:../packages/client/store