import { parseJsonWithRepair, parseStreamingJson } from "@oh-my-pi/pi-utils"; import type { Message, ToolCall } from "../types"; import { asRecord, mintToolCallId, partialSuffixOverlapAny } from "./coercion"; import dialectPrompt from "./hermes.md" with { type: "text" }; import { renderChatMlTranscript, renderDelimitedThinking, 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 HOLD_TAGS = [TOOL_OPEN, TOOL_CLOSE, THINK_OPEN, THINK_CLOSE] as const; export class HermesInbandScanner implements InbandScanner { #buffer = ""; #inside = false; #id = ""; #name = ""; #started = false; #parseThinking: boolean; #inThinking = false; #thinking = ""; constructor(options: InbandScannerOptions = {}) { this.#parseThinking = options.parseThinking === true; } 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.#inThinking) { const closeThink = this.#buffer.indexOf(THINK_CLOSE); if (closeThink === -1) { const hold = final ? 0 : partialSuffixOverlapAny(this.#buffer, [THINK_CLOSE]); const thinking = this.#buffer.slice(0, this.#buffer.length - hold); if (thinking.length > 0) { this.#thinking += thinking; events.push({ type: "thinkingDelta", delta: thinking }); } this.#buffer = this.#buffer.slice(this.#buffer.length - hold); if (final) { events.push({ type: "thinkingEnd", thinking: this.#thinking }); this.#thinking = ""; this.#inThinking = false; } break; } const thinking = this.#buffer.slice(0, closeThink); if (thinking.length > 0) { this.#thinking += thinking; events.push({ type: "thinkingDelta", delta: thinking }); } this.#buffer = this.#buffer.slice(closeThink + THINK_CLOSE.length); events.push({ type: "thinkingEnd", thinking: this.#thinking }); this.#thinking = ""; this.#inThinking = false; continue; } if (!this.#inside) { const open = this.#buffer.indexOf(TOOL_OPEN); const think = this.#parseThinking ? this.#buffer.indexOf(THINK_OPEN) : -1; const start = open === -1 ? think : think === -1 ? open : Math.min(open, think); if (start === -1) { const hold = final ? 0 : partialSuffixOverlapAny(this.#buffer, HOLD_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); break; } if (start > 0) events.push({ type: "text", text: this.#buffer.slice(0, start) }); if (start === think) { this.#buffer = this.#buffer.slice(start + THINK_OPEN.length); this.#inThinking = true; this.#thinking = ""; events.push({ type: "thinkingStart" }); continue; } this.#buffer = this.#buffer.slice(start + TOOL_OPEN.length); this.#inside = true; this.#id = mintToolCallId(); this.#name = ""; this.#started = false; continue; } 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.#reset(); break; } 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.#reset(); } return events; } #tryStart(body: string, events: InbandScanEvent[]): void { try { const partial = parseStreamingJson<{ name?: unknown }>(body); if (typeof partial.name !== "string" || partial.name.length === 0) return; this.#name = partial.name; this.#started = true; events.push({ type: "toolStart", id: this.#id, name: this.#name }); } catch { // Partial JSON is allowed until the closing tag arrives. } } #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; } } #reset(): void { this.#inside = false; this.#id = ""; this.#name = ""; this.#started = false; } } function renderToolCall(call: ToolCall, _options: DialectRenderOptions = {}): string { return `\n${stringifyJson({ name: call.name, arguments: call.arguments })}\n`; } 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 { return renderDelimitedThinking(THINK_OPEN, THINK_CLOSE, text); } function renderTranscript(messages: readonly Message[], options: DialectRenderOptions = {}): string { return renderChatMlTranscript(messages, options, { toolResultRole: "tool", renderThinking, renderCalls: renderAssistantToolCalls, renderResultsBody: renderToolResults, }); } const definition: DialectDefinition = { dialect: "hermes", prompt: dialectPrompt, createScanner: options => new HermesInbandScanner(options), renderToolCall, renderAssistantToolCalls, renderToolResults, renderThinking, renderTranscript, }; export default definition;