Files
cassady/src/agent.rs
T
2026-06-21 09:58:05 -05:00

177 lines
5.2 KiB
Rust

use crate::access::AccessMode;
use crate::config::Config;
use crate::conversation::{now_ts, Conversation, Record, StoredToolCall};
use crate::prompt;
use crate::providers::openai_compatible::OpenAiCompatibleProvider;
use crate::providers::types::ModelMessage;
use crate::tools::{self, ToolContext};
use anyhow::Result;
use serde_json::Value;
use std::path::PathBuf;
use tokio::sync::mpsc;
#[derive(Debug, Clone)]
pub enum AgentEvent {
AssistantChunk(String),
ToolCallStarted {
id: String,
name: String,
arguments: Value,
},
ToolResult {
id: String,
name: String,
ok: bool,
content: String,
},
Status(String),
TurnFinished,
}
#[derive(Debug, Clone)]
pub struct AgentSettings {
pub config: Config,
pub cwd: PathBuf,
pub mode: AccessMode,
}
pub async fn run_turn(
mut conversation: Conversation,
user_message: String,
settings: AgentSettings,
tx: mpsc::UnboundedSender<AgentEvent>,
) -> Result<Conversation> {
conversation.append(Record::User {
content: user_message,
ts: now_ts(),
})?;
let provider = match OpenAiCompatibleProvider::new(
settings.config.model.clone(),
settings.config.base_url.clone(),
settings.config.api_key_env.clone(),
) {
Ok(p) => p,
Err(err) => {
let _ = tx.send(AgentEvent::Status(err.to_string()));
let _ = tx.send(AgentEvent::TurnFinished);
return Ok(conversation);
}
};
let tool_ctx = ToolContext {
mode: settings.mode,
cwd: settings.cwd.clone(),
read_only_root: settings.cwd.clone(),
model_result_limit: settings.config.model_tool_result_limit,
};
for _ in 0..8 {
let allowed = tools::available_tool_names(settings.mode);
let system = prompt::build_effective_system_prompt(
&conversation.base_system_prompt(),
settings.mode,
&settings.cwd,
&settings.config.model,
&allowed,
);
let messages = build_messages(
&conversation.records,
system,
settings.config.context_message_limit,
);
let completion = match provider
.complete(messages, tools::specs(settings.mode), &tx)
.await
{
Ok(c) => c,
Err(err) => {
let _ = tx.send(AgentEvent::Status(format!("provider error: {err}")));
break;
}
};
let tool_calls = completion.tool_calls.clone();
conversation.append(Record::Assistant {
content: completion.content,
tool_calls: tool_calls.clone(),
ts: now_ts(),
})?;
if tool_calls.is_empty() {
break;
}
for call in tool_calls {
let _ = tx.send(AgentEvent::ToolCallStarted {
id: call.id.clone(),
name: call.name.clone(),
arguments: call.arguments.clone(),
});
let output = tools::execute(&call.name, call.arguments.clone(), &tool_ctx).await;
let _ = tx.send(AgentEvent::ToolResult {
id: call.id.clone(),
name: call.name.clone(),
ok: output.ok,
content: output.content.clone(),
});
conversation.append(Record::Tool {
tool_call_id: call.id,
name: call.name,
ok: output.ok,
content: output.content,
ts: now_ts(),
})?;
}
}
let _ = tx.send(AgentEvent::TurnFinished);
Ok(conversation)
}
fn build_messages(records: &[Record], system: String, limit: usize) -> Vec<ModelMessage> {
let mut non_system = Vec::new();
for r in records {
match r {
Record::User { content, .. } => non_system.push(ModelMessage::User {
content: content.clone(),
}),
Record::Assistant {
content,
tool_calls,
..
} => non_system.push(ModelMessage::Assistant {
content: content.clone(),
tool_calls: tool_calls.clone(),
}),
Record::Tool {
tool_call_id,
name,
content,
..
} => non_system.push(ModelMessage::Tool {
tool_call_id: tool_call_id.clone(),
name: name.clone(),
content: content.clone(),
}),
_ => {}
}
}
if non_system.len() > limit {
non_system = non_system.split_off(non_system.len() - limit);
}
let mut messages = vec![ModelMessage::System { content: system }];
messages.extend(non_system);
repair_tool_message_prefix(messages)
}
fn repair_tool_message_prefix(mut messages: Vec<ModelMessage>) -> Vec<ModelMessage> {
// If context trimming begins with tool results, remove them because OpenAI-compatible APIs
// require tool messages to follow an assistant tool call.
while matches!(messages.get(1), Some(ModelMessage::Tool { .. })) {
messages.remove(1);
}
messages
}
#[allow(dead_code)]
fn _calls(_calls: Vec<StoredToolCall>) {}