/**
* Chat-template tool-call envelopes.
*
* Some templates wrap a call in their own envelope — marker pairs, an XML
* shell, a bare JSON list — instead of the fence the route taught. This module
* owns recognizing those shells and unwrapping them; what the unwrapped body
* MEANS is std/llm/tool_parse's business.
*
* Split out of std/llm/tool_parse so that module stays a composition layer over
* the byte-oriented host scanners rather than also carrying every envelope
* dialect.
*/
import { json_fields } from "std/llm/tool_parse_json_support"
import { empty_parse, protocol_violation, provider_result } from "std/llm/tool_parse_result"
fn __tool_parse_envelope_error(kind: string, detail: string, prose: string) -> dict {
return {
matched: true,
result: empty_parse()
+ {
tool_parse_errors: [
"The `<"
+ kind
+ ">` chat-template envelope is malformed: "
+ detail
+ ". Re-emit complete calls as canonical ```tool JSON blocks; "
+ "incomplete entries were not executed.",
],
prose: trim(prose),
},
}
}
/**
* What may stand between a `<tool_call>` opener and its JSON body.
*
* Models label the block before the body — the literal word `tool`, the tool's
* own name, or a template channel token — and frequently omit the closing tag.
* This is the one owner of that vocabulary: both the envelope guard below and
* the tagged-grammar body parser ask it what the opener is actually carrying,
* so the two grammars cannot disagree about which spans are a JSON call.
*
* `name_hint` is the label, offered as a fallback name for a body that carries
* bare arguments and no `name` field.
*
* @effects: []
* @errors: []
* @api_stability: internal
*/
pub fn tool_call_label_body(body: string) -> dict {
const trimmed = trim(body)
if starts_with(trimmed, "{") || starts_with(trimmed, "[") {
return {ok: true, body: trimmed, name_hint: ""}
}
const labeled = regex_captures("^([A-Za-z_][A-Za-z0-9_.\\-]{0,63})[ \\t]*\\r?\\n", trimmed)
if len(labeled) == 0 {
return {ok: false}
}
const rest = trim(trimmed.slice(labeled[0].end, len(trimmed)))
if !starts_with(rest, "{") && !starts_with(rest, "[") {
return {ok: false}
}
return {ok: true, body: rest, name_hint: to_string(labeled[0].groups[0])}
}
fn __tool_parse_envelope_call(value, name_hint: string = "") -> dict {
if type_of(value) != "dict" {
return {ok: false, error: "each chat-template tool entry must be a JSON object"}
}
const fields = json_fields(value)
const name = if fields.name != "" {
fields.name
} else {
trim(name_hint)
}
if name == "" {
return {ok: false, error: "a JSON tool object was missing a non-empty name"}
}
let arguments = fields.arguments
if type_of(arguments) == "string" {
arguments = try {
json_parse(arguments)
} catch (error) {
return {ok: false, error: "arguments string did not parse: " + to_string(error)}
}
}
if type_of(arguments) != "dict" {
return {ok: false, error: "arguments must be a JSON object"}
}
return {ok: true, call: {id: "tc_envelope", name: name, arguments: arguments}}
}
fn __tool_parse_envelope_json_list(
kind: string,
body: string,
close: string,
prose: string,
name_hint: string = "",
) -> dict {
const source = trim(body)
const stream = __host_tool_json_stream(source)
if len(stream?.values ?? []) == 0 {
const detail =
stream?.eof ?? false ? "a JSON tool object ended before its closing `}`" : "expected a JSON tool object, found `"
+ trim(
body,
)
.slice(0, 80)
+ "`"
return __tool_parse_envelope_error("tool_calls", detail, prose)
}
let calls: list<dict> = []
for row in stream.values {
const normalized = __tool_parse_envelope_call(row.value, name_hint)
if !(normalized?.ok ?? false) {
return __tool_parse_envelope_error("tool_calls", to_string(normalized.error), prose)
}
calls = calls.appending(normalized.call)
}
let after = trim(source.slice(stream.end, len(source)))
if starts_with(after, close) {
after = trim(after.slice(len(close), len(after)))
}
// Whatever follows the envelope's objects is NOT this module's to judge. An
// unclosed envelope runs to end of output, so the tail routinely holds the
// rest of the turn — including a well-formed call in another grammar. This
// used to become an "expected a JSON tool object" error that discarded both
// the tail AND the calls already parsed above, which is how one malformed
// envelope destroyed every other call in the message. Hand the tail back and
// let the composition layer parse it; it restores the error if the tail turns
// out to hold nothing.
return {matched: true, calls: calls, prose: trim(prose), trailing: after}
}
fn __tool_parse_envelope_markers(body: string, prose: string) -> dict {
let rest = trim(body)
let calls: list<dict> = []
let saw_marker = false
let marker_active = false
let marker_completed = false
while rest != "" {
if starts_with(rest, "</tool_calls>") {
rest = trim(rest.slice(len("</tool_calls>"), len(rest)))
break
}
if starts_with(rest, "<tool>") {
if marker_active {
return __tool_parse_envelope_error(
"tool_calls",
"a `<tool>` marker must be followed by a JSON object before another `<tool>` marker",
prose,
)
}
saw_marker = true
marker_active = true
marker_completed = false
rest = trim(rest.slice(len("<tool>"), len(rest)))
continue
}
if starts_with(rest, "</tool>") {
if marker_active || !marker_completed {
return __tool_parse_envelope_error(
"tool_calls",
"found an unmatched `</tool>` close without a preceding `<tool>` marker",
prose,
)
}
marker_active = false
marker_completed = false
rest = trim(rest.slice(len("</tool>"), len(rest)))
continue
}
if !marker_active {
if marker_completed && starts_with(rest, "{") {
marker_active = true
} else {
const detail =
saw_marker ? "expected a `<tool>` marker before this JSON object" : "the envelope contained no `<tool>` marker"
return __tool_parse_envelope_error("tool_calls", detail, prose)
}
}
if !starts_with(rest, "{") {
return __tool_parse_envelope_error(
"tool_calls",
"expected a JSON tool object, found `" + rest.slice(0, 80) + "`",
prose,
)
}
const object_len = __host_tool_balanced_json_len(rest)
if object_len == 0 {
return __tool_parse_envelope_error(
"tool_calls",
"a JSON tool object ended before its closing `}`",
prose,
)
}
const decoded = try {
json_parse(rest.slice(0, object_len))
} catch (error) {
return __tool_parse_envelope_error(
"tool_calls",
"a JSON tool object did not parse: " + to_string(error),
prose,
)
}
const normalized = __tool_parse_envelope_call(decoded)
if !(normalized?.ok ?? false) {
return __tool_parse_envelope_error("tool_calls", to_string(normalized.error), prose)
}
calls = calls.appending(normalized.call)
marker_active = false
marker_completed = true
rest = trim(rest.slice(object_len, len(rest)))
}
if marker_active {
return __tool_parse_envelope_error(
"tool_calls",
"a `<tool>` marker ended without a complete JSON object",
prose,
)
}
if !saw_marker {
return __tool_parse_envelope_error(
"tool_calls",
"the envelope contained no `<tool>` marker",
prose,
)
}
return {
matched: true,
calls: calls,
prose: [trim(prose), rest].filter(fn(p) { return p != "" }).join("\n"),
}
}
fn __tool_parse_envelope_xml(body: string, prose: string) -> dict {
let rest = trim(body)
let calls: list<dict> = []
while rest != "" {
if starts_with(rest, "</tool_calls>") {
rest = trim(rest.slice(len("</tool_calls>"), len(rest)))
break
}
const opened = regex_captures("^<([A-Za-z_][A-Za-z0-9_.-]*)>", rest)
if len(opened) == 0 {
return __tool_parse_envelope_error(
"tool_calls",
"expected a tool-call tag inside `<tool_calls>`, found `" + rest.slice(0, 60) + "`",
prose,
)
}
const name = to_string(opened[0].groups[0])
const close = "</" + name + ">"
const after_open = rest.slice(opened[0].end, len(rest))
const close_at = after_open.index_of(close)
const inner = close_at >= 0 ? after_open.slice(0, close_at) : after_open
let arguments: dict = {}
let argument_rest = trim(inner)
while argument_rest != "" {
const argument_open = regex_captures("^<([A-Za-z_][A-Za-z0-9_.-]*)>", argument_rest)
if len(argument_open) == 0 {
return __tool_parse_envelope_error(
"tool_calls",
"expected an argument tag inside `<"
+ name
+ ">`, found `"
+ argument_rest.slice(0, 60)
+ "`",
prose,
)
}
const key = to_string(argument_open[0].groups[0])
if arguments[key] != nil {
return __tool_parse_envelope_error(
"tool_calls",
"the `<"
+ name
+ ">` call repeated the `<"
+ key
+ ">` argument; a call with an ambiguous duplicate argument is not executed",
prose,
)
}
const argument_close = "</" + key + ">"
const value_source = argument_rest.slice(argument_open[0].end, len(argument_rest))
const argument_close_at = value_source.index_of(argument_close)
if argument_close_at < 0 {
return __tool_parse_envelope_error(
"tool_calls",
"the `<"
+ key
+ ">` argument tag was not closed with `"
+ argument_close
+ "` before end of output",
prose,
)
}
arguments[key] = trim(value_source.slice(0, argument_close_at))
argument_rest = trim(
value_source.slice(argument_close_at + len(argument_close), len(value_source)),
)
}
if close_at < 0 {
return __tool_parse_envelope_error(
"tool_calls",
"the `<" + name + ">` call tag was not closed with `" + close + "` before end of output",
prose,
)
}
calls = calls.appending({id: "tc_envelope_xml", name: name, arguments: arguments})
rest = trim(after_open.slice(close_at + len(close), len(after_open)))
}
if len(calls) == 0 {
return __tool_parse_envelope_error(
"tool_calls",
"the `<tool_calls>` envelope contained no tool-call tags",
prose,
)
}
return {
matched: true,
calls: calls,
prose: [trim(prose), rest].filter(fn(p) { return p != "" }).join("\n"),
}
}
/**
* The opener spellings a chat template may wrap a JSON call body in.
*
* `<tool>`, `<tool_use>`, and `[[tool]]` are not inventions of this parser's
* imagination: models reach for them under tool-format pressure and then, when
* nothing comes back, try another spelling — one mined turn narrates it, saying
* the call "was malformed" before emitting the same call under a different
* opener. Recognizing the spelling costs nothing, because the body still has to
* be a JSON call object to match at all; refusing to recognize it costs the
* call and teaches the model nothing.
*
* Order is not precedence — the EARLIEST opener in the text wins — so
* `<tool_calls>` still claims a `<tool>` that sits inside it.
*/
const ENVELOPE_OPENERS: list<dict> = [
{open: "<tool_calls>", close: "</tool_calls>", kind: "tool_calls"},
{open: "<tool_code>", close: "</tool_code>", kind: "tool_code"},
{open: "<tool_call>", close: "</tool_call>", kind: "tool_call"},
{open: "<tool_use>", close: "</tool_use>", kind: "tool_call"},
{open: "<tool>", close: "</tool>", kind: "tool_call"},
{open: "[[tool]]", close: "[[/tool]]", kind: "tool_call"},
]
/**
* An envelope whose body is not JSON at all, read through the caller's body
* ladder.
*
* This module owns where an envelope starts and ends. It does not own what
* grammar the body is written in, and it used to behave as though it did:
* requiring JSON meant `[[tool]]look({ ... })[[/tool]]` matched an opener this
* module knows, failed its JSON check, and was declined all the way back to the
* stray scan, which said nothing. The ladder arrives as a callback because
* `tool_parse_body` already imports this module for the label unwrap, so the
* dependency can only run one way.
*/
fn __tool_parse_envelope_direct_body(
raw_body: string,
close: string,
prose: string,
parse_body,
) -> dict {
if parse_body == nil {
return {matched: false}
}
const end = raw_body.index_of(close)
if end < 0 {
return {matched: false}
}
const parsed = parse_body(trim(raw_body.slice(0, end)))
if !(parsed?.ok ?? false) || parsed?.call == nil {
return {matched: false}
}
return {
matched: true,
calls: [parsed.call],
prose: trim(prose),
trailing: trim(raw_body.slice(end + len(close), len(raw_body))),
}
}
/**
* Offset of the earliest envelope opener that is not `<tool_call>`, or -1.
*
* The tagged lane owns `<tool_call>` and asks this to decide whether an
* alternate opener gets to the text first. Asking "does the text contain a
* `<tool_call>` anywhere" instead is what made a mixed message lose a call:
* one tagged block far down the turn switched envelope reading off for the
* whole message, including an alternate opener that had come before it.
*
* @effects: []
* @errors: []
* @api_stability: experimental
*/
pub fn tool_envelope_alternate_opener_at(text: string) -> int {
let at = -1
for candidate in ENVELOPE_OPENERS {
if candidate.open == "<tool_call>" {
continue
}
const found = text.index_of(candidate.open)
if found >= 0 && (at < 0 || found < at) {
at = found
}
}
return at
}
/**
* Read a chat-template tool envelope out of `text`.
*
* Finds the earliest known envelope opener, delimits its span, and returns the
* calls inside it along with the prose before it and whatever trailed after.
* `{matched: false}` means no opener this module knows, which leaves the text
* to its caller rather than claiming it.
*
* `parse_body` is the caller's body ladder, used for an envelope whose body is
* not JSON. This module owns where a span starts and ends; it does not own what
* grammar the body is written in, and the callback is how that stays true
* without an import cycle.
*
* @effects: []
* @errors: []
* @api_stability: experimental
*/
pub fn tool_parse_envelope(text: string, parse_body = nil) -> dict {
let opener = ""
let kind = ""
let close_tag = ""
let at = -1
for candidate in ENVELOPE_OPENERS {
const found = text.index_of(candidate.open)
if found >= 0 && (at < 0 || found < at) {
at = found
opener = candidate.open
kind = candidate.kind
close_tag = candidate.close
}
}
if at < 0 {
return {matched: false}
}
const prose = text.slice(0, at)
const raw_body = text.slice(at + len(opener), len(text))
// A `<tool_call>` opener is only this module's business when it wraps a JSON
// body. Requiring the body to START with `{` also rejected the common shape
// where the model labels the block first, so a complete call was discarded
// with the label as the only discriminator.
const unwrapped = if kind == "tool_call" {
tool_call_label_body(raw_body)
} else {
{ok: true, body: raw_body, name_hint: ""}
}
// A body this module cannot read as JSON is not thereby a non-envelope. The
// opener already said what the span is; the body's grammar is the caller's
// ladder to judge.
// `<tool_call>` is excluded on purpose: the tagged lane owns that spelling and
// reads its heredoc bodies far better than a single-call ladder can, so
// declining here is what routes it home. The alternates have no better owner.
const direct = if (unwrapped?.ok ?? false) || opener == "<tool_call>" {
{matched: false}
} else {
__tool_parse_envelope_direct_body(raw_body, close_tag, prose, parse_body)
}
if !(unwrapped?.ok ?? false) && !(direct?.matched ?? false) {
return {matched: false}
}
const body = to_string(unwrapped?.body ?? "")
const name_hint = to_string(unwrapped?.name_hint ?? "")
let parsed = if direct?.matched ?? false {
direct
} else if kind == "tool_calls" {
const leading = trim(body)
if starts_with(leading, "<tool>") || starts_with(leading, "{")
|| starts_with(leading, "</tool>") {
__tool_parse_envelope_markers(body, prose)
} else {
__tool_parse_envelope_xml(body, prose)
}
} else {
__tool_parse_envelope_json_list(kind, body, close_tag, prose, name_hint)
}
if parsed?.result != nil {
return parsed
}
const violation = protocol_violation(
"wrong_tool_format",
"protocol_violation: a tool call was emitted in a chat-template tool envelope "
+ "while `tool_format` is `json`; accepted this turn, but emit canonical "
+ "```tool JSON blocks next turn.",
)
parsed = parsed
+ {result: provider_result(parsed.calls, [], parsed.prose, [violation])}
return parsed
}