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
}
}
}
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);
}
}
#[test]
fn role_display_and_from_str_agree() {
for role in [Role::User, Role::Assistant] {
assert_eq!(role.to_string().parse::<Role>().unwrap(), role);
}
}
}