Files
OmniRoute/open-sse/utils/composerToolCalls.ts
Diego Rodrigues de Sa e Souza 0130a4bbb2 fix(sse): handle space-separated arg name/value in Composer tool calls (port from 9router#1811) (#7116)
parseInnerCall only split arg segments on a newline between the arg name
and its value. Cursor's live Composer/Auto output has been observed using a
single space instead, so those segments were treated as one long
(space-containing) arg name with an empty value, silently no-opping
Write/tool calls for Composer/Auto models.

Fall back to splitting on the first whitespace boundary when no newline is
present in the segment.

Reported-by: way-art (https://github.com/decolua/9router/issues/1811)
2026-07-16 14:13:43 -03:00

321 lines
11 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Parser for Cursor Composer's DeepSeek-style inline tool call format.
*
* Composer (Cursor's `cu/composer-2.5*` models) emits tool calls inside its
* normal text output using sentinel markers, e.g.:
*
* Optional preamble text...
* <tool▁calls▁begin>
* <tool▁call▁begin>
* tool_name
* <tool▁sep>arg_name
* arg_value
* <tool▁sep>arg_name_2
* arg_value_2
* <tool▁call▁end>
* <tool▁call▁begin>
* other_tool
* <tool▁sep>arg
* value
* <tool▁call▁end>
* <tool▁calls▁end>
* Optional trailing text...
*
* Markers use full-width pipes (``, U+FF5C) and small-triangle separator
* (`▁`, U+2581). We also accept ASCII fallbacks (`<|tool_calls_begin|>` etc.)
* defensively in case Cursor ever changes encoding.
*
* The parser converts this into the OpenAI Chat Completions `tool_calls`
* shape and returns the residual content (preamble + trailing text) so the
* caller can decide whether to surface it as the assistant's visible message.
*/
const FW = "[|]"; // full-width or ASCII pipe
const SEP = "[▁_]"; // full-width separator or ASCII underscore
// Match the outer tool-calls block, lazily.
const OUTER_RE = new RegExp(
`<${FW}tool${SEP}calls${SEP}begin${FW}>([\\s\\S]*?)<${FW}tool${SEP}calls${SEP}end${FW}>`,
"i"
);
// Match a single tool-call block.
const INNER_RE = new RegExp(
`<${FW}tool${SEP}call${SEP}begin${FW}>([\\s\\S]*?)<${FW}tool${SEP}call${SEP}end${FW}>`,
"gi"
);
// Match an arg separator.
const ARG_SEP_RE = new RegExp(`<${FW}tool${SEP}sep${FW}>`, "gi");
// Heuristic: any partial opening marker (start of `<tool` ... without the
// final `>`). Used by the streaming parser to know it must hold back text.
const PARTIAL_OPEN_MARKER_RE = new RegExp(
`<${FW}?(?:t(?:o(?:o(?:l(?:${SEP}(?:c(?:a(?:l(?:l(?:s)?(?:${SEP}(?:b(?:e(?:g(?:i(?:n${FW}?>?)?)?)?)?)?)?)?)?)?)?)?)?)?)?)?$`,
"i"
);
export interface ComposerToolCall {
id: string;
type: "function";
function: {
name: string;
arguments: string;
};
}
export interface ParseComposerResult {
content: string;
toolCalls: ComposerToolCall[];
}
export interface StreamingState {
emitted: number;
done: boolean;
}
export interface FeedChunkResult {
safeDelta: string;
ready: boolean;
toolCalls: ComposerToolCall[];
holdback: boolean;
}
// Detection helper
export function hasComposerToolCalls(text: string): boolean {
if (!text || typeof text !== "string") return false;
return OUTER_RE.test(text);
}
function generateToolCallId(index: number): string {
// Format: call_<random>; mirrors what OpenAI clients expect.
// Use crypto for deterministic-quality randomness (Hard Rule: no Math.random for IDs).
const rand = crypto.randomUUID().replace(/-/g, "").slice(0, 10);
return `call_${rand}${index}`;
}
/**
* Parse a single inner tool-call block body (the text between
* `<tool▁call▁begin>` and `<tool▁call▁end>`).
*
* Body shape:
* tool_name
* <tool▁sep>arg_name
* arg_value
* <tool▁sep>arg_name_2
* arg_value_2
*
* Returns {name, arguments} where arguments is a JSON string suitable for
* the OpenAI tool_calls schema. Arg values are taken verbatim from the
* text between one separator's argname-line and the next separator (or
* end of body). We attempt to detect when a value is already valid JSON
* (object/array/number/bool/null) and store it natively; otherwise we keep
* it as a string.
*/
function parseInnerCall(body: string): { name: string; arguments: string } | null {
// Body starts with the tool name on (typically) its own line, optionally
// surrounded by whitespace, then the first `<tool▁sep>`.
const trimmed = body.replace(/^\s+|\s+$/g, "");
// Split by argument separator first to isolate name + arg blocks.
const segments = trimmed.split(ARG_SEP_RE);
// First segment is the tool name (and any preamble whitespace).
const name = (segments.shift() ?? "").trim();
if (!name) {
return null;
}
const args: Record<string, unknown> = {};
for (const seg of segments) {
if (!seg) continue;
// Each segment is normally `arg_name\nvalue\n...`: the arg name is the
// first line, everything after the first newline is the value
// (verbatim, including additional newlines). Some live Composer/Auto
// captures instead separate the arg name and value with a single space
// on the same line (no newline at all in the segment) — fall back to
// splitting on the first whitespace boundary in that case so the value
// isn't swallowed into an empty-valued, space-containing "arg name".
const idxNl = seg.indexOf("\n");
let argName: string;
let argValue: string;
if (idxNl < 0) {
const idxSp = seg.search(/\s/);
if (idxSp < 0) {
argName = seg.trim();
argValue = "";
} else {
argName = seg.slice(0, idxSp).trim();
// Unlike the newline-delimited form, a space-delimited value has no
// multi-line content to preserve — trim the trailing whitespace left
// over from the boundary with the next `<tool▁sep>` marker.
argValue = seg.slice(idxSp + 1).trim();
}
} else {
argName = seg.slice(0, idxNl).trim();
argValue = seg.slice(idxNl + 1);
}
if (!argName) continue;
// Strip the trailing newline before the next separator (the separator
// marker itself was already consumed by the split).
argValue = argValue.replace(/\n+$/, "");
// Attempt JSON parse so structured args (objects/arrays/numbers/bools)
// come through as native JSON values rather than quoted strings.
args[argName] = coerceArgValue(argValue);
}
return { name, arguments: JSON.stringify(args) };
}
function coerceArgValue(raw: string): unknown {
if (raw === "") return "";
const stripped = raw.trim();
if (
(stripped.startsWith("{") && stripped.endsWith("}")) ||
(stripped.startsWith("[") && stripped.endsWith("]"))
) {
try {
return JSON.parse(stripped);
} catch {
// not valid JSON — fall through to string
}
}
if (stripped === "true") return true;
if (stripped === "false") return false;
if (stripped === "null") return null;
if (/^-?\d+$/.test(stripped)) {
const n = Number(stripped);
if (Number.isSafeInteger(n)) return n;
}
if (/^-?\d*\.\d+$/.test(stripped)) {
const n = Number(stripped);
if (Number.isFinite(n)) return n;
}
return raw;
}
/**
* Parse a complete (non-streaming) Composer content string.
*
* Returns { content, toolCalls } where:
* - content: the residual visible text (preamble + trailing text combined
* and trimmed; empty string if nothing left).
* - toolCalls: array of OpenAI-shaped tool_calls; empty if none found.
*
* If the input has no tool-call block, returns { content: input, toolCalls: [] }.
*/
export function parseComposerToolCalls(text: string): ParseComposerResult {
if (!text || typeof text !== "string") {
return { content: text || "", toolCalls: [] };
}
const match = text.match(OUTER_RE);
if (!match || match.index === undefined) {
return { content: text, toolCalls: [] };
}
const preamble = text.slice(0, match.index);
const trailing = text.slice(match.index + match[0].length);
const block = match[1];
const toolCalls: ComposerToolCall[] = [];
let idx = 0;
for (const innerMatch of block.matchAll(INNER_RE)) {
const parsed = parseInnerCall(innerMatch[1]);
if (!parsed) continue;
toolCalls.push({
id: generateToolCallId(idx),
type: "function",
function: parsed,
});
idx += 1;
}
const residual = (preamble + trailing).trim();
return { content: residual, toolCalls };
}
/**
* Streaming helper: feed it the *accumulated* content seen so far and it
* returns what is safe to emit as visible text, plus whether tool calls
* are now ready to be flushed.
*
* { safeDelta, ready, toolCalls, holdback }
*
* safeDelta: text delta that can be emitted to the client right now as a
* content delta (relative to how much was already emitted via state.emitted).
* ready: true once a complete `<tool▁calls▁end>` has been seen and
* toolCalls are parsed.
* toolCalls: parsed tool calls (only populated when ready=true).
* holdback: whether more bytes are being held back (an outer-block has
* opened but not yet closed, OR a partial opening marker is at the
* tail of the buffer).
*
* Usage pattern:
* const state = createStreamingState();
* for each frame: const out = feedStreamingChunk(state, accumulated);
* emit out.safeDelta as content delta;
* if (out.ready) emit out.toolCalls and stop emitting content.
*/
export function createStreamingState(): StreamingState {
return {
emitted: 0, // number of safe content chars already emitted
done: false,
};
}
export function feedStreamingChunk(state: StreamingState, accumulated: string): FeedChunkResult {
if (state.done) {
return { safeDelta: "", ready: false, toolCalls: [], holdback: false };
}
if (!accumulated) {
return { safeDelta: "", ready: false, toolCalls: [], holdback: false };
}
// 1. Complete block already in buffer? Parse it.
const m = accumulated.match(OUTER_RE);
if (m && m.index !== undefined) {
const preamble = accumulated.slice(0, m.index);
const block = m[1];
const toolCalls: ComposerToolCall[] = [];
let idx = 0;
for (const innerMatch of block.matchAll(INNER_RE)) {
const parsed = parseInnerCall(innerMatch[1]);
if (!parsed) continue;
toolCalls.push({
id: generateToolCallId(idx),
type: "function",
function: parsed,
});
idx += 1;
}
// Emit any preamble we haven't emitted yet.
const safe = preamble;
const safeDelta = safe.length > state.emitted ? safe.slice(state.emitted) : "";
state.emitted = safe.length;
state.done = true;
return { safeDelta, ready: true, toolCalls, holdback: false };
}
// 2. Look for an opening-only marker. If found, everything before it is
// safe; everything after must be held until we see the closing marker.
const openOnlyRe = new RegExp(`<${FW}tool${SEP}calls${SEP}begin${FW}>`, "i");
const openMatch = accumulated.match(openOnlyRe);
if (openMatch && openMatch.index !== undefined) {
const safe = accumulated.slice(0, openMatch.index);
const safeDelta = safe.length > state.emitted ? safe.slice(state.emitted) : "";
state.emitted = safe.length;
return { safeDelta, ready: false, toolCalls: [], holdback: true };
}
// 3. Partial opening marker at the tail? Hold back the suspicious tail.
const tailMatch = accumulated.match(PARTIAL_OPEN_MARKER_RE);
if (tailMatch && tailMatch.index !== undefined) {
const safe = accumulated.slice(0, tailMatch.index);
const safeDelta = safe.length > state.emitted ? safe.slice(state.emitted) : "";
state.emitted = safe.length;
return { safeDelta, ready: false, toolCalls: [], holdback: true };
}
// 4. No markers anywhere. Emit everything new.
const safeDelta = accumulated.length > state.emitted ? accumulated.slice(state.emitted) : "";
state.emitted = accumulated.length;
return { safeDelta, ready: false, toolCalls: [], holdback: false };
}