From 827cde4b941f01f4434cf12a76c55c340d075c9e Mon Sep 17 00:00:00 2001 From: elkaix Date: Fri, 4 Sep 2026 23:29:27 -0400 Subject: [PATCH 1/4] fix: harden parser, lifecycle, executor, fs, fetch, and gateway paths from the astra-6 audit Verified the 36-item audit against the checkout and fixed the confirmed defects, each with a regression test that fails on the previous code. Parser (F01-F08): rewrite the DSML/Hermes stream parser as a chunk-invariant state machine with fence awareness, strict invoke bodies, null-prototype argument objects, tag/envelope budgets, linear scanning, and stream/non-stream text parity; native tool calls now win over recovered content calls in all three OpenAI-compatible adapters; both parser copies are held byte-identical by a drift test. Lifecycle (F09, F10, F12-F14, F24): launching prompts are owned by abort/drain/clear, compaction no longer recurses through the launch finally, agent removal is memoized and phase-isolated with a quiescence guard and dispatcher flush, failed creation unregisters its metadata, and metadata publishes memory only after the store write succeeds. Executor (F15-F17, F30): the scheduler keeps a resource lease until an abandoned execution settles, including across batches, caps unrelated concurrency, checks the abort signal before resolution, and classifies telemetry from execution state instead of output text. Filesystem and web (F18-F21): read/write deny aliases whose real target is sensitive, overwrites are atomic replacements that keep symlinks and modes, line reads are bounded per line, and web fetches stop at the byte cap while streaming. Gateway (F26-F29): close is memoized and runs every phase, WebSocket gets a payload cap, control-queue and subscription budgets, a hard slow-consumer bound, and closed-connection rechecks after async subscribe. Subagents (F23): run completion reports per-run usage and exposes the cumulative total separately. --- .changeset/atomic-file-writes.md | 5 + .changeset/cancel-prompt-while-starting.md | 5 + .changeset/dsml-parser-chunk-invariance.md | 5 + .changeset/sensitive-file-symlink-alias.md | 5 + .changeset/subagent-usage-per-run.md | 5 + .changeset/tool-cancel-holds-file-lease.md | 5 + .changeset/web-fetch-streaming-limit.md | 5 + .../src/agent/prompt/promptService.ts | 29 +- .../agent/toolExecutor/toolExecutorService.ts | 114 ++-- .../src/agent/toolExecutor/toolScheduler.ts | 61 +- .../src/agent/tools/os/read/readTool.ts | 5 +- .../src/agent/tools/os/write/writeTool.ts | 3 + .../src/app/web/providers/local-fetch-url.ts | 37 +- .../src/kosong/contract/usage.ts | 16 + .../provider/bases/openai/dsml-tool-parser.ts | 574 ++++++++++++------ .../provider/bases/openai/openai-legacy.ts | 23 +- .../os/backends/node-local/hostFsService.ts | 61 +- .../src/os/interface/hostFileSystem.ts | 2 +- .../agentLifecycle/agentLifecycleService.ts | 68 ++- .../session/agentLifecycle/managedAgent.ts | 1 + .../session/expertTalk/expertTalkService.ts | 18 +- .../sessionMetadata/sessionMetadata.ts | 1 + .../sessionMetadata/sessionMetadataService.ts | 14 +- .../src/session/subagent/runAgentTurn.ts | 14 +- .../src/session/subagent/subagent.ts | 6 +- .../agent-core-v2/src/tool/path-access.ts | 27 + .../test/agent/prompt/promptService.test.ts | 85 +++ .../agent/toolExecutor/toolExecutor.test.ts | 88 +++ .../agent/toolExecutor/toolScheduler.test.ts | 76 ++- .../app/sessionExport/sessionExport.test.ts | 1 + .../app/web/providers/local-fetch-url.test.ts | 40 ++ .../sessionDynamicWorkflow.test.ts | 3 + .../externalHooks/integration.test.ts | 1 + .../dsml-tool-parser-conformance.test.ts | 332 ++++++++++ .../kosong/provider/dsml-tool-parser.test.ts | 109 ++++ .../backends/node-local/hostFsService.test.ts | 73 ++- .../os/backends/node-local/tools/read.test.ts | 18 +- .../backends/node-local/tools/write.test.ts | 19 +- .../agentLifecycle/agentLifecycle.test.ts | 49 ++ .../sessionMetadata/sessionMetadata.test.ts | 28 + .../session/subagent/runAgentTurn.test.ts | 71 +++ packages/agent-core-v2/test/tool/tool.test.ts | 1 + packages/agent-gateway/src/start.ts | 77 ++- .../src/transport/ws/v1/registerWsV1.ts | 8 +- .../src/transport/ws/v1/wsConnectionV1.ts | 52 +- packages/agent-gateway/test/boot.test.ts | 44 ++ .../agent-gateway/test/wsConnectionV1.test.ts | 142 +++++ .../kosong/src/providers/dsml-tool-parser.ts | 574 ++++++++++++------ .../kosong/src/providers/openai-legacy.ts | 23 +- packages/kosong/src/providers/pythinker.ts | 23 +- packages/kosong/test/openai-legacy.test.ts | 49 ++ 51 files changed, 2550 insertions(+), 545 deletions(-) create mode 100644 .changeset/atomic-file-writes.md create mode 100644 .changeset/cancel-prompt-while-starting.md create mode 100644 .changeset/dsml-parser-chunk-invariance.md create mode 100644 .changeset/sensitive-file-symlink-alias.md create mode 100644 .changeset/subagent-usage-per-run.md create mode 100644 .changeset/tool-cancel-holds-file-lease.md create mode 100644 .changeset/web-fetch-streaming-limit.md create mode 100644 packages/agent-core-v2/test/kosong/provider/dsml-tool-parser-conformance.test.ts create mode 100644 packages/agent-core-v2/test/session/subagent/runAgentTurn.test.ts diff --git a/.changeset/atomic-file-writes.md b/.changeset/atomic-file-writes.md new file mode 100644 index 000000000..10a70074f --- /dev/null +++ b/.changeset/atomic-file-writes.md @@ -0,0 +1,5 @@ +--- +"@pymodel/pythinker-code": patch +--- + +Write files atomically so an interrupted write leaves the previous content intact. diff --git a/.changeset/cancel-prompt-while-starting.md b/.changeset/cancel-prompt-while-starting.md new file mode 100644 index 000000000..b7fe5ac72 --- /dev/null +++ b/.changeset/cancel-prompt-while-starting.md @@ -0,0 +1,5 @@ +--- +"@pymodel/pythinker-code": patch +--- + +Allow cancelling a prompt while it is still starting. diff --git a/.changeset/dsml-parser-chunk-invariance.md b/.changeset/dsml-parser-chunk-invariance.md new file mode 100644 index 000000000..7410fca85 --- /dev/null +++ b/.changeset/dsml-parser-chunk-invariance.md @@ -0,0 +1,5 @@ +--- +"@pymodel/pythinker-code": patch +--- + +Fix DSML and Hermes tool calls in streamed responses being dropped, split, or mistaken for quoted documentation depending on how the response was chunked. diff --git a/.changeset/sensitive-file-symlink-alias.md b/.changeset/sensitive-file-symlink-alias.md new file mode 100644 index 000000000..907dd2cdb --- /dev/null +++ b/.changeset/sensitive-file-symlink-alias.md @@ -0,0 +1,5 @@ +--- +"@pymodel/pythinker-code": patch +--- + +Block file reads and writes that reach a sensitive file through a symlink alias. diff --git a/.changeset/subagent-usage-per-run.md b/.changeset/subagent-usage-per-run.md new file mode 100644 index 000000000..d92781b74 --- /dev/null +++ b/.changeset/subagent-usage-per-run.md @@ -0,0 +1,5 @@ +--- +"@pymodel/pythinker-code": patch +--- + +Report each subagent run's own token usage instead of the agent's lifetime total. diff --git a/.changeset/tool-cancel-holds-file-lease.md b/.changeset/tool-cancel-holds-file-lease.md new file mode 100644 index 000000000..a0090ab88 --- /dev/null +++ b/.changeset/tool-cancel-holds-file-lease.md @@ -0,0 +1,5 @@ +--- +"@pymodel/pythinker-code": patch +--- + +Keep a cancelled tool that ignores the stop signal from overlapping with the next tool on the same file. diff --git a/.changeset/web-fetch-streaming-limit.md b/.changeset/web-fetch-streaming-limit.md new file mode 100644 index 000000000..da731f49f --- /dev/null +++ b/.changeset/web-fetch-streaming-limit.md @@ -0,0 +1,5 @@ +--- +"@pymodel/pythinker-code": patch +--- + +Stop downloading a web page as soon as it exceeds the size limit instead of buffering it first. diff --git a/packages/agent-core-v2/src/agent/prompt/promptService.ts b/packages/agent-core-v2/src/agent/prompt/promptService.ts index 8532cfbb4..4716f68c7 100644 --- a/packages/agent-core-v2/src/agent/prompt/promptService.ts +++ b/packages/agent-core-v2/src/agent/prompt/promptService.ts @@ -187,6 +187,7 @@ interface Record extends PromptSnapshot { readonly launchedDeferred: Deferred; readonly completionDeferred: Deferred; handle: PromptHandle; + cancelReason?: Error; } function bundledSkillBlockCount(message: ContextMessage): number { @@ -227,6 +228,7 @@ export const promptLaunchingKey = defineState('prompt.launching', () => export class AgentPromptService implements IAgentPromptService { declare readonly _serviceBrand: undefined; private active: (Record & { turn: Turn }) | undefined; + private launchingRecord: Record | undefined; private readonly pending: Record[] = []; private readonly steered = new Map(); private readonly reservedPromptIds = new Set(); @@ -455,6 +457,7 @@ export class AgentPromptService implements IAgentPromptService { abort(promptId: string, reason: Error = userCancellationReason()): boolean { if (this.active?.id === promptId) { this.loop.cancel(this.active.turn.id, reason); return true; } + if (this.launchingRecord?.id === promptId) { this.launchingRecord.cancelReason ??= reason; return true; } const index = this.pending.findIndex((item) => item.id === promptId); if (index < 0) throw new Error2(ErrorCodes.PROMPT_NOT_FOUND, `prompt ${promptId} not found`); const [item] = this.pending.splice(index, 1) as [Record]; @@ -466,6 +469,11 @@ export class AgentPromptService implements IAgentPromptService { async drain(reason: Error = userCancellationReason()): Promise { for (const item of this.pending.slice()) this.abort(item.id, reason); + const launching = this.launchingRecord; + if (launching !== undefined) { + this.abort(launching.id, reason); + await launching.launchedDeferred.promise; + } if (this.active !== undefined) this.abort(this.active.id, reason); } @@ -488,6 +496,7 @@ export class AgentPromptService implements IAgentPromptService { clear(): void { for (const item of this.pending.slice()) this.abort(item.id); + if (this.launchingRecord !== undefined) this.abort(this.launchingRecord.id); if (this.active !== undefined) this.abort(this.active.id); this.context.clear(); } @@ -495,17 +504,20 @@ export class AgentPromptService implements IAgentPromptService { private async startNext(): Promise { if (this.active !== undefined || this.launching || this.steering > 0) return; const item = this.pending.shift(); if (item === undefined) return; + if (this.fullCompaction.compacting !== null && this.loop.status().state !== 'running') { this.pending.unshift(item); return; } this.launching = true; + this.launchingRecord = item; try { - if (this.fullCompaction.compacting !== null && this.loop.status().state !== 'running') { this.pending.unshift(item); return; } const { message, captions } = this.extractCompressionCaptions(item.message); await this.materializeDaemonRefs(message); + if (this.settleCancelledLaunch(item)) return; if (await this.blockedByHook(message, false)) { this.appendPrompt(message, captions); item.state = 'blocked'; item.launchedDeferred.resolve(undefined); item.completionDeferred.resolve({ promptId: item.id, result: undefined, state: 'blocked' }); this.publishCompleted(item.id, 'blocked'); return; } - const turn = (await this.loop.enqueue( + if (this.settleCancelledLaunch(item)) return; + const receipt = this.loop.enqueue( new PromptStepRequest( message, captions, @@ -513,10 +525,12 @@ export class AgentPromptService implements IAgentPromptService { item.maxOutputSize, item.infiniteRetry, ), - ).assigned).turn; + ); + const turn = (await receipt.assigned).turn; if (turn === undefined) { this.pending.unshift(item); return; } item.state = 'running'; item.launchedDeferred.resolve(turn); this.active = Object.assign(item, { turn }); this.publishStarted(item); + if (item.cancelReason !== undefined) this.loop.cancel(turn.id, item.cancelReason); void turn.result.then((result) => this.settle(item, result)); } catch { item.state = 'failed'; @@ -524,11 +538,20 @@ export class AgentPromptService implements IAgentPromptService { item.completionDeferred.resolve({ promptId: item.id, result: undefined, state: 'failed' }); this.publishCompleted(item.id, 'failed'); } finally { + this.launchingRecord = undefined; this.launching = false; if (this.active === undefined) void this.startNext(); } } + private settleCancelledLaunch(item: Record): boolean { + if (item.cancelReason === undefined) return false; + item.state = 'cancelled'; item.launchedDeferred.resolve(undefined); + item.completionDeferred.resolve({ promptId: item.id, result: undefined, state: 'cancelled' }); + this.publishAborted(item.id); + return true; + } + private settle(item: Record, result: TurnResult): void { if (this.active?.id !== item.id) return; this.active = undefined; diff --git a/packages/agent-core-v2/src/agent/toolExecutor/toolExecutorService.ts b/packages/agent-core-v2/src/agent/toolExecutor/toolExecutorService.ts index 712e97e34..8e8da042d 100644 --- a/packages/agent-core-v2/src/agent/toolExecutor/toolExecutorService.ts +++ b/packages/agent-core-v2/src/agent/toolExecutor/toolExecutorService.ts @@ -52,7 +52,7 @@ import { type UnavailableToolDescriber, } from './toolExecutor'; import { ToolCallStarted, ToolProgress, ToolResultEvent } from './toolExecutorEvents'; -import { ToolScheduler } from './toolScheduler'; +import { ToolScheduler, type OutstandingEffect } from './toolScheduler'; const ABORT_GRACE_MS = 2_000; const TOOL_OUTPUT_EMPTY = 'Tool output is empty.'; @@ -71,12 +71,15 @@ export interface ToolExecutionTask { export interface ToolExecutionRunResult { readonly result: ToolResult; readonly outcome: ToolExecutionOutcome; + readonly cancelled?: boolean; + readonly effectsSettled?: Promise; } interface TimedToolResult { readonly index: number; readonly result: ToolResult; readonly outcome: ToolExecutionOutcome; + readonly cancelled: boolean; readonly durationMs: number; } @@ -111,6 +114,7 @@ export const toolExecutorDupTypeTurnIdKey = defineState( export class AgentToolExecutorService implements IAgentToolExecutorService { declare readonly _serviceBrand: undefined; + private readonly outstandingEffects = new Set(); private readonly beforeExecuteEmitter = new BeforeToolExecuteEmitter(); readonly onBeforeExecuteTool: Event = this.beforeExecuteEmitter.event; private readonly willExecuteEmitter = new AsyncEmitter(); @@ -302,7 +306,13 @@ export class AgentToolExecutorService implements IAgentToolExecutorService { ); this.dispatchToolResult(call, finalized, options); - this.trackToolCall(call, finalized, timedResult.durationMs, options); + this.trackToolCall( + call, + finalized, + timedResult.durationMs, + options, + timedResult.cancelled || timedResult.outcome === 'aborted', + ); return { toolCallId: call.toolCall.id, @@ -316,8 +326,9 @@ export class AgentToolExecutorService implements IAgentToolExecutorService { result: ToolResult, durationMs: number, options: ToolExecutorExecuteOptions, + cancelled: boolean, ): void { - const outcome = toolTelemetryOutcome(result); + const outcome = toolTelemetryOutcome(result, cancelled); const toolCallId = call.toolCall.id; const dupType = this.toolCallDupTypes.get(toolCallId) ?? 'normal'; this.toolCallDupTypes.delete(toolCallId); @@ -385,6 +396,10 @@ export class AgentToolExecutorService implements IAgentToolExecutorService { return settleError(call.args, call.output, 'preflight-rejected'); } + if (options.signal.aborted) { + return settleError(call.args, abortedToolOutput(call.toolName, options.signal), 'aborted'); + } + let execution: ToolExecution; try { execution = await call.tool.resolveExecution(call.args); @@ -458,7 +473,7 @@ export class AgentToolExecutorService implements IAgentToolExecutorService { tasks: ToolExecutionTask[], signal: AbortSignal, ): AsyncIterable { - const scheduler = new ToolScheduler(); + const scheduler = new ToolScheduler(this.outstandingEffects); const allResults: Array> = []; const pendingResults = new Map>(); @@ -468,13 +483,24 @@ export class AgentToolExecutorService implements IAgentToolExecutorService { accesses: task.accesses, start: async () => { const startedAt = Date.now(); + const run = task.execute(signal); return { - result: task.execute(signal).then(({ result, outcome }) => ({ - index, - result, - outcome, - durationMs: Math.max(0, Date.now() - startedAt), - })), + result: run.then(({ result, outcome, cancelled, effectsSettled }) => { + if (effectsSettled !== undefined) { + this.trackOutstandingEffect(task.accesses, effectsSettled); + } + return { + index, + result, + outcome, + cancelled: cancelled === true, + durationMs: Math.max(0, Date.now() - startedAt), + }; + }), + effectsSettled: run.then( + (value) => value.effectsSettled, + () => undefined, + ), }; }, }); @@ -501,6 +527,14 @@ export class AgentToolExecutorService implements IAgentToolExecutorService { } } + private trackOutstandingEffect(accesses: ToolAccesses, settled: Promise): void { + const effect: OutstandingEffect = { accesses, settled }; + this.outstandingEffects.add(effect); + void settled.finally(() => { + this.outstandingEffects.delete(effect); + }); + } + private async runSingleExecution( call: RunnableToolCall, execution: RunnableToolExecution, @@ -516,12 +550,14 @@ export class AgentToolExecutorService implements IAgentToolExecutorService { abortedToolOutput(call.toolName, signal), ).result, outcome: 'aborted', + cancelled: true, }; } let rawResult: ExecutableToolResult; + let executePromise: Promise; try { - const executePromise = execution.execute({ + executePromise = execution.execute({ turnId: options.turnId, toolCallId: call.toolCall.id, trace: options.trace, @@ -532,7 +568,20 @@ export class AgentToolExecutorService implements IAgentToolExecutorService { this.dispatchToolProgress(call, update, options); }, }); - rawResult = await raceWithAbortGrace(executePromise, signal, call.toolName); + const raced = await raceWithAbortGrace(executePromise, signal); + if (raced.graceExpired) { + return { + result: makeErrorToolResult(call, call.args, abortedToolOutput(call.toolName, signal)) + .result, + outcome: 'executed', + cancelled: true, + effectsSettled: executePromise.then( + () => undefined, + () => undefined, + ), + }; + } + rawResult = raced.value; } catch (error) { const aborted = isAbortError(error) || signal.aborted; const output = aborted @@ -541,6 +590,7 @@ export class AgentToolExecutorService implements IAgentToolExecutorService { return { result: makeErrorToolResult(call, call.args, output).result, outcome: 'executed', + cancelled: aborted, }; } @@ -904,14 +954,12 @@ function normalizeToolResult(result: ExecutableToolResult): ToolResult { return base; } -function toolTelemetryOutcome(result: ToolResult): 'success' | 'error' | 'cancelled' { - if (result.isError !== true) return 'success'; - const text = toolOutputText(result.output).toLowerCase(); - return text.includes('aborted') || - text.includes('cancelled') || - text.includes('manually interrupted') - ? 'cancelled' - : 'error'; +function toolTelemetryOutcome( + result: ToolResult, + cancelled: boolean, +): 'success' | 'error' | 'cancelled' { + if (cancelled) return 'cancelled'; + return result.isError === true ? 'error' : 'success'; } function toolTelemetryErrorType(outcome: 'success' | 'error' | 'cancelled'): 'cancelled' | 'error' { @@ -919,13 +967,6 @@ function toolTelemetryErrorType(outcome: 'success' | 'error' | 'cancelled'): 'ca return 'error'; } -function toolOutputText(output: ToolResult['output']): string { - if (typeof output === 'string') return output; - return output - .filter((part): part is Extract => part.type === 'text') - .map((part) => part.text) - .join(''); -} function isMediaContentPart(part: ContentPart): boolean { return part.type === 'image_url' || part.type === 'audio_url' || part.type === 'video_url'; @@ -938,21 +979,21 @@ function abortedToolOutput(toolName: string, signal: AbortSignal): string { return `Tool "${toolName}" was aborted`; } +type AbortGraceOutcome = + | { readonly graceExpired: false; readonly value: Result } + | { readonly graceExpired: true }; + async function raceWithAbortGrace( executePromise: Promise, signal: AbortSignal, - toolName: string, -): Promise { +): Promise> { let graceTimer: ReturnType | undefined; let onAbort: (() => void) | undefined; - const graceSentinel: Promise = new Promise((resolve) => { + const graceSentinel: Promise> = new Promise((resolve) => { const armTimer = (): void => { graceTimer = setTimeout(() => { - resolve({ - output: abortedToolOutput(toolName, signal), - isError: true, - } as unknown as Result); + resolve({ graceExpired: true }); }, ABORT_GRACE_MS); }; if (signal.aborted) { @@ -964,7 +1005,10 @@ async function raceWithAbortGrace( }); try { - return await Promise.race([executePromise, graceSentinel]); + return await Promise.race([ + executePromise.then((value) => ({ graceExpired: false, value }) as const), + graceSentinel, + ]); } finally { if (graceTimer !== undefined) clearTimeout(graceTimer); if (onAbort !== undefined) { diff --git a/packages/agent-core-v2/src/agent/toolExecutor/toolScheduler.ts b/packages/agent-core-v2/src/agent/toolExecutor/toolScheduler.ts index 64be16d2a..c09c383cb 100644 --- a/packages/agent-core-v2/src/agent/toolExecutor/toolScheduler.ts +++ b/packages/agent-core-v2/src/agent/toolExecutor/toolScheduler.ts @@ -2,28 +2,64 @@ import { ToolAccesses } from '#/tool/toolContract'; export interface ToolCallTask { readonly accesses: ToolAccesses; - readonly start: () => Promise<{ readonly result: Promise }>; + readonly start: () => Promise<{ + readonly result: Promise; + readonly effectsSettled?: Promise; + }>; } +export interface OutstandingEffect { + readonly accesses: ToolAccesses; + readonly settled: Promise; +} + +export const DEFAULT_TOOL_CONCURRENCY = 16; + interface ScheduledToolCallTask extends ToolCallTask { readonly result: ControlledPromise; } +interface ActiveEntry { + readonly accesses: ToolAccesses; +} + type ControlledPromise = Promise & { readonly resolve: (value: Result | PromiseLike) => void; readonly reject: (reason?: unknown) => void; }; export class ToolScheduler { - private readonly activeTasks: Array> = []; + private readonly activeTasks: ActiveEntry[] = []; private queuedTasks: Array> = []; + constructor( + outstanding: Iterable = [], + private readonly maxConcurrency: number = DEFAULT_TOOL_CONCURRENCY, + ) { + for (const effect of outstanding) { + const entry: ActiveEntry = { accesses: effect.accesses }; + this.activeTasks.push(entry); + void effect.settled.then( + () => this.finish(entry), + () => this.finish(entry), + ); + } + } + + get running(): number { + return this.activeTasks.length; + } + + private get atCapacity(): boolean { + return this.activeTasks.length >= this.maxConcurrency; + } + add(task: ToolCallTask): Promise { const result = createControlledPromise(); void result.catch(() => undefined); const scheduledTask: ScheduledToolCallTask = { ...task, result }; - if (this.isBlocked(task, this.queuedTasks)) { + if (this.atCapacity || this.isBlocked(task, this.queuedTasks)) { this.queuedTasks.push(scheduledTask); } else { this.start(scheduledTask); @@ -43,7 +79,7 @@ export class ToolScheduler { private conflictsWithAny( task: ToolCallTask, - candidates: readonly ToolCallTask[], + candidates: readonly ActiveEntry[], ): boolean { return candidates.some((candidate) => ToolAccesses.conflict(task.accesses, candidate.accesses), @@ -52,7 +88,7 @@ export class ToolScheduler { private start(task: ScheduledToolCallTask): void { this.activeTasks.push(task); - let started: Promise<{ readonly result: Promise }>; + let started: ReturnType['start']>; try { started = task.start(); } catch (error) { @@ -62,14 +98,21 @@ export class ToolScheduler { } void started - .then(({ result }) => result) - .then(task.result.resolve, task.result.reject) + .then( + ({ result, effectsSettled }) => { + result.then(task.result.resolve, task.result.reject); + return Promise.allSettled([result, effectsSettled ?? result]); + }, + (error) => { + task.result.reject(error); + }, + ) .finally(() => { this.finish(task); }); } - private finish(task: ScheduledToolCallTask): void { + private finish(task: ActiveEntry): void { const index = this.activeTasks.indexOf(task); if (index >= 0) this.activeTasks.splice(index, 1); this.startQueuedTasks(); @@ -78,7 +121,7 @@ export class ToolScheduler { private startQueuedTasks(): void { const stillQueued: Array> = []; for (const task of this.queuedTasks) { - if (this.isBlocked(task, stillQueued)) { + if (this.atCapacity || this.isBlocked(task, stillQueued)) { stillQueued.push(task); } else { this.start(task); diff --git a/packages/agent-core-v2/src/agent/tools/os/read/readTool.ts b/packages/agent-core-v2/src/agent/tools/os/read/readTool.ts index 85fb3f32a..95d8aa2e8 100644 --- a/packages/agent-core-v2/src/agent/tools/os/read/readTool.ts +++ b/packages/agent-core-v2/src/agent/tools/os/read/readTool.ts @@ -12,6 +12,7 @@ import { import { registerAgentToolService } from '#/agent/toolRegistry/toolContribution'; import { resolvePathAccessPath, + sensitiveTargetError, type WorkspaceConfig, } from '#/tool/path-access'; import { MEDIA_SNIFF_BYTES, detectFileType } from '#/agent/media/file-type'; @@ -245,6 +246,8 @@ export class ReadTool implements IReadTool { if (lease.runtime.identity.generation !== inspected.identity.generation) { return { isError: true, output: 'Runtime changed before execution. Retry the tool call.' }; } + const denied = await sensitiveTargetError(lease.runtime.fs!, args.path, path); + if (denied !== undefined) return { isError: true, output: denied }; const result = await this.execution(lease.runtime.fs!, args, path); return this.resultTruncation.isSpillFilePath(path) ? { ...result, spillExempt: true as const } @@ -302,7 +305,7 @@ export class ReadTool implements IReadTool { output: notReadableFileOutput(args.path), }; } else { - lines = fs.readLines(safePath, { errors: 'strict' }); + lines = fs.readLines(safePath, { errors: 'strict', maxLineBytes: MAX_LINE_LENGTH * 4 }); } const lineOffset = args.line_offset ?? 1; diff --git a/packages/agent-core-v2/src/agent/tools/os/write/writeTool.ts b/packages/agent-core-v2/src/agent/tools/os/write/writeTool.ts index e9cc37ddb..2423b37ce 100644 --- a/packages/agent-core-v2/src/agent/tools/os/write/writeTool.ts +++ b/packages/agent-core-v2/src/agent/tools/os/write/writeTool.ts @@ -14,6 +14,7 @@ import { import { registerAgentToolService } from '#/agent/toolRegistry/toolContribution'; import { resolvePathAccessPath, + sensitiveTargetError, type WorkspaceConfig, } from '#/tool/path-access'; import { toInputJsonSchema } from '#/tool/input-schema'; @@ -70,6 +71,8 @@ export class WriteTool implements IWriteTool { if (lease.runtime.identity.generation !== inspected.identity.generation) { return { isError: true, output: 'Runtime changed before execution. Retry the tool call.' }; } + const denied = await sensitiveTargetError(lease.runtime.fs!, args.path, path); + if (denied !== undefined) return { isError: true, output: denied }; return await this.execution(lease.runtime.fs!, args, path); } finally { lease.dispose(); 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 ff7afe754..723d89fb2 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 @@ -74,6 +74,32 @@ export class LocalFetchURLProvider implements UrlFetcher { } } + private async readBoundedBody(response: Response): Promise { + if (response.body === null) return ''; + const reader = response.body.getReader(); + const chunks: Uint8Array[] = []; + let received = 0; + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + received += value.byteLength; + if (received > this.maxBytes) { + await reader.cancel().catch(() => {}); + throw new Error2( + ErrorCodes.WEB_FETCH_FAILED, + `Response body too large: more than ${String(this.maxBytes)} bytes were delivered (maxBytes ${String(this.maxBytes)}).`, + { details: { bytes: received, maxBytes: this.maxBytes } }, + ); + } + chunks.push(value); + } + } finally { + reader.releaseLock(); + } + return new TextDecoder('utf-8').decode(Buffer.concat(chunks)); + } + private async readResponse(response: Response): Promise { if (response.status >= 400) { await response.body?.cancel().catch(() => { @@ -98,16 +124,7 @@ export class LocalFetchURLProvider implements UrlFetcher { } } - const body = await response.text(); - - const actualBytes = Buffer.byteLength(body, 'utf8'); - if (actualBytes > this.maxBytes) { - throw new Error2( - ErrorCodes.WEB_FETCH_FAILED, - `Response body too large: ${String(actualBytes)} bytes exceeds maxBytes (${String(this.maxBytes)}).`, - { details: { bytes: actualBytes, maxBytes: this.maxBytes } }, - ); - } + const body = await this.readBoundedBody(response); const contentType = (response.headers.get('content-type') ?? '').toLowerCase(); if (contentType.startsWith('text/plain') || contentType.startsWith('text/markdown')) { diff --git a/packages/agent-core-v2/src/kosong/contract/usage.ts b/packages/agent-core-v2/src/kosong/contract/usage.ts index ad59bd6bc..fee365174 100644 --- a/packages/agent-core-v2/src/kosong/contract/usage.ts +++ b/packages/agent-core-v2/src/kosong/contract/usage.ts @@ -5,6 +5,22 @@ export interface TokenUsage { inputCacheCreation: number; } +export function usageDelta( + after: TokenUsage | undefined, + before: TokenUsage | undefined, +): TokenUsage | undefined { + if (after === undefined) return undefined; + return { + inputOther: Math.max(0, after.inputOther - (before?.inputOther ?? 0)), + output: Math.max(0, after.output - (before?.output ?? 0)), + inputCacheRead: Math.max(0, after.inputCacheRead - (before?.inputCacheRead ?? 0)), + inputCacheCreation: Math.max( + 0, + after.inputCacheCreation - (before?.inputCacheCreation ?? 0), + ), + }; +} + export function inputTotal(usage: TokenUsage): number { return usage.inputOther + usage.inputCacheRead + usage.inputCacheCreation; } diff --git a/packages/agent-core-v2/src/kosong/provider/bases/openai/dsml-tool-parser.ts b/packages/agent-core-v2/src/kosong/provider/bases/openai/dsml-tool-parser.ts index 998e3d0f8..3c6143e56 100644 --- a/packages/agent-core-v2/src/kosong/provider/bases/openai/dsml-tool-parser.ts +++ b/packages/agent-core-v2/src/kosong/provider/bases/openai/dsml-tool-parser.ts @@ -1,11 +1,22 @@ import type { StreamedMessagePart, ToolCall } from '#/kosong/contract/message'; -const CONTAINER_OPEN_RE = /^<\s*[||]?\s*(?:DSML\s*[||]?)?\s*tool_calls\s*>/i; -const CONTAINER_CLOSE_RE = /^<\/\s*[||]?\s*(?:DSML\s*[||]?)?\s*tool_calls\s*>/i; -const INVOKE_OPEN_RE = /^<\s*[||]?\s*(?:DSML\s*[||]?)?\s*invoke(?:\s+[^>]*)?>/i; -const INVOKE_CLOSE_RE = /<\/\s*[||]?\s*(?:DSML\s*[||]?)?\s*invoke\s*>/i; -const HERMES_OPEN_RE = /^/i; -const HERMES_CLOSE_RE = /<\/tool_call>/i; +const MARK = String.raw`\s*[||]?\s*(?:DSML\s*[||]?)?\s*`; +const CONTAINER_OPEN_RE = new RegExp(String.raw`<${MARK}tool_calls\s*>`, 'iy'); +const CONTAINER_CLOSE_RE = new RegExp(String.raw``, 'iy'); +const INVOKE_OPEN_RE = new RegExp(String.raw`<${MARK}invoke(?:\s+[^>]*)?>`, 'iy'); +const INVOKE_CLOSE_RE = new RegExp(String.raw``, 'gi'); +const PARAM_OPEN_RE = new RegExp(String.raw`<${MARK}parameter\s+([^>]*?)>`, 'iy'); +const PARAM_CLOSE_RE = new RegExp(String.raw``, 'gi'); +const HERMES_OPEN_RE = //iy; +const HERMES_CLOSE_RE = /<\/tool_call>/gi; +const MARKED_RE = /<\/?\s*(?:[||]|DSML)/iy; +const NAME_ATTR_RE = /\bname\s*=\s*(?:"([^"]+)"|'([^']+)'|([^\s>]+))/i; +const STRING_ATTR_RE = /\bstring\s*=\s*(?:"(true|false)"|'(true|false)'|(true|false))/i; + +export const DSML_MAX_TAG_CHARS = 4096; +export const DSML_MAX_ENVELOPE_CHARS = 2 * 1024 * 1024; +const TAIL_MAX = 1024; +const TAG_NAMES = ['tool_calls', 'tool_call', 'invoke', 'parameter']; function unescapeXml(value: string): string { return value @@ -46,115 +57,165 @@ function parseParameterValue(rawVal: string, isStringAttr: boolean | undefined): return unescaped; } -function parseInvokeBody(invokeContent: string): Record { - const paramRegex = - /<\s*[||]?\s*(?:DSML\s*[||]?)?\s*parameter\s+([^>]*?)>([\s\S]*?)<\/\s*[||]?\s*(?:DSML\s*[||]?)?\s*parameter\s*>/gi; - const args: Record = {}; - let paramFound = false; - let match: RegExpExecArray | null = null; - - while ((match = paramRegex.exec(invokeContent)) !== null) { - const attrStr = match[1] ?? ''; - const rawVal = match[2] ?? ''; - const nameMatch = /\bname\s*=\s*(?:"([^"]+)"|'([^']+)'|([^\s>]+))/i.exec(attrStr); - const paramName = nameMatch ? (nameMatch[1] ?? nameMatch[2] ?? nameMatch[3]) : undefined; - if (paramName) { - paramFound = true; - const stringAttrMatch = - /\bstring\s*=\s*(?:"(true|false)"|'(true|false)'|(true|false))/i.exec(attrStr); - const stringAttrVal = stringAttrMatch - ? (stringAttrMatch[1] ?? stringAttrMatch[2] ?? stringAttrMatch[3]) - : undefined; - const isStringAttr = - stringAttrVal !== undefined ? stringAttrVal.toLowerCase() === 'true' : undefined; - args[paramName] = parseParameterValue(rawVal, isStringAttr); - } - } +function attrValue(match: RegExpExecArray | null): string | undefined { + if (!match) return undefined; + return match[1] ?? match[2] ?? match[3]; +} - if (paramFound) { - return args; - } +function skipWhitespace(text: string, from: number): number { + let pos = from; + while (pos < text.length && /\s/.test(text[pos] as string)) pos += 1; + return pos; +} - const trimmed = invokeContent.trim(); - if (trimmed.startsWith('{') && trimmed.endsWith('}')) { +function stickyExec(re: RegExp, text: string, at: number): RegExpExecArray | null { + re.lastIndex = at; + return re.exec(text); +} + +export function parseInvokeBody(invokeContent: string): Record | null { + const args: Record = Object.create(null) as Record; + let pos = skipWhitespace(invokeContent, 0); + if (pos === invokeContent.length) return args; + + if (invokeContent[pos] === '{') { + const trimmed = invokeContent.trim(); + if (!trimmed.endsWith('}')) return null; try { - const parsed = JSON.parse(trimmed); - if (typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed)) { - return parsed as Record; + const parsed: unknown = JSON.parse(trimmed); + if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) return null; + for (const key of Object.keys(parsed)) { + args[key] = (parsed as Record)[key]; } - } catch {} + return args; + } catch { + return null; + } + } + + while (pos < invokeContent.length) { + const open = stickyExec(PARAM_OPEN_RE, invokeContent, pos); + if (!open) return null; + const attrStr = open[1] ?? ''; + const paramName = attrValue(NAME_ATTR_RE.exec(attrStr)); + if (!paramName) return null; + const valueStart = open.index + open[0].length; + const close = stickyExec(PARAM_CLOSE_RE, invokeContent, valueStart); + if (!close) return null; + const stringAttrVal = attrValue(STRING_ATTR_RE.exec(attrStr)); + const isStringAttr = + stringAttrVal !== undefined ? stringAttrVal.toLowerCase() === 'true' : undefined; + args[paramName] = parseParameterValue( + invokeContent.slice(valueStart, close.index), + isStringAttr, + ); + pos = skipWhitespace(invokeContent, close.index + close[0].length); } + return args; +} - return {}; +function newCallId(): string { + return `call_${crypto.randomUUID().replaceAll('-', '').slice(0, 24)}`; } function parseInvokeTag(invokeBlock: string): ToolCall | null { - const openMatch = /^<\s*[||]?\s*(?:DSML\s*[||]?)?\s*invoke\s+([^>]*?)>/i.exec(invokeBlock); + const openMatch = stickyExec(INVOKE_OPEN_RE, invokeBlock, 0); if (!openMatch) return null; - - const attrStr = openMatch[1] ?? ''; - const nameMatch = /\bname\s*=\s*(?:"([^"]+)"|'([^']+)'|([^\s>]+))/i.exec(attrStr); - const toolName = nameMatch ? (nameMatch[1] ?? nameMatch[2] ?? nameMatch[3]) : undefined; + const toolName = attrValue(NAME_ATTR_RE.exec(openMatch[0].slice(0, -1))); if (!toolName) return null; - const closeMatch = INVOKE_CLOSE_RE.exec(invokeBlock); - if (!closeMatch) return null; - const innerContent = invokeBlock.slice(openMatch[0].length, closeMatch.index); - - const args = parseInvokeBody(innerContent); - return { - type: 'function', - id: `call_${crypto.randomUUID().replaceAll('-', '').slice(0, 24)}`, - name: toolName, - arguments: JSON.stringify(args), - }; + const closeMatch = stickyExec(INVOKE_CLOSE_RE, invokeBlock, openMatch[0].length); + if (!closeMatch || closeMatch.index + closeMatch[0].length !== invokeBlock.length) return null; + const args = parseInvokeBody(invokeBlock.slice(openMatch[0].length, closeMatch.index)); + if (args === null) return null; + return { type: 'function', id: newCallId(), name: toolName, arguments: JSON.stringify(args) }; } function parseHermesToolCall(toolCallBlock: string): ToolCall | null { - if (!HERMES_CLOSE_RE.test(toolCallBlock)) return null; const inner = toolCallBlock .replace(/^/i, '') .replace(/<\/tool_call>$/i, '') .trim(); try { - const parsed = JSON.parse(inner); - if (parsed && typeof parsed.name === 'string') { - const args = - typeof parsed.arguments === 'string' - ? parsed.arguments - : JSON.stringify(parsed.arguments ?? {}); - return { - type: 'function', - id: `call_${crypto.randomUUID().replaceAll('-', '').slice(0, 24)}`, - name: parsed.name, - arguments: args, - }; - } - } catch {} - return null; + const parsed: unknown = JSON.parse(inner); + if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) return null; + const record = parsed as Record; + if (typeof record['name'] !== 'string') return null; + const rawArgs = record['arguments']; + const args = typeof rawArgs === 'string' ? rawArgs : JSON.stringify(rawArgs ?? {}); + return { type: 'function', id: newCallId(), name: record['name'], arguments: args }; + } catch { + return null; + } } -function isPotentialTagPrefix(s: string): boolean { - if (!s.startsWith('<')) return false; - const lower = s.toLowerCase(); +type TagKind = + | 'container-open' + | 'container-close' + | 'invoke-open' + | 'hermes-open' + | 'partial' + | 'none'; + +function markerPrefixState(lower: string): { rest: string; partial: boolean } { let rest = lower.startsWith(' 0 && rest.length < 4 && 'dsml'.startsWith(rest)) { + return { rest: '', partial: true }; } if (rest.startsWith('dsml')) { rest = rest.slice(4).trimStart(); - if (rest.startsWith('|') || rest.startsWith('|')) { - rest = rest.slice(1).trimStart(); + if (rest.startsWith('|') || rest.startsWith('|')) rest = rest.slice(1).trimStart(); + } + return { rest, partial: rest.length === 0 }; +} + +function classifyTag(buffer: string, at: number): { kind: TagKind; length: number } { + const containerOpen = stickyExec(CONTAINER_OPEN_RE, buffer, at); + if (containerOpen) return { kind: 'container-open', length: containerOpen[0].length }; + const containerClose = stickyExec(CONTAINER_CLOSE_RE, buffer, at); + if (containerClose) return { kind: 'container-close', length: containerClose[0].length }; + const invokeOpen = stickyExec(INVOKE_OPEN_RE, buffer, at); + if (invokeOpen) return { kind: 'invoke-open', length: invokeOpen[0].length }; + const hermesOpen = stickyExec(HERMES_OPEN_RE, buffer, at); + if (hermesOpen) return { kind: 'hermes-open', length: hermesOpen[0].length }; + + const lower = buffer.slice(at, at + DSML_MAX_TAG_CHARS + 1).toLowerCase(); + const { rest, partial } = markerPrefixState(lower); + if (partial) return { kind: 'partial', length: 0 }; + if (rest.includes('>')) return { kind: 'none', length: 0 }; + for (const name of TAG_NAMES) { + if (name.startsWith(rest)) return { kind: 'partial', length: 0 }; + if (rest.startsWith(name)) { + const after = rest.slice(name.length); + if (after.length === 0 || /^\s/.test(after)) return { kind: 'partial', length: 0 }; } } - if (rest.length === 0) return true; - const targets = ['tool_calls', 'tool_call', 'invoke', 'parameter']; - return targets.some((t) => t.startsWith(rest) || rest.startsWith(t)); + return { kind: 'none', length: 0 }; +} + +type Mode = 'text' | 'invoke' | 'hermes'; + +interface ContainerHold { + raw: string; + parts: StreamedMessagePart[]; } export class DsmlStreamParser { private _buffer = ''; + private _pos = 0; + private _mode: Mode = 'text'; + private _envelope: string[] = []; + private _envelopeLength = 0; + private _tail = ''; + private _stripWhitespace = false; + private _whitespaceHold = ''; + private _inContainer = false; + private _hold: ContainerHold | null = null; + private _lineStart = true; + private _linePrefix = ''; + private _fence: string | null = null; private _hasExtractedToolCalls = false; get hasExtractedToolCalls(): boolean { @@ -162,139 +223,276 @@ export class DsmlStreamParser { } feed(chunk: string): StreamedMessagePart[] { - this._buffer += chunk; const parts: StreamedMessagePart[] = []; + if (this._mode === 'text') { + this._buffer += chunk; + } else { + this._scanEnvelope(parts, chunk); + } + if (this._mode === 'text') { + this._drainText(parts); + } + return parts; + } - while (this._buffer.length > 0) { - const ltIdx = this._buffer.indexOf('<'); - if (ltIdx === -1) { - parts.push({ type: 'text', text: this._buffer }); - this._buffer = ''; - break; - } - - if (ltIdx > 0) { - parts.push({ type: 'text', text: this._buffer.slice(0, ltIdx) }); - this._buffer = this._buffer.slice(ltIdx); - } + flush(): StreamedMessagePart[] { + const parts: StreamedMessagePart[] = []; + if (this._mode !== 'text') { + const block = this._envelope.join('') + this._tail; + this._leaveEnvelope(); + this._buffer = block; + this._pos = 0; + } + if (this._whitespaceHold.length > 0) { + this._emitText(parts, this._whitespaceHold.replace(/^\r?\n/, '')); + this._whitespaceHold = ''; + } + this._stripWhitespace = false; + if (this._pos < this._buffer.length) { + this._emitText(parts, this._take(this._buffer.length - this._pos)); + } + this._buffer = ''; + this._pos = 0; + if (this._inContainer) { + this._releaseHold(parts, true); + this._inContainer = false; + } + return parts; + } - const openContainer = CONTAINER_OPEN_RE.exec(this._buffer); - if (openContainer) { - this._buffer = this._buffer.slice(openContainer[0].length); - if (/^\s* this._pos) { + this._whitespaceHold += this._take(end - this._pos); + } + if (this._pos >= this._buffer.length) break; + this._stripWhitespace = false; + if (this._buffer[this._pos] === '<') { + this._noteText(this._whitespaceHold); } else { - this._buffer = this._buffer.replace(/^\r?\n/, ''); + this._emitText(parts, this._whitespaceHold.replace(/^\r?\n/, '')); } - continue; + this._whitespaceHold = ''; } - const closeContainer = CONTAINER_CLOSE_RE.exec(this._buffer); - if (closeContainer) { - this._buffer = this._buffer.slice(closeContainer[0].length); - if (/^\s* this._pos) { + this._emitText(parts, this._take(ltIdx - this._pos)); continue; } - const invokeOpen = INVOKE_OPEN_RE.exec(this._buffer); - if (invokeOpen) { - const closeMatch = INVOKE_CLOSE_RE.exec(this._buffer); - if (!closeMatch) { - break; - } - const invokeEnd = closeMatch.index + closeMatch[0].length; - const invokeBlock = this._buffer.slice(0, invokeEnd); - const toolCall = parseInvokeTag(invokeBlock); - if (toolCall) { - this._hasExtractedToolCalls = true; - parts.push(toolCall); - this._buffer = this._buffer.slice(invokeEnd); - if (/^\s* DSML_MAX_TAG_CHARS) { + this._emitPlainTag(parts); + continue; } - const toolCallEnd = closeMatch.index + closeMatch[0].length; - const block = this._buffer.slice(0, toolCallEnd); - const toolCall = parseHermesToolCall(block); - if (toolCall) { - this._hasExtractedToolCalls = true; - parts.push(toolCall); - this._buffer = this._buffer.slice(toolCallEnd); - if (/^\s* DSML_MAX_ENVELOPE_CHARS) { + this._abandonEnvelope(parts, block, rest); + return; + } + const call = mode === 'invoke' ? parseInvokeTag(block) : parseHermesToolCall(block); + this._record(block); + if (call) { + this._emitCall(parts, call); + this._stripWhitespace = true; + } else { + this._emitText(parts, block); } + this._buffer = rest; + this._pos = 0; + return; + } - parts.push({ type: 'text', text: this._buffer.slice(0, nextLt) }); - this._buffer = this._buffer.slice(nextLt); + if (this._envelopeLength + probe.length >= DSML_MAX_ENVELOPE_CHARS) { + const block = this._envelope.join('') + probe; + this._leaveEnvelope(); + this._abandonEnvelope(parts, block, ''); + return; } - return parts; + const lastLt = probe.lastIndexOf('<'); + if (lastLt === -1 || probe.length - lastLt > TAIL_MAX) { + this._envelope.push(probe); + this._envelopeLength += probe.length; + this._tail = ''; + } else { + const committed = probe.slice(0, lastLt); + this._envelope.push(committed); + this._envelopeLength += committed.length; + this._tail = probe.slice(lastLt); + } } - flush(): StreamedMessagePart[] { - const parts: StreamedMessagePart[] = []; - if (this._buffer.length > 0) { - const invokeOpen = INVOKE_OPEN_RE.exec(this._buffer); - if (invokeOpen && INVOKE_CLOSE_RE.test(this._buffer)) { - const toolCall = parseInvokeTag(this._buffer); - if (toolCall) { - this._hasExtractedToolCalls = true; - parts.push(toolCall); - this._buffer = ''; - return parts; - } + private _abandonEnvelope(parts: StreamedMessagePart[], block: string, rest: string): void { + const nextLt = block.indexOf('<', 1); + const cut = nextLt === -1 ? block.length : nextLt; + const text = block.slice(0, cut); + this._record(text); + this._emitText(parts, text); + this._buffer = block.slice(cut) + rest; + this._pos = 0; + } + + private _take(length: number): string { + const taken = this._buffer.slice(this._pos, this._pos + length); + this._pos += length; + this._record(taken); + return taken; + } + + private _record(text: string): void { + if (this._hold === null) return; + this._hold.raw += text; + } + + private _releaseHold(parts: StreamedMessagePart[], asText: boolean): void { + const hold = this._hold; + this._hold = null; + if (hold === null) return; + if (asText) { + if (hold.raw.length > 0) parts.push({ type: 'text', text: hold.raw }); + } else { + parts.push(...hold.parts); + } + } + + private _emitCall(parts: StreamedMessagePart[], call: ToolCall): void { + this._hasExtractedToolCalls = true; + this._releaseHold(parts, false); + parts.push(call); + } + + private _emitText(parts: StreamedMessagePart[], text: string): void { + if (text.length === 0) return; + this._noteText(text); + const part: StreamedMessagePart = { type: 'text', text }; + if (this._hold !== null) { + this._hold.parts.push(part); + if (this._hold.raw.length > DSML_MAX_ENVELOPE_CHARS) { + this._releaseHold(parts, true); } - const hermesOpen = HERMES_OPEN_RE.exec(this._buffer); - if (hermesOpen && HERMES_CLOSE_RE.test(this._buffer)) { - const toolCall = parseHermesToolCall(this._buffer); - if (toolCall) { - this._hasExtractedToolCalls = true; - parts.push(toolCall); - this._buffer = ''; - return parts; + return; + } + parts.push(part); + } + + private _noteText(text: string): void { + for (const ch of text) { + if (ch === '\n') { + this._lineStart = true; + this._linePrefix = ''; + continue; + } + if (!this._lineStart) continue; + if (ch === ' ' && this._linePrefix.length < 3 && /^ *$/.test(this._linePrefix)) { + this._linePrefix += ch; + continue; + } + if (ch !== '`' && ch !== '~') { + this._lineStart = false; + continue; + } + const run = this._linePrefix.trimStart(); + if (run.length > 0 && run[0] !== ch) { + this._lineStart = false; + continue; + } + this._linePrefix += ch; + if (run.length + 1 === 3) { + this._lineStart = false; + if (this._fence === null) { + this._fence = ch; + } else if (this._fence === ch) { + this._fence = null; } } - parts.push({ type: 'text', text: this._buffer }); - this._buffer = ''; } - return parts; } } @@ -315,6 +513,6 @@ export function extractDsmlToolCalls(text: string): { } } - const cleanText = toolCalls.length > 0 ? textParts.join('').trim() : text; - return { cleanText, toolCalls }; + const joined = textParts.join(''); + return { cleanText: toolCalls.length > 0 ? joined.trim() : joined, toolCalls }; } diff --git a/packages/agent-core-v2/src/kosong/provider/bases/openai/openai-legacy.ts b/packages/agent-core-v2/src/kosong/provider/bases/openai/openai-legacy.ts index 25ad3daf7..bdd8e3ed9 100644 --- a/packages/agent-core-v2/src/kosong/provider/bases/openai/openai-legacy.ts +++ b/packages/agent-core-v2/src/kosong/provider/bases/openai/openai-legacy.ts @@ -429,7 +429,8 @@ export class OpenAILegacyStreamedMessage implements StreamedMessage { let text = message.content ?? null; let extractedToolCalls: ToolCall[] = []; - if (text) { + const hasNativeToolCalls = (message.tool_calls ?? []).some(isFunctionToolCall); + if (text && !hasNativeToolCalls) { const parsed = extractDsmlToolCalls(text); if (parsed.toolCalls.length > 0) { text = parsed.cleanText; @@ -465,6 +466,8 @@ export class OpenAILegacyStreamedMessage implements StreamedMessage { ): AsyncGenerator { const bufferedToolCalls = new Map(); const dsmlParser = new DsmlStreamParser(); + const recoveredToolCalls: ToolCall[] = []; + let nativeToolCallsSeen = false; try { for await (const chunk of response) { @@ -494,11 +497,16 @@ export class OpenAILegacyStreamedMessage implements StreamedMessage { if (delta.content) { for (const part of dsmlParser.feed(delta.content)) { - yield part; + if (part.type === 'function') { + recoveredToolCalls.push(part); + } else { + yield part; + } } } for (const toolCall of delta.tool_calls ?? []) { + nativeToolCallsSeen = true; for (const part of convertChatCompletionStreamToolCall(toolCall, bufferedToolCalls)) { yield part; } @@ -506,10 +514,17 @@ export class OpenAILegacyStreamedMessage implements StreamedMessage { } for (const part of dsmlParser.flush()) { - yield part; + if (part.type === 'function') { + recoveredToolCalls.push(part); + } else { + yield part; + } } - if (dsmlParser.hasExtractedToolCalls) { + if (!nativeToolCallsSeen && recoveredToolCalls.length > 0) { this._hasExtractedToolCalls = true; + for (const toolCall of recoveredToolCalls) { + yield toolCall; + } } } catch (error: unknown) { throw convertOpenAIError(error, this._convertErrorHook); diff --git a/packages/agent-core-v2/src/os/backends/node-local/hostFsService.ts b/packages/agent-core-v2/src/os/backends/node-local/hostFsService.ts index 9591a2e5b..20dd439ad 100644 --- a/packages/agent-core-v2/src/os/backends/node-local/hostFsService.ts +++ b/packages/agent-core-v2/src/os/backends/node-local/hostFsService.ts @@ -8,7 +8,6 @@ import { realpath as nodeRealpath, rm, stat as nodeStat, - writeFile, } from 'node:fs/promises'; import { LifecycleScope } from '#/app/scopes'; import { ScopeActivation, registerScopedService } from '#/_base/di/scope'; @@ -16,7 +15,9 @@ import { decodeTextWithErrors, type TextDecodeErrors } from '#/_base/execEnv/dec import { type HostDirEntry, type HostFileStat, IHostFileSystem } from '#/os/interface/hostFileSystem'; import { toHostFsError } from '#/os/interface/hostFsErrors'; +import { atomicWrite } from '#/_base/utils/fs'; +const NEWLINE = Buffer.from([0x0a]); const READ_CHUNK_SIZE = 64 * 1024; function isUtf8Encoding(encoding: BufferEncoding): boolean { @@ -57,8 +58,23 @@ export class HostFileSystem implements IHostFileSystem { } async writeText(path: string, data: string): Promise { + await this._replaceAtomically(path, data); + } + + private async _replaceAtomically(path: string, data: string | Uint8Array): Promise { try { - await writeFile(path, data, 'utf8'); + let target = path; + let mode: number | undefined; + try { + const existing = await nodeStat(path); + if (existing.isFile()) { + target = await nodeRealpath(path); + mode = existing.mode & 0o7777; + } + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error; + } + await atomicWrite(target, data, undefined, mode); } catch (error) { throw toHostFsError(error, { path, op: 'write' }); } @@ -93,16 +109,12 @@ export class HostFileSystem implements IHostFileSystem { } async writeBytes(path: string, data: Uint8Array): Promise { - try { - await writeFile(path, data); - } catch (error) { - throw toHostFsError(error, { path, op: 'write' }); - } + await this._replaceAtomically(path, data); } async *readLines( path: string, - options?: { encoding?: BufferEncoding; errors?: TextDecodeErrors }, + options?: { encoding?: BufferEncoding; errors?: TextDecodeErrors; maxLineBytes?: number }, ): AsyncGenerator { try { const encoding = options?.encoding ?? 'utf-8'; @@ -114,7 +126,7 @@ export class HostFileSystem implements IHostFileSystem { return; } - yield* this._readUtf8Lines(path, errors); + yield* this._readUtf8Lines(path, errors, options?.maxLineBytes ?? Number.POSITIVE_INFINITY); } catch (error) { throw toHostFsError(error, { path, op: 'read' }); } @@ -123,13 +135,37 @@ export class HostFileSystem implements IHostFileSystem { private async *_readUtf8Lines( path: string, errors: TextDecodeErrors, + maxLineBytes: number, ): AsyncGenerator { const fh = await open(path, 'r'); try { const buf = Buffer.alloc(READ_CHUNK_SIZE); let pending: Buffer[] = []; + let pendingBytes = 0; + let pendingTruncated = false; let pendingOffset = 0; let fileOffset = 0; + const retain = (piece: Buffer): void => { + const room = maxLineBytes - pendingBytes; + if (room <= 0) { + pendingTruncated = true; + return; + } + if (piece.length > room) pendingTruncated = true; + const kept = Buffer.from(piece.subarray(0, Math.min(piece.length, room))); + pending.push(kept); + pendingBytes += kept.length; + }; + const takeLine = (piece: Buffer): Buffer => { + if (pending.length === 0 && piece.length <= maxLineBytes) return piece; + retain(piece); + const parts = pendingTruncated && piece.at(-1) === 0x0a ? [...pending, NEWLINE] : pending; + const line = Buffer.concat(parts); + pending = []; + pendingBytes = 0; + pendingTruncated = false; + return line; + }; while (true) { const { bytesRead } = await fh.read(buf, 0, buf.length, null); @@ -140,18 +176,15 @@ export class HostFileSystem implements IHostFileSystem { for (let i = 0; i < chunk.length; i += 1) { const byte = chunk[i]; if (byte !== 0x0a) continue; - const piece = chunk.subarray(lineStart, i + 1); const lineOffset = pending.length === 0 ? fileOffset + lineStart : pendingOffset; - const line = pending.length === 0 ? piece : Buffer.concat([...pending, piece]); + const line = takeLine(chunk.subarray(lineStart, i + 1)); yield decodeTextWithErrors(line, 'utf-8', errors, lineOffset !== 0); - pending = []; lineStart = i + 1; } if (lineStart < chunk.length) { - const tail = Buffer.from(chunk.subarray(lineStart)); if (pending.length === 0) pendingOffset = fileOffset + lineStart; - pending.push(tail); + retain(chunk.subarray(lineStart)); } fileOffset += bytesRead; } diff --git a/packages/agent-core-v2/src/os/interface/hostFileSystem.ts b/packages/agent-core-v2/src/os/interface/hostFileSystem.ts index cadd91538..04929660a 100644 --- a/packages/agent-core-v2/src/os/interface/hostFileSystem.ts +++ b/packages/agent-core-v2/src/os/interface/hostFileSystem.ts @@ -30,7 +30,7 @@ export interface IHostFileSystem { writeBytes(path: string, data: Uint8Array): Promise; readLines( path: string, - options?: { encoding?: BufferEncoding; errors?: TextDecodeErrors }, + options?: { encoding?: BufferEncoding; errors?: TextDecodeErrors; maxLineBytes?: number }, ): AsyncGenerator; createExclusive(path: string, data: Uint8Array): Promise; stat(path: string): Promise; diff --git a/packages/agent-core-v2/src/session/agentLifecycle/agentLifecycleService.ts b/packages/agent-core-v2/src/session/agentLifecycle/agentLifecycleService.ts index 105e66a9a..8a58f9686 100644 --- a/packages/agent-core-v2/src/session/agentLifecycle/agentLifecycleService.ts +++ b/packages/agent-core-v2/src/session/agentLifecycle/agentLifecycleService.ts @@ -1,7 +1,7 @@ import { join } from 'pathe'; import { IInstantiationService } from '#/_base/di/instantiation'; -import { Disposable, toDisposable } from '#/_base/di/lifecycle'; +import { Disposable, toDisposable, type IDisposable } from '#/_base/di/lifecycle'; import { type CollectionView } from '#/_base/di/collection'; import { Emitter } from '#/_base/event'; import { onUnexpectedError } from '#/_base/errors/unexpectedError'; @@ -159,7 +159,7 @@ export class AgentLifecycleService extends Disposable implements IAgentLifecycle if (this.records.get(id) === record) { const fallback = [...this.contributions.values()] .filter((candidate) => candidate.active && getAgentRuntimeDefinitionId(candidate.definition) === id) - .sort((left, right) => (right.providerGeneration ?? right.generation) - (left.providerGeneration ?? left.generation))[0]; + .toSorted((left, right) => (right.providerGeneration ?? right.generation) - (left.providerGeneration ?? left.generation))[0]; if (fallback !== undefined) this.records.set(id, fallback); else this.records.delete(id); } @@ -294,6 +294,7 @@ export class AgentLifecycleService extends Disposable implements IAgentLifecycle await managed.handle.dispose(); } catch { } } + if (didCreate) await this.sessionMetadata.unregisterAgent(agentId).catch(() => undefined); if (!finalizerArmed) eventBus?.deactivateAgent(agent); if (didCreate) this.onDidCloseEmitter.fire(agent); throw error; @@ -443,29 +444,58 @@ export class AgentLifecycleService extends Disposable implements IAgentLifecycle async remove(agent: AgentContext): Promise { const managed = this.roster.get(agent.agentId); - if (managed === undefined || managed.context !== agent || managed.closing) return; + if (managed === undefined || managed.context !== agent) return; + if (managed.closePromise !== undefined) return managed.closePromise; + if (managed.closing) return; managed.closing = true; + managed.closePromise = this.close(agent, managed); + return managed.closePromise; + } + + private async close(agent: AgentContext, managed: ManagedAgent): Promise { + const failures: unknown[] = []; + const phase = async (work: () => Promise | void): Promise => { + try { + await work(); + } catch (error) { + failures.push(error); + } + }; this.onWillCloseEmitter.fire(agent); const handle = managed.handle; - await handle.accessor.get(IAgentTaskService).stopAllOnExit('Session closed'); const loop = handle.accessor.get(IAgentLoopService); - const compaction = handle.accessor.get(IAgentFullCompactionService).compacting; - const compactionSettled = compaction?.promise.catch(() => undefined) ?? Promise.resolve(); const reason = abortError('Agent removed'); - const prompt = handle.accessor.get(IAgentPromptService); - for (const turnId of loop.status().pendingTurnIds) { - loop.cancel(turnId, reason); - } - loop.cancel(undefined, reason); - if (compaction !== null && !compaction.abortController.signal.aborted) { - compaction.abortController.abort(reason); + let quiescence: IDisposable | undefined; + try { + await phase(() => handle.accessor.get(IAgentTaskService).stopAllOnExit('Session closed')); + const compaction = handle.accessor.get(IAgentFullCompactionService).compacting; + const compactionSettled = compaction?.promise.catch(() => undefined) ?? Promise.resolve(); + const prompt = handle.accessor.get(IAgentPromptService); + await phase(async () => { + await prompt.drain(reason); + for (const turnId of loop.status().pendingTurnIds) { + loop.cancel(turnId, reason); + } + loop.cancel(undefined, reason); + if (compaction !== null && !compaction.abortController.signal.aborted) { + compaction.abortController.abort(reason); + } + await Promise.all([loop.settled(), compactionSettled]); + }); + await phase(() => { + quiescence = loop.tryAcquireQuiescence(); + }); + await phase(() => handle.accessor.get(IEventDispatcher).flush()); + await phase(() => managed.runtimeSet.close()); + managed.killSpace(); + await phase(() => handle.dispose()); + } finally { + quiescence?.dispose(); + if (this.roster.get(agent.agentId) === managed) this.roster.delete(agent.agentId); + this.onDidCloseEmitter.fire(agent); } - await Promise.all([loop.settled(), compactionSettled, prompt.drain(reason)]); - await managed.runtimeSet.close(); - managed.killSpace(); - await handle.dispose(); - if (this.roster.get(agent.agentId) === managed) this.roster.delete(agent.agentId); - this.onDidCloseEmitter.fire(agent); + if (failures.length === 1) throw failures[0]; + if (failures.length > 1) throw new AggregateError(failures, `Agent ${agent.agentId} removal failed`); } private managedFor(agent: AgentContext): ManagedAgent | undefined { diff --git a/packages/agent-core-v2/src/session/agentLifecycle/managedAgent.ts b/packages/agent-core-v2/src/session/agentLifecycle/managedAgent.ts index 31735f1de..78e3bd39d 100644 --- a/packages/agent-core-v2/src/session/agentLifecycle/managedAgent.ts +++ b/packages/agent-core-v2/src/session/agentLifecycle/managedAgent.ts @@ -8,6 +8,7 @@ import { IEventDispatcher } from '#/state/eventDispatcher'; export class ManagedAgent { active = false; closing = false; + closePromise: Promise | undefined; readonly runtimeSet: AgentRuntimeSet; constructor( diff --git a/packages/agent-core-v2/src/session/expertTalk/expertTalkService.ts b/packages/agent-core-v2/src/session/expertTalk/expertTalkService.ts index c46b47822..963a9353d 100644 --- a/packages/agent-core-v2/src/session/expertTalk/expertTalkService.ts +++ b/packages/agent-core-v2/src/session/expertTalk/expertTalkService.ts @@ -52,7 +52,7 @@ import { } from '#/kosong/contract/errors'; import { extractText } from '#/kosong/contract/message'; import { estimateTokensForContentParts } from '#/kosong/contract/tokens'; -import type { TokenUsage } from '#/kosong/contract/usage'; +import { usageDelta } from '#/kosong/contract/usage'; import { IModelCatalog, type Model } from '#/kosong/model/catalog'; import type { ModelRequester } from '#/kosong/model/modelRequester'; import { IAtomicDocumentStore } from '#/persistence/interface/atomicDocumentStore'; @@ -1905,22 +1905,6 @@ function hasMarkdownSections(text: string, sections: readonly string[]): boolean return sections.every((section) => headings.has(section)); } -function usageDelta( - after: TokenUsage | undefined, - before: TokenUsage | undefined, -): TokenUsage | undefined { - if (after === undefined) return undefined; - return { - inputOther: Math.max(0, after.inputOther - (before?.inputOther ?? 0)), - output: Math.max(0, after.output - (before?.output ?? 0)), - inputCacheRead: Math.max(0, after.inputCacheRead - (before?.inputCacheRead ?? 0)), - inputCacheCreation: Math.max( - 0, - after.inputCacheCreation - (before?.inputCacheCreation ?? 0), - ), - }; -} - function estimateToolResultTokens(output: string | readonly ContentPart[]): number { return typeof output === 'string' ? Math.ceil(Buffer.byteLength(output, 'utf8') / 4) diff --git a/packages/agent-core-v2/src/session/sessionMetadata/sessionMetadata.ts b/packages/agent-core-v2/src/session/sessionMetadata/sessionMetadata.ts index d7b11c489..8ec0c041b 100644 --- a/packages/agent-core-v2/src/session/sessionMetadata/sessionMetadata.ts +++ b/packages/agent-core-v2/src/session/sessionMetadata/sessionMetadata.ts @@ -51,6 +51,7 @@ export interface ISessionMetadata { ): Promise; setArchived(archived: boolean): Promise; registerAgent(agentId: string, meta: AgentMeta): Promise; + unregisterAgent(agentId: string): Promise; } export const ISessionMetadata: ServiceIdentifier = diff --git a/packages/agent-core-v2/src/session/sessionMetadata/sessionMetadataService.ts b/packages/agent-core-v2/src/session/sessionMetadata/sessionMetadataService.ts index b5115b279..221f973e8 100644 --- a/packages/agent-core-v2/src/session/sessionMetadata/sessionMetadataService.ts +++ b/packages/agent-core-v2/src/session/sessionMetadata/sessionMetadataService.ts @@ -95,9 +95,10 @@ export class SessionMetadata extends Service implements ISessionMetadata { if (this.disposed) return false; const updatedAt = patch.updatedAt ?? (opts?.touchUpdatedAt === false ? this.data.updatedAt : Date.now()); - this.data = { ...this.data, ...patch, updatedAt }; - await this.store.set(this.scope, META_KEY, encodeSessionMeta(this.data)); + const next = { ...this.data, ...patch, updatedAt }; + await this.store.set(this.scope, META_KEY, encodeSessionMeta(next)); if (this.disposed) return false; + this.data = next; this.mirrorToReadModel(); this._onDidChangeMetadata.fire({ changed: Object.keys(patch) as (keyof SessionMeta)[], @@ -137,6 +138,15 @@ export class SessionMetadata extends Service implements ISessionMetadata { }); } + async unregisterAgent(agentId: string): Promise { + return this.enqueueUpdate(async () => { + await this.ready; + if (this.data.agents?.[agentId] === undefined) return; + const { [agentId]: _removed, ...agents } = this.data.agents; + await this.applyUpdate({ agents }, { touchUpdatedAt: false }); + }); + } + private enqueueUpdate(work: () => Promise): Promise { const run = this.updateQueue.then(work, work); const tracked: Promise = run.then( diff --git a/packages/agent-core-v2/src/session/subagent/runAgentTurn.ts b/packages/agent-core-v2/src/session/subagent/runAgentTurn.ts index e492ec20c..fc01acfff 100644 --- a/packages/agent-core-v2/src/session/subagent/runAgentTurn.ts +++ b/packages/agent-core-v2/src/session/subagent/runAgentTurn.ts @@ -1,5 +1,5 @@ import { APIProviderRateLimitError, isProviderRateLimitError } from '#/kosong/contract/errors'; -import { type TokenUsage } from '#/kosong/contract/usage'; +import { type TokenUsage, usageDelta } from '#/kosong/contract/usage'; import { linkAbortSignal, userCancellationReason } from '#/_base/utils/abort'; import type { IAgentScopeHandle } from '#/_base/di/scope'; @@ -36,6 +36,7 @@ export async function runAgentTurn( options: RunAgentTurnOptions, ): Promise { options.signal.throwIfAborted(); + const usageBefore = target.accessor.get(ISessionUsageService)?.status(agentContextOf(target)).total; const promptService = target.accessor.get(IAgentPromptService); const turn = request.kind === 'prompt' @@ -58,7 +59,7 @@ export async function runAgentTurn( void turn.ready.then(() => options.onReady?.()).catch(() => {}); } - const completion = awaitRun(target, turn, options); + const completion = awaitRun(target, turn, options, usageBefore); return { agentId: target.id, turn, completion }; } @@ -66,7 +67,8 @@ async function awaitRun( target: IAgentScopeHandle, turn: Turn, options: RunAgentTurnOptions, -): Promise<{ summary: string; usage?: TokenUsage }> { + usageBefore: TokenUsage | undefined, +): Promise<{ summary: string; usage?: TokenUsage; cumulativeUsage?: TokenUsage }> { const controller = new AbortController(); const unlink = linkAbortSignal(options.signal, controller); const loop = target.accessor.get(IAgentLoopService); @@ -86,8 +88,10 @@ async function awaitRun( }, cancelTurn, ); - const usage = target.accessor.get(ISessionUsageService)?.status(agentContextOf(target)).total; - return { summary, usage }; + const cumulativeUsage = target.accessor + .get(ISessionUsageService) + ?.status(agentContextOf(target)).total; + return { summary, usage: usageDelta(cumulativeUsage, usageBefore), cumulativeUsage }; } finally { unlink(); if (controller.signal.aborted) { diff --git a/packages/agent-core-v2/src/session/subagent/subagent.ts b/packages/agent-core-v2/src/session/subagent/subagent.ts index 35de4cbec..40e30eea3 100644 --- a/packages/agent-core-v2/src/session/subagent/subagent.ts +++ b/packages/agent-core-v2/src/session/subagent/subagent.ts @@ -27,7 +27,11 @@ export interface RunAgentOptions { export interface AgentRunHandle { readonly agentId: string; readonly turn: Turn; - readonly completion: Promise<{ readonly summary: string; readonly usage?: TokenUsage }>; + readonly completion: Promise<{ + readonly summary: string; + readonly usage?: TokenUsage; + readonly cumulativeUsage?: TokenUsage; + }>; } export class SubagentRunStartError extends Error { diff --git a/packages/agent-core-v2/src/tool/path-access.ts b/packages/agent-core-v2/src/tool/path-access.ts index 7e11c4631..68e6672d7 100644 --- a/packages/agent-core-v2/src/tool/path-access.ts +++ b/packages/agent-core-v2/src/tool/path-access.ts @@ -243,6 +243,33 @@ function relativeOutsideMessage(path: string, operation: PathAccessOperation): s ); } +export interface ResolvedTargetFs { + realpath(path: string): Promise; +} + +export async function resolveRealTarget(fs: ResolvedTargetFs, path: string): Promise { + try { + return await fs.realpath(path); + } catch { + const parent = pathe.dirname(path); + if (parent === path) return path; + return pathe.join(await resolveRealTarget(fs, parent), pathe.basename(path)); + } +} + +export async function sensitiveTargetError( + fs: ResolvedTargetFs, + requestedPath: string, + safePath: string, +): Promise { + const target = await resolveRealTarget(fs, safePath); + if (target === safePath || !isSensitiveFile(target)) return undefined; + return ( + `"${requestedPath}" resolves to "${target}", which matches a sensitive-file pattern ` + + `(env / credential / SSH key). Access is blocked to protect secrets.` + ); +} + export function resolvePathAccess( path: string, cwd: string, diff --git a/packages/agent-core-v2/test/agent/prompt/promptService.test.ts b/packages/agent-core-v2/test/agent/prompt/promptService.test.ts index c928e3d40..f84c1b18d 100644 --- a/packages/agent-core-v2/test/agent/prompt/promptService.test.ts +++ b/packages/agent-core-v2/test/agent/prompt/promptService.test.ts @@ -230,6 +230,91 @@ describe('AgentPromptService', () => { expect(events[0]).not.toHaveProperty('promptIds'); }); + it('abort during launch cancels the prompt before it reaches the loop', async () => { + const { prompt, loop, eventBus } = harness(); + const aborted: PromptAborted[] = []; + eventBus.subscribe(PromptAborted, (event) => aborted.push(event)); + const enqueueSpy = vi.spyOn(loop, 'enqueue'); + let release!: () => void; + const gate = new Promise((resolve) => { release = resolve; }); + let promptId: string | undefined; + prompt.hooks.onBeforeSubmitPrompt.register('gate', async (_ctx, next) => { promptId = prompt.list().active?.id; await gate; await next(); }); + const handlePromise = prompt.enqueue({ id: 'launching', message: message('slow start') }); + await vi.waitFor(() => { expect(prompt.list().pending).toEqual([]); }); + expect(promptId).toBeUndefined(); + expect(prompt.abort('launching')).toBe(true); + release(); + const handle = await handlePromise; + await expect(handle.completion).resolves.toMatchObject({ state: 'cancelled' }); + await expect(handle.launched).resolves.toBeUndefined(); + expect(enqueueSpy).not.toHaveBeenCalled(); + expect(prompt.list()).toEqual({ active: undefined, pending: [] }); + expect(aborted.map((event) => event.promptId)).toEqual(['launching']); + }); + + it('drain waits for a launching prompt and cancels it', async () => { + const { prompt, loop } = harness(); + const enqueueSpy = vi.spyOn(loop, 'enqueue'); + let release!: () => void; + const gate = new Promise((resolve) => { release = resolve; }); + prompt.hooks.onBeforeSubmitPrompt.register('gate', async (_ctx, next) => { await gate; await next(); }); + const handlePromise = prompt.enqueue({ id: 'launching', message: message('slow start') }); + await vi.waitFor(() => { expect(prompt.list().pending).toEqual([]); }); + let drained = false; + const drain = prompt.drain().then(() => { drained = true; }); + await Promise.resolve(); + expect(drained).toBe(false); + release(); + await drain; + const handle = await handlePromise; + await expect(handle.completion).resolves.toMatchObject({ state: 'cancelled' }); + expect(enqueueSpy).not.toHaveBeenCalled(); + }); + + it('clear cancels a launching prompt', async () => { + const { prompt, loop } = harness(); + const enqueueSpy = vi.spyOn(loop, 'enqueue'); + let release!: () => void; + const gate = new Promise((resolve) => { release = resolve; }); + prompt.hooks.onBeforeSubmitPrompt.register('gate', async (_ctx, next) => { await gate; await next(); }); + const handlePromise = prompt.enqueue({ id: 'launching', message: message('slow start') }); + await vi.waitFor(() => { expect(prompt.list().pending).toEqual([]); }); + prompt.clear(); + release(); + await expect((await handlePromise).completion).resolves.toMatchObject({ state: 'cancelled' }); + expect(enqueueSpy).not.toHaveBeenCalled(); + }); + + it('cancels the turn when abort lands after the loop assigned it', async () => { + const { prompt, loop } = harness(); + const cancelSpy = vi.spyOn(loop, 'cancel'); + const originalEnqueue = loop.enqueue.bind(loop); + let releaseAssignment!: () => void; + const assignmentGate = new Promise((resolve) => { releaseAssignment = resolve; }); + const enqueueSpy = vi.spyOn(loop, 'enqueue').mockImplementationOnce((request, options) => { + const receipt = originalEnqueue(request, options); + return { ...receipt, assigned: assignmentGate.then(() => receipt.assigned) }; + }); + const handlePromise = prompt.enqueue({ id: 'launching', message: message('slow start') }); + await vi.waitFor(() => { expect(enqueueSpy).toHaveBeenCalledOnce(); }); + expect(prompt.abort('launching')).toBe(true); + releaseAssignment(); + const handle = await handlePromise; + await expect(handle.launched).resolves.toBeDefined(); + expect(cancelSpy).toHaveBeenCalledOnce(); + }); + + it('parks a queued prompt while compaction runs instead of recursing', async () => { + const { prompt, fullCompaction } = harness(); + (fullCompaction as unknown as { compacting: unknown }).compacting = { promise: new Promise(() => {}), abortController: new AbortController() }; + const handle = await prompt.enqueue({ id: 'parked', message: message('later') }); + expect(handle.state).toBe('pending'); + await (prompt as unknown as { startNext(): Promise }).startNext(); + await (prompt as unknown as { startNext(): Promise }).startNext(); + expect(prompt.list().pending.map((item) => item.id)).toEqual(['parked']); + expect(prompt.list().active).toBeUndefined(); + }); + it('aborts pending prompts and settles completion', async () => { const { prompt, eventBus } = harness(); const aborted: PromptAborted[] = []; diff --git a/packages/agent-core-v2/test/agent/toolExecutor/toolExecutor.test.ts b/packages/agent-core-v2/test/agent/toolExecutor/toolExecutor.test.ts index 3d25f0cc0..d65ef62aa 100644 --- a/packages/agent-core-v2/test/agent/toolExecutor/toolExecutor.test.ts +++ b/packages/agent-core-v2/test/agent/toolExecutor/toolExecutor.test.ts @@ -647,6 +647,94 @@ describe('AgentToolExecutorService', () => { ]); }); + it('classifies telemetry by execution state, not by output text', async () => { + const tool = new TestTool('noisy', { + result: { output: 'upstream said: request aborted by peer', isError: true }, + }); + registry.register(tool); + + await execute([toolCall('call_noisy', 'noisy', {})]); + + expect(telemetryEvents).toContainEqual({ + event: 'tool_call', + properties: expect.objectContaining({ + tool_call_id: 'call_noisy', + outcome: 'error', + error_type: 'error', + }), + }); + }); + + it('reports cancelled telemetry for a tool aborted by the signal', async () => { + const controller = new AbortController(); + const tool = new ControlledTool('slow', ToolAccesses.writeFile('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/repo/a.ts')); + registry.register(tool); + + const execution = execute([toolCall('call_slow', 'slow', {})], controller.signal); + await tool.started; + controller.abort(); + await execution; + + expect(telemetryEvents).toContainEqual({ + event: 'tool_call', + properties: expect.objectContaining({ + tool_call_id: 'call_slow', + outcome: 'cancelled', + error_type: 'cancelled', + }), + }); + }); + + it('holds the resource lease past the abort grace until the ignored execution settles', async () => { + vi.useFakeTimers(); + try { + const controller = new AbortController(); + let finishStubborn: () => void = () => {}; + const stubbornDone = new Promise((resolve) => { + finishStubborn = resolve; + }); + const stubborn = new TestTool('stubborn', { + accesses: ToolAccesses.writeFile('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/repo/a.ts'), + execute: async () => { + await stubbornDone; + return { output: 'late write' }; + }, + }); + const follower = new TestTool('follower', { + accesses: ToolAccesses.writeFile('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/repo/a.ts'), + result: { output: 'follower ran' }, + }); + registry.register(stubborn); + registry.register(follower); + + const firstBatch = execute([toolCall('call_stubborn', 'stubborn', {})], controller.signal); + await vi.waitFor(() => expect(stubborn.calls).toHaveLength(1)); + controller.abort(); + await vi.advanceTimersByTimeAsync(2_000); + const results = await firstBatch; + expect(results).toEqual([ + expect.objectContaining({ output: 'Tool "stubborn" was aborted', isError: true }), + ]); + + let secondSettled = false; + const secondBatch = execute([toolCall('call_follower', 'follower', {})]).then((value) => { + secondSettled = true; + return value; + }); + await vi.advanceTimersByTimeAsync(10); + expect(follower.calls).toHaveLength(0); + expect(secondSettled).toBe(false); + + finishStubborn(); + await vi.advanceTimersByTimeAsync(10); + const secondResults = await secondBatch; + expect(follower.calls).toHaveLength(1); + expect(secondResults).toEqual([expect.objectContaining({ output: 'follower ran' })]); + } finally { + vi.useRealTimers(); + } + }); + it('does not start a queued conflicting tool after abort', async () => { const controller = new AbortController(); const first = new ControlledTool('first', ToolAccesses.writeFile('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/repo/a.ts')); diff --git a/packages/agent-core-v2/test/agent/toolExecutor/toolScheduler.test.ts b/packages/agent-core-v2/test/agent/toolExecutor/toolScheduler.test.ts index 1abb2d22c..f687cca03 100644 --- a/packages/agent-core-v2/test/agent/toolExecutor/toolScheduler.test.ts +++ b/packages/agent-core-v2/test/agent/toolExecutor/toolScheduler.test.ts @@ -239,6 +239,79 @@ interface ControlledTask { readonly reject: (error: unknown) => void; } +describe('ToolScheduler leases and budgets', () => { + it('keeps a conflicting task queued until the abandoned effect settles', async () => { + const started: string[] = []; + const drained: string[] = []; + const scheduler = makeScheduler(drained); + let settleEffects: () => void = () => {}; + const effectsSettled = new Promise((resolve) => { + settleEffects = resolve; + }); + const abandoned = makeControlledTask('abandoned', writePath('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/repo/a.ts'), started, effectsSettled); + const follower = makeControlledTask('follower', writePath('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/repo/a.ts'), started); + + scheduler.add(abandoned.task); + scheduler.add(follower.task); + abandoned.resolve(); + await waitOneMacrotask(); + + expect(drained).toEqual([]); + expect(started).toEqual(['abandoned']); + settleEffects(); + await waitOneMacrotask(); + expect(started).toEqual(['abandoned', 'follower']); + follower.resolve(); + await scheduler.collectResults(); + expect(drained).toEqual(['abandoned', 'follower']); + }); + + it('blocks on outstanding effects from a previous scheduler', async () => { + const started: string[] = []; + let settleEffects: () => void = () => {}; + const settled = new Promise((resolve) => { + settleEffects = resolve; + }); + const scheduler = new ToolScheduler([{ accesses: writePath('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/repo/a.ts'), settled }]); + const conflicting = makeControlledTask('conflicting', readPath('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/repo/a.ts'), started); + const unrelated = makeControlledTask('unrelated', readPath('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/repo/b.ts'), started); + const results = [scheduler.add(conflicting.task), scheduler.add(unrelated.task)]; + await waitOneMacrotask(); + + expect(started).toEqual(['unrelated']); + settleEffects(); + await waitOneMacrotask(); + expect(started).toEqual(['unrelated', 'conflicting']); + conflicting.resolve(); + unrelated.resolve(); + await Promise.all(results); + }); + + it('caps unrelated concurrency at the configured budget', async () => { + const started: string[] = []; + const scheduler = new ToolScheduler([], 2); + const tasks = ['a', 'b', 'c', 'd'].map((name) => + makeControlledTask(name, readPath(`/repo/${name}.ts`), started), + ); + const results = tasks.map((task) => scheduler.add(task.task)); + await waitOneMacrotask(); + + expect(started).toEqual(['a', 'b']); + expect(scheduler.running).toBe(2); + tasks[0]!.resolve(); + await waitOneMacrotask(); + expect(started).toEqual(['a', 'b', 'c']); + tasks[1]!.resolve(); + tasks[2]!.resolve(); + await waitOneMacrotask(); + expect(started).toEqual(['a', 'b', 'c', 'd']); + tasks[3]!.resolve(); + await Promise.all(results); + await waitOneMacrotask(); + expect(scheduler.running).toBe(0); + }); +}); + function makeScheduler(drained: string[]): { readonly add: (task: ToolCallTask) => void; readonly collectResults: () => Promise; @@ -265,6 +338,7 @@ function makeControlledTask( name: string, accesses: ToolAccesses, startedNames: string[], + effectsSettled?: Promise, ): ControlledTask { let resolveResult: (value: string) => void = () => {}; let rejectResult: (error: unknown) => void = () => {}; @@ -278,7 +352,7 @@ function makeControlledTask( accesses, start: async () => { startedNames.push(name); - return { result }; + return { result, effectsSettled }; }, }, resolve: () => { diff --git a/packages/agent-core-v2/test/app/sessionExport/sessionExport.test.ts b/packages/agent-core-v2/test/app/sessionExport/sessionExport.test.ts index d217cb0dc..f7a151fac 100644 --- a/packages/agent-core-v2/test/app/sessionExport/sessionExport.test.ts +++ b/packages/agent-core-v2/test/app/sessionExport/sessionExport.test.ts @@ -985,6 +985,7 @@ function stubSessionMetadata(meta: SessionMeta): ISessionMetadata { setGeneratedTitleIfUncustomized: async () => false, setArchived: async () => {}, registerAgent: async () => {}, + unregisterAgent: async () => {}, }; } diff --git a/packages/agent-core-v2/test/app/web/providers/local-fetch-url.test.ts b/packages/agent-core-v2/test/app/web/providers/local-fetch-url.test.ts index 88d07a10f..ceb43ef27 100644 --- a/packages/agent-core-v2/test/app/web/providers/local-fetch-url.test.ts +++ b/packages/agent-core-v2/test/app/web/providers/local-fetch-url.test.ts @@ -313,6 +313,46 @@ describe('LocalFetchURLProvider connection pinning', () => { expect((fetchImpl.mock.calls[0]![1] as RequestInit).dispatcher).toBeInstanceOf(Agent); }); + it('stops reading an oversized chunked body before buffering it', async () => { + let pulls = 0; + let cancelled = false; + const chunk = new Uint8Array(1024 * 1024); + const body = new ReadableStream({ + pull(controller) { + pulls += 1; + controller.enqueue(chunk); + }, + cancel() { + cancelled = true; + }, + }); + const fetchImpl = vi.fn().mockResolvedValue( + new Response(body, { status: 200, headers: { 'content-type': 'text/plain' } }), + ); + const provider = new LocalFetchURLProvider({ fetchImpl, maxBytes: 4 * 1024 * 1024 }); + + await expect(provider.fetch('https://example.com/stream')).rejects.toThrow( + 'Response body too large', + ); + + expect(cancelled).toBe(true); + expect(pulls).toBeLessThanOrEqual(8); + }); + + it('rejects a body larger than an understated content-length', async () => { + const fetchImpl = vi.fn().mockResolvedValue( + new Response('x'.repeat(2048), { + status: 200, + headers: { 'content-type': 'text/plain', 'content-length': '10' }, + }), + ); + const provider = new LocalFetchURLProvider({ fetchImpl, maxBytes: 1024 }); + + await expect(provider.fetch('https://example.com/lying')).rejects.toThrow( + 'Response body too large', + ); + }); + it('rejects oversized responses by content-length and still closes the pinned Agent', async () => { const fetchImpl = vi.fn().mockResolvedValue( new Response('short', { diff --git a/packages/agent-core-v2/test/features/dynamic_workflow/sessionDynamicWorkflow.test.ts b/packages/agent-core-v2/test/features/dynamic_workflow/sessionDynamicWorkflow.test.ts index 624ead8ff..f72792ee2 100644 --- a/packages/agent-core-v2/test/features/dynamic_workflow/sessionDynamicWorkflow.test.ts +++ b/packages/agent-core-v2/test/features/dynamic_workflow/sessionDynamicWorkflow.test.ts @@ -959,6 +959,9 @@ describe('SessionDynamicWorkflowService metadata compatibility', () => { registerAgent: async (agentId, meta) => { agents[agentId] = meta; }, + unregisterAgent: async (agentId) => { + delete agents[agentId]; + }, }); ix.stub(ISubagentRoutingService, { _serviceBrand: undefined, diff --git a/packages/agent-core-v2/test/features/externalHooks/integration.test.ts b/packages/agent-core-v2/test/features/externalHooks/integration.test.ts index e18764055..2fb8131a7 100644 --- a/packages/agent-core-v2/test/features/externalHooks/integration.test.ts +++ b/packages/agent-core-v2/test/features/externalHooks/integration.test.ts @@ -184,6 +184,7 @@ function stubSessionMetadata(title?: string): ISessionMetadata { setTitle: async () => {}, setArchived: async () => {}, registerAgent: async () => {}, + unregisterAgent: async () => {}, } as unknown as ISessionMetadata; } diff --git a/packages/agent-core-v2/test/kosong/provider/dsml-tool-parser-conformance.test.ts b/packages/agent-core-v2/test/kosong/provider/dsml-tool-parser-conformance.test.ts new file mode 100644 index 000000000..b82380de7 --- /dev/null +++ b/packages/agent-core-v2/test/kosong/provider/dsml-tool-parser-conformance.test.ts @@ -0,0 +1,332 @@ +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; + +import { describe, expect, it } from 'vitest'; + +import type { StreamedMessagePart } from '#/kosong/contract/message'; +import { + DSML_MAX_ENVELOPE_CHARS, + DSML_MAX_TAG_CHARS, + DsmlStreamParser, + extractDsmlToolCalls, + parseInvokeBody, +} from '#/kosong/provider/bases/openai/dsml-tool-parser'; + +interface Normalized { + text: string; + calls: Array<{ name: string; arguments: string }>; +} + +function normalize(parts: StreamedMessagePart[]): Normalized { + const calls: Normalized['calls'] = []; + let text = ''; + for (const part of parts) { + if (part.type === 'function') calls.push({ name: part.name, arguments: part.arguments ?? '' }); + else if (part.type === 'text') text += part.text; + } + return { text, calls }; +} + +function runChunks(chunks: string[]): Normalized { + const parser = new DsmlStreamParser(); + const parts: StreamedMessagePart[] = []; + for (const chunk of chunks) parts.push(...parser.feed(chunk)); + parts.push(...parser.flush()); + return normalize(parts); +} + +function seeded(seed: number): () => number { + let state = seed >>> 0; + return () => { + state = (state * 1664525 + 1013904223) >>> 0; + return state / 0x100000000; + }; +} + +function randomPartition(input: string, random: () => number): string[] { + const chunks: string[] = []; + let pos = 0; + while (pos < input.length) { + const size = 1 + Math.floor(random() * 12); + chunks.push(input.slice(pos, pos + size)); + pos += size; + } + return chunks; +} + +const DSML_CALL = + '<|DSML|tool_calls>\n<|DSML|invoke name="Read">\n<|DSML|parameter name="filePath" string="true">src/app.ts\n\n'; + +const CORPUS: Record = { + plainText: 'Check if 5 < 10 and 20 > 15, or use
Hello
and vector.', + singleCall: `Looking into the code...\n\n${DSML_CALL}\nDone reading.`, + asciiBars: + '<|DSML|tool_calls>\n<|DSML|invoke name="Glob">\n<|DSML|parameter name="pattern" string="true">**/*.ts\n\n', + spacedMarkers: + '< | DSML | invoke name="Read">\n< | DSML | parameter name="filePath">src/app.ts\n', + twoInvokes: + '<|DSML|tool_calls>\n<|DSML|invoke name="Search">\n<|DSML|parameter name="query" string="true">export function\n<|DSML|parameter name="limit" string="false">25\n\n<|DSML|invoke name="Read">\n<|DSML|parameter name="path">src/main.ts\n\n', + bareInvoke: + 'Checking directory:\n<|DSML|invoke name="ListDir">\n<|DSML|parameter name="dir" string="true">packages\n', + jsonBody: '<|DSML|invoke name="Eval">{"code":"a < b && c > d"}', + hermes: 'Sure.\n\n{"name": "Read", "arguments": {"filePath": "package.json"}}\n\n', + hermesUnclosed: '{"name": "Read"}', + malformedInvoke: 'before <|DSML|invoke>malformed content without name after', + malformedBody: 'x <|DSML|invoke name="noop">definitely not JSON or parameters y', + containerNoCalls: 'before not a call after', + containerOnlyText: '<|DSML|tool_calls>\nplain prose\n', + unclosedContainer: 'a <|DSML|tool_calls>\nb', + fencedDoc: + 'Example only. Do not execute:\n```xml\n<|DSML|invoke name="noop">{"value":"documentation"}\n```\nEnd.', + fencedThenReal: `Use this syntax:\n\`\`\`\n${DSML_CALL}\n\`\`\`\nNow for real:\n${DSML_CALL}`, + tildeFence: '~~~\n{"name":"Read","arguments":{}}\n~~~\n', + unmarkedInvokeOutsideContainer: + 'Do not execute:\n{"value":"documentation"}\n', + unmarkedInvokeInsideContainer: + '\n\na.ts\n\n', + entities: + '<|DSML|invoke name="Eval">\n<|DSML|parameter name="code" string="true">a && b < c\n', + codeComparison: 'if (x < 5 && y > 2) { return a ', + multibyte: `café ✓ ${DSML_CALL} \u65E5\u672C\u8A9E 😀 done`, + strayClose: 'no container here still text', + emptyInvoke: '<|DSML|invoke name="Noop">', + whitespaceInvoke: '<|DSML|invoke name="Noop">\n \n', +}; + +describe('DsmlStreamParser conformance', () => { + describe('chunk invariance', () => { + for (const [name, input] of Object.entries(CORPUS)) { + it(`${name}: every two-way split equals the unsplit result`, () => { + const whole = runChunks([input]); + for (let offset = 1; offset < input.length; offset += 1) { + const split = runChunks([input.slice(0, offset), input.slice(offset)]); + expect(split, `split at ${offset}`).toEqual(whole); + } + }); + + it(`${name}: character-at-a-time equals the unsplit result`, () => { + const whole = runChunks([input]); + expect(runChunks(Array.from(input))).toEqual(whole); + }); + + it(`${name}: seeded random partitions equal the unsplit result`, () => { + const whole = runChunks([input]); + const random = seeded(name.length * 7919 + input.length); + for (let round = 0; round < 25; round += 1) { + const chunks = randomPartition(input, random); + expect(runChunks(chunks), chunks.join('|')).toEqual(whole); + } + }); + } + + it('handles a UTF-16 surrogate pair split between chunks', () => { + const input = `😀 ${DSML_CALL} 😀`; + const whole = runChunks([input]); + expect(runChunks([input.slice(0, 1), input.slice(1)])).toEqual(whole); + expect(runChunks([input.slice(0, input.length - 1), input.slice(-1)])).toEqual(whole); + }); + }); + + describe('streaming and non-streaming parity', () => { + for (const [name, input] of Object.entries(CORPUS)) { + it(`${name}: streamed text matches extractDsmlToolCalls`, () => { + const streamed = runChunks(Array.from(input)); + const extracted = extractDsmlToolCalls(input); + expect(extracted.toolCalls.map((c) => ({ name: c.name, arguments: c.arguments }))).toEqual( + streamed.calls, + ); + if (streamed.calls.length === 0) { + expect(extracted.cleanText).toBe(input); + expect(streamed.text).toBe(input); + } else { + expect(extracted.cleanText).toBe(streamed.text.trim()); + } + }); + } + }); + + describe('literal documentation stays text', () => { + it('does not promote a fenced DSML example to a tool call', () => { + const result = extractDsmlToolCalls(CORPUS['fencedDoc'] as string); + expect(result.toolCalls).toHaveLength(0); + expect(result.cleanText).toBe(CORPUS['fencedDoc']); + }); + + it('does not promote a fenced Hermes example to a tool call', () => { + const result = extractDsmlToolCalls(CORPUS['tildeFence'] as string); + expect(result.toolCalls).toHaveLength(0); + expect(result.cleanText).toBe(CORPUS['tildeFence']); + }); + + it('does not promote an unmarked invoke outside a container', () => { + const result = extractDsmlToolCalls(CORPUS['unmarkedInvokeOutsideContainer'] as string); + expect(result.toolCalls).toHaveLength(0); + expect(result.cleanText).toBe(CORPUS['unmarkedInvokeOutsideContainer']); + }); + + it('accepts an unmarked invoke inside a container', () => { + const result = extractDsmlToolCalls(CORPUS['unmarkedInvokeInsideContainer'] as string); + expect(result.toolCalls).toHaveLength(1); + expect(result.toolCalls[0]?.name).toBe('Read'); + expect(result.cleanText).toBe(''); + }); + + it('parses the real call that follows a fenced example', () => { + const result = extractDsmlToolCalls(CORPUS['fencedThenReal'] as string); + expect(result.toolCalls).toHaveLength(1); + expect(result.cleanText).toBe(`Use this syntax:\n\`\`\`\n${DSML_CALL}\n\`\`\`\nNow for real:`); + }); + + it('resumes recognition after the fence closes', () => { + const input = '```\n{"name":"A"}\n```\n{"name":"B","arguments":{}}'; + const result = extractDsmlToolCalls(input); + expect(result.toolCalls.map((c) => c.name)).toEqual(['B']); + }); + }); + + describe('invoke body validation', () => { + it('rejects malformed nonempty bodies instead of producing empty arguments', () => { + const result = extractDsmlToolCalls(CORPUS['malformedBody'] as string); + expect(result.toolCalls).toHaveLength(0); + expect(result.cleanText).toBe(CORPUS['malformedBody']); + }); + + it('accepts an empty body as empty arguments', () => { + expect(extractDsmlToolCalls(CORPUS['emptyInvoke'] as string).toolCalls[0]?.arguments).toBe('{}'); + expect(extractDsmlToolCalls(CORPUS['whitespaceInvoke'] as string).toolCalls[0]?.arguments).toBe( + '{}', + ); + }); + + it('rejects prose mixed between parameter blocks', () => { + const input = + '<|DSML|invoke name="Read">\n<|DSML|parameter name="a">1\nstray prose\n'; + expect(extractDsmlToolCalls(input).toolCalls).toHaveLength(0); + }); + + it('rejects unnamed parameters, unclosed parameters, arrays and scalars', () => { + expect(parseInvokeBody('1')).toBeNull(); + expect(parseInvokeBody('1')).toBeNull(); + expect(parseInvokeBody('[1,2]')).toBeNull(); + expect(parseInvokeBody('42')).toBeNull(); + expect(parseInvokeBody('{"a":')).toBeNull(); + }); + + it('lets the last duplicate parameter win', () => { + const body = '12'; + expect(parseInvokeBody(body)).toEqual({ a: 2 }); + }); + + it('preserves reserved keys as own properties without touching the global prototype', () => { + const input = + '<|DSML|invoke name="noop"><|DSML|parameter name="__proto__" string="false">{"value":"kept"}<|DSML|parameter name="constructor">c<|DSML|parameter name="prototype">p<|DSML|parameter name="normal">ok'; + const result = extractDsmlToolCalls(input); + expect(result.toolCalls).toHaveLength(1); + expect(JSON.parse(result.toolCalls[0]?.arguments ?? '{}')).toEqual( + JSON.parse('{"__proto__":{"value":"kept"},"constructor":"c","prototype":"p","normal":"ok"}'), + ); + expect(result.toolCalls[0]?.arguments).toContain('"__proto__":{"value":"kept"}'); + expect(({} as Record)['value']).toBeUndefined(); + }); + + it('keeps reserved keys from JSON bodies', () => { + const args = parseInvokeBody('{"__proto__":{"x":1},"y":2}'); + expect(JSON.stringify(args)).toBe('{"__proto__":{"x":1},"y":2}'); + }); + }); + + describe('text preservation', () => { + it('keeps container tags when the container yields no call', () => { + const streamed = runChunks([CORPUS['containerNoCalls'] as string]); + expect(streamed).toEqual({ text: CORPUS['containerNoCalls'], calls: [] }); + }); + + it('drops container tags once a call is accepted and keeps later text', () => { + const streamed = runChunks([CORPUS['singleCall'] as string]); + expect(streamed.text).toBe('Looking into the code...\n\nDone reading.'); + expect(streamed.calls).toEqual([{ name: 'Read', arguments: '{"filePath":"src/app.ts"}' }]); + }); + + it('emits held text in order when a container has an invalid block then a valid call', () => { + const input = `<|DSML|tool_calls>\n<|DSML|invoke>bad\n<|DSML|invoke name="Ok">\n`; + const parser = new DsmlStreamParser(); + const parts = [...parser.feed(input), ...parser.flush()]; + expect(parts.map((p) => p.type)).toEqual(['text', 'text', 'function']); + expect(normalize(parts).text).toBe('<|DSML|invoke>bad\n'); + }); + + it('preserves an unclosed container at flush', () => { + expect(runChunks([CORPUS['unclosedContainer'] as string])).toEqual({ + text: CORPUS['unclosedContainer'], + calls: [], + }); + }); + }); + + describe('resource budgets', () => { + it('stops holding a tag prefix beyond the tag budget', () => { + const input = ` { + const body = 'x'.repeat(DSML_MAX_ENVELOPE_CHARS + 16); + const input = `<|DSML|invoke name="Big">${body}`; + const whole = runChunks([input]); + expect(whole.calls).toHaveLength(0); + expect(whole.text).toBe(input); + const random = seeded(7); + const chunks: string[] = []; + let pos = 0; + while (pos < input.length) { + const size = 1 + Math.floor(random() * 70000); + chunks.push(input.slice(pos, pos + size)); + pos += size; + } + expect(runChunks(chunks)).toEqual(whole); + }); + + it('accepts an envelope exactly at the budget', () => { + const open = '<|DSML|invoke name="Big">'; + const close = ''; + const body = 'x'.repeat(DSML_MAX_ENVELOPE_CHARS - open.length - close.length); + const input = `${open}${body}${close}`; + expect(input.length).toBe(DSML_MAX_ENVELOPE_CHARS); + expect(runChunks([input]).calls).toHaveLength(0); + const jsonBody = `{"v":"${'x'.repeat(DSML_MAX_ENVELOPE_CHARS - open.length - close.length - 8)}"}`; + const valid = `${open}${jsonBody}${close}`; + expect(valid.length).toBe(DSML_MAX_ENVELOPE_CHARS); + expect(runChunks([valid]).calls).toHaveLength(1); + expect(runChunks([valid.slice(0, 5000), valid.slice(5000)]).calls).toHaveLength(1); + }); + + it('scans an unclosed envelope in linear time', () => { + const parser = new DsmlStreamParser(); + parser.feed('<|DSML|invoke name="Big">'); + const chunk = 'y'.repeat(64); + const rounds = Math.floor((DSML_MAX_ENVELOPE_CHARS - 64) / 64); + const started = performance.now(); + for (let i = 0; i < rounds; i += 1) parser.feed(chunk); + const elapsed = performance.now() - started; + expect(elapsed).toBeLessThan(2000); + expect(normalize(parser.flush()).calls).toHaveLength(0); + }); + }); + + describe('implementation parity with @pymodel/kosong', () => { + it('keeps both parser copies byte-identical except for the message import', () => { + const here = resolve(__dirname, '../../../src/kosong/provider/bases/openai/dsml-tool-parser.ts'); + const legacy = resolve(__dirname, '../../../../kosong/src/providers/dsml-tool-parser.ts'); + const v2 = readFileSync(here, 'utf8').replace( + "from '#/kosong/contract/message';", + "from '#/message';", + ); + expect(readFileSync(legacy, 'utf8')).toBe(v2); + }); + }); +}); diff --git a/packages/agent-core-v2/test/kosong/provider/dsml-tool-parser.test.ts b/packages/agent-core-v2/test/kosong/provider/dsml-tool-parser.test.ts index fc66fe0b7..41f8977db 100644 --- a/packages/agent-core-v2/test/kosong/provider/dsml-tool-parser.test.ts +++ b/packages/agent-core-v2/test/kosong/provider/dsml-tool-parser.test.ts @@ -295,6 +295,115 @@ describe('agent-core-v2: DsmlStreamParser and extractDsmlToolCalls', () => { }); }); + function mockClientFor(stream: unknown): unknown { + return { + chat: { + completions: { + create: () => ({ + withResponse: async () => ({ data: stream, response: { headers: new Headers() } }), + }), + }, + }, + }; + } + + async function* chunksOf(chunks: unknown[]) { + for (const chunk of chunks) yield chunk; + } + + const DSML_ECHO = + '<|DSML|tool_calls>\n<|DSML|invoke name="Read">\n<|DSML|parameter name="filePath" string="true">src/server.ts\n\n'; + + it('lets native streamed tool calls win over a DSML echo in content', async () => { + const provider = new OpenAILegacyChatProvider({ model: 'deepseek-chat', apiKey: 'k', stream: true }); + const chunks = [ + { id: 'c1', choices: [{ index: 0, delta: { content: DSML_ECHO }, finish_reason: null }] }, + { + id: 'c1', + choices: [ + { + index: 0, + delta: { + tool_calls: [ + { index: 0, id: 'call_native', type: 'function', function: { name: 'Read', arguments: '{"filePath":"src/server.ts"}' } }, + ], + }, + finish_reason: 'tool_calls', + }, + ], + }, + ]; + (provider as unknown as { _client: unknown })._client = mockClientFor(chunksOf(chunks)); + const stream = await provider.generate('', [], []); + const parts: Array> = []; + for await (const p of stream) parts.push(p as unknown as Record); + const calls = parts.filter((p) => p['type'] === 'function'); + expect(calls).toHaveLength(1); + expect(calls[0]).toMatchObject({ id: 'call_native', name: 'Read' }); + expect(stream.finishReason).toBe('tool_calls'); + }); + + it('keeps intentional repeated native streamed calls distinct', async () => { + const provider = new OpenAILegacyChatProvider({ model: 'deepseek-chat', apiKey: 'k', stream: true }); + const native = (id: string, index: number) => ({ + index, + id, + type: 'function', + function: { name: 'Read', arguments: '{"filePath":"a.ts"}' }, + }); + const chunks = [ + { id: 'c1', choices: [{ index: 0, delta: { tool_calls: [native('call_a', 0)] }, finish_reason: null }] }, + { id: 'c1', choices: [{ index: 0, delta: { tool_calls: [native('call_b', 1)] }, finish_reason: 'tool_calls' }] }, + ]; + (provider as unknown as { _client: unknown })._client = mockClientFor(chunksOf(chunks)); + const stream = await provider.generate('', [], []); + const parts: Array> = []; + for await (const p of stream) parts.push(p as unknown as Record); + expect(parts.filter((p) => p['type'] === 'function').map((p) => p['id'])).toEqual(['call_a', 'call_b']); + }); + + it('defers recovered streamed calls until the response ends so a late native call can win', async () => { + const provider = new OpenAILegacyChatProvider({ model: 'deepseek-chat', apiKey: 'k', stream: true }); + const chunks = [ + { id: 'c1', choices: [{ index: 0, delta: { content: 'Reading.\n' + DSML_ECHO }, finish_reason: null }] }, + { id: 'c1', choices: [{ index: 0, delta: { content: '\nDone.' }, finish_reason: 'stop' }] }, + ]; + (provider as unknown as { _client: unknown })._client = mockClientFor(chunksOf(chunks)); + const stream = await provider.generate('', [], []); + const parts: Array> = []; + for await (const p of stream) parts.push(p as unknown as Record); + expect(parts.map((p) => p['type'])).toEqual(['text', 'text', 'function']); + expect(stream.finishReason).toBe('tool_calls'); + }); + + it('ignores a DSML echo in non-stream content when native tool calls are present', async () => { + const provider = new OpenAILegacyChatProvider({ model: 'deepseek-chat', apiKey: 'k', stream: false }); + const responseData = { + id: 'n1', + choices: [ + { + index: 0, + message: { + role: 'assistant', + content: DSML_ECHO, + tool_calls: [ + { id: 'call_native', type: 'function', function: { name: 'Read', arguments: '{"filePath":"src/server.ts"}' } }, + ], + }, + finish_reason: 'tool_calls', + }, + ], + }; + (provider as unknown as { _client: unknown })._client = mockClientFor(responseData); + const stream = await provider.generate('', [], []); + const parts: Array> = []; + for await (const p of stream) parts.push(p as unknown as Record); + const calls = parts.filter((p) => p['type'] === 'function'); + expect(calls).toHaveLength(1); + expect(calls[0]).toMatchObject({ id: 'call_native' }); + expect(parts.filter((p) => p['type'] === 'text').map((p) => p['text'])).toEqual([DSML_ECHO]); + }); + it('preserves surrounding whitespace and markdown hard breaks in non-stream response', async () => { const provider = new OpenAILegacyChatProvider({ model: 'deepseek-chat', diff --git a/packages/agent-core-v2/test/os/backends/node-local/hostFsService.test.ts b/packages/agent-core-v2/test/os/backends/node-local/hostFsService.test.ts index 54f2d8028..b80ca19a4 100644 --- a/packages/agent-core-v2/test/os/backends/node-local/hostFsService.test.ts +++ b/packages/agent-core-v2/test/os/backends/node-local/hostFsService.test.ts @@ -1,4 +1,4 @@ -import { mkdir, mkdtemp, rm, symlink, writeFile } from 'node:fs/promises'; +import { chmod, lstat, mkdir, mkdtemp, readFile, readdir, rm, stat, symlink, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'pathe'; @@ -18,6 +18,77 @@ afterEach(async () => { await rm(dir, { recursive: true, force: true }); }); +describe('HostFileSystem atomic writes', () => { + it('replaces the symlink target in place and keeps the link', async () => { + const target = join(dir, 'target.txt'); + await writeFile(target, 'old', 'utf-8'); + await chmod(target, 0o600); + const link = join(dir, 'link.txt'); + await symlink(target, link); + + await fs.writeText(link, 'new'); + + expect(await readFile(target, 'utf-8')).toBe('new'); + expect((await lstat(link)).isSymbolicLink()).toBe(true); + expect((await stat(target)).mode & 0o777).toBe(0o600); + expect((await readdir(dir)).toSorted()).toEqual(['link.txt', 'target.txt']); + }); + + it('creates a missing file and leaves no staging files behind', async () => { + const path = join(dir, 'fresh.txt'); + await fs.writeBytes(path, new Uint8Array([104, 105])); + expect(await readFile(path, 'utf-8')).toBe('hi'); + expect(await readdir(dir)).toEqual(['fresh.txt']); + }); + + it('keeps the previous content when the replacement cannot be staged', async () => { + const path = join(dir, 'keep.txt'); + await writeFile(path, 'intact', 'utf-8'); + await chmod(dir, 0o500); + try { + await expect(fs.writeText(path, 'partial')).rejects.toThrow(); + } finally { + await chmod(dir, 0o700); + } + expect(await readFile(path, 'utf-8')).toBe('intact'); + }); +}); + +describe('HostFileSystem readLines budget', () => { + it('bounds a newline-free line to maxLineBytes', async () => { + const path = join(dir, 'long.txt'); + await writeFile(path, `${'x'.repeat(300_000)}\nshort\n`, 'utf-8'); + + const lines: string[] = []; + for await (const line of fs.readLines(path, { maxLineBytes: 1024 })) lines.push(line); + + expect(lines).toHaveLength(2); + expect(lines[0]).toBe(`${'x'.repeat(1024)}\n`); + expect(lines[1]).toBe('short\n'); + }); + + it('bounds a trailing line without a terminator', async () => { + const path = join(dir, 'tail.txt'); + await writeFile(path, `a\n${'y'.repeat(5000)}`, 'utf-8'); + + const lines: string[] = []; + for await (const line of fs.readLines(path, { maxLineBytes: 16 })) lines.push(line); + + expect(lines).toEqual(['a\n', 'y'.repeat(16)]); + }); + + it('reads every byte when no budget is given', async () => { + const path = join(dir, 'full.txt'); + const long = 'z'.repeat(200_000); + await writeFile(path, `${long}\n`, 'utf-8'); + + const lines: string[] = []; + for await (const line of fs.readLines(path)) lines.push(line); + + expect(lines).toEqual([`${long}\n`]); + }); +}); + describe('HostFileSystem stat / lstat', () => { it('stat follows a symlink to a regular file while lstat stats the link', async () => { const target = join(dir, 'target.txt'); diff --git a/packages/agent-core-v2/test/os/backends/node-local/tools/read.test.ts b/packages/agent-core-v2/test/os/backends/node-local/tools/read.test.ts index 38cd4e8be..39056a2b9 100644 --- a/packages/agent-core-v2/test/os/backends/node-local/tools/read.test.ts +++ b/packages/agent-core-v2/test/os/backends/node-local/tools/read.test.ts @@ -234,6 +234,20 @@ describe('ReadTool', () => { }); }); + it('denies a benign alias that resolves to a sensitive file', async () => { + const { fs, readBytes } = createSpiedFs('SECRET=1\n'); + (fs as { realpath?: (path: string) => Promise }).realpath = vi.fn(async (path: string) => + path === '/tmp/notes.txt' ? '/home/user/.env' : path, + ); + const tool = createReadTool(fs, createTestEnv(), PERMISSIVE_WORKSPACE); + + const result = await execute(tool, { path: '/tmp/notes.txt' }); + + expect(result.isError).toBe(true); + expect(result.output).toContain('resolves to "/home/user/.env"'); + expect(readBytes).not.toHaveBeenCalled(); + }); + it('stats the resolved target so symlinked files stay readable', async () => { const { fs, stat } = createSpiedFs('alpha\n'); const tool = createReadTool(fs, createTestEnv(), PERMISSIVE_WORKSPACE); @@ -359,7 +373,7 @@ describe('ReadTool', () => { ), ); expect(readBytes).toHaveBeenCalledWith('/tmp/external.txt', MEDIA_SNIFF_BYTES); - expect(readLines).toHaveBeenCalledWith('/tmp/external.txt', { errors: 'strict' }); + expect(readLines).toHaveBeenCalledWith('/tmp/external.txt', { errors: 'strict', maxLineBytes: MAX_LINE_LENGTH * 4 }); }); it('returns a friendly error for missing files before sniffing bytes', async () => { @@ -405,7 +419,7 @@ describe('ReadTool', () => { ), ); expect(readBytes).toHaveBeenCalledWith('/home/test/notes/today.txt', MEDIA_SNIFF_BYTES); - expect(readLines).toHaveBeenCalledWith('/home/test/notes/today.txt', { errors: 'strict' }); + expect(readLines).toHaveBeenCalledWith('/home/test/notes/today.txt', { errors: 'strict', maxLineBytes: MAX_LINE_LENGTH * 4 }); }); it('blocks sensitive files independently from workspace access', async () => { diff --git a/packages/agent-core-v2/test/os/backends/node-local/tools/write.test.ts b/packages/agent-core-v2/test/os/backends/node-local/tools/write.test.ts index 57dc3f1cf..a2bed15f5 100644 --- a/packages/agent-core-v2/test/os/backends/node-local/tools/write.test.ts +++ b/packages/agent-core-v2/test/os/backends/node-local/tools/write.test.ts @@ -41,6 +41,7 @@ interface WriteFsOptions { appendText?: (path: string, data: string) => Promise; stat?: (path: string) => Promise; mkdir?: (path: string) => Promise; + readonly realpath?: (path: string) => Promise; } function createWriteFs(options: WriteFsOptions = {}) { @@ -56,8 +57,9 @@ function createWriteFs(options: WriteFsOptions = {}) { options.stat ?? (async () => ({ isFile: false, isDirectory: true, size: 0 })), ); const mkdir = vi.fn(options.mkdir ?? (async () => {})); - const fs = { cwd: '/', readText, writeText, appendText, stat, mkdir } as unknown as IHostFileSystem; - return { fs, readText, writeText, appendText, stat, mkdir }; + const realpath = vi.fn(options.realpath ?? (async (path: string) => path)); + const fs = { cwd: '/', readText, writeText, appendText, stat, mkdir, realpath } as unknown as IHostFileSystem; + return { fs, readText, writeText, appendText, stat, mkdir, realpath }; } function makeTool(options: WriteFsOptions = {}, workspace = PERMISSIVE_WORKSPACE) { @@ -335,6 +337,19 @@ describe('WriteTool', () => { expect(writeText).not.toHaveBeenCalled(); }); + it('denies a benign alias whose real target is a sensitive file', async () => { + const { tool, writeText } = makeTool({ + realpath: async (path) => (path === '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/workspace/notes.txt' ? '/home/user/.env' : path), + stat: async () => ({ isFile: true, isDirectory: false, size: 1 }), + }); + + const result = await execute(tool, { path: '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/workspace/notes.txt', content: 'x' }); + + expect(result.isError).toBe(true); + expect(result.output).toContain('resolves to "/home/user/.env"'); + expect(writeText).not.toHaveBeenCalled(); + }); + it('blocks sensitive file writes', async () => { const { tool, writeText } = makeTool({}, stubWorkspaceContext('/workspace')); diff --git a/packages/agent-core-v2/test/session/agentLifecycle/agentLifecycle.test.ts b/packages/agent-core-v2/test/session/agentLifecycle/agentLifecycle.test.ts index e5936379a..b017184dd 100644 --- a/packages/agent-core-v2/test/session/agentLifecycle/agentLifecycle.test.ts +++ b/packages/agent-core-v2/test/session/agentLifecycle/agentLifecycle.test.ts @@ -196,6 +196,7 @@ describe('AgentLifecycleService', () => { let disposables: DisposableStore; let ix: TestInstantiationService; let registerAgent: ReturnType>; + let unregisterAgent: ReturnType>; let atomicDocs: Map; let permissionModeSetMode: ReturnType; let stopAllOnExit: ReturnType; @@ -220,6 +221,7 @@ describe('AgentLifecycleService', () => { ix.stub(IFileSystemStorageService, new InMemoryStorageService()); stubBlobPassThrough(ix); registerAgent = vi.fn().mockResolvedValue(undefined); + unregisterAgent = vi.fn().mockResolvedValue(undefined); atomicDocs = new Map(); ix.stub(ISessionContext, { _serviceBrand: undefined, @@ -255,6 +257,7 @@ describe('AgentLifecycleService', () => { setTitle: () => Promise.resolve(), setArchived: () => Promise.resolve(), registerAgent, + unregisterAgent, }); ix.stub(IBootstrapService, { _serviceBrand: undefined, @@ -352,6 +355,7 @@ describe('AgentLifecycleService', () => { }), cancel: loopCancel, settled: loopSettled, + tryAcquireQuiescence: () => ({ dispose: () => {} }), } as unknown as IAgentLoopService); promptDrain = vi.fn(async () => {}); ix.stub(IAgentPromptService, { @@ -685,6 +689,50 @@ describe('AgentLifecycleService', () => { } }); + it('remove runs every cleanup phase, rethrows the failure, and stays retry-safe', async () => { + stopAllOnExit.mockRejectedValueOnce(new Error('stop failed')); + const svc = ix.get(IAgentLifecycleService); + const main = await svc.create({ agentId: 'main' }); + const closed: string[] = []; + disposables.add(svc.onDidClose((agent) => closed.push(agent.agentId))); + + await expect(svc.remove(main)).rejects.toThrow('stop failed'); + + expect(promptDrain).toHaveBeenCalledOnce(); + expect(closed).toEqual(['main']); + expect(svc.get('main')).toBeUndefined(); + await expect(svc.remove(main)).resolves.toBeUndefined(); + const recreated = await svc.create({ agentId: 'main' }); + expect(recreated).not.toBe(main); + }); + + it('concurrent remove calls join a single close', async () => { + const svc = ix.get(IAgentLifecycleService); + const main = await svc.create({ agentId: 'main' }); + const closed: string[] = []; + disposables.add(svc.onDidClose((agent) => closed.push(agent.agentId))); + + await Promise.all([svc.remove(main), svc.remove(main), svc.remove(main)]); + + expect(stopAllOnExit).toHaveBeenCalledTimes(1); + expect(promptDrain).toHaveBeenCalledOnce(); + expect(closed).toEqual(['main']); + }); + + it('create failure after registration compensates the persisted agent metadata', async () => { + const config = ix.get(IConfigService); + vi.spyOn(config, 'get').mockImplementationOnce(() => { + throw new Error('bootstrap boom'); + }); + const svc = ix.get(IAgentLifecycleService); + + await expect(svc.create({ agentId: 'main' })).rejects.toThrow('bootstrap boom'); + + expect(registerAgent).toHaveBeenCalledOnce(); + expect(unregisterAgent).toHaveBeenCalledWith('main'); + expect(svc.get('main')).toBeUndefined(); + }); + it('remove stops the agent background tasks before disposal', async () => { const svc = ix.get(IAgentLifecycleService); const main = await svc.create({ agentId: 'main' }); @@ -802,6 +850,7 @@ describe('AgentLifecycleService', () => { setTitle: () => Promise.resolve(), setArchived: () => Promise.resolve(), registerAgent, + unregisterAgent: () => Promise.resolve(), }); const svc = ix.get(IAgentLifecycleService); diff --git a/packages/agent-core-v2/test/session/sessionMetadata/sessionMetadata.test.ts b/packages/agent-core-v2/test/session/sessionMetadata/sessionMetadata.test.ts index 5bde4d5bc..7cea5e1c3 100644 --- a/packages/agent-core-v2/test/session/sessionMetadata/sessionMetadata.test.ts +++ b/packages/agent-core-v2/test/session/sessionMetadata/sessionMetadata.test.ts @@ -214,6 +214,34 @@ describe('SessionMetadata', () => { expect(atUpdate).toMatchObject({ title: 'durable-first' }); }); + it('a rejected persist leaves memory at the last committed state', async () => { + const store = ix.get(IAtomicDocumentStore); + const meta = ix.get(ISessionMetadata); + await meta.ready; + await meta.update({ title: 'committed' }); + vi.spyOn(store, 'set').mockRejectedValueOnce(new Error('disk full')); + + await expect(meta.update({ title: 'lost' })).rejects.toThrow('disk full'); + expect((await meta.read()).title).toBe('committed'); + + await meta.setArchived(true); + const fresh = createFreshMetadata(ix); + expect(await fresh.read()).toMatchObject({ title: 'committed', archived: true }); + }); + + it('unregisterAgent removes the agent entry without bumping updatedAt', async () => { + const meta = ix.get(ISessionMetadata); + await meta.ready; + await meta.registerAgent('sub-1', { homedir: '/tmp/sub-1', type: 'sub', parentAgentId: 'main' }); + const before = (await meta.read()).updatedAt; + await meta.unregisterAgent('sub-1'); + await meta.unregisterAgent('missing'); + const after = await meta.read(); + expect(after.agents).toEqual({}); + expect(after.updatedAt).toBe(before); + expect((await createFreshMetadata(ix).read()).agents).toEqual({}); + }); + it('a mirror failure degrades the read model but never fails the metadata mutation', async () => { mirror.record = () => { throw new Error('mirror down'); diff --git a/packages/agent-core-v2/test/session/subagent/runAgentTurn.test.ts b/packages/agent-core-v2/test/session/subagent/runAgentTurn.test.ts new file mode 100644 index 000000000..379a544aa --- /dev/null +++ b/packages/agent-core-v2/test/session/subagent/runAgentTurn.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, it } from 'vitest'; + +import type { IAgentScopeHandle } from '#/_base/di/scope'; +import { LifecycleScope } from '#/app/scopes'; +import { IAgentContextMemoryService } from '#/agent/contextMemory/contextMemory'; +import { IAgentPromptService } from '#/agent/prompt/prompt'; +import { IAgentLoopService, type Turn } from '#/agent/loop/loop'; +import { IAgentScopeContext } from '#/agent/scopeContext/scopeContext'; +import { ISessionUsageService } from '#/session/usage/sessionUsage'; +import { runAgentTurn } from '#/session/subagent/runAgentTurn'; +import type { TokenUsage } from '#/kosong/contract/usage'; + +function usage(inputOther: number, output: number): TokenUsage { + return { inputOther, output, inputCacheRead: 0, inputCacheCreation: 0 }; +} + +function fakeHandle(totals: TokenUsage[]): IAgentScopeHandle { + let cursor = 0; + const agentContext = { agentId: 'sub', space: {} }; + const turn: Turn = { + id: 1, + signal: new AbortController().signal, + ready: Promise.resolve(), + result: Promise.resolve({ type: 'completed', steps: 1, truncated: false } as never), + cancel: () => true, + }; + return { + id: 'sub', + kind: LifecycleScope.Agent, + accessor: { + get: (serviceId: unknown) => { + if (serviceId === ISessionUsageService) { + return { + status: () => ({ total: totals[Math.min(cursor, totals.length - 1)] }), + }; + } + if (serviceId === IAgentPromptService) { + return { + enqueue: async () => { + cursor += 1; + return { launched: Promise.resolve(turn) }; + }, + }; + } + if (serviceId === IAgentLoopService) return { cancel: () => true }; + if (serviceId === IAgentContextMemoryService) { + return { get: () => [{ role: 'assistant', content: [{ type: 'text', text: 'done' }] }] }; + } + if (serviceId === IAgentScopeContext) return { agentContext }; + throw new Error(`unexpected service ${String(serviceId)}`); + }, + }, + } as unknown as IAgentScopeHandle; +} + +describe('runAgentTurn usage attribution', () => { + it('reports per-run usage as a delta and exposes the cumulative total separately', async () => { + const handle = fakeHandle([usage(100, 10), usage(160, 25), usage(200, 30)]); + const options = { signal: new AbortController().signal }; + + const first = await runAgentTurn(handle, { kind: 'prompt', prompt: 'one' }, options); + const firstResult = await first.completion; + expect(firstResult.usage).toEqual(usage(60, 15)); + expect(firstResult.cumulativeUsage).toEqual(usage(160, 25)); + + const second = await runAgentTurn(handle, { kind: 'prompt', prompt: 'two' }, options); + const secondResult = await second.completion; + expect(secondResult.usage).toEqual(usage(40, 5)); + expect(secondResult.cumulativeUsage).toEqual(usage(200, 30)); + }); +}); diff --git a/packages/agent-core-v2/test/tool/tool.test.ts b/packages/agent-core-v2/test/tool/tool.test.ts index d63567108..1030f9f12 100644 --- a/packages/agent-core-v2/test/tool/tool.test.ts +++ b/packages/agent-core-v2/test/tool/tool.test.ts @@ -602,6 +602,7 @@ function sessionMetadataStub(agents: Readonly>): ISess setGeneratedTitleIfUncustomized: async () => false, setArchived: async () => {}, registerAgent: async () => {}, + unregisterAgent: async () => {}, }; } diff --git a/packages/agent-gateway/src/start.ts b/packages/agent-gateway/src/start.ts index 062544b09..6fa289f94 100644 --- a/packages/agent-gateway/src/start.ts +++ b/packages/agent-gateway/src/start.ts @@ -292,35 +292,54 @@ export async function startServer(opts: ServerStartOptions): Promise => { - configChangedPublisher.close(); - await app.close(); - configWarningSubscription.dispose(); - pluginChangeSubscription.dispose(); - capabilityInstallSubscription.dispose(); - authFailureLimiter?.dispose(); - modelCatalogRefreshScheduler.dispose(); - try { - await shutdownServerTelemetry(telemetry); - } catch (error) { - logger.warn( - { err: error instanceof Error ? error.message : String(error) }, - 'telemetry shutdown failed; continuing server cleanup', - ); - } + let closePromise: Promise | undefined; + const close = (): Promise => { + closePromise ??= runClose(); + return closePromise; + }; + const runClose = async (): Promise => { + const failures: unknown[] = []; + const phase = async ( + name: string, + work: () => unknown, + required = true, + ): Promise => { + try { + await work(); + } catch (error) { + if (required) failures.push(error); + logger.warn( + { err: error instanceof Error ? error.message : String(error), phase: name }, + 'server cleanup phase failed; continuing', + ); + } + }; try { - await drainSessionMetadataWrites(); - await core.accessor.get(ISessionIndexMirror).drain(); - await core.accessor.get(IMcpOAuthService).shutdown(); - fsWatchBridge.dispose(); - const appendLogStore = core.accessor.get(IAppendLogStore); - core.dispose(); - await appendLogStore.drainRetirements(); - await drainSessionIndexMirror(); - await drainGlobalSearchDisposals(); - await drainQueryStoreDisposals(); - await drainSessionMetadataWrites(); - await drainLogCloses(); + await phase('config-publisher', () => configChangedPublisher.close()); + await phase('http', () => app.close()); + await phase('subscriptions', () => { + configWarningSubscription.dispose(); + pluginChangeSubscription.dispose(); + capabilityInstallSubscription.dispose(); + authFailureLimiter?.dispose(); + modelCatalogRefreshScheduler.dispose(); + }); + await phase('telemetry', () => shutdownServerTelemetry(telemetry), false); + await phase('session-metadata', () => drainSessionMetadataWrites()); + await phase('session-index', () => core.accessor.get(ISessionIndexMirror).drain()); + await phase('mcp-oauth', () => core.accessor.get(IMcpOAuthService).shutdown()); + await phase('fs-watch', () => fsWatchBridge.dispose()); + let appendLogStore: IAppendLogStore | undefined; + await phase('append-log', () => { + appendLogStore = core.accessor.get(IAppendLogStore); + }); + await phase('core', () => core.dispose()); + await phase('append-log-retirements', () => appendLogStore?.drainRetirements()); + await phase('session-index-drain', () => drainSessionIndexMirror()); + await phase('global-search', () => drainGlobalSearchDisposals()); + await phase('query-store', () => drainQueryStoreDisposals()); + await phase('session-metadata-final', () => drainSessionMetadataWrites()); + await phase('log-closes', () => drainLogCloses()); } finally { try { await registration.release(); @@ -329,6 +348,8 @@ export async function startServer(opts: ServerStartOptions): Promise 1) throw new AggregateError(failures, 'server cleanup failed'); }; const connectionRegistry = new ConnectionRegistry(); diff --git a/packages/agent-gateway/src/transport/ws/v1/registerWsV1.ts b/packages/agent-gateway/src/transport/ws/v1/registerWsV1.ts index 56c0a6513..02d2fdb00 100644 --- a/packages/agent-gateway/src/transport/ws/v1/registerWsV1.ts +++ b/packages/agent-gateway/src/transport/ws/v1/registerWsV1.ts @@ -6,7 +6,7 @@ import { type IConnectionRegistry } from '../connectionRegistry'; import type { SessionEventBroadcaster } from './sessionEventBroadcaster'; import type { FsWatchBridge } from './fsWatchBridge'; import type { JournalLogger } from './sessionEventJournal'; -import { WsConnectionV1 } from './wsConnectionV1'; +import { WS_MAX_PAYLOAD_BYTES, WsConnectionV1 } from './wsConnectionV1'; import { selectWsBearerProtocol } from '../bearerProtocol'; export const WS_PATH = '/api/v1/ws'; @@ -25,7 +25,11 @@ export interface RegisterWsV1Options { } export function registerWsV1(core: Scope, opts: RegisterWsV1Options): WebSocketServer { - const wss = new WebSocketServer({ noServer: true, handleProtocols: selectWsBearerProtocol }); + const wss = new WebSocketServer({ + noServer: true, + handleProtocols: selectWsBearerProtocol, + maxPayload: WS_MAX_PAYLOAD_BYTES, + }); const { registry, broadcaster } = opts; wss.on('connection', (socket, req) => { diff --git a/packages/agent-gateway/src/transport/ws/v1/wsConnectionV1.ts b/packages/agent-gateway/src/transport/ws/v1/wsConnectionV1.ts index 05fa7e374..ac1732de8 100644 --- a/packages/agent-gateway/src/transport/ws/v1/wsConnectionV1.ts +++ b/packages/agent-gateway/src/transport/ws/v1/wsConnectionV1.ts @@ -39,6 +39,11 @@ import { import { FsWatchBridge } from './fsWatchBridge'; const DEFAULT_MAX_BUFFER_SIZE = 1000; +export const WS_MAX_PAYLOAD_BYTES = 4 << 20; +export const WS_MAX_PENDING_CONTROLS = 64; +export const WS_MAX_SUBSCRIPTIONS = 256; +export const WS_CLOSE_OVERLOADED = 1013; +export const WS_CLOSE_POLICY = 1008; const DEFAULT_HEARTBEAT_INTERVAL_MS = 10_000; const HEARTBEAT_MISS_LIMIT = 2; @@ -50,6 +55,7 @@ const DEFAULT_MAX_BATCH_SIZE = 64; const DEFAULT_HIGH_WATER_MARK_BYTES = 1 << 20; const DEFAULT_BACKPRESSURE_RETRY_MS = 5; const DEFAULT_BACKPRESSURE_MAX_DELAY_MS = 100; +const DEFAULT_MAX_BUFFERED_BYTES_FACTOR = 8; interface InboundFrame { type: string; @@ -98,6 +104,7 @@ export class WsConnectionV1 implements BroadcastTarget { private expertTalkEvents = false; readonly subscriptions = new Map(); private controlQueue: Promise = Promise.resolve(); + private pendingControls = 0; private outbound: unknown[] = []; private flushTimer?: ReturnType; @@ -158,7 +165,7 @@ export class WsConnectionV1 implements BroadcastTarget { } get subscriptionSessionIds(): readonly string[] { - return Array.from(this.subscriptions.keys()).sort(); + return Array.from(this.subscriptions.keys()).toSorted(); } send(envelope: EventEnvelope, delivery: BroadcastDelivery = 'subscription'): void { @@ -206,9 +213,23 @@ export class WsConnectionV1 implements BroadcastTarget { } } + get pendingControlCount(): number { + return this.pendingControls; + } + private enqueueControl(task: () => Promise): void { - this.controlQueue = this.controlQueue.then(task).catch(() => { - }); + if (this.pendingControls >= WS_MAX_PENDING_CONTROLS) { + this.closeOverloaded('control queue overflow'); + return; + } + this.pendingControls += 1; + this.controlQueue = this.controlQueue + .then(() => (this.closed ? undefined : task())) + .catch(() => { + }) + .finally(() => { + this.pendingControls -= 1; + }); } private onHeartbeat(): void { @@ -420,10 +441,19 @@ export class WsConnectionV1 implements BroadcastTarget { }, ): Promise { const { accepted, resyncRequired, serverCursors, notFound } = collectors; + if (this.closed) return; + if (!this.subscriptions.has(sid) && this.subscriptions.size >= WS_MAX_SUBSCRIPTIONS) { + this.close(WS_CLOSE_POLICY, 'subscription limit exceeded'); + return; + } const ok = await this.broadcaster.subscribe(sid, this, filter, transcriptGrades, { deferTranscriptReset: cursor !== undefined, transcriptSince, }); + if (this.closed) { + if (ok) this.broadcaster.unsubscribe(sid, this); + return; + } if (!ok) { if (notFound !== undefined) notFound.push(sid); else resyncRequired.push(sid); @@ -490,6 +520,10 @@ export class WsConnectionV1 implements BroadcastTarget { private sendSubscribedFrame(msg: unknown): void { if (this.closed) return; + if (this.outbound.length >= this.maxBufferSize) { + this.closeOverloaded('outbound buffer overflow'); + return; + } this.outbound.push(msg); if (this.outbound.length >= this.maxBatchSize) { this.flush(); @@ -545,6 +579,10 @@ export class WsConnectionV1 implements BroadcastTarget { const now = Date.now(); if (this.backpressureSince === undefined) this.backpressureSince = now; if (now - this.backpressureSince >= DEFAULT_BACKPRESSURE_MAX_DELAY_MS) { + if (this.socket.bufferedAmount > this.highWaterMarkBytes * DEFAULT_MAX_BUFFERED_BYTES_FACTOR) { + this.closeOverloaded('slow consumer'); + return; + } this.flush(true); return; } @@ -556,6 +594,12 @@ export class WsConnectionV1 implements BroadcastTarget { this.backpressureRetryTimer.unref?.(); } + private closeOverloaded(reason: string): void { + if (this.closed) return; + this.outbound = []; + this.close(WS_CLOSE_OVERLOADED, reason); + } + close(code = 1000, reason?: string): void { if (this.closed) return; this.flush(true); @@ -585,7 +629,7 @@ export class WsConnectionV1 implements BroadcastTarget { function asStringArray(value: unknown): string[] { if (!Array.isArray(value)) return []; - return value.filter((v): v is string => typeof v === 'string'); + return value.filter((v): v is string => typeof v === 'string').slice(0, WS_MAX_SUBSCRIPTIONS); } function parseAgentFilter(value: unknown): Record | undefined { diff --git a/packages/agent-gateway/test/boot.test.ts b/packages/agent-gateway/test/boot.test.ts index e44ac2a7e..14e07905e 100644 --- a/packages/agent-gateway/test/boot.test.ts +++ b/packages/agent-gateway/test/boot.test.ts @@ -11,6 +11,7 @@ import { IBootstrapService, IFileSystemStorageService, IHostRequestHeaders, + IMcpOAuthService, InMemoryStorageService, ITelemetryService, noopTelemetryService, @@ -274,6 +275,49 @@ describe('server-v2 boot', () => { expect(process.listenerCount('uncaughtException')).toBe(exceptionBefore.length); }); + it('joins concurrent close calls into one shutdown', async () => { + home = await mkdtemp(join(tmpdir(), 'pythinker-server-v2-close-once-')); + server = await startServer({ + hostIdentity: TEST_HOST_IDENTITY, + host: '127.0.0.1', + port: 0, + homeDir: home, + logLevel: 'silent', + }); + const dispose = vi.spyOn(server.core, 'dispose'); + + await Promise.all([server.close(), server.close()]); + await server.close(); + server = undefined; + + expect(dispose).toHaveBeenCalledTimes(1); + expect(await listLiveServerInstances(home)).toEqual([]); + }); + + it('runs every cleanup phase and rethrows when a required drain fails', async () => { + home = await mkdtemp(join(tmpdir(), 'pythinker-server-v2-close-fail-')); + const rejectionBefore = process.listenerCount('unhandledRejection'); + const exceptionBefore = process.listenerCount('uncaughtException'); + server = await startServer({ + hostIdentity: TEST_HOST_IDENTITY, + host: '127.0.0.1', + port: 0, + homeDir: home, + logLevel: 'silent', + }); + const core = server.core; + const oauth = core.accessor.get(IMcpOAuthService); + vi.spyOn(oauth, 'shutdown').mockRejectedValueOnce(new Error('oauth shutdown failed')); + + await expect(server.close()).rejects.toThrow('oauth shutdown failed'); + server = undefined; + + expect(() => core.accessor.get(IBootstrapService)).toThrow(); + expect(await listLiveServerInstances(home)).toEqual([]); + expect(process.listenerCount('unhandledRejection')).toBe(rejectionBefore); + expect(process.listenerCount('uncaughtException')).toBe(exceptionBefore); + }); + it('does not leave process handlers installed when startup fails', async () => { home = await mkdtemp(join(tmpdir(), 'pythinker-server-v2-')); const emptyAssets = await mkdtemp(join(tmpdir(), 'pythinker-server-v2-assets-')); diff --git a/packages/agent-gateway/test/wsConnectionV1.test.ts b/packages/agent-gateway/test/wsConnectionV1.test.ts index bbf068eff..ffb6c0f1c 100644 --- a/packages/agent-gateway/test/wsConnectionV1.test.ts +++ b/packages/agent-gateway/test/wsConnectionV1.test.ts @@ -7,6 +7,10 @@ import type { IConnectionRegistry } from '../src/transport/ws/connectionRegistry import type { SessionEventBroadcaster } from '../src/transport/ws/v1/sessionEventBroadcaster'; import { type WsConnectionV1Options, + WS_CLOSE_OVERLOADED, + WS_CLOSE_POLICY, + WS_MAX_PENDING_CONTROLS, + WS_MAX_SUBSCRIPTIONS, WsConnectionV1, coalesceFrames, } from '../src/transport/ws/v1/wsConnectionV1'; @@ -67,6 +71,13 @@ function makeBroadcaster(): SessionEventBroadcaster { } as unknown as SessionEventBroadcaster; } +function withBroadcaster(overrides: Record): SessionEventBroadcaster { + return Object.assign( + makeBroadcaster() as unknown as Record, + overrides, + ) as unknown as SessionEventBroadcaster; +} + function makeRegistry(): IConnectionRegistry { return { add: () => {}, @@ -764,6 +775,137 @@ describe('WsConnectionV1 outbound buffer', () => { conn.close(); }); + it('force-flushes a moderately slow consumer after the backpressure delay', async () => { + const socket = new FakeSocket(); + const conn = makeConn(socket, { flushIntervalMs: 16, highWaterMarkBytes: 100 }); + socket.sent = []; + + socket.bufferedAmount = 200; + conn.send(delta('s1', 'main', 1, 'Hello', 0)); + await vi.advanceTimersByTimeAsync(120); + + expect(socket.frames()).toHaveLength(1); + expect(socket.closeCalls).toEqual([]); + conn.close(); + }); + + it('disconnects a consumer whose socket buffer stays past the hard byte bound', async () => { + const socket = new FakeSocket(); + const conn = makeConn(socket, { flushIntervalMs: 16, highWaterMarkBytes: 100 }); + socket.sent = []; + + socket.bufferedAmount = 100 * 8 + 1; + conn.send(delta('s1', 'main', 1, 'Hello', 0)); + await vi.advanceTimersByTimeAsync(120); + + expect(socket.sent).toEqual([]); + expect(socket.closeCalls).toEqual([{ code: WS_CLOSE_OVERLOADED, reason: 'slow consumer' }]); + expect(conn.subscriptionSessionIds).toEqual([]); + }); + + it('disconnects when the outbound queue exceeds the advertised buffer size', () => { + const socket = new FakeSocket(); + const conn = makeConn(socket, { + flushIntervalMs: 16, + highWaterMarkBytes: 100, + maxBufferSize: 3, + maxBatchSize: 100, + }); + socket.sent = []; + socket.bufferedAmount = 200; + + for (let i = 0; i < 4; i += 1) conn.send(delta('s1', 'main', 1, `chunk${String(i)}`, i)); + + expect(socket.sent).toEqual([]); + expect(socket.closeCalls).toEqual([ + { code: WS_CLOSE_OVERLOADED, reason: 'outbound buffer overflow' }, + ]); + }); + + it('disconnects a client that floods control frames faster than they drain', () => { + const socket = new FakeSocket(); + const conn = makeConn(socket, { + broadcaster: withBroadcaster({ subscribe: () => new Promise(() => {}) }), + }); + socket.sent = []; + + for (let i = 0; i <= WS_MAX_PENDING_CONTROLS; i += 1) { + socket.emit( + 'message', + JSON.stringify({ type: 'subscribe', id: `f${String(i)}`, payload: { session_ids: ['s1'] } }), + ); + } + + expect(conn.pendingControlCount).toBe(WS_MAX_PENDING_CONTROLS); + expect(socket.closeCalls).toEqual([ + { code: WS_CLOSE_OVERLOADED, reason: 'control queue overflow' }, + ]); + }); + + it('releases a subscription that completes after the connection closed', async () => { + const socket = new FakeSocket(); + let releaseSubscribe: (ok: boolean) => void = () => {}; + const unsubscribe = vi.fn(); + const conn = makeConn(socket, { + broadcaster: withBroadcaster({ + subscribe: () => + new Promise((resolve) => { + releaseSubscribe = resolve; + }), + unsubscribe, + }), + }); + socket.emit( + 'message', + JSON.stringify({ type: 'subscribe', id: 'f1', payload: { session_ids: ['s1'] } }), + ); + await vi.advanceTimersByTimeAsync(0); + + socket.emit('close'); + releaseSubscribe(true); + await vi.advanceTimersByTimeAsync(0); + + expect(unsubscribe).toHaveBeenCalledWith('s1', conn); + expect(conn.subscriptionSessionIds).toEqual([]); + expect(conn.pendingControlCount).toBe(0); + }); + + it('skips queued controls once the connection is closed', async () => { + const socket = new FakeSocket(); + const subscribe = vi.fn(async () => true); + makeConn(socket, { broadcaster: withBroadcaster({ subscribe }) }); + socket.emit( + 'message', + JSON.stringify({ type: 'subscribe', id: 'f1', payload: { session_ids: ['s1'] } }), + ); + socket.emit('close'); + await vi.advanceTimersByTimeAsync(0); + + expect(subscribe).not.toHaveBeenCalled(); + }); + + it('caps the number of session subscriptions per connection', async () => { + const socket = new FakeSocket(); + const conn = makeConn(socket); + const ids = Array.from({ length: WS_MAX_SUBSCRIPTIONS + 1 }, (_, i) => `s${String(i)}`); + socket.emit( + 'message', + JSON.stringify({ type: 'subscribe', id: 'f1', payload: { session_ids: ids } }), + ); + await vi.advanceTimersByTimeAsync(0); + expect(conn.subscriptionSessionIds).toHaveLength(WS_MAX_SUBSCRIPTIONS); + + socket.emit( + 'message', + JSON.stringify({ type: 'subscribe', id: 'f2', payload: { session_ids: ['extra'] } }), + ); + await vi.advanceTimersByTimeAsync(0); + + expect(socket.closeCalls).toEqual([ + { code: WS_CLOSE_POLICY, reason: 'subscription limit exceeded' }, + ]); + }); + it('force-flushes buffered subscription frames on close', () => { const socket = new FakeSocket(); const conn = makeConn(socket, { flushIntervalMs: 1000 }); diff --git a/packages/kosong/src/providers/dsml-tool-parser.ts b/packages/kosong/src/providers/dsml-tool-parser.ts index 63bd8f69c..cfee892c7 100644 --- a/packages/kosong/src/providers/dsml-tool-parser.ts +++ b/packages/kosong/src/providers/dsml-tool-parser.ts @@ -1,11 +1,22 @@ import type { StreamedMessagePart, ToolCall } from '#/message'; -const CONTAINER_OPEN_RE = /^<\s*[||]?\s*(?:DSML\s*[||]?)?\s*tool_calls\s*>/i; -const CONTAINER_CLOSE_RE = /^<\/\s*[||]?\s*(?:DSML\s*[||]?)?\s*tool_calls\s*>/i; -const INVOKE_OPEN_RE = /^<\s*[||]?\s*(?:DSML\s*[||]?)?\s*invoke(?:\s+[^>]*)?>/i; -const INVOKE_CLOSE_RE = /<\/\s*[||]?\s*(?:DSML\s*[||]?)?\s*invoke\s*>/i; -const HERMES_OPEN_RE = /^/i; -const HERMES_CLOSE_RE = /<\/tool_call>/i; +const MARK = String.raw`\s*[||]?\s*(?:DSML\s*[||]?)?\s*`; +const CONTAINER_OPEN_RE = new RegExp(String.raw`<${MARK}tool_calls\s*>`, 'iy'); +const CONTAINER_CLOSE_RE = new RegExp(String.raw``, 'iy'); +const INVOKE_OPEN_RE = new RegExp(String.raw`<${MARK}invoke(?:\s+[^>]*)?>`, 'iy'); +const INVOKE_CLOSE_RE = new RegExp(String.raw``, 'gi'); +const PARAM_OPEN_RE = new RegExp(String.raw`<${MARK}parameter\s+([^>]*?)>`, 'iy'); +const PARAM_CLOSE_RE = new RegExp(String.raw``, 'gi'); +const HERMES_OPEN_RE = //iy; +const HERMES_CLOSE_RE = /<\/tool_call>/gi; +const MARKED_RE = /<\/?\s*(?:[||]|DSML)/iy; +const NAME_ATTR_RE = /\bname\s*=\s*(?:"([^"]+)"|'([^']+)'|([^\s>]+))/i; +const STRING_ATTR_RE = /\bstring\s*=\s*(?:"(true|false)"|'(true|false)'|(true|false))/i; + +export const DSML_MAX_TAG_CHARS = 4096; +export const DSML_MAX_ENVELOPE_CHARS = 2 * 1024 * 1024; +const TAIL_MAX = 1024; +const TAG_NAMES = ['tool_calls', 'tool_call', 'invoke', 'parameter']; function unescapeXml(value: string): string { return value @@ -46,115 +57,165 @@ function parseParameterValue(rawVal: string, isStringAttr: boolean | undefined): return unescaped; } -function parseInvokeBody(invokeContent: string): Record { - const paramRegex = - /<\s*[||]?\s*(?:DSML\s*[||]?)?\s*parameter\s+([^>]*?)>([\s\S]*?)<\/\s*[||]?\s*(?:DSML\s*[||]?)?\s*parameter\s*>/gi; - const args: Record = {}; - let paramFound = false; - let match: RegExpExecArray | null = null; - - while ((match = paramRegex.exec(invokeContent)) !== null) { - const attrStr = match[1] ?? ''; - const rawVal = match[2] ?? ''; - const nameMatch = /\bname\s*=\s*(?:"([^"]+)"|'([^']+)'|([^\s>]+))/i.exec(attrStr); - const paramName = nameMatch ? (nameMatch[1] ?? nameMatch[2] ?? nameMatch[3]) : undefined; - if (paramName) { - paramFound = true; - const stringAttrMatch = - /\bstring\s*=\s*(?:"(true|false)"|'(true|false)'|(true|false))/i.exec(attrStr); - const stringAttrVal = stringAttrMatch - ? (stringAttrMatch[1] ?? stringAttrMatch[2] ?? stringAttrMatch[3]) - : undefined; - const isStringAttr = - stringAttrVal !== undefined ? stringAttrVal.toLowerCase() === 'true' : undefined; - args[paramName] = parseParameterValue(rawVal, isStringAttr); - } - } +function attrValue(match: RegExpExecArray | null): string | undefined { + if (!match) return undefined; + return match[1] ?? match[2] ?? match[3]; +} - if (paramFound) { - return args; - } +function skipWhitespace(text: string, from: number): number { + let pos = from; + while (pos < text.length && /\s/.test(text[pos] as string)) pos += 1; + return pos; +} - const trimmed = invokeContent.trim(); - if (trimmed.startsWith('{') && trimmed.endsWith('}')) { +function stickyExec(re: RegExp, text: string, at: number): RegExpExecArray | null { + re.lastIndex = at; + return re.exec(text); +} + +export function parseInvokeBody(invokeContent: string): Record | null { + const args: Record = Object.create(null) as Record; + let pos = skipWhitespace(invokeContent, 0); + if (pos === invokeContent.length) return args; + + if (invokeContent[pos] === '{') { + const trimmed = invokeContent.trim(); + if (!trimmed.endsWith('}')) return null; try { - const parsed = JSON.parse(trimmed); - if (typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed)) { - return parsed as Record; + const parsed: unknown = JSON.parse(trimmed); + if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) return null; + for (const key of Object.keys(parsed)) { + args[key] = (parsed as Record)[key]; } - } catch {} + return args; + } catch { + return null; + } + } + + while (pos < invokeContent.length) { + const open = stickyExec(PARAM_OPEN_RE, invokeContent, pos); + if (!open) return null; + const attrStr = open[1] ?? ''; + const paramName = attrValue(NAME_ATTR_RE.exec(attrStr)); + if (!paramName) return null; + const valueStart = open.index + open[0].length; + const close = stickyExec(PARAM_CLOSE_RE, invokeContent, valueStart); + if (!close) return null; + const stringAttrVal = attrValue(STRING_ATTR_RE.exec(attrStr)); + const isStringAttr = + stringAttrVal !== undefined ? stringAttrVal.toLowerCase() === 'true' : undefined; + args[paramName] = parseParameterValue( + invokeContent.slice(valueStart, close.index), + isStringAttr, + ); + pos = skipWhitespace(invokeContent, close.index + close[0].length); } + return args; +} - return {}; +function newCallId(): string { + return `call_${crypto.randomUUID().replaceAll('-', '').slice(0, 24)}`; } function parseInvokeTag(invokeBlock: string): ToolCall | null { - const openMatch = /^<\s*[||]?\s*(?:DSML\s*[||]?)?\s*invoke\s+([^>]*?)>/i.exec(invokeBlock); + const openMatch = stickyExec(INVOKE_OPEN_RE, invokeBlock, 0); if (!openMatch) return null; - - const attrStr = openMatch[1] ?? ''; - const nameMatch = /\bname\s*=\s*(?:"([^"]+)"|'([^']+)'|([^\s>]+))/i.exec(attrStr); - const toolName = nameMatch ? (nameMatch[1] ?? nameMatch[2] ?? nameMatch[3]) : undefined; + const toolName = attrValue(NAME_ATTR_RE.exec(openMatch[0].slice(0, -1))); if (!toolName) return null; - const closeMatch = INVOKE_CLOSE_RE.exec(invokeBlock); - if (!closeMatch) return null; - const innerContent = invokeBlock.slice(openMatch[0].length, closeMatch.index); - - const args = parseInvokeBody(innerContent); - return { - type: 'function', - id: `call_${crypto.randomUUID().replaceAll('-', '').slice(0, 24)}`, - name: toolName, - arguments: JSON.stringify(args), - }; + const closeMatch = stickyExec(INVOKE_CLOSE_RE, invokeBlock, openMatch[0].length); + if (!closeMatch || closeMatch.index + closeMatch[0].length !== invokeBlock.length) return null; + const args = parseInvokeBody(invokeBlock.slice(openMatch[0].length, closeMatch.index)); + if (args === null) return null; + return { type: 'function', id: newCallId(), name: toolName, arguments: JSON.stringify(args) }; } function parseHermesToolCall(toolCallBlock: string): ToolCall | null { - if (!HERMES_CLOSE_RE.test(toolCallBlock)) return null; const inner = toolCallBlock .replace(/^/i, '') .replace(/<\/tool_call>$/i, '') .trim(); try { - const parsed = JSON.parse(inner); - if (parsed && typeof parsed.name === 'string') { - const args = - typeof parsed.arguments === 'string' - ? parsed.arguments - : JSON.stringify(parsed.arguments ?? {}); - return { - type: 'function', - id: `call_${crypto.randomUUID().replaceAll('-', '').slice(0, 24)}`, - name: parsed.name, - arguments: args, - }; - } - } catch {} - return null; + const parsed: unknown = JSON.parse(inner); + if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) return null; + const record = parsed as Record; + if (typeof record['name'] !== 'string') return null; + const rawArgs = record['arguments']; + const args = typeof rawArgs === 'string' ? rawArgs : JSON.stringify(rawArgs ?? {}); + return { type: 'function', id: newCallId(), name: record['name'], arguments: args }; + } catch { + return null; + } } -function isPotentialTagPrefix(s: string): boolean { - if (!s.startsWith('<')) return false; - const lower = s.toLowerCase(); +type TagKind = + | 'container-open' + | 'container-close' + | 'invoke-open' + | 'hermes-open' + | 'partial' + | 'none'; + +function markerPrefixState(lower: string): { rest: string; partial: boolean } { let rest = lower.startsWith(' 0 && rest.length < 4 && 'dsml'.startsWith(rest)) { + return { rest: '', partial: true }; } if (rest.startsWith('dsml')) { rest = rest.slice(4).trimStart(); - if (rest.startsWith('|') || rest.startsWith('|')) { - rest = rest.slice(1).trimStart(); + if (rest.startsWith('|') || rest.startsWith('|')) rest = rest.slice(1).trimStart(); + } + return { rest, partial: rest.length === 0 }; +} + +function classifyTag(buffer: string, at: number): { kind: TagKind; length: number } { + const containerOpen = stickyExec(CONTAINER_OPEN_RE, buffer, at); + if (containerOpen) return { kind: 'container-open', length: containerOpen[0].length }; + const containerClose = stickyExec(CONTAINER_CLOSE_RE, buffer, at); + if (containerClose) return { kind: 'container-close', length: containerClose[0].length }; + const invokeOpen = stickyExec(INVOKE_OPEN_RE, buffer, at); + if (invokeOpen) return { kind: 'invoke-open', length: invokeOpen[0].length }; + const hermesOpen = stickyExec(HERMES_OPEN_RE, buffer, at); + if (hermesOpen) return { kind: 'hermes-open', length: hermesOpen[0].length }; + + const lower = buffer.slice(at, at + DSML_MAX_TAG_CHARS + 1).toLowerCase(); + const { rest, partial } = markerPrefixState(lower); + if (partial) return { kind: 'partial', length: 0 }; + if (rest.includes('>')) return { kind: 'none', length: 0 }; + for (const name of TAG_NAMES) { + if (name.startsWith(rest)) return { kind: 'partial', length: 0 }; + if (rest.startsWith(name)) { + const after = rest.slice(name.length); + if (after.length === 0 || /^\s/.test(after)) return { kind: 'partial', length: 0 }; } } - if (rest.length === 0) return true; - const targets = ['tool_calls', 'tool_call', 'invoke', 'parameter']; - return targets.some((t) => t.startsWith(rest) || rest.startsWith(t)); + return { kind: 'none', length: 0 }; +} + +type Mode = 'text' | 'invoke' | 'hermes'; + +interface ContainerHold { + raw: string; + parts: StreamedMessagePart[]; } export class DsmlStreamParser { private _buffer = ''; + private _pos = 0; + private _mode: Mode = 'text'; + private _envelope: string[] = []; + private _envelopeLength = 0; + private _tail = ''; + private _stripWhitespace = false; + private _whitespaceHold = ''; + private _inContainer = false; + private _hold: ContainerHold | null = null; + private _lineStart = true; + private _linePrefix = ''; + private _fence: string | null = null; private _hasExtractedToolCalls = false; get hasExtractedToolCalls(): boolean { @@ -162,139 +223,276 @@ export class DsmlStreamParser { } feed(chunk: string): StreamedMessagePart[] { - this._buffer += chunk; const parts: StreamedMessagePart[] = []; + if (this._mode === 'text') { + this._buffer += chunk; + } else { + this._scanEnvelope(parts, chunk); + } + if (this._mode === 'text') { + this._drainText(parts); + } + return parts; + } - while (this._buffer.length > 0) { - const ltIdx = this._buffer.indexOf('<'); - if (ltIdx === -1) { - parts.push({ type: 'text', text: this._buffer }); - this._buffer = ''; - break; - } - - if (ltIdx > 0) { - parts.push({ type: 'text', text: this._buffer.slice(0, ltIdx) }); - this._buffer = this._buffer.slice(ltIdx); - } + flush(): StreamedMessagePart[] { + const parts: StreamedMessagePart[] = []; + if (this._mode !== 'text') { + const block = this._envelope.join('') + this._tail; + this._leaveEnvelope(); + this._buffer = block; + this._pos = 0; + } + if (this._whitespaceHold.length > 0) { + this._emitText(parts, this._whitespaceHold.replace(/^\r?\n/, '')); + this._whitespaceHold = ''; + } + this._stripWhitespace = false; + if (this._pos < this._buffer.length) { + this._emitText(parts, this._take(this._buffer.length - this._pos)); + } + this._buffer = ''; + this._pos = 0; + if (this._inContainer) { + this._releaseHold(parts, true); + this._inContainer = false; + } + return parts; + } - const openContainer = CONTAINER_OPEN_RE.exec(this._buffer); - if (openContainer) { - this._buffer = this._buffer.slice(openContainer[0].length); - if (/^\s* this._pos) { + this._whitespaceHold += this._take(end - this._pos); + } + if (this._pos >= this._buffer.length) break; + this._stripWhitespace = false; + if (this._buffer[this._pos] === '<') { + this._noteText(this._whitespaceHold); } else { - this._buffer = this._buffer.replace(/^\r?\n/, ''); + this._emitText(parts, this._whitespaceHold.replace(/^\r?\n/, '')); } - continue; + this._whitespaceHold = ''; } - const closeContainer = CONTAINER_CLOSE_RE.exec(this._buffer); - if (closeContainer) { - this._buffer = this._buffer.slice(closeContainer[0].length); - if (/^\s* this._pos) { + this._emitText(parts, this._take(ltIdx - this._pos)); continue; } - const invokeOpen = INVOKE_OPEN_RE.exec(this._buffer); - if (invokeOpen) { - const closeMatch = INVOKE_CLOSE_RE.exec(this._buffer); - if (!closeMatch) { - break; - } - const invokeEnd = closeMatch.index + closeMatch[0].length; - const invokeBlock = this._buffer.slice(0, invokeEnd); - const toolCall = parseInvokeTag(invokeBlock); - if (toolCall) { - this._hasExtractedToolCalls = true; - parts.push(toolCall); - this._buffer = this._buffer.slice(invokeEnd); - if (/^\s* DSML_MAX_TAG_CHARS) { + this._emitPlainTag(parts); + continue; } - const toolCallEnd = closeMatch.index + closeMatch[0].length; - const block = this._buffer.slice(0, toolCallEnd); - const toolCall = parseHermesToolCall(block); - if (toolCall) { - this._hasExtractedToolCalls = true; - parts.push(toolCall); - this._buffer = this._buffer.slice(toolCallEnd); - if (/^\s* DSML_MAX_ENVELOPE_CHARS) { + this._abandonEnvelope(parts, block, rest); + return; + } + const call = mode === 'invoke' ? parseInvokeTag(block) : parseHermesToolCall(block); + this._record(block); + if (call) { + this._emitCall(parts, call); + this._stripWhitespace = true; + } else { + this._emitText(parts, block); } + this._buffer = rest; + this._pos = 0; + return; + } - parts.push({ type: 'text', text: this._buffer.slice(0, nextLt) }); - this._buffer = this._buffer.slice(nextLt); + if (this._envelopeLength + probe.length >= DSML_MAX_ENVELOPE_CHARS) { + const block = this._envelope.join('') + probe; + this._leaveEnvelope(); + this._abandonEnvelope(parts, block, ''); + return; } - return parts; + const lastLt = probe.lastIndexOf('<'); + if (lastLt === -1 || probe.length - lastLt > TAIL_MAX) { + this._envelope.push(probe); + this._envelopeLength += probe.length; + this._tail = ''; + } else { + const committed = probe.slice(0, lastLt); + this._envelope.push(committed); + this._envelopeLength += committed.length; + this._tail = probe.slice(lastLt); + } } - flush(): StreamedMessagePart[] { - const parts: StreamedMessagePart[] = []; - if (this._buffer.length > 0) { - const invokeOpen = INVOKE_OPEN_RE.exec(this._buffer); - if (invokeOpen && INVOKE_CLOSE_RE.test(this._buffer)) { - const toolCall = parseInvokeTag(this._buffer); - if (toolCall) { - this._hasExtractedToolCalls = true; - parts.push(toolCall); - this._buffer = ''; - return parts; - } + private _abandonEnvelope(parts: StreamedMessagePart[], block: string, rest: string): void { + const nextLt = block.indexOf('<', 1); + const cut = nextLt === -1 ? block.length : nextLt; + const text = block.slice(0, cut); + this._record(text); + this._emitText(parts, text); + this._buffer = block.slice(cut) + rest; + this._pos = 0; + } + + private _take(length: number): string { + const taken = this._buffer.slice(this._pos, this._pos + length); + this._pos += length; + this._record(taken); + return taken; + } + + private _record(text: string): void { + if (this._hold === null) return; + this._hold.raw += text; + } + + private _releaseHold(parts: StreamedMessagePart[], asText: boolean): void { + const hold = this._hold; + this._hold = null; + if (hold === null) return; + if (asText) { + if (hold.raw.length > 0) parts.push({ type: 'text', text: hold.raw }); + } else { + parts.push(...hold.parts); + } + } + + private _emitCall(parts: StreamedMessagePart[], call: ToolCall): void { + this._hasExtractedToolCalls = true; + this._releaseHold(parts, false); + parts.push(call); + } + + private _emitText(parts: StreamedMessagePart[], text: string): void { + if (text.length === 0) return; + this._noteText(text); + const part: StreamedMessagePart = { type: 'text', text }; + if (this._hold !== null) { + this._hold.parts.push(part); + if (this._hold.raw.length > DSML_MAX_ENVELOPE_CHARS) { + this._releaseHold(parts, true); } - const hermesOpen = HERMES_OPEN_RE.exec(this._buffer); - if (hermesOpen && HERMES_CLOSE_RE.test(this._buffer)) { - const toolCall = parseHermesToolCall(this._buffer); - if (toolCall) { - this._hasExtractedToolCalls = true; - parts.push(toolCall); - this._buffer = ''; - return parts; + return; + } + parts.push(part); + } + + private _noteText(text: string): void { + for (const ch of text) { + if (ch === '\n') { + this._lineStart = true; + this._linePrefix = ''; + continue; + } + if (!this._lineStart) continue; + if (ch === ' ' && this._linePrefix.length < 3 && /^ *$/.test(this._linePrefix)) { + this._linePrefix += ch; + continue; + } + if (ch !== '`' && ch !== '~') { + this._lineStart = false; + continue; + } + const run = this._linePrefix.trimStart(); + if (run.length > 0 && run[0] !== ch) { + this._lineStart = false; + continue; + } + this._linePrefix += ch; + if (run.length + 1 === 3) { + this._lineStart = false; + if (this._fence === null) { + this._fence = ch; + } else if (this._fence === ch) { + this._fence = null; } } - parts.push({ type: 'text', text: this._buffer }); - this._buffer = ''; } - return parts; } } @@ -315,6 +513,6 @@ export function extractDsmlToolCalls(text: string): { } } - const cleanText = toolCalls.length > 0 ? textParts.join('').trim() : text; - return { cleanText, toolCalls }; + const joined = textParts.join(''); + return { cleanText: toolCalls.length > 0 ? joined.trim() : joined, toolCalls }; } diff --git a/packages/kosong/src/providers/openai-legacy.ts b/packages/kosong/src/providers/openai-legacy.ts index ffbc6d5ab..c9008a8c3 100644 --- a/packages/kosong/src/providers/openai-legacy.ts +++ b/packages/kosong/src/providers/openai-legacy.ts @@ -409,7 +409,8 @@ export class OpenAILegacyStreamedMessage implements StreamedMessage { let text = message.content ?? null; let extractedToolCalls: ToolCall[] = []; - if (text) { + const hasNativeToolCalls = (message.tool_calls ?? []).some(isFunctionToolCall); + if (text && !hasNativeToolCalls) { const parsed = extractDsmlToolCalls(text); if (parsed.toolCalls.length > 0) { text = parsed.cleanText; @@ -445,6 +446,8 @@ export class OpenAILegacyStreamedMessage implements StreamedMessage { ): AsyncGenerator { const bufferedToolCalls = new Map(); const dsmlParser = new DsmlStreamParser(); + const recoveredToolCalls: ToolCall[] = []; + let nativeToolCallsSeen = false; try { for await (const chunk of response) { @@ -481,13 +484,18 @@ export class OpenAILegacyStreamedMessage implements StreamedMessage { // text content if (delta.content) { for (const part of dsmlParser.feed(delta.content)) { - yield part; + if (part.type === 'function') { + recoveredToolCalls.push(part); + } else { + yield part; + } } } // tool calls — preserve `index` on every yielded part so the generate // loop can route interleaved argument deltas from parallel tool calls. for (const toolCall of delta.tool_calls ?? []) { + nativeToolCallsSeen = true; for (const part of convertChatCompletionStreamToolCall(toolCall, bufferedToolCalls)) { yield part; } @@ -495,10 +503,17 @@ export class OpenAILegacyStreamedMessage implements StreamedMessage { } for (const part of dsmlParser.flush()) { - yield part; + if (part.type === 'function') { + recoveredToolCalls.push(part); + } else { + yield part; + } } - if (dsmlParser.hasExtractedToolCalls) { + if (!nativeToolCallsSeen && recoveredToolCalls.length > 0) { this._hasExtractedToolCalls = true; + for (const toolCall of recoveredToolCalls) { + yield toolCall; + } } } catch (error: unknown) { throw convertOpenAIError(error); diff --git a/packages/kosong/src/providers/pythinker.ts b/packages/kosong/src/providers/pythinker.ts index 9ce464109..c40e34114 100644 --- a/packages/kosong/src/providers/pythinker.ts +++ b/packages/kosong/src/providers/pythinker.ts @@ -339,7 +339,8 @@ class PythinkerStreamedMessage implements StreamedMessage { let text = message.content ?? null; let extractedToolCalls: ToolCall[] = []; - if (text) { + const hasNativeToolCalls = (message.tool_calls ?? []).some(isFunctionToolCall); + if (text && !hasNativeToolCalls) { const parsed = extractDsmlToolCalls(text); if (parsed.toolCalls.length > 0) { text = parsed.cleanText; @@ -374,6 +375,8 @@ class PythinkerStreamedMessage implements StreamedMessage { ): AsyncGenerator { const bufferedToolCalls = new Map(); const dsmlParser = new DsmlStreamParser(); + const recoveredToolCalls: ToolCall[] = []; + let nativeToolCallsSeen = false; try { for await (const chunk of response) { @@ -414,13 +417,18 @@ class PythinkerStreamedMessage implements StreamedMessage { // text content if (delta.content) { for (const part of dsmlParser.feed(delta.content)) { - yield part; + if (part.type === 'function') { + recoveredToolCalls.push(part); + } else { + yield part; + } } } // tool calls — preserve `index` on every yielded part so the generate // loop can route interleaved argument deltas from parallel tool calls. for (const toolCall of delta.tool_calls ?? []) { + nativeToolCallsSeen = true; for (const part of convertChatCompletionStreamToolCall(toolCall, bufferedToolCalls)) { yield part; } @@ -428,10 +436,17 @@ class PythinkerStreamedMessage implements StreamedMessage { } for (const part of dsmlParser.flush()) { - yield part; + if (part.type === 'function') { + recoveredToolCalls.push(part); + } else { + yield part; + } } - if (dsmlParser.hasExtractedToolCalls) { + if (!nativeToolCallsSeen && recoveredToolCalls.length > 0) { this._hasExtractedToolCalls = true; + for (const toolCall of recoveredToolCalls) { + yield toolCall; + } } } catch (error: unknown) { throw convertOpenAIError(error, classifyPythinkerQuotaError); diff --git a/packages/kosong/test/openai-legacy.test.ts b/packages/kosong/test/openai-legacy.test.ts index 687e4020a..25f30685a 100644 --- a/packages/kosong/test/openai-legacy.test.ts +++ b/packages/kosong/test/openai-legacy.test.ts @@ -1507,6 +1507,55 @@ describe('OpenAILegacyChatProvider', () => { expect(messages[0]).not.toHaveProperty('reasoning_content'); }); + it('lets native streamed tool calls win over a DSML echo in content', async () => { + const provider = new OpenAILegacyChatProvider({ + model: 'deepseek-chat', + apiKey: 'test-key', + stream: true, + }); + const echo = + '<|DSML|tool_calls>\n<|DSML|invoke name="Read">\n<|DSML|parameter name="filePath" string="true">a.ts\n\n'; + + async function* mockedStream(): AsyncIterable> { + yield { id: 'c1', choices: [{ index: 0, delta: { content: echo } }] }; + yield { + id: 'c1', + choices: [ + { + index: 0, + delta: { + tool_calls: [ + { + index: 0, + id: 'call_native', + type: 'function', + function: { name: 'Read', arguments: '{"filePath":"a.ts"}' }, + }, + ], + }, + finish_reason: 'tool_calls', + }, + ], + }; + } + + (provider as any)._client.chat.completions.create = vi + .fn() + .mockResolvedValue(mockedStream()); + + const stream = await provider.generate( + '', + [], + [{ role: 'user', content: [{ type: 'text', text: 'q' }], toolCalls: [] }], + ); + const parts: StreamedMessagePart[] = []; + for await (const part of stream) parts.push(part); + + const calls = parts.filter((part) => part.type === 'function'); + expect(calls).toHaveLength(1); + expect(calls[0]).toMatchObject({ id: 'call_native', name: 'Read' }); + }); + it('yields ThinkPart from streaming response even without explicit reasoningKey', async () => { const provider = new OpenAILegacyChatProvider({ model: 'deepseek-reasoner', From a8b61434aa994ac894a26a2d87e92494bed0625b Mon Sep 17 00:00:00 2001 From: elkaix Date: Sat, 5 Sep 2026 00:06:43 -0400 Subject: [PATCH 2/4] fix: reorder regex flags in the dsml parser so the spell check passes --- .../kosong/provider/bases/openai/dsml-tool-parser.ts | 12 ++++++------ packages/kosong/src/providers/dsml-tool-parser.ts | 12 ++++++------ 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/packages/agent-core-v2/src/kosong/provider/bases/openai/dsml-tool-parser.ts b/packages/agent-core-v2/src/kosong/provider/bases/openai/dsml-tool-parser.ts index 3c6143e56..7e7bc42e4 100644 --- a/packages/agent-core-v2/src/kosong/provider/bases/openai/dsml-tool-parser.ts +++ b/packages/agent-core-v2/src/kosong/provider/bases/openai/dsml-tool-parser.ts @@ -1,15 +1,15 @@ import type { StreamedMessagePart, ToolCall } from '#/kosong/contract/message'; const MARK = String.raw`\s*[||]?\s*(?:DSML\s*[||]?)?\s*`; -const CONTAINER_OPEN_RE = new RegExp(String.raw`<${MARK}tool_calls\s*>`, 'iy'); -const CONTAINER_CLOSE_RE = new RegExp(String.raw``, 'iy'); -const INVOKE_OPEN_RE = new RegExp(String.raw`<${MARK}invoke(?:\s+[^>]*)?>`, 'iy'); +const CONTAINER_OPEN_RE = new RegExp(String.raw`<${MARK}tool_calls\s*>`, 'yi'); +const CONTAINER_CLOSE_RE = new RegExp(String.raw``, 'yi'); +const INVOKE_OPEN_RE = new RegExp(String.raw`<${MARK}invoke(?:\s+[^>]*)?>`, 'yi'); const INVOKE_CLOSE_RE = new RegExp(String.raw``, 'gi'); -const PARAM_OPEN_RE = new RegExp(String.raw`<${MARK}parameter\s+([^>]*?)>`, 'iy'); +const PARAM_OPEN_RE = new RegExp(String.raw`<${MARK}parameter\s+([^>]*?)>`, 'yi'); const PARAM_CLOSE_RE = new RegExp(String.raw``, 'gi'); -const HERMES_OPEN_RE = //iy; +const HERMES_OPEN_RE = //yi; const HERMES_CLOSE_RE = /<\/tool_call>/gi; -const MARKED_RE = /<\/?\s*(?:[||]|DSML)/iy; +const MARKED_RE = /<\/?\s*(?:[||]|DSML)/yi; const NAME_ATTR_RE = /\bname\s*=\s*(?:"([^"]+)"|'([^']+)'|([^\s>]+))/i; const STRING_ATTR_RE = /\bstring\s*=\s*(?:"(true|false)"|'(true|false)'|(true|false))/i; diff --git a/packages/kosong/src/providers/dsml-tool-parser.ts b/packages/kosong/src/providers/dsml-tool-parser.ts index cfee892c7..2b89bdabd 100644 --- a/packages/kosong/src/providers/dsml-tool-parser.ts +++ b/packages/kosong/src/providers/dsml-tool-parser.ts @@ -1,15 +1,15 @@ import type { StreamedMessagePart, ToolCall } from '#/message'; const MARK = String.raw`\s*[||]?\s*(?:DSML\s*[||]?)?\s*`; -const CONTAINER_OPEN_RE = new RegExp(String.raw`<${MARK}tool_calls\s*>`, 'iy'); -const CONTAINER_CLOSE_RE = new RegExp(String.raw``, 'iy'); -const INVOKE_OPEN_RE = new RegExp(String.raw`<${MARK}invoke(?:\s+[^>]*)?>`, 'iy'); +const CONTAINER_OPEN_RE = new RegExp(String.raw`<${MARK}tool_calls\s*>`, 'yi'); +const CONTAINER_CLOSE_RE = new RegExp(String.raw``, 'yi'); +const INVOKE_OPEN_RE = new RegExp(String.raw`<${MARK}invoke(?:\s+[^>]*)?>`, 'yi'); const INVOKE_CLOSE_RE = new RegExp(String.raw``, 'gi'); -const PARAM_OPEN_RE = new RegExp(String.raw`<${MARK}parameter\s+([^>]*?)>`, 'iy'); +const PARAM_OPEN_RE = new RegExp(String.raw`<${MARK}parameter\s+([^>]*?)>`, 'yi'); const PARAM_CLOSE_RE = new RegExp(String.raw``, 'gi'); -const HERMES_OPEN_RE = //iy; +const HERMES_OPEN_RE = //yi; const HERMES_CLOSE_RE = /<\/tool_call>/gi; -const MARKED_RE = /<\/?\s*(?:[||]|DSML)/iy; +const MARKED_RE = /<\/?\s*(?:[||]|DSML)/yi; const NAME_ATTR_RE = /\bname\s*=\s*(?:"([^"]+)"|'([^']+)'|([^\s>]+))/i; const STRING_ATTR_RE = /\bstring\s*=\s*(?:"(true|false)"|'(true|false)'|(true|false))/i; From e99ca64d40fbde2ac7284f163d353c0cb35d47e2 Mon Sep 17 00:00:00 2001 From: elkaix Date: Sat, 5 Sep 2026 00:41:23 -0400 Subject: [PATCH 3/4] fix: address review feedback on utf8 line chunking, shutdown isolation, and test contracts --- .../os/backends/node-local/hostFsService.ts | 40 +++++++++++++-- .../test/agent/prompt/promptService.test.ts | 51 +++++++++++++++---- .../agent/toolExecutor/toolScheduler.test.ts | 1 - .../dsml-tool-parser-conformance.test.ts | 8 +-- .../backends/node-local/hostFsService.test.ts | 18 +++++++ .../sessionMetadata/sessionMetadata.test.ts | 9 +++- .../session/subagent/runAgentTurn.test.ts | 17 ++++--- packages/agent-gateway/src/start.ts | 21 ++++++-- .../agent-gateway/test/wsConnectionV1.test.ts | 7 +-- packages/kosong/test/openai-legacy.test.ts | 4 +- 10 files changed, 136 insertions(+), 40 deletions(-) diff --git a/packages/agent-core-v2/src/os/backends/node-local/hostFsService.ts b/packages/agent-core-v2/src/os/backends/node-local/hostFsService.ts index 20dd439ad..ca776a700 100644 --- a/packages/agent-core-v2/src/os/backends/node-local/hostFsService.ts +++ b/packages/agent-core-v2/src/os/backends/node-local/hostFsService.ts @@ -14,7 +14,7 @@ import { ScopeActivation, registerScopedService } from '#/_base/di/scope'; import { decodeTextWithErrors, type TextDecodeErrors } from '#/_base/execEnv/decodeText'; import { type HostDirEntry, type HostFileStat, IHostFileSystem } from '#/os/interface/hostFileSystem'; -import { toHostFsError } from '#/os/interface/hostFsErrors'; +import { toHostFsError, HostFsError, OsFsErrors } from '#/os/interface/hostFsErrors'; import { atomicWrite } from '#/_base/utils/fs'; const NEWLINE = Buffer.from([0x0a]); @@ -38,6 +38,33 @@ function* splitLinesKeepingTerminator(text: string): Generator { } } +function trimToValidUtf8Boundary(buf: Buffer): Buffer { + let i = buf.length - 1; + let continuations = 0; + while (i >= 0 && continuations < 3) { + const byte = buf[i]; + if (byte === undefined || (byte & 0xc0) !== 0x80) break; + continuations += 1; + i -= 1; + } + if (i < 0) return buf; + const lead = buf[i]; + if (lead === undefined) return buf; + if ((lead & 0x80) === 0) { + return continuations === 0 ? buf : buf.subarray(0, i + 1); + } + let needed = 0; + if ((lead & 0xe0) === 0xc0) needed = 1; + else if ((lead & 0xf0) === 0xe0) needed = 2; + else if ((lead & 0xf8) === 0xf0) needed = 3; + else return buf.subarray(0, i); + + if (continuations < needed) { + return buf.subarray(0, i); + } + return buf; +} + export class HostFileSystem implements IHostFileSystem { declare readonly _serviceBrand: undefined; @@ -121,6 +148,9 @@ export class HostFileSystem implements IHostFileSystem { const errors = options?.errors ?? 'strict'; if (!isUtf8Encoding(encoding)) { + if (options?.maxLineBytes !== undefined) { + throw new HostFsError(OsFsErrors.codes.OS_FS_UNKNOWN, 'maxLineBytes is only supported for UTF-8 encoding'); + } const content = decodeTextWithErrors(await readFile(path), encoding, errors); yield* splitLinesKeepingTerminator(content); return; @@ -159,8 +189,9 @@ export class HostFileSystem implements IHostFileSystem { const takeLine = (piece: Buffer): Buffer => { if (pending.length === 0 && piece.length <= maxLineBytes) return piece; retain(piece); - const parts = pendingTruncated && piece.at(-1) === 0x0a ? [...pending, NEWLINE] : pending; - const line = Buffer.concat(parts); + const kept = Buffer.concat(pending); + const safe = pendingTruncated ? trimToValidUtf8Boundary(kept) : kept; + const line = pendingTruncated && piece.at(-1) === 0x0a ? Buffer.concat([safe, NEWLINE]) : safe; pending = []; pendingBytes = 0; pendingTruncated = false; @@ -190,7 +221,8 @@ export class HostFileSystem implements IHostFileSystem { } if (pending.length > 0) { - const line = Buffer.concat(pending); + const kept = Buffer.concat(pending); + const line = pendingTruncated ? trimToValidUtf8Boundary(kept) : kept; yield decodeTextWithErrors(line, 'utf-8', errors, pendingOffset !== 0); } } finally { diff --git a/packages/agent-core-v2/test/agent/prompt/promptService.test.ts b/packages/agent-core-v2/test/agent/prompt/promptService.test.ts index f84c1b18d..410b89a30 100644 --- a/packages/agent-core-v2/test/agent/prompt/promptService.test.ts +++ b/packages/agent-core-v2/test/agent/prompt/promptService.test.ts @@ -4,12 +4,12 @@ import { Readable } from 'node:stream'; import { DisposableStore } from '#/_base/di/lifecycle'; import { createServices } from '#/_base/di/test'; -import { Event } from '#/_base/event'; +import { Emitter, Event } from '#/_base/event'; import { IAgentBlobService } from '#/agent/blob/agentBlobService'; import { IAgentContextMemoryService } from '#/agent/contextMemory/contextMemory'; import type { ContextMessage } from '#/agent/contextMemory/types'; import type { ContentPart } from '#/kosong/contract/message'; -import { IAgentFullCompactionService } from '#/agent/fullCompaction/fullCompaction'; +import { IAgentFullCompactionService, type FullCompactionTask } from '#/agent/fullCompaction/fullCompaction'; import { IAgentLoopService } from '#/agent/loop/loop'; import { TurnSteer } from '#/agent/loop/turnOps'; import { IAgentPromptService } from '#/agent/prompt/prompt'; @@ -74,13 +74,19 @@ function harness(loopOptions: StubLoopOptions = { pendingTurnResult: true }) { }, }); const loop = stubLoopWithHooks(loopOptions); - const fullCompaction = { + let activeTask: FullCompactionTask | null = null; + const finishCompactionEmitter = new Emitter(); + disposables.add(finishCompactionEmitter); + const fullCompaction: IAgentFullCompactionService = { _serviceBrand: undefined, - compacting: null, + get compacting() { + return activeTask; + }, begin: () => false, + cancel: () => {}, hooks: createHooks(['onWillCompact']), - onDidFinishCompaction: Event.None, - } as unknown as IAgentFullCompactionService; + onDidFinishCompaction: finishCompactionEmitter.event, + }; const intake = { get: vi.fn(async () => ({ meta: { @@ -124,7 +130,21 @@ function harness(loopOptions: StubLoopOptions = { pendingTurnResult: true }) { (ix.get(IEventBus) as ISessionEventBus).activateAgent( ix.get(IAgentScopeContext).agentContext, ); - return { prompt: ix.get(IAgentPromptService), loop, context, fullCompaction, eventBus: ix.get(IEventBus), intake }; + return { + prompt: ix.get(IAgentPromptService), + loop, + context, + fullCompaction, + eventBus: ix.get(IEventBus), + intake, + setCompacting: (task: FullCompactionTask | null) => { + activeTask = task; + }, + finishCompaction: (task: FullCompactionTask) => { + activeTask = null; + finishCompactionEmitter.fire(task); + }, + }; } describe('AgentPromptService', () => { @@ -305,14 +325,23 @@ describe('AgentPromptService', () => { }); it('parks a queued prompt while compaction runs instead of recursing', async () => { - const { prompt, fullCompaction } = harness(); - (fullCompaction as unknown as { compacting: unknown }).compacting = { promise: new Promise(() => {}), abortController: new AbortController() }; + const { prompt, setCompacting, finishCompaction } = harness(); + const task: FullCompactionTask = { + promise: new Promise(() => {}), + abortController: new AbortController(), + trigger: 'manual', + tokenCount: 100, + }; + setCompacting(task); const handle = await prompt.enqueue({ id: 'parked', message: message('later') }); expect(handle.state).toBe('pending'); - await (prompt as unknown as { startNext(): Promise }).startNext(); - await (prompt as unknown as { startNext(): Promise }).startNext(); expect(prompt.list().pending.map((item) => item.id)).toEqual(['parked']); expect(prompt.list().active).toBeUndefined(); + + finishCompaction(task); + await expect(handle.launched).resolves.toBeDefined(); + expect(prompt.list().pending).toEqual([]); + expect(prompt.list().active?.id).toBe('parked'); }); it('aborts pending prompts and settles completion', async () => { diff --git a/packages/agent-core-v2/test/agent/toolExecutor/toolScheduler.test.ts b/packages/agent-core-v2/test/agent/toolExecutor/toolScheduler.test.ts index f687cca03..9bf5767eb 100644 --- a/packages/agent-core-v2/test/agent/toolExecutor/toolScheduler.test.ts +++ b/packages/agent-core-v2/test/agent/toolExecutor/toolScheduler.test.ts @@ -256,7 +256,6 @@ describe('ToolScheduler leases and budgets', () => { abandoned.resolve(); await waitOneMacrotask(); - expect(drained).toEqual([]); expect(started).toEqual(['abandoned']); settleEffects(); await waitOneMacrotask(); diff --git a/packages/agent-core-v2/test/kosong/provider/dsml-tool-parser-conformance.test.ts b/packages/agent-core-v2/test/kosong/provider/dsml-tool-parser-conformance.test.ts index b82380de7..1f736266a 100644 --- a/packages/agent-core-v2/test/kosong/provider/dsml-tool-parser-conformance.test.ts +++ b/packages/agent-core-v2/test/kosong/provider/dsml-tool-parser-conformance.test.ts @@ -1,5 +1,6 @@ import { readFileSync } from 'node:fs'; -import { resolve } from 'node:path'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; import { describe, expect, it } from 'vitest'; @@ -320,8 +321,9 @@ describe('DsmlStreamParser conformance', () => { describe('implementation parity with @pymodel/kosong', () => { it('keeps both parser copies byte-identical except for the message import', () => { - const here = resolve(__dirname, '../../../src/kosong/provider/bases/openai/dsml-tool-parser.ts'); - const legacy = resolve(__dirname, '../../../../kosong/src/providers/dsml-tool-parser.ts'); + const dir = import.meta.dirname; + const here = resolve(dir, '../../../src/kosong/provider/bases/openai/dsml-tool-parser.ts'); + const legacy = resolve(dir, '../../../../kosong/src/providers/dsml-tool-parser.ts'); const v2 = readFileSync(here, 'utf8').replace( "from '#/kosong/contract/message';", "from '#/message';", diff --git a/packages/agent-core-v2/test/os/backends/node-local/hostFsService.test.ts b/packages/agent-core-v2/test/os/backends/node-local/hostFsService.test.ts index b80ca19a4..62ea014fc 100644 --- a/packages/agent-core-v2/test/os/backends/node-local/hostFsService.test.ts +++ b/packages/agent-core-v2/test/os/backends/node-local/hostFsService.test.ts @@ -87,6 +87,24 @@ describe('HostFileSystem readLines budget', () => { expect(lines).toEqual([`${long}\n`]); }); + + it('preserves complete UTF-8 code points when line budget cuts into multibyte characters', async () => { + const path = join(dir, 'multibyte.txt'); + await writeFile(path, `${'€'.repeat(100)}\n`, 'utf-8'); + + const lines: string[] = []; + for await (const line of fs.readLines(path, { maxLineBytes: 16, errors: 'strict' })) lines.push(line); + + expect(lines).toEqual([`${'€'.repeat(5)}\n`]); + }); + + it('rejects maxLineBytes when encoding is not UTF-8', async () => { + const path = join(dir, 'other.txt'); + await writeFile(path, 'content', 'utf-8'); + + const iter = fs.readLines(path, { encoding: 'latin1', maxLineBytes: 4 }); + await expect(iter.next()).rejects.toThrow('maxLineBytes is only supported for UTF-8 encoding'); + }); }); describe('HostFileSystem stat / lstat', () => { diff --git a/packages/agent-core-v2/test/session/sessionMetadata/sessionMetadata.test.ts b/packages/agent-core-v2/test/session/sessionMetadata/sessionMetadata.test.ts index 7cea5e1c3..b94abfe45 100644 --- a/packages/agent-core-v2/test/session/sessionMetadata/sessionMetadata.test.ts +++ b/packages/agent-core-v2/test/session/sessionMetadata/sessionMetadata.test.ts @@ -234,8 +234,13 @@ describe('SessionMetadata', () => { await meta.ready; await meta.registerAgent('sub-1', { homedir: '/tmp/sub-1', type: 'sub', parentAgentId: 'main' }); const before = (await meta.read()).updatedAt; - await meta.unregisterAgent('sub-1'); - await meta.unregisterAgent('missing'); + const nowSpy = vi.spyOn(Date, 'now').mockReturnValue(before + 10_000); + try { + await meta.unregisterAgent('sub-1'); + await meta.unregisterAgent('missing'); + } finally { + nowSpy.mockRestore(); + } const after = await meta.read(); expect(after.agents).toEqual({}); expect(after.updatedAt).toBe(before); diff --git a/packages/agent-core-v2/test/session/subagent/runAgentTurn.test.ts b/packages/agent-core-v2/test/session/subagent/runAgentTurn.test.ts index 379a544aa..f9c428782 100644 --- a/packages/agent-core-v2/test/session/subagent/runAgentTurn.test.ts +++ b/packages/agent-core-v2/test/session/subagent/runAgentTurn.test.ts @@ -21,18 +21,18 @@ function fakeHandle(totals: TokenUsage[]): IAgentScopeHandle { id: 1, signal: new AbortController().signal, ready: Promise.resolve(), - result: Promise.resolve({ type: 'completed', steps: 1, truncated: false } as never), + result: Promise.resolve({ type: 'completed', steps: 1, truncated: false }), cancel: () => true, }; return { id: 'sub', kind: LifecycleScope.Agent, accessor: { - get: (serviceId: unknown) => { + get: (serviceId: unknown): T => { if (serviceId === ISessionUsageService) { return { status: () => ({ total: totals[Math.min(cursor, totals.length - 1)] }), - }; + } as T; } if (serviceId === IAgentPromptService) { return { @@ -40,17 +40,18 @@ function fakeHandle(totals: TokenUsage[]): IAgentScopeHandle { cursor += 1; return { launched: Promise.resolve(turn) }; }, - }; + } as T; } - if (serviceId === IAgentLoopService) return { cancel: () => true }; + if (serviceId === IAgentLoopService) return { cancel: () => true } as T; if (serviceId === IAgentContextMemoryService) { - return { get: () => [{ role: 'assistant', content: [{ type: 'text', text: 'done' }] }] }; + return { get: () => [{ role: 'assistant', content: [{ type: 'text', text: 'done' }] }] } as T; } - if (serviceId === IAgentScopeContext) return { agentContext }; + if (serviceId === IAgentScopeContext) return { agentContext } as T; throw new Error(`unexpected service ${String(serviceId)}`); }, }, - } as unknown as IAgentScopeHandle; + dispose: () => {}, + }; } describe('runAgentTurn usage attribution', () => { diff --git a/packages/agent-gateway/src/start.ts b/packages/agent-gateway/src/start.ts index 6fa289f94..9c12d9b7e 100644 --- a/packages/agent-gateway/src/start.ts +++ b/packages/agent-gateway/src/start.ts @@ -318,11 +318,22 @@ export async function startServer(opts: ServerStartOptions): Promise configChangedPublisher.close()); await phase('http', () => app.close()); await phase('subscriptions', () => { - configWarningSubscription.dispose(); - pluginChangeSubscription.dispose(); - capabilityInstallSubscription.dispose(); - authFailureLimiter?.dispose(); - modelCatalogRefreshScheduler.dispose(); + for (const sub of [ + configWarningSubscription, + pluginChangeSubscription, + capabilityInstallSubscription, + authFailureLimiter, + modelCatalogRefreshScheduler, + ]) { + try { + sub?.dispose(); + } catch (error) { + logger.warn( + { err: error instanceof Error ? error.message : String(error) }, + 'subscription disposal failed; continuing', + ); + } + } }); await phase('telemetry', () => shutdownServerTelemetry(telemetry), false); await phase('session-metadata', () => drainSessionMetadataWrites()); diff --git a/packages/agent-gateway/test/wsConnectionV1.test.ts b/packages/agent-gateway/test/wsConnectionV1.test.ts index ffb6c0f1c..6d04dd870 100644 --- a/packages/agent-gateway/test/wsConnectionV1.test.ts +++ b/packages/agent-gateway/test/wsConnectionV1.test.ts @@ -71,11 +71,8 @@ function makeBroadcaster(): SessionEventBroadcaster { } as unknown as SessionEventBroadcaster; } -function withBroadcaster(overrides: Record): SessionEventBroadcaster { - return Object.assign( - makeBroadcaster() as unknown as Record, - overrides, - ) as unknown as SessionEventBroadcaster; +function withBroadcaster(overrides: Partial): SessionEventBroadcaster { + return Object.assign(makeBroadcaster(), overrides); } function makeRegistry(): IConnectionRegistry { diff --git a/packages/kosong/test/openai-legacy.test.ts b/packages/kosong/test/openai-legacy.test.ts index 25f30685a..0af904b40 100644 --- a/packages/kosong/test/openai-legacy.test.ts +++ b/packages/kosong/test/openai-legacy.test.ts @@ -1539,7 +1539,9 @@ describe('OpenAILegacyChatProvider', () => { }; } - (provider as any)._client.chat.completions.create = vi + ( + provider as unknown as { _client: { chat: { completions: { create: unknown } } } } + )._client.chat.completions.create = vi .fn() .mockResolvedValue(mockedStream()); From 62352549439fdb9c8a2af27cc1965c76ed8e2410 Mon Sep 17 00:00:00 2001 From: elkaix Date: Sat, 5 Sep 2026 06:06:33 -0400 Subject: [PATCH 4/4] test: remove unused imports in agent-core-v2 test files --- packages/agent-core-v2/test/agent/prompt/promptService.test.ts | 2 +- .../test/kosong/provider/dsml-tool-parser-conformance.test.ts | 3 +-- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/packages/agent-core-v2/test/agent/prompt/promptService.test.ts b/packages/agent-core-v2/test/agent/prompt/promptService.test.ts index 410b89a30..587f891ff 100644 --- a/packages/agent-core-v2/test/agent/prompt/promptService.test.ts +++ b/packages/agent-core-v2/test/agent/prompt/promptService.test.ts @@ -4,7 +4,7 @@ import { Readable } from 'node:stream'; import { DisposableStore } from '#/_base/di/lifecycle'; import { createServices } from '#/_base/di/test'; -import { Emitter, Event } from '#/_base/event'; +import { Emitter } from '#/_base/event'; import { IAgentBlobService } from '#/agent/blob/agentBlobService'; import { IAgentContextMemoryService } from '#/agent/contextMemory/contextMemory'; import type { ContextMessage } from '#/agent/contextMemory/types'; diff --git a/packages/agent-core-v2/test/kosong/provider/dsml-tool-parser-conformance.test.ts b/packages/agent-core-v2/test/kosong/provider/dsml-tool-parser-conformance.test.ts index 1f736266a..265d395bd 100644 --- a/packages/agent-core-v2/test/kosong/provider/dsml-tool-parser-conformance.test.ts +++ b/packages/agent-core-v2/test/kosong/provider/dsml-tool-parser-conformance.test.ts @@ -1,6 +1,5 @@ import { readFileSync } from 'node:fs'; -import { dirname, resolve } from 'node:path'; -import { fileURLToPath } from 'node:url'; +import { resolve } from 'node:path'; import { describe, expect, it } from 'vitest';