pub mod anthropic;
pub mod config;
pub mod delegate;
pub mod files;
pub mod openai;
pub mod sse;
pub mod tools;
use serde::Serialize;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Mask {
Read,
Write,
Build,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Protocol {
OpenAi,
Anthropic,
ClaudeCli,
CodexCli,
}
impl Protocol {
pub fn is_delegate(self) -> bool {
matches!(self, Protocol::ClaudeCli | Protocol::CodexCli)
}
}
#[derive(Debug, Clone)]
pub struct Provider {
pub protocol: Protocol,
pub base_url: String,
pub model: String,
pub key: Option<String>,
pub source: String,
}
#[derive(Debug, Clone)]
pub struct RunOptions {
pub max_turns: usize,
pub max_tokens: u32,
pub mask: Mask,
pub delegate_cwd: Option<std::path::PathBuf>,
}
impl Default for RunOptions {
fn default() -> Self {
Self {
max_turns: 15,
max_tokens: 4096,
mask: Mask::Read,
delegate_cwd: None,
}
}
}
#[derive(Debug, Clone)]
pub enum Block {
Text(String),
Thinking {
text: String,
signature: Option<String>,
},
ToolUse {
id: String,
name: String,
args: serde_json::Value,
raw_args: Option<String>,
},
ToolResult {
id: String,
name: String,
content: String,
is_error: bool,
},
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Role {
User,
Assistant,
}
#[derive(Debug, Clone)]
pub struct Msg {
pub role: Role,
pub blocks: Vec<Block>,
}
impl Msg {
pub fn user(text: impl Into<String>) -> Self {
Self {
role: Role::User,
blocks: vec![Block::Text(text.into())],
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Stop {
EndTurn,
ToolUse,
Length,
}
#[derive(Debug)]
pub struct Turn {
pub blocks: Vec<Block>,
pub stop: Stop,
pub usage: Option<(u64, u64)>,
}
#[derive(Debug, Clone, Serialize)]
#[serde(tag = "event", rename_all = "snake_case")]
pub enum Event {
ThinkingDelta {
text: String,
},
TextDelta {
text: String,
},
ToolCall {
id: String,
name: String,
args: serde_json::Value,
display: String,
},
ToolResult {
id: String,
name: String,
content: String,
is_error: bool,
},
Usage {
input: u64,
output: u64,
},
TurnEnd {
stop: String,
},
Done {
text: String,
turns: usize,
},
Error {
message: String,
},
}
#[derive(Debug, Clone)]
pub struct ToolOutcome {
pub content: String,
pub is_error: bool,
}
#[derive(Debug, thiserror::Error)]
pub enum HarnessError {
#[error("{0}")]
Config(String),
#[error("{0}")]
Provider(String),
}
pub trait ToolExecutor {
fn execute(&mut self, name: &str, args: &serde_json::Value) -> ToolOutcome;
fn describe(&self, name: &str, args: &serde_json::Value) -> String {
let _ = args;
name.to_string()
}
}
pub const TOOL_RESULT_CAP: usize = 24_000;
pub fn truncate_tool_result(content: &str) -> String {
if content.len() <= TOOL_RESULT_CAP {
return content.to_string();
}
let head_end = floor_char_boundary(content, TOOL_RESULT_CAP * 2 / 3);
let tail_start = ceil_char_boundary(content, content.len() - TOOL_RESULT_CAP / 4);
format!(
"{}\n[... truncated {} bytes — narrow the request (limit/type/path filters) and retry ...]\n{}",
&content[..head_end],
content.len() - head_end - (content.len() - tail_start),
&content[tail_start..]
)
}
fn floor_char_boundary(s: &str, mut at: usize) -> usize {
at = at.min(s.len());
while at > 0 && !s.is_char_boundary(at) {
at -= 1;
}
at
}
fn ceil_char_boundary(s: &str, mut at: usize) -> usize {
at = at.min(s.len());
while at < s.len() && !s.is_char_boundary(at) {
at += 1;
}
at
}
pub fn run(
provider: &Provider,
opts: &RunOptions,
system: &str,
mut messages: Vec<Msg>,
tool_specs: &[tools::ToolSpec],
executor: &mut dyn ToolExecutor,
emit: &mut dyn FnMut(Event),
) -> Result<String, HarnessError> {
if provider.protocol.is_delegate() {
return delegate::run(provider, opts, system, &messages, emit);
}
let mut turns = 0usize;
let mut final_text = String::new();
loop {
turns += 1;
let capped = turns > opts.max_turns;
if capped {
messages.push(Msg::user(
"[The tool-call limit for this run was reached. Answer now with \
what you already have; do not request more tools.]",
));
}
let specs: &[tools::ToolSpec] = if capped { &[] } else { tool_specs };
let turn = match provider.protocol {
Protocol::OpenAi => openai::stream_turn(provider, opts, system, &messages, specs, emit),
Protocol::Anthropic => {
anthropic::stream_turn(provider, opts, system, &messages, specs, emit)
}
Protocol::ClaudeCli | Protocol::CodexCli => unreachable!("delegates handled above"),
};
let turn = match turn {
Ok(turn) => turn,
Err(error) => {
emit(Event::Error {
message: error.to_string(),
});
return Err(error);
}
};
if let Some((input, output)) = turn.usage {
emit(Event::Usage { input, output });
}
emit(Event::TurnEnd {
stop: match turn.stop {
Stop::EndTurn => "end_turn",
Stop::ToolUse => "tool_use",
Stop::Length => "length",
}
.to_string(),
});
for block in &turn.blocks {
if let Block::Text(text) = block {
if !final_text.is_empty() {
final_text.push('\n');
}
final_text.push_str(text);
}
}
let calls: Vec<(String, String, serde_json::Value, Option<String>)> = turn
.blocks
.iter()
.filter_map(|block| match block {
Block::ToolUse {
id,
name,
args,
raw_args,
} => Some((id.clone(), name.clone(), args.clone(), raw_args.clone())),
_ => None,
})
.collect();
messages.push(Msg {
role: Role::Assistant,
blocks: turn.blocks,
});
if calls.is_empty() || capped {
let text = final_text.trim().to_string();
emit(Event::Done {
text: text.clone(),
turns,
});
return Ok(text);
}
final_text.clear();
let mut results: Vec<Block> = Vec::with_capacity(calls.len());
for (id, name, args, raw_args) in calls {
emit(Event::ToolCall {
id: id.clone(),
name: name.clone(),
args: args.clone(),
display: executor.describe(&name, &args),
});
let outcome = if raw_args.as_deref().is_some_and(|raw| {
!raw.trim().is_empty() && serde_json::from_str::<serde_json::Value>(raw).is_err()
}) {
ToolOutcome {
content: format!(
"tool arguments were not valid JSON; re-issue the call. raw: {}",
truncate_tool_result(raw_args.as_deref().unwrap_or_default())
),
is_error: true,
}
} else {
executor.execute(&name, &args)
};
emit(Event::ToolResult {
id: id.clone(),
name: name.clone(),
content: outcome.content.clone(),
is_error: outcome.is_error,
});
results.push(Block::ToolResult {
id,
name,
content: outcome.content,
is_error: outcome.is_error,
});
}
messages.push(Msg {
role: Role::User,
blocks: results,
});
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn truncation_keeps_head_and_tail_with_marker() {
let long = "a".repeat(30_000) + &"z".repeat(10_000);
let cut = truncate_tool_result(&long);
assert!(cut.len() < long.len());
assert!(cut.starts_with('a'));
assert!(cut.ends_with('z'));
assert!(cut.contains("truncated"));
}
#[test]
fn short_results_ride_verbatim() {
assert_eq!(truncate_tool_result("ok"), "ok");
}
}