perf(web): bound streamed tool argument work

This commit is contained in:
imccyu authored and GitHub committed 2026-10-01 00:19:52 +08:00
1 parent d1b2b5a63b
commit a33bd1a80b
7 files changed
+205 -36

No files matched your search

@@ -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>,
): ConversationFoldWorkerReport {
function requireReport<Report>(
run: BuiltBenchmarkWorkerRun<Report>,
): 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<PreparingToolWorkerReport>({
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)
})
})
@@ -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`)
+1
View File
@@ -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:*",
@@ -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 {
@@ -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'),
+8 -4
View File
@@ -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<Record<string, JsonValue>> | 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++
}
}
+3
View File
@@ -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