feat: post-review backlog hardening (cancel/parser/stall/history/9502)
Five independent items from the post-review backlog. F1: Stop on an external agent task now aborts the running child via a per-task AbortController registry reachable from the cancel route, and finalizes the assistant message as cancelled (fixing two latent bugs — catch blocks left the message streaming, and warm success-paths wrote complete on an aborted turn); warm pools/worktrees are preserved and the native path is unchanged. F2/F3: prune the tool-call parser to its two load-bearing exports (unexport eight zero-caller symbols, add a gate test for the <invoke>-as-text fallback) and route placeholder-rejection logging through pino. F6: a 90s per-chunk stall-timeout wraps native inference's fullStream via AbortSignal.any so a hung stream finalizes the message instead of hanging — no retry (a pure classifyStreamError helper is added). F7: a read-only view_session_history MCP tool (newest-N, chronological). F9: retire the unused apps/coder/web :9502 fallback SPA, keeping every API/WS/health/MCP route. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,29 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { classifyStreamError } from '../inference/stream-error-classifier.js';
|
||||
|
||||
describe('classifyStreamError', () => {
|
||||
it("classifies AbortError as 'stall'", () => {
|
||||
const err = new Error('aborted');
|
||||
err.name = 'AbortError';
|
||||
expect(classifyStreamError(err)).toBe('stall');
|
||||
});
|
||||
|
||||
it("classifies a 503 HTTP error as 'transient'", () => {
|
||||
const err = Object.assign(new Error('Service Unavailable'), { status: 503 });
|
||||
expect(classifyStreamError(err)).toBe('transient');
|
||||
});
|
||||
|
||||
it("classifies a 500 HTTP error as 'transient'", () => {
|
||||
const err = Object.assign(new Error('Internal Server Error'), { status: 500 });
|
||||
expect(classifyStreamError(err)).toBe('transient');
|
||||
});
|
||||
|
||||
it("classifies a 4xx HTTP error as 'non-retryable'", () => {
|
||||
const err = Object.assign(new Error('Bad Request'), { status: 400 });
|
||||
expect(classifyStreamError(err)).toBe('non-retryable');
|
||||
});
|
||||
|
||||
it("classifies a generic Error as 'non-retryable'", () => {
|
||||
expect(classifyStreamError(new Error('something went wrong'))).toBe('non-retryable');
|
||||
});
|
||||
});
|
||||
153
apps/server/src/services/__tests__/stream-phase-adapter.test.ts
Normal file
153
apps/server/src/services/__tests__/stream-phase-adapter.test.ts
Normal file
@@ -0,0 +1,153 @@
|
||||
// Gate test: pins the <invoke>-as-text fallback in the stream-phase text-delta
|
||||
// path. This test will fail if extractToolCallBlocks is ever removed from the
|
||||
// text-delta branch of streamCompletion, which is the only guard for models
|
||||
// that emit tool calls as inline XML rather than structured tool_calls.
|
||||
import { describe, expect, it, vi, afterEach } from 'vitest';
|
||||
import type { FastifyBaseLogger } from 'fastify';
|
||||
|
||||
// vi.mock is hoisted before all module imports. Spread the original so all
|
||||
// other ai exports (tool, jsonSchema, types, …) remain real; only streamText
|
||||
// is replaced with a controllable spy.
|
||||
vi.mock('ai', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('ai')>();
|
||||
return { ...actual, streamText: vi.fn() };
|
||||
});
|
||||
|
||||
import { streamText } from 'ai';
|
||||
import { streamCompletion, STALL_TIMEOUT_MS } from '../inference/stream-phase-adapter.js';
|
||||
import type { StreamAdapterContext } from '../inference/stream-phase-adapter.js';
|
||||
|
||||
const INVOKE_BLOCK =
|
||||
'<invoke name="view_file"><parameter name="path">/tmp/test.ts</parameter></invoke>';
|
||||
|
||||
// One-shot async generator that yields a single text-delta carrying a complete
|
||||
// <invoke> block, simulating a model that emits its tool call as plain XML text.
|
||||
async function* makeInvokeTextDeltaStream() {
|
||||
yield { type: 'text-delta' as const, text: INVOKE_BLOCK };
|
||||
}
|
||||
|
||||
const fakeLog = {
|
||||
debug: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
fatal: vi.fn(),
|
||||
trace: vi.fn(),
|
||||
child: vi.fn(),
|
||||
} as unknown as FastifyBaseLogger;
|
||||
|
||||
const fakeCtx: StreamAdapterContext = {
|
||||
config: { LLAMA_SWAP_URL: 'http://localhost:11434' } as StreamAdapterContext['config'],
|
||||
log: fakeLog,
|
||||
};
|
||||
|
||||
describe('<invoke>-as-text fallback gate (stream-phase text-delta path)', () => {
|
||||
it('surfaces a plain-text <invoke> block as a toolCall and strips markup from content and deltas', async () => {
|
||||
vi.mocked(streamText).mockReturnValue({
|
||||
fullStream: makeInvokeTextDeltaStream(),
|
||||
usage: Promise.resolve({ inputTokens: 1, outputTokens: 1 }),
|
||||
} as unknown as ReturnType<typeof streamText>);
|
||||
|
||||
const deltas: string[] = [];
|
||||
const result = await streamCompletion(
|
||||
fakeCtx,
|
||||
'test-model',
|
||||
[{ role: 'user', content: 'call a tool' }],
|
||||
{ tools: null },
|
||||
(d) => deltas.push(d),
|
||||
undefined,
|
||||
);
|
||||
|
||||
// The <invoke> block must surface as a structured tool call
|
||||
expect(result.toolCalls).toHaveLength(1);
|
||||
expect(result.toolCalls[0]).toMatchObject({
|
||||
id: 'xml_call_0',
|
||||
name: 'view_file',
|
||||
args: { path: '/tmp/test.ts' },
|
||||
});
|
||||
|
||||
// The XML markup must not appear in the saved content or any flushed delta
|
||||
expect(result.content).not.toContain('<invoke');
|
||||
expect(result.content).not.toContain('</invoke>');
|
||||
expect(deltas.join('')).not.toContain('<invoke');
|
||||
});
|
||||
});
|
||||
|
||||
// T9: stall timeout — fake hanging stream fires AbortError after STALL_TIMEOUT_MS.
|
||||
describe('stall timeout (F6)', () => {
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it(`aborts the stream after ${STALL_TIMEOUT_MS}ms with no chunks (stall path)`, async () => {
|
||||
vi.useFakeTimers();
|
||||
|
||||
// Capture the effectiveSignal the adapter passes to streamText so the fake
|
||||
// generator can unblock when the stall fires (matching real ReadableStream
|
||||
// abort behavior: the stream ends rather than throwing into the generator).
|
||||
let capturedSignal: AbortSignal | undefined;
|
||||
vi.mocked(streamText).mockImplementation((opts: Parameters<typeof streamText>[0]) => {
|
||||
capturedSignal = opts.abortSignal as AbortSignal | undefined;
|
||||
return {
|
||||
// Hang until the effective signal fires, then return without emitting
|
||||
// any parts — mirrors how a real fetch stream ends when aborted.
|
||||
fullStream: (async function* () {
|
||||
await new Promise<void>((resolve) => {
|
||||
if (capturedSignal?.aborted) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
capturedSignal?.addEventListener('abort', () => resolve(), { once: true });
|
||||
});
|
||||
})(),
|
||||
// Never resolves; the stall throw happens before usage is awaited.
|
||||
usage: new Promise<never>(() => {}),
|
||||
} as unknown as ReturnType<typeof streamText>;
|
||||
});
|
||||
|
||||
const streamPromise = streamCompletion(
|
||||
fakeCtx,
|
||||
'test-model',
|
||||
[{ role: 'user', content: 'hang' }],
|
||||
{ tools: null },
|
||||
() => {},
|
||||
undefined,
|
||||
);
|
||||
|
||||
// Attach the rejection handler BEFORE advancing timers so the rejection is
|
||||
// never unhandled (Node emits PromiseRejectionHandledWarning otherwise).
|
||||
const assertion = expect(streamPromise).rejects.toMatchObject({ name: 'AbortError' });
|
||||
|
||||
// Advance past the stall deadline — the stallAc fires, the hanging generator
|
||||
// resolves, the post-loop check sees stallAc.signal.aborted and throws.
|
||||
await vi.advanceTimersByTimeAsync(STALL_TIMEOUT_MS);
|
||||
|
||||
await assertion;
|
||||
});
|
||||
|
||||
// T10: regression pin — the original post-loop signal check for user-initiated
|
||||
// abort must still fire correctly after the stall logic was added.
|
||||
it('throws AbortError when the inbound signal is aborted (user-abort regression pin)', async () => {
|
||||
const ac = new AbortController();
|
||||
ac.abort();
|
||||
|
||||
vi.mocked(streamText).mockReturnValue({
|
||||
fullStream: (async function* () {
|
||||
// Yield nothing — stream ends immediately after user abort is already set
|
||||
})(),
|
||||
usage: Promise.resolve({ inputTokens: 0, outputTokens: 0 }),
|
||||
} as unknown as ReturnType<typeof streamText>);
|
||||
|
||||
await expect(
|
||||
streamCompletion(
|
||||
fakeCtx,
|
||||
'test-model',
|
||||
[{ role: 'user', content: 'aborted' }],
|
||||
{ tools: null },
|
||||
() => {},
|
||||
undefined,
|
||||
ac.signal,
|
||||
),
|
||||
).rejects.toMatchObject({ name: 'AbortError' });
|
||||
});
|
||||
});
|
||||
@@ -1,179 +1,9 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
parseXmlToolCall,
|
||||
parseInvokeToolCall,
|
||||
partialXmlOpenerStart,
|
||||
extractToolCallBlocks,
|
||||
stripToolMarkup,
|
||||
XML_TOOL_OPEN,
|
||||
XML_TOOL_CLOSE,
|
||||
INVOKE_TOOL_OPEN,
|
||||
INVOKE_TOOL_CLOSE,
|
||||
} from '../inference/tool-call-parser.js';
|
||||
|
||||
// ── Ported from xml-parser.test.ts ───────────────────────────────────────
|
||||
|
||||
describe('parseXmlToolCall (Qwen/Hermes <tool_call>)', () => {
|
||||
it('parses a well-formed single-parameter call', () => {
|
||||
const block = '<tool_call><function=view_file><parameter=path>/tmp/foo</parameter></function></tool_call>';
|
||||
expect(parseXmlToolCall(block)).toEqual({
|
||||
name: 'view_file',
|
||||
args: { path: '/tmp/foo' },
|
||||
});
|
||||
});
|
||||
|
||||
it('parses multi-parameter call', () => {
|
||||
const block = '<tool_call><function=grep><parameter=pattern>foo</parameter><parameter=path>src/</parameter></function></tool_call>';
|
||||
expect(parseXmlToolCall(block)).toEqual({
|
||||
name: 'grep',
|
||||
args: { pattern: 'foo', path: 'src/' },
|
||||
});
|
||||
});
|
||||
|
||||
it('JSON-parses numeric parameter values', () => {
|
||||
const block = '<tool_call><function=foo><parameter=count>42</parameter></function></tool_call>';
|
||||
expect(parseXmlToolCall(block)).toEqual({ name: 'foo', args: { count: 42 } });
|
||||
});
|
||||
|
||||
it('tolerates whitespace around = in function (v1.13.16 tightening)', () => {
|
||||
const block = '<tool_call><function = view_file><parameter=path>/tmp/foo</parameter></function></tool_call>';
|
||||
expect(parseXmlToolCall(block)).toEqual({
|
||||
name: 'view_file',
|
||||
args: { path: '/tmp/foo' },
|
||||
});
|
||||
});
|
||||
|
||||
it('tolerates whitespace around = in parameter (v1.13.16 tightening)', () => {
|
||||
const block = '<tool_call><function=view_file><parameter = path>/tmp/foo</parameter></function></tool_call>';
|
||||
expect(parseXmlToolCall(block)).toEqual({
|
||||
name: 'view_file',
|
||||
args: { path: '/tmp/foo' },
|
||||
});
|
||||
});
|
||||
|
||||
it('returns null when function name is missing', () => {
|
||||
const block = '<tool_call><parameter=path>/tmp/foo</parameter></tool_call>';
|
||||
expect(parseXmlToolCall(block)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseInvokeToolCall (Anthropic <invoke>) — v1.13.16', () => {
|
||||
it('parses a well-formed single-parameter call (spec case 1)', () => {
|
||||
const block = '<invoke name="view_file"><parameter name="path">/tmp/foo</parameter></invoke>';
|
||||
expect(parseInvokeToolCall(block)).toEqual({
|
||||
name: 'view_file',
|
||||
args: { path: '/tmp/foo' },
|
||||
});
|
||||
});
|
||||
|
||||
it('parses a multi-parameter call (spec case 2)', () => {
|
||||
const block = '<invoke name="grep"><parameter name="pattern">foo</parameter><parameter name="path">src/</parameter></invoke>';
|
||||
expect(parseInvokeToolCall(block)).toEqual({
|
||||
name: 'grep',
|
||||
args: { pattern: 'foo', path: 'src/' },
|
||||
});
|
||||
});
|
||||
|
||||
it('tolerates newlines and spaces in attributes (spec case 3)', () => {
|
||||
const block = `<invoke
|
||||
name="view_file"
|
||||
>
|
||||
<parameter
|
||||
name="path"
|
||||
>/tmp/foo</parameter>
|
||||
</invoke>`;
|
||||
expect(parseInvokeToolCall(block)).toEqual({
|
||||
name: 'view_file',
|
||||
args: { path: '/tmp/foo' },
|
||||
});
|
||||
});
|
||||
|
||||
it('parses a call whose name is not a registered BooCode tool (spec case 4)', () => {
|
||||
const block = '<invoke name="read_file"><parameter name="path">/tmp/foo</parameter></invoke>';
|
||||
expect(parseInvokeToolCall(block)).toEqual({
|
||||
name: 'read_file',
|
||||
args: { path: '/tmp/foo' },
|
||||
});
|
||||
});
|
||||
|
||||
it('supports single-quoted attribute values', () => {
|
||||
const block = "<invoke name='view_file'><parameter name='path'>/tmp/foo</parameter></invoke>";
|
||||
expect(parseInvokeToolCall(block)).toEqual({
|
||||
name: 'view_file',
|
||||
args: { path: '/tmp/foo' },
|
||||
});
|
||||
});
|
||||
|
||||
it('JSON-parses numeric parameter values', () => {
|
||||
const block = '<invoke name="foo"><parameter name="count">42</parameter></invoke>';
|
||||
expect(parseInvokeToolCall(block)).toEqual({ name: 'foo', args: { count: 42 } });
|
||||
});
|
||||
|
||||
it('tolerates spaces around = inside name attribute', () => {
|
||||
const block = '<invoke name = "view_file"><parameter name = "path">/tmp/foo</parameter></invoke>';
|
||||
expect(parseInvokeToolCall(block)).toEqual({
|
||||
name: 'view_file',
|
||||
args: { path: '/tmp/foo' },
|
||||
});
|
||||
});
|
||||
|
||||
it('returns null when name attribute is missing', () => {
|
||||
const block = '<invoke><parameter name="path">/tmp/foo</parameter></invoke>';
|
||||
expect(parseInvokeToolCall(block)).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when name attribute is empty', () => {
|
||||
const block = '<invoke name=""><parameter name="path">/tmp/foo</parameter></invoke>';
|
||||
expect(parseInvokeToolCall(block)).toBeNull();
|
||||
});
|
||||
|
||||
it('exports the expected delimiters', () => {
|
||||
expect(INVOKE_TOOL_OPEN).toBe('<invoke');
|
||||
expect(INVOKE_TOOL_CLOSE).toBe('</invoke>');
|
||||
expect(XML_TOOL_OPEN).toBe('<tool_call>');
|
||||
expect(XML_TOOL_CLOSE).toBe('</tool_call>');
|
||||
});
|
||||
});
|
||||
|
||||
describe('partialXmlOpenerStart (v1.13.16 — both flavors)', () => {
|
||||
it('returns -1 when the buffer is empty', () => {
|
||||
expect(partialXmlOpenerStart('')).toBe(-1);
|
||||
});
|
||||
|
||||
it('returns -1 when the buffer has no openers', () => {
|
||||
expect(partialXmlOpenerStart('plain prose, no markup')).toBe(-1);
|
||||
});
|
||||
|
||||
it('returns the index of a complete <tool_call> opener (existing)', () => {
|
||||
expect(partialXmlOpenerStart('prose <tool_call>more')).toBe(6);
|
||||
});
|
||||
|
||||
it('returns the index of a complete <invoke opener (v1.13.16)', () => {
|
||||
expect(partialXmlOpenerStart('prose <invoke name=')).toBe(6);
|
||||
});
|
||||
|
||||
it('holds a partial <tool_ prefix at end of buffer', () => {
|
||||
expect(partialXmlOpenerStart('text <tool_')).toBe(5);
|
||||
});
|
||||
|
||||
it('holds a partial <invo prefix at end of buffer (v1.13.16)', () => {
|
||||
expect(partialXmlOpenerStart('text <invo')).toBe(5);
|
||||
});
|
||||
|
||||
it('holds a bare < at end of buffer', () => {
|
||||
expect(partialXmlOpenerStart('text <')).toBe(5);
|
||||
});
|
||||
|
||||
it('returns -1 when < is followed by non-opener text', () => {
|
||||
expect(partialXmlOpenerStart('text <unknown>')).toBe(-1);
|
||||
});
|
||||
|
||||
it('returns the earliest opener when both flavors are present', () => {
|
||||
expect(partialXmlOpenerStart('xxx <tool_call>YYY <invoke>')).toBe(4);
|
||||
expect(partialXmlOpenerStart('xxx <invoke>YYY <tool_call>')).toBe(4);
|
||||
});
|
||||
});
|
||||
|
||||
describe('extractToolCallBlocks (v1.13.16 — unified extraction)', () => {
|
||||
it('extracts a single <invoke> block (spec case 1)', () => {
|
||||
const input = '<invoke name="view_file"><parameter name="path">/tmp/foo</parameter></invoke>';
|
||||
@@ -341,11 +171,3 @@ describe('stripToolMarkup', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('delimiter constants', () => {
|
||||
it('exports the expected delimiters', () => {
|
||||
expect(INVOKE_TOOL_OPEN).toBe('<invoke');
|
||||
expect(INVOKE_TOOL_CLOSE).toBe('</invoke>');
|
||||
expect(XML_TOOL_OPEN).toBe('<tool_call>');
|
||||
expect(XML_TOOL_CLOSE).toBe('</tool_call>');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
// Pure classifier for errors thrown from the fullStream loop. Establishes the
|
||||
// retry seam for when llama-swap gains restart-in-place-with-clear-partial
|
||||
// semantics. No retry is performed today (partial-stream re-emit is
|
||||
// non-idempotent at single-local-instance scale).
|
||||
export type StreamErrorKind = 'stall' | 'transient' | 'non-retryable';
|
||||
|
||||
export function classifyStreamError(err: unknown): StreamErrorKind {
|
||||
if (err instanceof Error && err.name === 'AbortError') {
|
||||
return 'stall';
|
||||
}
|
||||
if (err != null && typeof err === 'object') {
|
||||
const status = (err as Record<string, unknown>).status;
|
||||
if (typeof status === 'number' && status >= 500 && status < 600) {
|
||||
return 'transient';
|
||||
}
|
||||
}
|
||||
return 'non-retryable';
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import type { Agent, ToolCall } from '../../types/api.js';
|
||||
import type { ToolJsonSchema } from '../tools.js';
|
||||
import type { OpenAiMessage } from './payload.js';
|
||||
import { extractToolCallBlocks } from './tool-call-parser.js';
|
||||
import { classifyStreamError } from './stream-error-classifier.js';
|
||||
import type { StreamResult } from './types.js';
|
||||
import { upstreamModel } from './provider.js';
|
||||
import {
|
||||
@@ -193,6 +194,10 @@ function buildAiTools(schemas: ToolJsonSchema[]): Record<string, ReturnType<type
|
||||
return out;
|
||||
}
|
||||
|
||||
// F6: per-chunk stall deadline. Exported so tests can advance fake timers by
|
||||
// exactly this value without hardcoding a magic number.
|
||||
export const STALL_TIMEOUT_MS = 90_000;
|
||||
|
||||
// v1.10.5 Qwen-coder XML fallback. Some local models (notably qwen3-coder via
|
||||
// llama-swap) emit tool calls as inline XML inside delta.content rather than
|
||||
// the structured tool_calls field. We extract them out of the streamed text
|
||||
@@ -267,6 +272,22 @@ export async function streamCompletion(
|
||||
// before this. They now go through the same extraBody path as the new params.
|
||||
const samplerBody = buildSamplerProviderOptions(opts);
|
||||
|
||||
// F6: per-chunk stall deadline. If the model stops emitting chunks for
|
||||
// STALL_TIMEOUT_MS the stallAc fires through AbortSignal.any; the post-loop
|
||||
// abort check below then throws AbortError → handleAbortOrError writes
|
||||
// 'cancelled'. Timer is bumped on every chunk and cleared in the finally.
|
||||
// NO retry: partial-stream re-emit is non-idempotent at single-local-instance
|
||||
// scale; see stream-error-classifier.ts for the future retry seam.
|
||||
const stallAc = new AbortController();
|
||||
let stallTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
const bumpStallTimer = () => {
|
||||
if (stallTimer !== null) clearTimeout(stallTimer);
|
||||
stallTimer = setTimeout(() => stallAc.abort(), STALL_TIMEOUT_MS);
|
||||
};
|
||||
const effectiveSignal = signal
|
||||
? AbortSignal.any([signal, stallAc.signal])
|
||||
: stallAc.signal;
|
||||
|
||||
const result = streamText({
|
||||
model: upstreamModel(ctx.config, model, agent ?? null),
|
||||
messages: aiMessages,
|
||||
@@ -277,7 +298,7 @@ export async function streamCompletion(
|
||||
...(typeof opts.top_p === 'number' ? { topP: opts.top_p } : {}),
|
||||
...(typeof opts.presence_penalty === 'number' ? { presencePenalty: opts.presence_penalty } : {}),
|
||||
...(samplerBody ? { providerOptions: { openaiCompatible: samplerBody } } : {}),
|
||||
abortSignal: signal,
|
||||
abortSignal: effectiveSignal,
|
||||
});
|
||||
|
||||
let content = '';
|
||||
@@ -289,7 +310,11 @@ export async function streamCompletion(
|
||||
// same flat list and keep the v1.10.5 synthetic id convention.
|
||||
const toolCalls: ToolCall[] = [];
|
||||
|
||||
bumpStallTimer();
|
||||
|
||||
try {
|
||||
for await (const part of result.fullStream) {
|
||||
bumpStallTimer();
|
||||
switch (part.type) {
|
||||
case 'text-delta': {
|
||||
pendingBuffer += part.text;
|
||||
@@ -297,7 +322,7 @@ export async function streamCompletion(
|
||||
// complete <tool_call> or <invoke> block, flushes prose between/around
|
||||
// them, holds any partial opener for the next chunk, and silently
|
||||
// drops blocks that fail to parse (matches pre-v1.13.16 behavior).
|
||||
const extracted = extractToolCallBlocks(pendingBuffer);
|
||||
const extracted = extractToolCallBlocks(pendingBuffer, ctx.log);
|
||||
if (extracted.flushed.length > 0) {
|
||||
content += extracted.flushed;
|
||||
onDelta(extracted.flushed);
|
||||
@@ -339,7 +364,9 @@ export async function streamCompletion(
|
||||
}
|
||||
case 'error': {
|
||||
const err = part.error;
|
||||
throw err instanceof Error ? err : new Error(String(err));
|
||||
const actualErr = err instanceof Error ? err : new Error(String(err));
|
||||
ctx.log.warn({ kind: classifyStreamError(actualErr) }, 'stream error part');
|
||||
throw actualErr;
|
||||
}
|
||||
// Intentional no-op: start, start-step, text-start, text-end,
|
||||
// reasoning-start, reasoning-end, source, file, tool-input-start,
|
||||
@@ -365,7 +392,8 @@ export async function streamCompletion(
|
||||
// Without this throw the row would land as status='complete' with partial
|
||||
// content instead of going through handleAbortOrError → status='cancelled'.
|
||||
// Smoke D caught this in v1.13.1-A — don't refactor it away.
|
||||
if (signal?.aborted) {
|
||||
// F6: also catch the stall timeout arm (stallAc.signal.aborted).
|
||||
if (signal?.aborted || stallAc.signal.aborted) {
|
||||
const abortErr = new Error('aborted');
|
||||
abortErr.name = 'AbortError';
|
||||
throw abortErr;
|
||||
@@ -402,4 +430,12 @@ export async function streamCompletion(
|
||||
completionTokens,
|
||||
reasoning: reasoningAccumulated,
|
||||
};
|
||||
} finally {
|
||||
// Clear the stall timer whether the stream completes normally, throws, or
|
||||
// is aborted — prevents a dangling timer from firing after the turn ends.
|
||||
if (stallTimer !== null) {
|
||||
clearTimeout(stallTimer);
|
||||
stallTimer = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,10 +5,10 @@
|
||||
|
||||
// ── Constants ────────────────────────────────────────────────────────────
|
||||
|
||||
export const XML_TOOL_OPEN = '<tool_call>';
|
||||
export const XML_TOOL_CLOSE = '</tool_call>';
|
||||
export const INVOKE_TOOL_OPEN = '<invoke';
|
||||
export const INVOKE_TOOL_CLOSE = '</invoke>';
|
||||
const XML_TOOL_OPEN = '<tool_call>';
|
||||
const XML_TOOL_CLOSE = '</tool_call>';
|
||||
const INVOKE_TOOL_OPEN = '<invoke';
|
||||
const INVOKE_TOOL_CLOSE = '</invoke>';
|
||||
|
||||
// ── Strip patterns ───────────────────────────────────────────────────────
|
||||
|
||||
@@ -45,7 +45,7 @@ export interface ParsedCall {
|
||||
const PLACEHOLDER_LITERALS = new Set(['...', 'placeholder', '<path>', '<file>']);
|
||||
const ANGLE_BRACKET_SENTINEL_RE = /^<[^>]+>$/;
|
||||
|
||||
export function isPlaceholderArgValue(value: unknown): boolean {
|
||||
function isPlaceholderArgValue(value: unknown): boolean {
|
||||
if (typeof value !== 'string') return false;
|
||||
const trimmed = value.trim();
|
||||
if (trimmed === '') return true;
|
||||
@@ -61,17 +61,21 @@ function hasPlaceholderArgs(args: Record<string, unknown>): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
function logRejectedPlaceholder(parsed: ParsedCall): void {
|
||||
console.debug(
|
||||
{ toolName: parsed.name, args: parsed.args },
|
||||
'rejected placeholder tool call at parse time',
|
||||
);
|
||||
type MinLogger = { debug(obj: object, msg: string): void };
|
||||
|
||||
function logRejectedPlaceholder(parsed: ParsedCall, log?: MinLogger): void {
|
||||
if (log) {
|
||||
log.debug(
|
||||
{ toolName: parsed.name, args: parsed.args },
|
||||
'rejected placeholder tool call at parse time',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const QWEN_FUNCTION_RE = /<function\s*=\s*([^>\s]+)\s*>/;
|
||||
const QWEN_PARAM_RE = /<parameter\s*=\s*([^>\s]+)\s*>([\s\S]*?)<\/parameter>/g;
|
||||
|
||||
export function parseXmlToolCall(block: string): ParsedCall | null {
|
||||
function parseXmlToolCall(block: string): ParsedCall | null {
|
||||
const nameMatch = block.match(QWEN_FUNCTION_RE);
|
||||
if (!nameMatch || !nameMatch[1]) return null;
|
||||
const name = nameMatch[1].trim();
|
||||
@@ -95,7 +99,7 @@ const INVOKE_NAME_RE =
|
||||
const INVOKE_PARAM_RE =
|
||||
/<parameter\s+name\s*=\s*("([^"]*)"|'([^']*)')\s*>([\s\S]*?)<\/parameter>/g;
|
||||
|
||||
export function parseInvokeToolCall(block: string): ParsedCall | null {
|
||||
function parseInvokeToolCall(block: string): ParsedCall | null {
|
||||
const nameMatch = block.match(INVOKE_NAME_RE);
|
||||
if (!nameMatch) return null;
|
||||
const name = (nameMatch[2] ?? nameMatch[3] ?? '').trim();
|
||||
@@ -116,7 +120,7 @@ export function parseInvokeToolCall(block: string): ParsedCall | null {
|
||||
|
||||
const ALL_OPENERS = [XML_TOOL_OPEN, INVOKE_TOOL_OPEN] as const;
|
||||
|
||||
export function partialXmlOpenerStart(s: string): number {
|
||||
function partialXmlOpenerStart(s: string): number {
|
||||
let earliest = -1;
|
||||
for (const op of ALL_OPENERS) {
|
||||
const idx = s.indexOf(op);
|
||||
@@ -150,7 +154,7 @@ const OPENER_SPECS: ReadonlyArray<OpenerSpec> = [
|
||||
{ open: INVOKE_TOOL_OPEN, close: INVOKE_TOOL_CLOSE, parse: parseInvokeToolCall },
|
||||
];
|
||||
|
||||
export function extractToolCallBlocks(buffer: string): ToolCallExtraction {
|
||||
export function extractToolCallBlocks(buffer: string, log?: MinLogger): ToolCallExtraction {
|
||||
let flushed = '';
|
||||
const calls: ParsedCall[] = [];
|
||||
let pos = 0;
|
||||
@@ -176,7 +180,7 @@ export function extractToolCallBlocks(buffer: string): ToolCallExtraction {
|
||||
const parsed = next.spec.parse(block);
|
||||
if (parsed) {
|
||||
if (hasPlaceholderArgs(parsed.args)) {
|
||||
logRejectedPlaceholder(parsed);
|
||||
logRejectedPlaceholder(parsed, log);
|
||||
flushed += block;
|
||||
} else {
|
||||
calls.push(parsed);
|
||||
|
||||
Reference in New Issue
Block a user