mod account;
mod agent;
mod anthropic;
mod app;
mod budget;
mod channels;
mod config;
mod context;
mod credentials;
mod diag;
mod llm;
mod mcp;
mod oauth;
mod openai;
mod party;
mod personas;
mod project;
mod registries;
mod session;
mod skills;
mod sse;
mod tools;
mod ui;
mod wizard;
use color_eyre::Result;
use crossterm::cursor::SetCursorStyle;
use crossterm::event::{self, Event};
use ratatui::DefaultTerminal;
use tokio::sync::mpsc;
fn set_cursor_style(style: SetCursorStyle) {
let _ = crossterm::execute!(std::io::stdout(), style);
}
const USAGE: &str = "\
procyon - development harness for Stellar and Soroban
procyon start a new session
procyon --resume resume the most recent session for this directory
procyon --resume <id> resume a specific session
procyon --sessions list sessions for this directory
procyon --authorize <name> sign in to an MCP server that requires OAuth
procyon --help
";
#[derive(Debug)]
enum Startup {
New,
Resume(Option<String>),
ListSessions,
Authorize(Option<String>),
ShowUsage,
}
fn parse_args<I: Iterator<Item = String>>(args: I) -> Startup {
let mut args = args.peekable();
match args.next().as_deref() {
None => Startup::New,
Some("--sessions") => Startup::ListSessions,
Some("--help" | "-h") => Startup::ShowUsage,
Some("--resume") => Startup::Resume(args.next()),
Some("--authorize") => Startup::Authorize(args.next()),
Some(_) => Startup::ShowUsage,
}
}
async fn resolve_startup(
startup: Startup,
cfg: &config::AppConfig,
) -> Result<Option<std::path::PathBuf>> {
let cwd = std::env::current_dir()?;
match startup {
Startup::New => Ok(None),
Startup::ShowUsage => {
print!("{}", USAGE);
std::process::exit(0);
}
Startup::ListSessions => {
let sessions = session::list(&cwd).await?;
if sessions.is_empty() {
println!("No sessions recorded for {}", cwd.display());
} else {
for (_, header) in &sessions {
println!("{} {}", header.id, header.created_at);
}
}
std::process::exit(0);
}
Startup::Authorize(which) => {
let name = which.ok_or_else(|| {
color_eyre::eyre::eyre!("--authorize needs a server name from config.toml")
})?;
let server = cfg
.mcp_servers
.iter()
.find(|s| s.name == name)
.ok_or_else(|| {
color_eyre::eyre::eyre!("No mcp_servers entry named '{}' in the config", name)
})?;
mcp::authorize(server)
.await
.map_err(|e| color_eyre::eyre::eyre!(e))?;
std::process::exit(0);
}
Startup::Resume(which) => {
let sessions = session::list(&cwd).await?;
let found = match &which {
Some(id) => sessions.into_iter().find(|(_, h)| &h.id == id),
None => sessions.into_iter().next(),
};
match found {
Some((path, _)) => Ok(Some(path)),
None => match which {
Some(id) => color_eyre::eyre::bail!("No session {} for {}", id, cwd.display()),
None => color_eyre::eyre::bail!("No session to resume in {}", cwd.display()),
},
}
}
}
}
fn main() -> Result<()> {
color_eyre::install()?;
dotenvy::dotenv().ok();
let startup = parse_args(std::env::args().skip(1));
let cfg = config::AppConfig::load()?;
let cfg = if config::AppConfig::is_first_run() {
let mut terminal = ratatui::init();
set_cursor_style(SetCursorStyle::SteadyBar);
let result = wizard::run_wizard(&mut terminal);
set_cursor_style(SetCursorStyle::DefaultUserShape);
ratatui::restore();
match result? {
Some(wizard_cfg) => wizard_cfg,
None => cfg, }
} else {
cfg
};
let resume_from = tokio::runtime::Runtime::new()?.block_on(resolve_startup(startup, &cfg))?;
let mut terminal = ratatui::init();
set_cursor_style(SetCursorStyle::SteadyBar);
let result = run(&mut terminal, cfg, resume_from);
set_cursor_style(SetCursorStyle::DefaultUserShape);
ratatui::restore();
result
}
#[tokio::main]
async fn run(
terminal: &mut DefaultTerminal,
cfg: config::AppConfig,
resume_from: Option<std::path::PathBuf>,
) -> Result<()> {
let theme = cfg.theme.clone();
let mut state = app::AppState::new();
let channels = channels::Channels::new();
let user_tx = channels.user_tx.clone();
let agent_tx = channels.agent_tx.clone();
let mut agent_rx = channels.agent_rx;
tokio::spawn(async move {
agent_task(channels.user_rx, agent_tx, cfg, resume_from).await;
});
let (input_tx, mut input_rx) = mpsc::unbounded_channel();
std::thread::spawn(move || {
while let Ok(ev) = event::read() {
if input_tx.send(ev).is_err() {
break;
}
}
});
let mut spinner = tokio::time::interval(std::time::Duration::from_millis(120));
spinner.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
loop {
terminal.draw(|frame| {
ui::render(frame, &mut state, &theme);
})?;
tokio::select! {
Some(ev) = input_rx.recv() => {
if let Event::Key(key) = ev {
if state.handle_key(key, &user_tx) {
let _ = user_tx.send(channels::UserCommand::Quit);
return Ok(());
}
}
}
Some(update) = agent_rx.recv() => {
state.handle_agent_update(update);
}
_ = spinner.tick() => {
state.tick();
}
}
}
}
async fn agent_task(
mut user_rx: mpsc::UnboundedReceiver<channels::UserCommand>,
agent_tx: mpsc::UnboundedSender<channels::AgentUpdate>,
mut cfg: config::AppConfig,
resume_from: Option<std::path::PathBuf>,
) {
let mut context_window = budget::context_window(cfg.provider, &cfg.default_model);
let mut client = match llm::LlmClient::from_config(&cfg) {
Ok(client) => Some(client),
Err(e) => {
let _ = agent_tx.send(channels::AgentUpdate::Error(e.to_string()));
None
}
};
let _ = agent_tx.send(channels::AgentUpdate::Ready {
provider: cfg.provider.to_string(),
model: cfg.default_model.clone(),
credential: client.is_some(),
});
let mut registry = tools::ToolRegistry::new();
registry.register(Box::new(tools::search::ListDirTool));
registry.register(Box::new(tools::search::GlobTool));
registry.register(Box::new(tools::search::GrepTool));
registry.register(Box::new(tools::project::ProjectInitTool));
registry.register(Box::new(tools::project::ProjectInfoTool));
registry.register(Box::new(tools::caatinga::CaatingaBuildTool));
registry.register(Box::new(tools::caatinga::CaatingaDeployTool));
registry.register(Box::new(tools::file::ReadFileTool));
registry.register(Box::new(tools::file::WriteFileTool));
registry.register(Box::new(tools::file::EditFileTool));
registry.register(Box::new(tools::invoke::CaatingaInvokeTool));
registry.register(Box::new(tools::invoke::CaatingaReadTool));
registry.register(Box::new(tools::invoke::StellarCliInvokeTool));
registry.register(Box::new(tools::caatinga::CaatingaDoctorTool));
registry.register(Box::new(tools::accounts::AccountCreateTool));
registry.register(Box::new(tools::accounts::AccountListTool));
registry.register(Box::new(tools::accounts::AccountBalanceTool));
registry.register(Box::new(tools::test::RunTestsTool));
registry.register(Box::new(tools::bindings::GenerateBindingsTool));
registry.register(Box::new(tools::docs::GenerateDocsTool));
registry.register(Box::new(tools::events::SubscribeEventsTool));
registry.register(Box::new(tools::events::FilterEventsTool));
registry.register(Box::new(tools::plugin::ListPluginsTool));
registry.register(Box::new(tools::update::CheckUpdateTool));
registry.register(Box::new(tools::skill::RunSkillTool));
registry.register(Box::new(tools::skill::ListSkillsTool));
registry.register(Box::new(tools::persona::TalkToTool));
registry.register(Box::new(tools::persona::ListPersonasTool));
registry.register(Box::new(tools::party::PartyModeTool));
let (mcp_tools, mcp_connected, mcp_problems) = mcp::load_servers(&cfg.mcp_servers).await;
for tool in mcp_tools {
if let Err(e) = registry.try_register(tool) {
let _ = agent_tx.send(channels::AgentUpdate::Status(format!("MCP {}", e)));
}
}
let _ = &mcp_connected;
for problem in &mcp_problems {
let _ = agent_tx.send(channels::AgentUpdate::Error(problem.clone()));
}
{
let statuses: Vec<channels::McpServerStatus> = cfg
.mcp_servers
.iter()
.map(|s| {
let detail = s.endpoint_label();
let connected = mcp_connected.iter().any(|line| line.contains(&s.name));
channels::McpServerStatus {
name: s.name.clone(),
connected,
detail,
}
})
.collect();
let _ = agent_tx.send(channels::AgentUpdate::McpStatus(statuses));
}
let (plugin_tools, mut plugin_warnings) = tools::plugin::load_plugin_tools();
let plugin_count = plugin_tools.len();
for tool in plugin_tools {
if let Err(e) = registry.try_register(tool) {
plugin_warnings.push(format!("Plugin {}", e));
}
}
if plugin_count > 0 {
let _ = agent_tx.send(channels::AgentUpdate::Status(format!(
"Loaded {} plugin tool(s)",
plugin_count - plugin_warnings.len()
)));
}
for warning in plugin_warnings {
let _ = agent_tx.send(channels::AgentUpdate::Status(warning));
}
let tool_defs = registry.definitions();
let config_for_subagent = std::sync::Arc::new(cfg.clone());
let spawn_agent_def = crate::agent::ToolDefinition {
name: "spawn_agent".to_string(),
description: "Spawn a sub-agent with a custom system prompt and optional tool subset. \
The sub-agent runs independently with its own LLM context and returns \
a text response. Use this to delegate tasks to specialized personas."
.to_string(),
input_schema: serde_json::json!({
"type": "object",
"properties": {
"system_prompt": {
"type": "string",
"description": "System prompt defining the sub-agent's persona and instructions"
},
"message": {
"type": "string",
"description": "The task or question for the sub-agent to handle"
},
"model": {
"type": "string",
"description": "Optional model override (e.g. 'claude-haiku')"
},
"max_tokens": {
"type": "integer",
"description": "Optional max tokens override"
},
"allowed_tools": {
"type": "array",
"items": { "type": "string" },
"description": "Optional list of tool names the sub-agent may use. If omitted, all tools except spawn_agent are available."
},
"timeout_secs": {
"type": "integer",
"description": "Optional deadline for each LLM request the sub-agent makes, in seconds (default 120)"
}
},
"required": ["system_prompt", "message"]
}),
};
let mut tool_defs_with_spawn = tool_defs;
tool_defs_with_spawn.push(spawn_agent_def);
let cwd = std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from("."));
let (mut log, mut history) = match resume_from {
Some(path) => match session::resume(&path).await {
Ok((log, messages)) => {
let _ = agent_tx.send(channels::AgentUpdate::Notice(format!(
"Resumed session {} with {} message(s).",
log.id(),
messages.len()
)));
(Some(log), messages)
}
Err(e) => {
let _ = agent_tx.send(channels::AgentUpdate::Error(format!(
"Could not resume {}: {}",
path.display(),
e
)));
(None, Vec::new())
}
},
None => match session::SessionLog::create(&cwd).await {
Ok(log) => (Some(log), Vec::new()),
Err(e) => {
let _ = agent_tx.send(channels::AgentUpdate::Error(format!(
"Running without a session log: {}",
e
)));
(None, Vec::new())
}
},
};
let mut explain = false;
let mut budget = budget::Budget::new();
while let Some(cmd) = user_rx.recv().await {
match cmd {
channels::UserCommand::SendPrompt(prompt) => {
let Some(client) = client.as_ref() else {
let _ = agent_tx.send(channels::AgentUpdate::Error(format!(
"No credential for provider {}, so the prompt was not sent. Set {} and \
restart, or switch to a local provider with `/model provider ollama`.",
cfg.provider,
cfg.key_env_var()
)));
let _ = agent_tx.send(channels::AgentUpdate::ResponseEnd);
continue;
};
let _ = agent_tx.send(channels::AgentUpdate::Status("Thinking...".to_string()));
let workspace = match std::env::current_dir() {
Ok(cwd) => {
let ctx = context::WorkspaceContext::gather(&cwd, &mcp_connected).await;
{
let mcp_statuses: Vec<channels::McpServerStatus> = cfg
.mcp_servers
.iter()
.map(|s| {
let detail = s.endpoint_label();
let connected =
mcp_connected.iter().any(|line| line.contains(&s.name));
channels::McpServerStatus {
name: s.name.clone(),
connected,
detail,
}
})
.collect();
let snap = channels::WorkspaceSnapshot {
project_name: ctx
.project
.as_ref()
.map(|p| p.name.clone())
.unwrap_or_else(|| "No project".to_string()),
contract_name: ctx
.project
.as_ref()
.and_then(|p| p.contracts.first().map(|c| c.name.clone())),
network: ctx
.project
.as_ref()
.map(|p| p.default_network.to_string())
.unwrap_or_else(|| cfg.default_network.clone()),
account: ctx
.accounts
.first()
.map(|a| a.split(' ').next().unwrap_or("None").to_string())
.unwrap_or_else(|| "None".to_string()),
mcp_servers: mcp_statuses,
};
let _ = agent_tx.send(channels::AgentUpdate::Workspace(snap));
}
Some(ctx.system_prompt())
}
Err(e) => {
let _ = agent_tx.send(channels::AgentUpdate::Error(format!(
"Cannot determine the working directory: {}",
e
)));
None
}
};
record(&mut log, &agent_tx, session::SessionEvent::TurnStart).await;
record(
&mut log,
&agent_tx,
session::SessionEvent::UserMessage {
text: prompt.clone(),
},
)
.await;
history.push(agent::Message::user(&prompt));
let (text_tx, mut text_rx) = mpsc::unbounded_channel::<String>();
let agent_tx_clone = agent_tx.clone();
let forwarder = tokio::spawn(async move {
while let Some(text) = text_rx.recv().await {
let _ = agent_tx_clone.send(channels::AgentUpdate::ResponseChunk(text));
}
});
let mut turn_failed = false;
let mut overflow_retried = false;
let mut compaction_stalled = false;
let mut truncation_risk_warned = false;
let prompt_window = budget::usable_window(context_window, cfg.max_tokens as usize);
loop {
let system = build_system_prompt(workspace.as_deref(), explain);
let turn_tools = tools_for_provider(cfg.provider, &tool_defs_with_spawn);
budget.set_envelope(budget::price_envelope(system.as_deref(), &turn_tools));
if !compaction_stalled && budget.is_over_threshold(&history, prompt_window) {
let shrank = compact(
client,
&mut history,
&mut budget,
system.as_deref(),
&turn_tools,
budget::retain_tokens(prompt_window),
&agent_tx,
&mut log,
)
.await;
compaction_stalled = !shrank;
}
if !truncation_risk_warned
&& matches!(cfg.provider, config::Provider::Ollama)
&& budget.is_over_threshold(&history, prompt_window)
{
truncation_risk_warned = true;
let _ = agent_tx.send(channels::AgentUpdate::Status(format!(
"Warning: the system prompt and {} tool definitions already exceed \
Ollama's context window with nothing left to trim. Ollama truncates \
silently rather than erroring, so this response may be based on an \
incomplete prompt.",
turn_tools.len()
)));
}
barrier(&mut log, &agent_tx).await;
let outcome = match client
.send_message_streaming(
&history,
Some(&turn_tools),
system.as_deref(),
&text_tx,
)
.await
{
Ok(outcome) => outcome,
Err(e) => {
if is_context_overflow(&e) && !overflow_retried {
overflow_retried = true;
let _ = agent_tx.send(channels::AgentUpdate::Status(
"Context window exceeded, compacting and retrying.".to_string(),
));
let shrank = compact(
client,
&mut history,
&mut budget,
system.as_deref(),
&turn_tools,
budget::retain_tokens(prompt_window),
&agent_tx,
&mut log,
)
.await;
if shrank {
continue;
}
let _ = agent_tx.send(channels::AgentUpdate::Error(format!(
"Context window exceeded and the history could not be \
shrunk, so the request was not retried: {}",
e
)));
turn_failed = true;
break;
}
let _ = agent_tx.send(channels::AgentUpdate::Error(e.to_string()));
turn_failed = true;
break;
}
};
overflow_retried = false;
let usage = outcome.usage;
let blocks = outcome.blocks;
if blocks.is_empty() {
break;
}
record(
&mut log,
&agent_tx,
session::SessionEvent::AssistantMessage {
blocks: blocks.clone(),
},
)
.await;
history.push(agent::Message::assistant(blocks.clone()));
if let Some(usage) = usage {
budget.anchor(usage.total(), &history);
}
let tool_uses: Vec<_> = blocks
.iter()
.filter_map(|b| match b {
agent::ContentPart::ToolUse { id, name, input } => {
Some((id.clone(), name.clone(), input.clone()))
}
_ => None,
})
.collect();
if tool_uses.is_empty() {
break;
}
let mut results = Vec::with_capacity(tool_uses.len());
for (id, name, input) in tool_uses {
let _ = agent_tx.send(channels::AgentUpdate::Status(format!(
"Using tool: {}",
name
)));
record(
&mut log,
&agent_tx,
session::SessionEvent::ToolCall {
id: id.clone(),
name: name.clone(),
},
)
.await;
barrier(&mut log, &agent_tx).await;
let outcome = if name == "spawn_agent" {
handle_spawn_agent(&config_for_subagent, ®istry, input).await
} else {
registry.execute(&name, input).await
};
let is_error = outcome.is_err();
let result_str = agent::clamp_tool_result(match outcome {
Ok(r) => r,
Err(e) => format!("Error: {}", e),
});
record(
&mut log,
&agent_tx,
session::SessionEvent::ToolResult {
id: id.clone(),
content: result_str.clone(),
is_error,
},
)
.await;
results.push((id, result_str));
}
history.push(agent::Message::tool_results(results));
}
drop(text_tx);
let _ = forwarder.await;
record(
&mut log,
&agent_tx,
session::SessionEvent::TurnEnd {
reason: if turn_failed {
session::TurnEnd::Failed
} else {
session::TurnEnd::Complete
},
},
)
.await;
barrier(&mut log, &agent_tx).await;
let _ = agent_tx.send(channels::AgentUpdate::ResponseEnd);
}
channels::UserCommand::SetExplain(enabled) => {
explain = enabled;
budget.invalidate();
}
channels::UserCommand::SwitchModel { provider, model } => {
let mut new_cfg = cfg.clone();
new_cfg.provider = provider;
new_cfg.default_model = model.clone();
match llm::LlmClient::from_config(&new_cfg) {
Ok(new_client) => {
client = Some(new_client);
cfg.provider = provider;
cfg.default_model = model.clone();
context_window = budget::context_window(provider, &model);
budget.invalidate();
if let Err(e) = cfg.save() {
let _ = agent_tx.send(channels::AgentUpdate::Error(format!(
"Switched, but failed to save it to config.toml: {}",
e
)));
}
let _ = agent_tx.send(channels::AgentUpdate::Ready {
provider: provider.to_string(),
model,
credential: true,
});
}
Err(e) => {
let _ = agent_tx.send(channels::AgentUpdate::Error(format!(
"Failed to switch model: {}",
e
)));
let _ = agent_tx.send(channels::AgentUpdate::Ready {
provider: cfg.provider.to_string(),
model: cfg.default_model.clone(),
credential: client.is_some(),
});
}
}
}
channels::UserCommand::InstallStellarBuild => {
if !cfg!(unix) {
let _ = agent_tx.send(channels::AgentUpdate::Error(format!(
"Stellar Build's installer is a shell script and only runs on Unix-like \
systems. Install it manually from {}",
channels::STELLAR_BUILD_INSTALL_URL
)));
continue;
}
let _ = agent_tx.send(channels::AgentUpdate::Status(format!(
"Downloading {}",
channels::STELLAR_BUILD_INSTALL_URL
)));
let script = reqwest::Client::new()
.get(channels::STELLAR_BUILD_INSTALL_URL)
.send()
.await
.and_then(|r| r.error_for_status());
let script = match script {
Ok(resp) => resp.text().await,
Err(e) => Err(e),
};
let script = match script {
Ok(s) => s,
Err(e) => {
let _ = agent_tx.send(channels::AgentUpdate::Error(format!(
"Failed to download the Stellar Build installer: {}",
e
)));
continue;
}
};
let _ = agent_tx.send(channels::AgentUpdate::Status(
"Running the Stellar Build installer...".to_string(),
));
match run_shell_script(&script).await {
Ok(output) if output.status.success() => {
let _ = agent_tx.send(channels::AgentUpdate::Status(
"Stellar Build installed. Restart Procyon to pick up the new \
personas."
.to_string(),
));
}
Ok(output) => {
let _ = agent_tx.send(channels::AgentUpdate::Error(format!(
"Stellar Build's installer exited with {}: {}",
output.status,
String::from_utf8_lossy(&output.stderr).trim()
)));
}
Err(e) => {
let _ = agent_tx.send(channels::AgentUpdate::Error(format!(
"Failed to run the Stellar Build installer: {}",
e
)));
}
}
}
channels::UserCommand::ChangeProject(name) => {
let _ = agent_tx.send(channels::AgentUpdate::Status(format!(
"Project changed to: {}",
name
)));
}
channels::UserCommand::Quit => break,
}
}
}
async fn handle_spawn_agent(
config: &std::sync::Arc<config::AppConfig>,
registry: &tools::ToolRegistry,
input: serde_json::Value,
) -> Result<String, String> {
let system_prompt = input
.get("system_prompt")
.and_then(|v| v.as_str())
.ok_or_else(|| "Missing 'system_prompt' field".to_string())?
.to_string();
let message = input
.get("message")
.and_then(|v| v.as_str())
.ok_or_else(|| "Missing 'message' field".to_string())?
.to_string();
let model = input
.get("model")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
let max_tokens = input
.get("max_tokens")
.and_then(|v| v.as_u64())
.map(|n| n as u32);
let allowed_tools = input
.get("allowed_tools")
.and_then(|v| v.as_array())
.map(|arr| {
arr.iter()
.filter_map(|v| v.as_str().map(|s| s.to_string()))
.collect::<Vec<_>>()
});
let mut cfg = (**config).clone();
if let Some(m) = model {
cfg.default_model = m;
}
if let Some(t) = max_tokens {
cfg.max_tokens = t;
}
let timeout_secs = input.get("timeout_secs").and_then(|v| v.as_u64());
let subagent_config = crate::agent::subagent::SubAgentConfig {
system_prompt,
message,
model: None,
max_tokens: None,
max_rounds: None,
allowed_tools,
timeout_secs,
};
let response = crate::agent::subagent::run_subagent(&cfg, subagent_config, registry)
.await
.map_err(|e| format!("Sub-agent failed: {}", e))?;
Ok(format!(
"[Sub-agent completed in {} round trip(s)]\n\n{}",
response.round_trips, response.text
))
}
const COMPACT_INSTRUCTION: &str = "\
Summarize the conversation so far as a handoff checkpoint. Use exactly these sections:\n\
1. Primary request and intent\n\
2. Key technical concepts\n\
3. Files and code touched (with paths)\n\
4. Errors encountered and how they were fixed\n\
5. Pending work\n\
6. Current work in progress\n\
7. Next step\n\
8. Critical context worth carrying forward\n\
\n\
Be specific: keep file paths, contract ids, network names, addresses and error text verbatim. \
If the conversation already contains a <compacted-summary> block, merge it into your output \
rather than nesting it.";
const CHECKPOINT_PREAMBLE: &str =
"This conversation was compacted to fit the context window. Earlier turns are replaced by \
the checkpoint below.";
const OVERFLOW_PHRASES: &[&str] = &[
"context window",
"context_length_exceeded",
"model_context_window_exceeded",
"prompt is too long",
"prompt too long",
"input is too long",
"maximum context length",
"maximum prompt length",
"reduce the length",
"too many tokens",
"token limit exceeded",
"exceeded model token limit",
"request_too_large",
"request entity too large",
"longer than the model's context length",
"exceeds the available context size",
"greater than the context length",
];
const OVERFLOW_EXCLUSIONS: &[&str] = &["rate limit", "too many requests", "service unavailable"];
async fn run_shell_script(script: &str) -> std::io::Result<std::process::Output> {
use tokio::io::AsyncWriteExt;
let mut child = tokio::process::Command::new("bash")
.stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.spawn()?;
if let Some(mut stdin) = child.stdin.take() {
stdin.write_all(script.as_bytes()).await?;
}
child.wait_with_output().await
}
fn is_context_overflow(error: &color_eyre::Report) -> bool {
let text = error.to_string().to_lowercase();
if OVERFLOW_EXCLUSIONS
.iter()
.any(|phrase| text.contains(phrase))
{
return false;
}
OVERFLOW_PHRASES.iter().any(|phrase| text.contains(phrase))
}
fn frame_summary(summary: &str) -> agent::Message {
agent::Message::user(&format!(
"{}\n\n<compacted-summary>\n{}\n</compacted-summary>",
CHECKPOINT_PREAMBLE, summary
))
}
#[allow(clippy::too_many_arguments)]
async fn compact(
client: &llm::LlmClient,
history: &mut Vec<agent::Message>,
budget: &mut budget::Budget,
system: Option<&str>,
tool_defs: &[agent::ToolDefinition],
retain: usize,
agent_tx: &mpsc::UnboundedSender<channels::AgentUpdate>,
log: &mut Option<session::SessionLog>,
) -> bool {
let cut = match budget::select_cut(history, retain) {
budget::CutChoice::Compact(cut) => cut,
budget::CutChoice::NothingToCompact => return false,
budget::CutChoice::NoSafeCut => {
let _ = agent_tx.send(channels::AgentUpdate::Status(
"Cannot compact: no cut point leaves every tool call paired.".to_string(),
));
return false;
}
};
let _ = agent_tx.send(channels::AgentUpdate::Status(format!(
"Compacting {} of {} messages to fit the context window.",
cut,
history.len()
)));
let shadowed_tokens = budget::price_history(&history[..cut]);
let mut request = history[..cut].to_vec();
request.push(agent::Message::user(COMPACT_INSTRUCTION));
let blocks = match client.send_message(&request, Some(tool_defs), system).await {
Ok(blocks) => blocks,
Err(e) => {
let _ = agent_tx.send(channels::AgentUpdate::Error(format!(
"Compaction failed: {}",
e
)));
return false;
}
};
let summary: String = blocks
.iter()
.filter_map(|block| match block {
agent::ContentPart::Text { text } => Some(text.as_str()),
_ => None,
})
.collect::<Vec<_>>()
.join("\n");
if summary.trim().is_empty() {
let _ = agent_tx.send(channels::AgentUpdate::Error(
"Compaction produced no summary; history left unchanged.".to_string(),
));
return false;
}
let framed = frame_summary(&summary);
if budget::price_message(&framed) >= shadowed_tokens {
let _ = agent_tx.send(channels::AgentUpdate::Error(
"Compaction did not shrink the history; left unchanged.".to_string(),
));
return false;
}
let checkpoint = match &framed.content.first() {
Some(agent::ContentPart::Text { text }) => text.clone(),
_ => summary.clone(),
};
record(
log,
agent_tx,
session::SessionEvent::Compacted {
checkpoint,
replaced: cut,
},
)
.await;
history.splice(0..cut, std::iter::once(framed));
budget.invalidate();
let _ = agent_tx.send(channels::AgentUpdate::Status(format!(
"Compacted to {} messages.",
history.len()
)));
true
}
async fn record(
log: &mut Option<session::SessionLog>,
agent_tx: &mpsc::UnboundedSender<channels::AgentUpdate>,
event: session::SessionEvent,
) {
if let Some(log) = log.as_mut() {
if let Err(e) = log.append(event).await {
let _ = agent_tx.send(channels::AgentUpdate::Error(format!("Session log: {}", e)));
}
}
}
async fn barrier(
log: &mut Option<session::SessionLog>,
agent_tx: &mpsc::UnboundedSender<channels::AgentUpdate>,
) {
if let Some(log) = log.as_mut() {
if let Err(e) = log.flush().await {
let _ = agent_tx.send(channels::AgentUpdate::Error(format!("Session log: {}", e)));
}
}
}
const OLLAMA_CORE_TOOLS: &[&str] = &[
"list_dir",
"glob",
"grep",
"project_init",
"project_info",
"read_file",
"write_file",
"edit_file",
"caatinga_build",
"caatinga_deploy",
"caatinga_invoke",
"caatinga_read",
"caatinga_doctor",
"stellar_invoke",
];
fn tools_for_provider(
provider: config::Provider,
tools: &[agent::ToolDefinition],
) -> Vec<agent::ToolDefinition> {
if provider != config::Provider::Ollama {
return tools.to_vec();
}
tools
.iter()
.filter(|t| OLLAMA_CORE_TOOLS.contains(&t.name.as_str()))
.cloned()
.collect()
}
fn build_system_prompt(workspace: Option<&str>, explain: bool) -> Option<String> {
match (workspace, explain) {
(None, false) => None,
(None, true) => Some(agent::EXPLAIN_SYSTEM_PROMPT.to_string()),
(Some(base), false) => Some(base.to_string()),
(Some(base), true) => Some(format!("{}\n\n{}", base, agent::EXPLAIN_SYSTEM_PROMPT)),
}
}
#[cfg(test)]
mod tests {
use super::build_system_prompt;
use super::tools_for_provider;
use super::{parse_args, Startup};
use crate::agent;
use crate::config;
fn args(list: &[&str]) -> Startup {
parse_args(list.iter().map(|s| s.to_string()))
}
#[test]
fn no_arguments_starts_a_new_session() {
assert!(matches!(args(&[]), Startup::New));
}
#[test]
fn resume_without_an_id_means_the_latest() {
assert!(matches!(args(&["--resume"]), Startup::Resume(None)));
}
#[test]
fn resume_with_an_id_targets_that_session() {
match args(&["--resume", "20260821T120000-42"]) {
Startup::Resume(Some(id)) => assert_eq!(id, "20260821T120000-42"),
_ => panic!("expected a targeted resume"),
}
}
#[test]
fn authorize_takes_a_server_name() {
match args(&["--authorize", "raven"]) {
Startup::Authorize(Some(name)) => assert_eq!(name, "raven"),
other => panic!("expected an authorize request, got {:?}", other),
}
assert!(matches!(args(&["--authorize"]), Startup::Authorize(None)));
}
#[test]
fn sessions_and_help_are_recognised() {
assert!(matches!(args(&["--sessions"]), Startup::ListSessions));
assert!(matches!(args(&["--help"]), Startup::ShowUsage));
assert!(matches!(args(&["-h"]), Startup::ShowUsage));
}
#[test]
fn an_unknown_flag_shows_usage_rather_than_starting() {
assert!(matches!(args(&["--wat"]), Startup::ShowUsage));
}
#[test]
fn workspace_context_is_sent_even_with_explain_off() {
let prompt = build_system_prompt(Some("WORKSPACE"), false).unwrap();
assert_eq!(prompt, "WORKSPACE");
}
#[test]
fn explain_is_appended_without_dropping_the_workspace() {
let prompt = build_system_prompt(Some("WORKSPACE"), true).unwrap();
assert!(prompt.starts_with("WORKSPACE"));
assert!(prompt.contains(crate::agent::EXPLAIN_SYSTEM_PROMPT));
}
#[test]
fn no_workspace_and_no_explain_sends_no_system_prompt() {
assert!(build_system_prompt(None, false).is_none());
}
fn tool(name: &str) -> agent::ToolDefinition {
agent::ToolDefinition {
name: name.to_string(),
description: String::new(),
input_schema: serde_json::json!({}),
}
}
#[test]
fn a_non_ollama_provider_gets_every_tool() {
let all = vec![tool("grep"), tool("spawn_agent"), tool("party_mode")];
let kept = tools_for_provider(config::Provider::Anthropic, &all);
assert_eq!(kept.len(), all.len());
}
#[test]
fn ollama_keeps_only_the_core_edit_build_deploy_tools() {
let all = vec![
tool("grep"),
tool("read_file"),
tool("caatinga_deploy"),
tool("spawn_agent"),
tool("party_mode"),
tool("talk_to"),
tool("run_skill"),
];
let kept = tools_for_provider(config::Provider::Ollama, &all);
let names: Vec<_> = kept.iter().map(|t| t.name.as_str()).collect();
assert_eq!(names, vec!["grep", "read_file", "caatinga_deploy"]);
}
}
#[cfg(test)]
mod overflow_tests {
use super::is_context_overflow;
fn overflows(message: &str) -> bool {
is_context_overflow(&color_eyre::eyre::eyre!("{}", message))
}
#[test]
fn recognizes_each_providers_phrasing() {
assert!(overflows(
"prompt is too long: 210000 tokens > 200000 maximum"
));
assert!(overflows(
"This model's maximum context length is 128000 tokens. Please reduce the length of the messages."
));
assert!(overflows(
"API error 400: {\"code\":\"context_length_exceeded\"}"
));
assert!(overflows(
"This model's maximum context length is 65536 tokens"
));
assert!(overflows(
"the input (9000 tokens) is longer than the model's context length (8192 tokens)"
));
}
#[test]
fn a_rate_limit_is_not_an_overflow() {
assert!(!overflows(
"Rate limit reached for 200000 tokens per minute"
));
assert!(!overflows("429 Too Many Requests"));
assert!(!overflows("Service Unavailable: overloaded"));
}
#[test]
fn an_unrelated_failure_is_not_an_overflow() {
assert!(!overflows("error sending request: connection refused"));
assert!(!overflows("API error 401: invalid api key"));
}
}