Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
104 changes: 90 additions & 14 deletions helios/src/output/argument_tags.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ use serde_json::{Map, Value};
use crate::output::tagged::{OutputConfig, TaggedOutput};

pub struct ArgumentTags;

impl ToolDialect for ArgumentTags {
const NAME: &'static str = "GLM";
const TRIM_NEWLINES: bool = false;
Expand All @@ -20,47 +21,96 @@ impl ToolDialect for ArgumentTags {
let body = body.trim();
let split = body.find("<arg_key>").unwrap_or(body.len());
let name = body[..split].trim();

if name.is_empty() {
return Err("tool function name is empty".into());
}

let mut rest = &body[split..];

let function = tools
.iter()
.filter_map(|tool| tool.get("function"))
.find(|function| function.get("name").and_then(Value::as_str) == Some(name));
.find(|function| {
function.get("name").and_then(Value::as_str) == Some(name)
});

let properties = function
.and_then(|function| function.get("parameters"))
.and_then(|parameters| parameters.get("properties"))
.and_then(Value::as_object);

let mut arguments = Map::new();

while !rest.trim().is_empty() {
let key = rest
.trim_start()
.strip_prefix("<arg_key>")
.ok_or("GLM tool call requires an argument key")?;
let remaining = rest.trim_start();

let key = remaining.strip_prefix("<arg_key>").ok_or_else(|| {
let marker = if remaining.starts_with("</arg_value>") {
"closing-arg-value"
} else if remaining.starts_with("</tool_call>") {
"closing-tool-call"
} else if remaining.starts_with("<arg_value>") {
"arg-value-without-key"
} else if remaining.starts_with("<arg_key") {
"malformed-arg-key"
} else if remaining.starts_with('<') {
"unexpected-tag"
} else {
"unexpected-text"
};

format!(
"GLM tool call requires an argument key \
(remaining_bytes={}, next_marker={marker})",
remaining.len()
)
})?;

let (key, tail) = key
.split_once("</arg_key>")
.ok_or("unterminated GLM argument key")?;

if key.is_empty() || arguments.contains_key(key) {
return Err("GLM argument names must be nonempty and unique".into());
}

let value = tail
.trim_start()
.strip_prefix("<arg_value>")
.ok_or("GLM argument requires a value tag")?;

let (value, tail) = value
.split_once("</arg_value>")
.ok_or("unterminated GLM argument value")?;
arguments.insert(
key.to_owned(),
parameter_value(value, properties.and_then(|p| p.get(key)))?,
);

let schema = properties.and_then(|properties| properties.get(key));
let declared_type = schema
.and_then(|schema| schema.get("type"))
.map(Value::to_string)
.unwrap_or_else(|| "<missing-or-union>".into());

let converted = parameter_value(value, schema).map_err(|error| {
crate::FrontendError::Input(crate::Failure::caused_by(
format!(
"GLM argument '{key}' (declared type {declared_type}) \
failed conversion: {error}"
),
error,
))
})?;

arguments.insert(key.to_owned(), converted);
rest = tail;
}

Ok(vec![(
name.to_owned(),
serde_json::to_string(&arguments).map_err(|error| {
crate::FrontendError::Input(crate::Failure::caused_by(error.to_string(), error))
crate::FrontendError::Input(crate::Failure::caused_by(
error.to_string(),
error,
))
})?,
)])
}
Expand Down Expand Up @@ -93,13 +143,15 @@ mod tests {
#[test]
fn split_streams_preserve_reasoning_and_decode_typed_tool_arguments() {
let text = "\nreason α\n</think>\nAnswer\n<tool_call>run<arg_key>text</arg_key><arg_value>\nhello α\n</arg_value><arg_key>count</arg_key><arg_value>2</arg_value><arg_key>enabled</arg_key><arg_value>true</arg_value><arg_key>items</arg_key><arg_value>[1,2]</arg_value><arg_key>options</arg_key><arg_value>{\"x\":1}</arg_value><arg_key>empty</arg_key><arg_value>null</arg_value></tool_call><tool_call>run</tool_call>\nend";

for boundary in text
.char_indices()
.map(|(index, _)| index)
.chain([text.len()])
{
let mut parser = ArgumentTagOutput::new(config());
let mut output = Accumulator::new("test".into());

output
.apply(parser.push(Input::Text(&text[..boundary])).unwrap())
.unwrap();
Expand All @@ -109,19 +161,34 @@ mod tests {
output
.apply(parser.finish(Termination::Eos).unwrap())
.unwrap();

output.finish().unwrap();
let message = output.message();

assert_eq!(message["reasoning_content"], "\nreason α\n");
assert_eq!(message["content"], "\nAnswer\n\nend");

let calls = message["tool_calls"].as_array().unwrap();
assert_eq!(calls.len(), 2);
assert_eq!(calls[0]["function"]["name"], "run");
let arguments: Value =
serde_json::from_str(calls[0]["function"]["arguments"].as_str().unwrap()).unwrap();

let arguments: Value = serde_json::from_str(
calls[0]["function"]["arguments"].as_str().unwrap(),
)
.unwrap();

assert_eq!(
arguments,
json!({"text":"\nhello α\n", "count":2, "enabled":true, "items":[1,2], "options":{"x":1}, "empty":null})
json!({
"text":"\nhello α\n",
"count":2,
"enabled":true,
"items":[1,2],
"options":{"x":1},
"empty":null
})
);

assert_eq!(calls[1]["function"]["arguments"], "{}");
}
}
Expand All @@ -146,15 +213,24 @@ mod tests {
#[test]
fn truncation_and_structured_output_keep_protocol_semantics() {
let mut parser = ArgumentTagOutput::new(config());

parser
.push(Input::Text("</think><tool_call>run<arg_key>text</arg_key>"))
.unwrap();
assert!(parser.finish(Termination::OutputLimit).unwrap().is_empty());

assert!(
parser
.finish(Termination::OutputLimit)
.unwrap()
.is_empty()
);

let mut parser = ArgumentTagOutput::new(OutputConfig {
structured: true,
start: crate::output::tagged::OutputStart::Content,
..config()
});

assert_eq!(
parser
.push(Input::Text("<think>{\"x\":1}</think>"))
Expand Down
29 changes: 28 additions & 1 deletion helios/src/output/tagged.rs
Original file line number Diff line number Diff line change
Expand Up @@ -556,7 +556,7 @@ pub(crate) fn parameter_value(raw: &str, schema: Option<&Value>) -> Result<Value
_ => return Err("invalid tool parameter type declaration".into()),
};
let parsed: std::result::Result<Value, _> = serde_json::from_str(raw);
for kind in kinds {
for &kind in &kinds {
if kind == "string" {
return Ok(Value::String(raw.into()));
}
Expand All @@ -575,5 +575,32 @@ pub(crate) fn parameter_value(raw: &str, schema: Option<&Value>) -> Result<Value
}
}
}

// coercion fallback for array
if kinds.contains(&"array") {
if let Ok(Value::Array(_)) = &parsed {
return Ok(parsed.unwrap());
}
if let Ok(Value::String(s)) = &parsed {
if let Ok(Value::Array(a)) = serde_json::from_str(s) {
return Ok(Value::Array(a));
}
}
let trimmed = raw.trim();
if !trimmed.is_empty() && !trimmed.starts_with('[') {
let parts: Vec<Value> = trimmed
.split(',')
.map(|p| Value::String(p.trim().trim_matches('"').to_string()))
.filter(|v| !v.as_str().unwrap_or("").is_empty())
.collect();
if !parts.is_empty() {
return Ok(Value::Array(parts));
}
}
if !trimmed.is_empty() {
return Ok(Value::Array(vec![Value::String(trimmed.to_string())]));
}
}

Err("tool parameter value does not match its declared JSON type".into())
}