pub mod subagent;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Role {
User,
Assistant,
}
impl std::fmt::Display for Role {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Role::User => write!(f, "user"),
Role::Assistant => write!(f, "assistant"),
}
}
}
impl std::str::FromStr for Role {
type Err = String;
fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
match s {
"user" => Ok(Role::User),
"assistant" => Ok(Role::Assistant),
_ => Err(format!("Unknown role: {}", s)),
}
}
}
#[derive(Debug, Clone, Serialize)]
pub struct Message {
pub role: Role,
pub content: Vec<ContentPart>,
}
impl Message {
pub fn user(text: &str) -> Self {
Self {
role: Role::User,
content: vec![ContentPart::Text {
text: text.to_string(),
}],
}
}
pub fn assistant(blocks: Vec<ContentPart>) -> Self {
Self {
role: Role::Assistant,
content: blocks,
}
}
pub fn tool_results(results: Vec<(String, String)>) -> Self {
Self {
role: Role::User,
content: results
.into_iter()
.map(|(tool_use_id, content)| ContentPart::ToolResult {
tool_use_id,
content,
})
.collect(),
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(tag = "type")]
pub enum ContentPart {
#[serde(rename = "text")]
Text { text: String },
#[serde(rename = "tool_use")]
ToolUse {
id: String,
name: String,
input: serde_json::Value,
},
#[serde(rename = "tool_result")]
ToolResult {
tool_use_id: String,
content: String,
},
}
pub const EXPLAIN_SYSTEM_PROMPT: &str =
"Before each tool call, state in one sentence what you are about to do and why. \
After it returns, say in one sentence what the result means. Keep the narration brief.";
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ToolDefinition {
pub name: String,
pub description: String,
pub input_schema: serde_json::Value,
}
pub fn tool_input(name: &str, raw: &str) -> serde_json::Value {
let empty = serde_json::Value::Object(Default::default());
if raw.trim().is_empty() {
return empty;
}
match serde_json::from_str::<serde_json::Value>(raw) {
Ok(serde_json::Value::Object(map)) => serde_json::Value::Object(map),
Ok(serde_json::Value::Null) => empty,
Ok(other) => {
crate::diag::warn(format!(
"tool call {}: arguments are {} rather than an object, treated as empty",
name,
kind_of(&other)
));
empty
}
Err(e) => {
crate::diag::warn(format!(
"tool call {}: arguments did not parse ({}), treated as empty. raw: {}",
name, e, raw
));
empty
}
}
}
pub fn recover_text_tool_calls(blocks: Vec<ContentPart>) -> (Vec<ContentPart>, bool) {
if blocks
.iter()
.any(|b| matches!(b, ContentPart::ToolUse { .. }))
{
return (blocks, false);
}
let mut recovered = false;
let out = blocks
.into_iter()
.enumerate()
.map(|(index, block)| match &block {
ContentPart::Text { text } => match parse_text_tool_call(text) {
Some((name, input)) => {
recovered = true;
crate::diag::warn(format!(
"recovered a tool call the model wrote as text: {}",
name
));
ContentPart::ToolUse {
id: format!("procyon_recovered_{}", index),
name,
input,
}
}
None => block,
},
_ => block,
})
.collect();
(out, recovered)
}
fn parse_text_tool_call(text: &str) -> Option<(String, serde_json::Value)> {
let mut body = text.trim();
let mut declared = false;
for (open, close) in [
("<tool_call>", "</tool_call>"),
("<tool_use>", "</tool_use>"),
] {
if let Some(inner) = body.strip_prefix(open) {
body = inner.strip_suffix(close).unwrap_or(inner).trim();
declared = true;
}
}
if let Some(inner) = body.strip_prefix("```") {
let inner = inner.strip_suffix("```").unwrap_or(inner);
body = match inner.split_once('\n') {
Some((first, rest)) if !first.trim().starts_with('{') => rest.trim(),
_ => inner.trim(),
};
}
let value: serde_json::Value = serde_json::from_str(body).ok()?;
let object = value.as_object()?;
let name = object.get("name")?.as_str()?.trim();
if name.is_empty() {
return None;
}
let arguments = ["parameters", "arguments", "input", "args"]
.iter()
.find_map(|key| object.get(*key));
let input = match arguments {
Some(value) => value.clone(),
None if declared => serde_json::Value::Object(Default::default()),
None => return None,
};
if !input.is_object() {
return None;
}
Some((name.to_string(), input))
}
const MAX_TOOL_RESULT_CHARS: usize = 32_000;
pub fn clamp_tool_result(result: String) -> String {
if result.len() <= MAX_TOOL_RESULT_CHARS {
return result;
}
let half = MAX_TOOL_RESULT_CHARS / 2;
let head_end = floor_boundary(&result, half);
let tail_start = ceil_boundary(&result, result.len() - half);
let dropped = tail_start - head_end;
format!(
"{}\n\n[... {} bytes elided by Procyon: this tool result was too large for the context \
window. Narrow the call — a more specific path, a grep, or a smaller range — if the \
middle matters. ...]\n\n{}",
&result[..head_end],
dropped,
&result[tail_start..]
)
}
fn floor_boundary(s: &str, mut at: usize) -> usize {
while at > 0 && !s.is_char_boundary(at) {
at -= 1;
}
at
}
fn ceil_boundary(s: &str, mut at: usize) -> usize {
while at < s.len() && !s.is_char_boundary(at) {
at += 1;
}
at
}
fn kind_of(value: &serde_json::Value) -> &'static str {
match value {
serde_json::Value::Null => "null",
serde_json::Value::Bool(_) => "a boolean",
serde_json::Value::Number(_) => "a number",
serde_json::Value::String(_) => "a string",
serde_json::Value::Array(_) => "an array",
serde_json::Value::Object(_) => "an object",
}
}
#[derive(Debug, Default, Clone, Copy, PartialEq)]
pub struct TokenUsage {
pub input: usize,
pub cache_read: usize,
pub cache_write: usize,
pub output: usize,
}
impl TokenUsage {
pub fn total(&self) -> usize {
self.input + self.cache_read + self.cache_write + self.output
}
}
#[derive(Debug)]
pub struct StreamOutcome {
pub blocks: Vec<ContentPart>,
#[allow(dead_code)]
pub stop_reason: Option<String>,
pub usage: Option<TokenUsage>,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn usage_total_sums_input_cache_and_output() {
let usage = TokenUsage {
input: 1200,
cache_read: 400,
cache_write: 30,
output: 915,
};
assert_eq!(usage.total(), 2545);
}
#[test]
fn tool_results_share_one_user_message() {
let msg = Message::tool_results(vec![
("id_a".to_string(), "ra".to_string()),
("id_b".to_string(), "rb".to_string()),
]);
assert_eq!(msg.role, Role::User);
assert_eq!(
msg.content.len(),
2,
"the API requires one user message holding every tool_result of a turn"
);
}
#[test]
fn content_parts_round_trip_through_serde() {
let parts = vec![
ContentPart::Text {
text: "hi".to_string(),
},
ContentPart::ToolUse {
id: "t1".to_string(),
name: "read".to_string(),
input: serde_json::json!({"path": "a.rs"}),
},
ContentPart::ToolResult {
tool_use_id: "t1".to_string(),
content: "ok".to_string(),
},
];
let written = serde_json::to_string(&parts).unwrap();
let read: Vec<ContentPart> = serde_json::from_str(&written).unwrap();
assert_eq!(read, parts);
}
#[test]
fn well_formed_arguments_pass_through() {
assert_eq!(
tool_input("read", r#"{"path":"a.rs"}"#),
serde_json::json!({"path": "a.rs"})
);
}
#[test]
fn the_zero_argument_spellings_all_yield_an_object() {
for raw in ["", " ", "{}", "null"] {
assert_eq!(
tool_input("list", raw),
serde_json::json!({}),
"raw was {:?}",
raw
);
}
}
#[test]
fn a_non_object_is_emptied_and_recorded() {
let _guard = crate::diag::test_lock();
for raw in ["[1,2]", "42", "\"text\"", "{invalid"] {
crate::diag::drain();
assert_eq!(tool_input("read", raw), serde_json::json!({}));
assert!(
!crate::diag::drain().is_empty(),
"nothing recorded for {:?}",
raw
);
}
}
#[test]
fn a_result_that_fits_is_returned_untouched() {
let small = "ok".repeat(100);
assert_eq!(clamp_tool_result(small.clone()), small);
}
#[test]
fn an_oversized_result_is_clamped_and_says_so() {
let huge = "x".repeat(MAX_TOOL_RESULT_CHARS * 3);
let clamped = clamp_tool_result(huge);
assert!(
clamped.len() < MAX_TOOL_RESULT_CHARS + 500,
"clamped to {} bytes",
clamped.len()
);
assert!(
clamped.contains("elided by Procyon"),
"the model must be able to tell truncated output from complete output"
);
}
#[test]
fn both_ends_of_an_oversized_result_survive() {
let body = format!(
"FIRST LINE\n{}\nerror: deploy failed",
"filler ".repeat(MAX_TOOL_RESULT_CHARS)
);
let clamped = clamp_tool_result(body);
assert!(clamped.starts_with("FIRST LINE"));
assert!(clamped.ends_with("error: deploy failed"));
}
#[test]
fn clamping_never_splits_a_character() {
for pad in 0..4 {
let body = format!("{}{}", "a".repeat(pad), "é".repeat(MAX_TOOL_RESULT_CHARS));
let clamped = clamp_tool_result(body);
assert!(clamped.contains("elided"), "pad {} was not clamped", pad);
}
}
fn text(body: &str) -> Vec<ContentPart> {
vec![ContentPart::Text {
text: body.to_string(),
}]
}
fn recovered_call(blocks: Vec<ContentPart>) -> Option<(String, serde_json::Value)> {
let (out, flagged) = recover_text_tool_calls(blocks);
match out.into_iter().next() {
Some(ContentPart::ToolUse { name, input, .. }) => {
assert!(flagged, "a recovered call must be reported as one");
Some((name, input))
}
_ => {
assert!(!flagged, "nothing was recovered but the flag was set");
None
}
}
}
#[test]
fn a_call_written_as_json_becomes_a_real_call() {
let (name, input) = recovered_call(text(
r#"{"name": "list_dir", "parameters": {"path": "/w/demo"}}"#,
))
.expect("recovered");
assert_eq!(name, "list_dir");
assert_eq!(input, serde_json::json!({"path": "/w/demo"}));
}
#[test]
fn every_spelling_of_the_arguments_is_understood() {
for key in ["parameters", "arguments", "input", "args"] {
let body = format!(r#"{{"name": "grep", "{}": {{"q": "x"}}}}"#, key);
let (_, input) = recovered_call(text(&body)).unwrap_or_else(|| panic!("{}", key));
assert_eq!(input, serde_json::json!({"q": "x"}), "{}", key);
}
}
#[test]
fn the_wrappers_models_put_around_a_call_are_stripped() {
for body in [
r#"<tool_call>{"name": "glob", "arguments": {}}</tool_call>"#,
"```json\n{\"name\": \"glob\", \"arguments\": {}}\n```",
"```\n{\"name\": \"glob\", \"arguments\": {}}\n```",
] {
let (name, _) = recovered_call(text(body)).unwrap_or_else(|| panic!("{}", body));
assert_eq!(name, "glob", "{}", body);
}
}
#[test]
fn a_declared_call_with_no_arguments_is_still_a_call() {
let (name, input) =
recovered_call(text(r#"<tool_call>{"name": "project_info"}</tool_call>"#))
.expect("recovered");
assert_eq!(name, "project_info");
assert_eq!(input, serde_json::json!({}));
}
#[test]
fn a_bare_object_with_only_a_name_is_not_treated_as_a_call() {
assert!(recovered_call(text(r#"{"name": "project_info"}"#)).is_none());
}
#[test]
fn an_unknown_tool_name_is_still_recovered_so_the_model_hears_back() {
let (name, _) = recovered_call(text(r#"{"name": "ls", "parameters": {}}"#)).expect("ls");
assert_eq!(name, "ls");
}
#[test]
fn a_call_described_inside_a_sentence_is_left_as_prose() {
assert!(recovered_call(text(
r#"You could call {"name": "write_file", "parameters": {"path": "a"}} to do that."#
))
.is_none());
}
#[test]
fn ordinary_prose_and_ordinary_json_are_left_alone() {
for body in [
"Os arquivos no diretório atual são: contracts/, src/.",
r#"{"path": "a.rs", "size": 12}"#,
r#"{"name": "my-app", "version": "0.1.0"}"#,
"",
"{",
] {
assert!(recovered_call(text(body)).is_none(), "recovered {:?}", body);
}
}
#[test]
fn a_file_the_model_is_quoting_is_not_a_tool_call() {
let body = "```json\n{\"name\": \"my-app\", \"scripts\": {\"dev\": \"vite\"}}\n```";
assert!(recovered_call(text(body)).is_none());
}
#[test]
fn a_real_tool_call_in_the_same_message_disables_recovery() {
let blocks = vec![
ContentPart::Text {
text: r#"{"name": "list_dir", "parameters": {}}"#.to_string(),
},
ContentPart::ToolUse {
id: "t1".to_string(),
name: "grep".to_string(),
input: serde_json::json!({}),
},
];
let (out, recovered) = recover_text_tool_calls(blocks);
assert!(!recovered);
assert!(matches!(out[0], ContentPart::Text { .. }));
}
#[test]
fn a_recovered_call_gets_an_id_a_tool_result_can_answer() {
let (out, _) = recover_text_tool_calls(text(r#"{"name": "glob", "arguments": {}}"#));
match &out[0] {
ContentPart::ToolUse { id, .. } => assert!(!id.is_empty()),
other => panic!("expected a tool call, got {:?}", other),
}
}
#[test]
fn role_display_and_from_str_agree() {
for role in [Role::User, Role::Assistant] {
assert_eq!(role.to_string().parse::<Role>().unwrap(), role);
}
}
}