use std::collections::HashMap;
use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use super::Pricing;
use crate::quality::{Usage, parse_usage};
use llmtrim_core::compress_with_config;
use llmtrim_core::config::DenseConfig;
use llmtrim_core::ir::ProviderKind;
use llmtrim_core::memo::{self, Memo};
#[derive(Debug, Clone, Default, Serialize)]
pub struct IterUsage {
pub index: usize,
pub input_tokens: usize,
pub cached_tokens: usize,
pub output_tokens: usize,
pub tool_calls: usize,
}
#[derive(Debug, Clone, Default, Serialize)]
pub struct AgentRunResult {
pub task_id: String,
pub provider: String,
pub model: String,
pub condition: String,
pub iterations: usize,
pub completed: bool,
pub per_iter: Vec<IterUsage>,
pub input_tokens: usize,
pub cached_tokens: usize,
pub output_tokens: usize,
pub cost_usd: f64,
}
#[derive(Debug, Clone, Deserialize)]
pub struct AgentTask {
pub id: String,
pub model: String,
pub system: String,
pub user: String,
#[serde(default)]
pub tools: Value,
#[serde(default)]
pub tool_stubs: HashMap<String, Stub>,
#[serde(default = "default_stub")]
pub default_stub: String,
#[serde(default = "default_max_iter")]
pub max_iterations: usize,
#[serde(default = "default_max_tokens")]
pub max_tokens: u64,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(untagged)]
pub enum Stub {
One(String),
Seq(Vec<String>),
}
impl Stub {
fn at(&self, nth: usize) -> String {
match self {
Stub::One(s) => s.clone(),
Stub::Seq(v) if v.is_empty() => String::new(),
Stub::Seq(v) => v[nth.min(v.len() - 1)].clone(),
}
}
}
fn default_stub() -> String {
"ok".to_string()
}
fn default_max_iter() -> usize {
12
}
fn default_max_tokens() -> u64 {
512
}
impl AgentTask {
pub fn from_json(s: &str) -> Result<Self> {
serde_json::from_str(s).context("invalid agent task JSON")
}
fn stub_for(&self, name: &str, nth: usize) -> String {
self.tool_stubs
.get(name)
.map(|s| s.at(nth))
.unwrap_or_else(|| self.default_stub.clone())
}
}
pub struct ToolCall {
pub id: String,
pub name: String,
}
pub enum TurnAction {
Final(String),
Calls(Vec<ToolCall>),
}
pub struct Turn {
pub usage: Usage,
pub action: TurnAction,
pub assistant_msg: Value,
}
pub trait AgentProvider {
fn name(&self) -> &str;
fn kind(&self) -> ProviderKind;
fn initial_request(&self, task: &AgentTask) -> Value;
fn parse_turn(&self, response: &Value) -> Result<Turn>;
fn append_results(&self, body: &mut Value, assistant_msg: &Value, results: &[(String, String)]);
}
pub struct OpenAiAgent;
impl AgentProvider for OpenAiAgent {
fn name(&self) -> &str {
"openai"
}
fn kind(&self) -> ProviderKind {
ProviderKind::OpenAi
}
fn initial_request(&self, task: &AgentTask) -> Value {
json!({
"model": task.model,
"messages": [
{"role": "system", "content": task.system},
{"role": "user", "content": task.user},
],
"tools": task.tools,
"max_tokens": task.max_tokens,
"temperature": 0,
})
}
fn parse_turn(&self, response: &Value) -> Result<Turn> {
let usage = parse_usage(response);
let msg = response
.pointer("/choices/0/message")
.cloned()
.unwrap_or(Value::Null);
let calls = msg
.get("tool_calls")
.and_then(Value::as_array)
.filter(|a| !a.is_empty());
let action = match calls {
Some(calls) => TurnAction::Calls(
calls
.iter()
.map(|c| ToolCall {
id: c
.get("id")
.and_then(Value::as_str)
.unwrap_or("")
.to_string(),
name: c
.pointer("/function/name")
.and_then(Value::as_str)
.unwrap_or("")
.to_string(),
})
.collect(),
),
None => TurnAction::Final(
msg.get("content")
.and_then(Value::as_str)
.unwrap_or("")
.to_string(),
),
};
Ok(Turn {
usage,
action,
assistant_msg: msg,
})
}
fn append_results(
&self,
body: &mut Value,
assistant_msg: &Value,
results: &[(String, String)],
) {
if let Some(msgs) = body.get_mut("messages").and_then(Value::as_array_mut) {
msgs.push(assistant_msg.clone());
for (id, out) in results {
msgs.push(json!({"role": "tool", "tool_call_id": id, "content": out}));
}
}
}
}
pub enum Condition {
Baseline,
Preset(String),
}
impl Condition {
pub fn label(&self) -> String {
match self {
Condition::Baseline => "baseline".to_string(),
Condition::Preset(p) => p.clone(),
}
}
pub fn parse(s: &str) -> Self {
if s.eq_ignore_ascii_case("baseline") {
Condition::Baseline
} else {
Condition::Preset(s.to_string())
}
}
}
pub fn run_agent_loop(
provider: &dyn AgentProvider,
task: &AgentTask,
condition: &Condition,
price: &Pricing,
send: &mut dyn FnMut(&str) -> Result<Value>,
) -> Result<AgentRunResult> {
let kind = provider.kind();
let memo = Memo::with_capacity(memo::DEFAULT_CAPACITY);
let config = match condition {
Condition::Baseline => None,
Condition::Preset(p) => {
Some(DenseConfig::preset(p).with_context(|| format!("unknown preset '{p}'"))?)
}
};
let salt = config.as_ref().map(|c| {
format!(
"{:?}|{}",
kind,
serde_json::to_string(c).unwrap_or_default()
)
});
let mut body = provider.initial_request(task);
let mut tool_call_counts: HashMap<String, usize> = HashMap::new();
let mut result = AgentRunResult {
task_id: task.id.clone(),
provider: provider.name().to_string(),
model: task.model.clone(),
condition: condition.label(),
..Default::default()
};
for i in 0..task.max_iterations {
let to_send = match (&config, &salt) {
(Some(cfg), Some(salt)) => {
let original = body.clone();
let compressed_json =
compress_with_config(&body.to_string(), Some(kind), cfg)?.request_json;
let mut compressed: Value = serde_json::from_str(&compressed_json)
.context("compressed body is not valid JSON")?;
memo::apply(&memo, salt.as_bytes(), &original, &mut compressed);
compressed
}
_ => body.clone(),
};
let response = send(&to_send.to_string())?;
let turn = provider.parse_turn(&response)?;
let u = turn.usage;
let tool_calls = match &turn.action {
TurnAction::Calls(c) => c.len(),
TurnAction::Final(_) => 0,
};
let iter = IterUsage {
index: i,
input_tokens: u.prompt_tokens.unwrap_or(0) as usize,
cached_tokens: u.cached_tokens.unwrap_or(0) as usize,
output_tokens: u.completion_tokens.unwrap_or(0) as usize,
tool_calls,
};
result.input_tokens += iter.input_tokens;
result.cached_tokens += iter.cached_tokens;
result.output_tokens += iter.output_tokens;
result.per_iter.push(iter);
match turn.action {
TurnAction::Final(_) => {
result.completed = true;
break;
}
TurnAction::Calls(calls) => {
let results: Vec<(String, String)> = calls
.iter()
.map(|c| {
let nth = tool_call_counts.entry(c.name.clone()).or_insert(0);
let out = task.stub_for(&c.name, *nth);
*nth += 1;
(c.id.clone(), out)
})
.collect();
provider.append_results(&mut body, &turn.assistant_msg, &results);
}
}
}
result.iterations = result.per_iter.len();
result.cost_usd = price.cost_cached(
result.input_tokens,
result.cached_tokens,
result.output_tokens,
);
Ok(result)
}
pub fn dry_run_transport(tool_turns: usize) -> impl FnMut(&str) -> Result<Value> {
let mut prev: Option<String> = None;
let mut turn = 0usize;
move |request_json: &str| {
let prompt = request_json.chars().count() / 4;
let cached = prev
.as_deref()
.map(|p| common_prefix_chars(p, request_json) / 4)
.unwrap_or(0)
.min(prompt);
prev = Some(request_json.to_string());
let body: Value = serde_json::from_str(request_json).unwrap_or(Value::Null);
let first_tool = body
.pointer("/tools/0/function/name")
.and_then(Value::as_str)
.unwrap_or("noop")
.to_string();
let message = if turn < tool_turns {
json!({
"role": "assistant", "content": Value::Null,
"tool_calls": [{
"id": format!("call_{turn}"), "type": "function",
"function": {"name": first_tool, "arguments": "{}"}
}]
})
} else {
json!({"role": "assistant", "content": "done"})
};
turn += 1;
Ok(json!({
"choices": [{"message": message}],
"usage": {
"prompt_tokens": prompt,
"completion_tokens": 8,
"prompt_tokens_details": {"cached_tokens": cached}
}
}))
}
}
fn common_prefix_chars(a: &str, b: &str) -> usize {
a.chars().zip(b.chars()).take_while(|(x, y)| x == y).count()
}
#[cfg(test)]
mod tests {
use super::*;
fn task() -> AgentTask {
AgentTask::from_json(
r#"{
"id": "demo", "model": "gpt-4o-mini",
"system": "You are a coding agent.",
"user": "read the file then summarize it",
"tools": [
{"type":"function","function":{"name":"read_file","description":"read a file","parameters":{"type":"object","properties":{"path":{"type":"string"}}}}},
{"type":"function","function":{"name":"grep","description":"search","parameters":{"type":"object","properties":{"q":{"type":"string"}}}}}
],
"tool_stubs": {"read_file": "fn main() {}"},
"max_iterations": 6
}"#,
)
.unwrap()
}
fn price() -> Pricing {
Pricing {
input_per_1k: 0.15 / 1000.0 * 1000.0,
output_per_1k: 0.6,
cache_per_1k: 0.0375,
}
}
#[test]
fn loop_runs_to_completion_and_records_per_iteration() {
let t = task();
let mut send = dry_run_transport(2); let r =
run_agent_loop(&OpenAiAgent, &t, &Condition::Baseline, &price(), &mut send).unwrap();
assert_eq!(r.iterations, 3, "two tool rounds + one final answer");
assert!(r.completed, "reached a final answer");
assert_eq!(r.per_iter.len(), 3);
assert_eq!(r.per_iter[0].tool_calls, 1);
assert_eq!(r.per_iter[2].tool_calls, 0, "final turn has no tool call");
assert!(r.input_tokens > 0 && r.output_tokens > 0);
assert_eq!(r.condition, "baseline");
}
#[test]
fn caps_at_max_iterations_when_never_final() {
let t = task();
let mut send = dry_run_transport(usize::MAX); let r =
run_agent_loop(&OpenAiAgent, &t, &Condition::Baseline, &price(), &mut send).unwrap();
assert_eq!(r.iterations, t.max_iterations, "stops at the cap");
assert!(!r.completed, "never reached a final answer");
}
#[test]
fn preset_condition_compresses_and_stays_valid() {
let t = task();
let mut send = dry_run_transport(2);
let r = run_agent_loop(
&OpenAiAgent,
&t,
&Condition::Preset("agent".to_string()),
&price(),
&mut send,
)
.unwrap();
assert!(r.completed && r.iterations == 3);
assert_eq!(r.condition, "agent");
}
#[test]
fn sequential_stub_advances_then_clamps() {
let t = AgentTask::from_json(
r#"{"id":"s","model":"m","system":"s","user":"u",
"tool_stubs":{"run_tests":["FAIL","PASS"],"read_file":"x"}}"#,
)
.unwrap();
assert_eq!(t.stub_for("run_tests", 0), "FAIL");
assert_eq!(t.stub_for("run_tests", 1), "PASS");
assert_eq!(
t.stub_for("run_tests", 5),
"PASS",
"sequence clamps to its last entry"
);
assert_eq!(
t.stub_for("read_file", 3),
"x",
"single stub ignores the call index"
);
assert_eq!(t.stub_for("unknown", 0), "ok", "falls back to default_stub");
}
#[test]
fn unknown_preset_is_an_error() {
let t = task();
let mut send = dry_run_transport(1);
assert!(
run_agent_loop(
&OpenAiAgent,
&t,
&Condition::Preset("nope".to_string()),
&price(),
&mut send
)
.is_err()
);
}
}