use std::collections::BTreeMap;
use std::rc::Rc;
use super::vm_value_to_json;
use crate::value::{VmError, VmValue};
pub(crate) fn build_assistant_tool_message(
text: &str,
tool_calls: &[serde_json::Value],
provider: &str,
) -> serde_json::Value {
match provider {
"openai" | "openrouter" => {
let calls: Vec<serde_json::Value> = tool_calls
.iter()
.map(|tc| {
serde_json::json!({
"id": tc["id"],
"type": "function",
"function": {
"name": tc["name"],
"arguments": serde_json::to_string(&tc["arguments"]).unwrap_or_default(),
}
})
})
.collect();
let mut msg = serde_json::json!({
"role": "assistant",
"tool_calls": calls,
});
if !text.is_empty() {
msg["content"] = serde_json::json!(text);
}
msg
}
_ => {
let mut content = Vec::new();
if !text.is_empty() {
content.push(serde_json::json!({"type": "text", "text": text}));
}
for tc in tool_calls {
content.push(serde_json::json!({
"type": "tool_use",
"id": tc["id"],
"name": tc["name"],
"input": tc["arguments"],
}));
}
serde_json::json!({"role": "assistant", "content": content})
}
}
}
pub(crate) fn build_assistant_response_message(
text: &str,
blocks: &[serde_json::Value],
tool_calls: &[serde_json::Value],
provider: &str,
) -> serde_json::Value {
if !tool_calls.is_empty() {
return build_assistant_tool_message(text, tool_calls, provider);
}
if !blocks.is_empty() {
return serde_json::json!({
"role": "assistant",
"content": blocks,
});
}
serde_json::json!({
"role": "assistant",
"content": text,
})
}
pub(crate) fn build_tool_result_message(
tool_call_id: &str,
result: &str,
provider: &str,
) -> serde_json::Value {
match provider {
"openai" | "openrouter" => {
serde_json::json!({
"role": "tool",
"tool_call_id": tool_call_id,
"content": result,
})
}
_ => {
serde_json::json!({
"role": "user",
"content": [{
"type": "tool_result",
"tool_use_id": tool_call_id,
"content": result,
}]
})
}
}
}
pub(crate) fn normalize_tool_args(name: &str, args: &serde_json::Value) -> serde_json::Value {
let mut obj = match args.as_object() {
Some(o) => o.clone(),
None => return args.clone(),
};
if name == "edit" {
if !obj.contains_key("action") {
if let Some(v) = obj.remove("mode").or_else(|| obj.remove("command")) {
obj.insert("action".to_string(), v);
}
}
let action = obj
.get("action")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
if action == "patch" || action == "replace" {
if !obj.contains_key("old_string") {
if let Some(v) = obj.remove("find") {
obj.insert("old_string".to_string(), v);
}
}
if !obj.contains_key("new_string") {
if let Some(v) = obj.remove("content") {
obj.insert("new_string".to_string(), v);
}
}
}
if !obj.contains_key("path") {
if let Some(v) = obj.remove("file") {
obj.insert("path".to_string(), v);
}
}
}
if name == "run" || name == "exec" {
if !obj.contains_key("command") {
if let Some(v) = obj.remove("args").or_else(|| obj.remove("argv")) {
obj.insert("command".to_string(), v);
}
}
let command_value = obj.get("command").cloned();
let args_value = obj
.get("args")
.cloned()
.or_else(|| obj.get("argv").cloned());
if let Some(command) = normalize_run_command(command_value.as_ref(), args_value.as_ref()) {
obj.insert("command".to_string(), serde_json::json!(command));
}
obj.remove("args");
obj.remove("argv");
}
serde_json::Value::Object(obj)
}
fn normalize_run_command(
command_value: Option<&serde_json::Value>,
fallback_value: Option<&serde_json::Value>,
) -> Option<String> {
let command_parts = command_value
.and_then(run_command_tokens)
.unwrap_or_default();
let fallback_parts = fallback_value
.and_then(run_command_tokens)
.unwrap_or_default();
let parts = if command_parts.is_empty() {
fallback_parts
} else if fallback_parts.is_empty() {
command_parts
} else {
let mut combined = fallback_parts;
combined.extend(command_parts);
combined
};
if parts.is_empty() {
None
} else {
Some(parts.join(" "))
}
}
fn run_command_tokens(value: &serde_json::Value) -> Option<Vec<String>> {
match value {
serde_json::Value::Array(parts) => {
let tokens = parts
.iter()
.filter_map(|part| part.as_str())
.map(|part| part.trim().to_string())
.filter(|part| !part.is_empty())
.collect::<Vec<_>>();
(!tokens.is_empty()).then_some(tokens)
}
serde_json::Value::String(text) => run_command_tokens_from_str(text),
_ => None,
}
}
fn run_command_tokens_from_str(text: &str) -> Option<Vec<String>> {
let trimmed = text.trim();
if trimmed.is_empty() {
return None;
}
if trimmed.starts_with('[') && trimmed.ends_with(']') {
if let Ok(parts) = serde_json::from_str::<Vec<String>>(trimmed) {
let tokens = parts
.into_iter()
.map(|part| part.trim().to_string())
.filter(|part| !part.is_empty())
.collect::<Vec<_>>();
if !tokens.is_empty() {
return Some(tokens);
}
}
}
if (trimmed.contains('[') || trimmed.contains(']') || trimmed.contains("\\\""))
&& trimmed.contains('"')
{
let tokens = extract_quoted_tokens(trimmed);
if !tokens.is_empty() {
return Some(tokens);
}
}
Some(vec![trimmed.to_string()])
}
fn extract_quoted_tokens(text: &str) -> Vec<String> {
let mut tokens = Vec::new();
let mut current = String::new();
let mut in_quote = false;
let mut escape = false;
for ch in text.chars() {
if !in_quote {
if ch == '"' {
in_quote = true;
current.clear();
}
continue;
}
if escape {
current.push(ch);
escape = false;
continue;
}
match ch {
'\\' => escape = true,
'"' => {
if !current.trim().is_empty() {
tokens.push(current.trim().to_string());
}
current.clear();
in_quote = false;
}
_ => current.push(ch),
}
}
tokens
}
pub(crate) fn handle_tool_locally(name: &str, args: &serde_json::Value) -> Option<String> {
match name {
"read_file" | "read" => {
let path = args
.get("path")
.or_else(|| args.get("name"))
.and_then(|v| v.as_str())
.unwrap_or("");
if path.is_empty() {
return Some("Error: missing path parameter".to_string());
}
match std::fs::read_to_string(path) {
Ok(content) => {
let numbered: String = content
.lines()
.enumerate()
.map(|(i, line)| format!("{}\t{}", i + 1, line))
.collect::<Vec<_>>()
.join("\n");
Some(numbered)
}
Err(e) => Some(format!("Error: cannot read file '{}': {}", path, e)),
}
}
"list_directory" => {
let path = args.get("path").and_then(|v| v.as_str()).unwrap_or(".");
match std::fs::read_dir(path) {
Ok(entries) => {
let mut names: Vec<String> = entries
.filter_map(|e| e.ok())
.map(|e| {
let name = e.file_name().to_string_lossy().to_string();
if e.path().is_dir() {
format!("{}/", name)
} else {
name
}
})
.collect();
names.sort();
Some(names.join("\n"))
}
Err(e) => Some(format!("Error: cannot list directory '{}': {}", path, e)),
}
}
_ => None,
}
}
fn extract_params_from_vm_dict(td: &BTreeMap<String, VmValue>) -> Vec<(String, String, String)> {
let mut params = Vec::new();
if let Some(VmValue::Dict(pd)) = td.get("parameters") {
for (pname, pval) in pd.iter() {
if let VmValue::Dict(pdef) = pval {
let ptype = pdef
.get("type")
.map(|v| v.display())
.unwrap_or_else(|| "str".to_string());
let pdesc = pdef
.get("description")
.map(|v| v.display())
.unwrap_or_default();
params.push((pname.clone(), ptype, pdesc));
} else {
params.push((pname.clone(), "str".to_string(), pval.display()));
}
}
}
params
}
pub(crate) fn build_text_tool_prompt(tools_val: Option<&VmValue>, include_format: bool) -> String {
let mut prompt = String::from("\n\n## Available tools\n\n");
type ToolSchema = (String, String, Vec<(String, String, String)>);
let schemas: Vec<ToolSchema> = match tools_val {
Some(VmValue::List(list)) => list
.iter()
.filter_map(|v| match v {
VmValue::Dict(td) => {
let name = td.get("name")?.display();
let desc = td
.get("description")
.map(|v| v.display())
.unwrap_or_default();
let params = extract_params_from_vm_dict(td);
Some((name, desc, params))
}
_ => None,
})
.collect(),
Some(VmValue::Dict(d)) => {
if let Some(VmValue::List(tools)) = d.get("tools") {
tools
.iter()
.filter_map(|v| {
if let VmValue::Dict(td) = v {
let name = td.get("name")?.display();
let desc = td
.get("description")
.map(|v| v.display())
.unwrap_or_default();
let params = extract_params_from_vm_dict(td);
Some((name, desc, params))
} else {
None
}
})
.collect()
} else {
Vec::new()
}
}
_ => Vec::new(),
};
for (tool_name, desc, params) in &schemas {
let sig = params
.iter()
.map(|(pname, ptype, _)| format!("{pname}: {ptype}"))
.collect::<Vec<_>>()
.join(", ");
prompt.push_str(&format!("### {tool_name}({sig})\n{desc}\n"));
for (pname, _, pdesc) in params {
if !pdesc.is_empty() {
prompt.push_str(&format!("- `{pname}`: {pdesc}\n"));
}
}
prompt.push('\n');
}
prompt.push_str(
"Only the `### name(...)` headings above are tools. Parameter names like `path`, `pattern`, or `file_glob` are arguments, not standalone tools.\n\
Example: use `search(pattern=\"parser\", file_glob=\"**/*.go\")`, never `file_glob(...)`.\n\
For `run`, pass one shell command string such as `run(command=\"go test ./internal/manifest/\")`; do not pass JSON arrays unless the tool schema explicitly asks for one.\n\n",
);
if include_format {
prompt.push_str(
"\n## How to use tools\n\
To call a tool, wrap it in a fenced code block with the `call` language tag:\n\
````\n\
```call\n\
tool_name(param=\"value\", param2=\"value2\")\n\
```\n\
````\n\
For multiline string values (like file content), use triple quotes:\n\
````\n\
```call\n\
edit(action=\"create\", path=\"file.py\", content=\"\"\"\n\
line 1\n\
line 2\n\
\"\"\")\n\
```\n\
````\n\
You can make multiple tool calls in one response (each in its own block).\n\
After each call, you will see the result in a <tool_result> tag.\n\
ALWAYS read files before modifying them.\n",
);
}
prompt
}
pub(crate) fn parse_text_tool_calls(text: &str) -> Vec<serde_json::Value> {
let mut calls = Vec::new();
let mut search_from = 0;
while let Some(start_offset) = text[search_from..].find("```call") {
let after_marker = search_from + start_offset + "```call".len();
let content_start = if text.as_bytes().get(after_marker) == Some(&b'\n') {
after_marker + 1
} else {
after_marker
};
if let Some(end_offset) = text[content_start..].find("```") {
let content_end = content_start + end_offset;
let call_text = text[content_start..content_end].trim();
if let Some((name, arguments)) = parse_function_call_syntax(call_text) {
calls.push(serde_json::json!({
"id": format!("tc_{}", calls.len()),
"name": name,
"arguments": arguments,
}));
}
search_from = content_end + "```".len();
} else {
break;
}
}
calls
}
fn default_param_name(tool_name: &str, position: usize) -> &'static str {
match (tool_name, position) {
("read_file" | "read", 0) => "path",
("search", 0) => "pattern",
("search", 1) => "file_glob",
("edit", 0) => "action",
("edit", 1) => "path",
("edit", 2) => "content",
("run" | "exec", 0) => "command",
("outline" | "get_file_outline", 0) => "path",
("list_directory", 0) => "path",
("web_search", 0) => "query",
("web_fetch", 0) => "url",
("lsp_hover" | "lsp_definition" | "lsp_references", 0) => "file",
("lsp_hover" | "lsp_definition" | "lsp_references", 1) => "line",
("lsp_hover" | "lsp_definition" | "lsp_references", 2) => "col",
_ => "arg",
}
}
fn parse_function_call_syntax(text: &str) -> Option<(String, serde_json::Value)> {
let text = text.trim();
let paren_start = text.find('(')?;
let name = text[..paren_start].trim().to_string();
if name.is_empty() {
return None;
}
let args_str = text[paren_start + 1..].strip_suffix(')');
let args_str = args_str?.trim();
if args_str.is_empty() {
return Some((name, serde_json::json!({})));
}
let mut args = serde_json::Map::new();
let mut positional_index = 0usize;
for part in split_call_args(args_str) {
let part = part.trim();
if let Some(eq_pos) = part.find('=') {
let key = part[..eq_pos].trim().to_string();
let val_str = part[eq_pos + 1..].trim();
let val = if val_str.starts_with('[') && val_str.ends_with(']') {
serde_json::from_str(val_str).unwrap_or_else(|_| serde_json::json!(val_str))
} else if val_str.starts_with('{') && val_str.ends_with('}') {
serde_json::from_str(val_str).unwrap_or_else(|_| serde_json::json!(val_str))
} else if val_str.starts_with("\"\"\"")
&& val_str.ends_with("\"\"\"")
&& val_str.len() >= 6
{
let raw = &val_str[3..val_str.len() - 3];
let unescaped = raw.replace("\\\"", "\"").replace("\\\\", "\\");
serde_json::json!(unescaped)
} else if (val_str.starts_with('"') && val_str.ends_with('"'))
|| (val_str.starts_with('\'') && val_str.ends_with('\''))
{
let inner = &val_str[1..val_str.len() - 1];
let unescaped = inner
.replace("\\n", "\n")
.replace("\\t", "\t")
.replace("\\\"", "\"")
.replace("\\'", "'")
.replace("\\\\", "\\");
serde_json::json!(unescaped)
} else if val_str == "true" {
serde_json::json!(true)
} else if val_str == "false" {
serde_json::json!(false)
} else if let Ok(n) = val_str.parse::<i64>() {
serde_json::json!(n)
} else {
serde_json::json!(val_str)
};
args.insert(key, val);
} else if !part.is_empty() {
let key = default_param_name(&name, positional_index).to_string();
let val = if part.starts_with('[') && part.ends_with(']') {
serde_json::from_str(part).unwrap_or_else(|_| serde_json::json!(part))
} else if part.starts_with('{') && part.ends_with('}') {
serde_json::from_str(part).unwrap_or_else(|_| serde_json::json!(part))
} else if part.starts_with("\"\"\"") && part.ends_with("\"\"\"") && part.len() >= 6 {
let raw = &part[3..part.len() - 3];
serde_json::json!(raw.replace("\\\"", "\"").replace("\\\\", "\\"))
} else if (part.starts_with('"') && part.ends_with('"'))
|| (part.starts_with('\'') && part.ends_with('\''))
{
let inner = &part[1..part.len() - 1];
serde_json::json!(inner
.replace("\\n", "\n")
.replace("\\t", "\t")
.replace("\\\"", "\"")
.replace("\\'", "'")
.replace("\\\\", "\\"))
} else {
serde_json::json!(part)
};
args.insert(key, val);
positional_index += 1;
}
}
Some((name, serde_json::Value::Object(args)))
}
fn split_call_args(s: &str) -> Vec<String> {
let mut parts = Vec::new();
let mut current = String::new();
let mut in_quote = false;
let mut quote_char = '"';
let mut in_triple = false;
let mut bracket_depth = 0usize;
let mut brace_depth = 0usize;
let chars: Vec<char> = s.chars().collect();
let mut i = 0;
while i < chars.len() {
let ch = chars[i];
if !in_quote
&& i + 2 < chars.len()
&& chars[i] == '"'
&& chars[i + 1] == '"'
&& chars[i + 2] == '"'
{
if in_triple {
current.push_str("\"\"\"");
i += 3;
in_triple = false;
continue;
}
current.push_str("\"\"\"");
i += 3;
in_triple = true;
continue;
}
if in_triple {
current.push(ch);
i += 1;
continue;
}
if !in_quote && (ch == '"' || ch == '\'') {
in_quote = true;
quote_char = ch;
current.push(ch);
} else if in_quote && ch == quote_char && (i == 0 || chars[i - 1] != '\\') {
in_quote = false;
current.push(ch);
} else if !in_quote && ch == '[' {
bracket_depth += 1;
current.push(ch);
} else if !in_quote && ch == ']' {
bracket_depth = bracket_depth.saturating_sub(1);
current.push(ch);
} else if !in_quote && ch == '{' {
brace_depth += 1;
current.push(ch);
} else if !in_quote && ch == '}' {
brace_depth = brace_depth.saturating_sub(1);
current.push(ch);
} else if !in_quote && bracket_depth == 0 && brace_depth == 0 && ch == ',' {
parts.push(current.trim().to_string());
current = String::new();
} else {
current.push(ch);
}
i += 1;
}
if !current.trim().is_empty() {
parts.push(current.trim().to_string());
}
parts
}
#[cfg(test)]
mod tests {
use super::{normalize_tool_args, parse_text_tool_calls, split_call_args};
use serde_json::json;
#[test]
fn split_call_args_keeps_array_values_intact() {
let parts = split_call_args(r#"command=["ls","internal/manifest/"], timeout=30"#);
assert_eq!(
parts,
vec![r#"command=["ls","internal/manifest/"]"#, "timeout=30"]
);
}
#[test]
fn parse_text_tool_calls_supports_json_array_arguments() {
let calls = parse_text_tool_calls(
"```call\nrun(command=[\"ls\",\"internal/manifest/\"], timeout=30)\n```",
);
assert_eq!(calls.len(), 1);
assert_eq!(calls[0]["name"], json!("run"));
assert_eq!(
calls[0]["arguments"]["command"],
json!(["ls", "internal/manifest/"])
);
assert_eq!(calls[0]["arguments"]["timeout"], json!(30));
}
#[test]
fn normalize_tool_args_joins_run_command_arrays() {
let normalized =
normalize_tool_args("run", &json!({"command": ["ls", "internal/manifest/"]}));
assert_eq!(normalized["command"], json!("ls internal/manifest/"));
}
#[test]
fn normalize_tool_args_accepts_run_args_alias() {
let normalized = normalize_tool_args(
"run",
&json!({"args": ["go", "test", "./internal/manifest/"]}),
);
assert_eq!(normalized["command"], json!("go test ./internal/manifest/"));
assert!(normalized.get("args").is_none());
}
#[test]
fn normalize_tool_args_recovers_stringified_run_array() {
let normalized = normalize_tool_args(
"run",
&json!({"command": "[\"go\",\"test\",\"./internal/manifest/\"]"}),
);
assert_eq!(normalized["command"], json!("go test ./internal/manifest/"));
}
#[test]
fn normalize_tool_args_recovers_fragmented_run_array() {
let normalized = normalize_tool_args(
"run",
&json!({"command": "\"internal/manifest/\"]", "args": "[\"ls\""}),
);
assert_eq!(normalized["command"], json!("ls internal/manifest/"));
}
}
pub(crate) fn vm_tools_to_native(
tools_val: &VmValue,
provider: &str,
) -> Result<Vec<serde_json::Value>, VmError> {
let tools_list = match tools_val {
VmValue::Dict(d) => {
match d.get("tools") {
Some(VmValue::List(list)) => list.as_ref().clone(),
_ => Vec::new(),
}
}
VmValue::List(list) => list.as_ref().clone(),
_ => {
return Err(VmError::Thrown(VmValue::String(Rc::from(
"tools must be a tool_registry or a list of tool definition dicts",
))));
}
};
let mut native_tools = Vec::new();
for tool in &tools_list {
match tool {
VmValue::Dict(entry) => {
let name = entry.get("name").map(|v| v.display()).unwrap_or_default();
let description = entry
.get("description")
.map(|v| v.display())
.unwrap_or_default();
let params = entry.get("parameters").and_then(|v| v.as_dict());
let output_schema = entry.get("outputSchema").map(vm_value_to_json);
let input_schema = vm_build_json_schema(params);
match provider {
"openai" | "openrouter" => {
let mut tool = serde_json::json!({
"type": "function",
"function": {
"name": name,
"description": description,
"parameters": input_schema,
}
});
if let Some(output_schema) = output_schema.clone() {
tool["function"]["x-harn-output-schema"] = output_schema;
}
native_tools.push(tool);
}
_ => {
let mut tool = serde_json::json!({
"name": name,
"description": description,
"input_schema": input_schema,
});
if let Some(output_schema) = output_schema {
tool["x-harn-output-schema"] = output_schema;
}
native_tools.push(tool);
}
}
}
VmValue::String(_) => {
return Err(VmError::Thrown(VmValue::String(Rc::from(
"tools must be declared as tool definition dicts or a tool_registry",
))));
}
_ => {
return Err(VmError::Thrown(VmValue::String(Rc::from(
"tools must contain only tool definition dicts",
))));
}
}
}
Ok(native_tools)
}
fn vm_build_json_schema(params: Option<&BTreeMap<String, VmValue>>) -> serde_json::Value {
let mut properties = serde_json::Map::new();
let mut required = Vec::new();
if let Some(params) = params {
for (name, type_val) in params {
let type_str = type_val.display();
let json_type = match type_str.as_str() {
"int" | "integer" => "integer",
"float" | "number" => "number",
"bool" | "boolean" => "boolean",
"list" | "array" => "array",
"dict" | "object" => "object",
_ => "string",
};
properties.insert(name.clone(), serde_json::json!({"type": json_type}));
required.push(serde_json::Value::String(name.clone()));
}
}
serde_json::json!({
"type": "object",
"properties": properties,
"required": required,
"additionalProperties": false,
})
}