import { parseJsonWithRepair } from "@oh-my-pi/pi-utils"; import type { Message, ToolCall } from "../types"; import { asRecord, mintToolCallId, partialSuffixOverlapAny } from "./coercion"; import dialectPrompt from "./qwen3.md" with { type: "text" }; import { renderChatMlTranscript, renderToolResponseResults, stringifyJson } from "./rendering"; import type { DialectDefinition, DialectRenderOptions, DialectToolResult, InbandScanEvent, InbandScanner, InbandScannerOptions, } from "./types"; const TOOL_OPEN = ""; const TOOL_CLOSE = ""; const THINK_OPEN = ""; const THINK_CLOSE = ""; const TOOL_START_TAGS = [TOOL_OPEN] as const; const START_TAGS = [TOOL_OPEN, THINK_OPEN] as const; const THINK_CLOSE_TAGS = [THINK_CLOSE] as const; const COMPLETE_NAME = /^\s*\{\s*"name"\s*:\s*("(?:\\.|[^"\\])*")/; type State = "outside" | "thinking" | "tool"; export class Qwen3InbandScanner implements InbandScanner { #buffer = ""; #state: State = "outside"; #id = ""; #name = ""; #started = false; #thinking = ""; readonly #parseThinking: boolean; constructor(options: InbandScannerOptions = {}) { this.#parseThinking = options.parseThinking !== false; } feed(text: string): InbandScanEvent[] { if (text.length === 0) return []; this.#buffer += text; return this.#consume(false); } flush(): InbandScanEvent[] { return this.#consume(true); } #consume(final: boolean): InbandScanEvent[] { const events: InbandScanEvent[] = []; while (this.#buffer.length > 0) { if (this.#state === "outside") { this.#consumeOutside(final, events); if (this.#state === "outside") break; continue; } if (this.#state === "thinking") { this.#consumeThinking(final, events); if (this.#state === "thinking") break; continue; } this.#consumeTool(final, events); if (this.#state === "tool") break; } if (final && this.#state === "thinking") this.#endThinking(events); return events; } #consumeOutside(final: boolean, events: InbandScanEvent[]): void { const tool = this.#buffer.indexOf(TOOL_OPEN); const think = this.#parseThinking ? this.#buffer.indexOf(THINK_OPEN) : -1; let start = tool; let isThink = false; if (think !== -1 && (start === -1 || think < start)) { start = think; isThink = true; } if (start === -1) { const tags = this.#parseThinking ? START_TAGS : TOOL_START_TAGS; const hold = final ? 0 : partialSuffixOverlapAny(this.#buffer, tags); const emit = this.#buffer.slice(0, this.#buffer.length - hold); if (emit.length > 0) events.push({ type: "text", text: emit }); this.#buffer = this.#buffer.slice(this.#buffer.length - hold); return; } if (start > 0) events.push({ type: "text", text: this.#buffer.slice(0, start) }); if (isThink) { this.#buffer = this.#buffer.slice(start + THINK_OPEN.length); this.#state = "thinking"; this.#thinking = ""; events.push({ type: "thinkingStart" }); return; } this.#buffer = this.#buffer.slice(start + TOOL_OPEN.length); this.#state = "tool"; this.#id = mintToolCallId(); this.#name = ""; this.#started = false; } #consumeThinking(final: boolean, events: InbandScanEvent[]): void { const close = this.#buffer.indexOf(THINK_CLOSE); if (close === -1) { const hold = final ? 0 : partialSuffixOverlapAny(this.#buffer, THINK_CLOSE_TAGS); const delta = this.#buffer.slice(0, this.#buffer.length - hold); this.#emitThinkingDelta(delta, events); this.#buffer = this.#buffer.slice(this.#buffer.length - hold); if (final) this.#endThinking(events); return; } this.#emitThinkingDelta(this.#buffer.slice(0, close), events); this.#buffer = this.#buffer.slice(close + THINK_CLOSE.length); this.#endThinking(events); } #consumeTool(final: boolean, events: InbandScanEvent[]): void { const close = this.#buffer.indexOf(TOOL_CLOSE); const body = close === -1 ? this.#buffer : this.#buffer.slice(0, close); if (!this.#started) this.#tryStart(body, events); if (close === -1) { if (final) this.#resetTool(); return; } const parsed = this.#parseCall(body); if (parsed) { if (!this.#started) { events.push({ type: "toolStart", id: this.#id, name: parsed.name }); this.#started = true; } events.push({ type: "toolEnd", id: this.#id, name: parsed.name, arguments: parsed.arguments, rawBlock: `${TOOL_OPEN}${body}${TOOL_CLOSE}`, }); } this.#buffer = this.#buffer.slice(close + TOOL_CLOSE.length); this.#resetTool(); } #emitThinkingDelta(delta: string, events: InbandScanEvent[]): void { if (delta.length === 0) return; this.#thinking += delta; events.push({ type: "thinkingDelta", delta }); } #endThinking(events: InbandScanEvent[]): void { events.push({ type: "thinkingEnd", thinking: this.#thinking }); this.#thinking = ""; this.#state = "outside"; } #tryStart(body: string, events: InbandScanEvent[]): void { const nameMatch = COMPLETE_NAME.exec(body); if (!nameMatch) return; let name: unknown; try { name = JSON.parse(nameMatch[1]!); } catch { return; } if (typeof name !== "string" || name.length === 0) return; this.#name = name; this.#started = true; events.push({ type: "toolStart", id: this.#id, name: this.#name }); } #parseCall(body: string): { name: string; arguments: Record } | undefined { try { const parsed = parseJsonWithRepair<{ name?: unknown; arguments?: unknown }>(body.trim()); if (typeof parsed.name !== "string" || parsed.name.length === 0) return undefined; let args = parsed.arguments; if (typeof args === "string") { try { args = parseJsonWithRepair(args); } catch { args = {}; } } return { name: parsed.name, arguments: asRecord(args) }; } catch { return undefined; } } #resetTool(): void { this.#state = "outside"; this.#id = ""; this.#name = ""; this.#started = false; } } function renderToolCall(call: ToolCall, _options: DialectRenderOptions = {}): string { return `${TOOL_OPEN}\n${stringifyJson({ name: call.name, arguments: call.arguments })}\n${TOOL_CLOSE}`; } function renderAssistantToolCalls(calls: readonly ToolCall[], options: DialectRenderOptions = {}): string { return calls.map(call => renderToolCall(call, options)).join("\n"); } function renderToolResults(results: readonly DialectToolResult[], _options: DialectRenderOptions = {}): string { return renderToolResponseResults(results); } function renderThinking(text: string): string { if (!text) return ""; return `${THINK_OPEN}\n${text}\n${THINK_CLOSE}`; } function renderTranscript(messages: readonly Message[], options: DialectRenderOptions = {}): string { return renderChatMlTranscript(messages, options, { toolResultRole: "user", renderThinking, renderCalls: renderAssistantToolCalls, renderResultsBody: renderToolResults, }); } const definition: DialectDefinition = { dialect: "qwen3", prompt: dialectPrompt, createScanner: options => new Qwen3InbandScanner(options), renderToolCall, renderAssistantToolCalls, renderToolResults, renderThinking, renderTranscript, }; export default definition;