mod account;
mod agent;
mod anthropic;
mod app;
mod bench;
mod budget;
mod channels;
mod config;
mod context;
mod credentials;
mod diag;
mod knowledge;
mod llm;
mod mcp;
mod oauth;
mod openai;
mod party;
mod personas;
mod project;
mod registries;
mod risk;
mod runtime;
mod session;
mod skills;
mod sse;
mod tools;
mod ui;
mod verify;
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 --knowledge print the knowledge snapshot (skills, digests, servers)
procyon --bench [name] run the Developer Parity tasks in bench/tasks
procyon --exec <prompt> run one prompt with no terminal and print the answer
procyon --exec <prompt> --allow-changes ...and let it write, deploy or sign
procyon --help
";
#[derive(Debug)]
enum Startup {
New,
Resume(Option<String>),
ListSessions,
Authorize(Option<String>),
Bench(Option<String>),
Knowledge,
Exec {
prompt: String,
allow_changes: bool,
},
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("--knowledge") => Startup::Knowledge,
Some("--bench") => Startup::Bench(args.next().filter(|name| !name.starts_with("--"))),
Some("--exec") => {
let rest: Vec<String> = args.collect();
let allow_changes = rest.iter().any(|arg| arg == "--allow-changes");
match rest
.into_iter()
.find(|arg| !arg.starts_with("--") && !arg.trim().is_empty())
{
Some(prompt) => Startup::Exec {
prompt,
allow_changes,
},
None => Startup::ShowUsage,
}
}
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::Bench(only) => {
let tasks = bench::load_tasks(&cwd.join("bench").join("tasks"))?;
let tasks: Vec<_> = match &only {
Some(name) => tasks.into_iter().filter(|t| &t.name == name).collect(),
None => tasks,
};
if tasks.is_empty() {
color_eyre::eyre::bail!("No benchmark task named {}", only.unwrap_or_default());
}
let mut outcomes = Vec::new();
for task in &tasks {
let outcome = bench::run_task(cfg, task).await;
eprintln!("{}", outcome.summary());
outcomes.push(outcome);
}
print!("{}", bench::report_toml(&outcomes));
std::process::exit(if outcomes.iter().all(|o| o.accepted) {
0
} else {
1
});
}
Startup::Knowledge => {
print!("{}", knowledge::snapshot(cfg).await.to_toml());
std::process::exit(0);
}
Startup::Exec {
prompt,
allow_changes,
} => match runtime::run_once(cfg, &prompt, allow_changes).await {
Ok(run) => {
println!("{}", run.text.trim_end());
std::process::exit(0);
}
Err(e) => {
eprintln!("procyon: {}", e);
std::process::exit(1);
}
},
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;
state.cancel = channels.cancel.clone();
state.approval_tx = Some(channels.approval_tx);
let cancel = channels.cancel;
let approvals = channels.approval_rx;
tokio::spawn(async move {
agent_task(
channels.user_rx,
agent_tx,
cfg,
resume_from,
cancel,
approvals,
)
.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();
}
}
}
}
fn interrupted_text(partial: &str) -> String {
let partial = partial.trim_end();
if partial.is_empty() {
"[The user interrupted this turn before any reply was produced.]".to_string()
} else {
format!("{}\n\n[The user interrupted this turn here.]", partial)
}
}
fn workspace_snapshot(
ctx: &context::WorkspaceContext,
cfg: &config::AppConfig,
mcp_connected: &[String],
) -> channels::WorkspaceSnapshot {
let mcp_servers = cfg
.mcp_servers
.iter()
.map(|s| channels::McpServerStatus {
name: s.name.clone(),
connected: mcp_connected.iter().any(|line| line.contains(&s.name)),
detail: s.endpoint_label(),
})
.collect();
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()
.filter(|p| !p.is_inferred())
.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,
mainnet_allowed: tools::mainnet::mainnet_allowed(),
}
}
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>,
cancel: channels::CancelFlag,
approvals: mpsc::UnboundedReceiver<channels::ApprovalDecision>,
) {
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 loaded = runtime::load_tools(
&cfg,
Some(std::sync::Arc::new(tools::approval::Approver::new(
agent_tx.clone(),
approvals,
cancel.clone(),
))),
)
.await;
let registry = loaded.registry;
let mcp_connected = loaded.mcp_connected;
for problem in &loaded.problems {
let _ = agent_tx.send(channels::AgentUpdate::Error(problem.clone()));
}
for notice in &loaded.notices {
let _ = agent_tx.send(channels::AgentUpdate::Notice(notice.clone()));
}
{
let statuses: Vec<channels::McpServerStatus> = cfg
.mcp_servers
.iter()
.map(|s| channels::McpServerStatus {
name: s.name.clone(),
connected: mcp_connected.iter().any(|line| line.contains(&s.name)),
detail: s.endpoint_label(),
})
.collect();
let _ = agent_tx.send(channels::AgentUpdate::McpStatus(statuses));
}
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(resumed) => {
let _ = agent_tx.send(channels::AgentUpdate::History(resumed.transcript));
let _ = agent_tx.send(channels::AgentUpdate::Notice(format!(
"Resumed session {}.",
resumed.log.id(),
)));
(Some(resumed.log), resumed.history)
}
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();
let mut quick_action_output: Vec<String> = Vec::new();
let mut operations = context::OperationLog::default();
let _ = agent_tx.send(channels::AgentUpdate::LocalModels(
llm::installed_models(&cfg).await.unwrap_or_default(),
));
if let Ok(cwd) = std::env::current_dir() {
let ctx = context::WorkspaceContext::gather(&cwd, &mcp_connected).await;
let _ = agent_tx.send(channels::AgentUpdate::Workspace(workspace_snapshot(
&ctx,
&cfg,
&mcp_connected,
)));
}
while let Some(cmd) = user_rx.recv().await {
match cmd {
channels::UserCommand::SendPrompt(prompt) => {
cancel.take();
let prompt = if quick_action_output.is_empty() {
prompt
} else {
format!(
"{}\n\n{}",
std::mem::take(&mut quick_action_output).join("\n\n"),
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
.with_session(skill_names().await, operations.recent())
.with_unverified(verify::session().pending());
let _ = agent_tx.send(channels::AgentUpdate::Workspace(
workspace_snapshot(&ctx, &cfg, &mcp_connected),
));
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 streamed = std::sync::Arc::new(std::sync::Mutex::new(String::new()));
let agent_tx_clone = agent_tx.clone();
let streamed_w = streamed.clone();
let forwarder = tokio::spawn(async move {
while let Some(text) = text_rx.recv().await {
if let Ok(mut buf) = streamed_w.lock() {
buf.push_str(&text);
}
let _ = agent_tx_clone.send(channels::AgentUpdate::ResponseChunk(text));
}
});
let mut turn_failed = false;
let mut turn_cancelled = false;
let mut overflow_retried = false;
let mut compaction_stalled = false;
let mut truncation_risk_warned = false;
if let Some(actual) = llm::ollama_context_length(&cfg, &cfg.default_model).await {
if actual != context_window {
context_window = actual;
budget.invalidate();
}
}
let prompt_window = budget::usable_window(context_window, cfg.max_tokens as usize);
loop {
if cancel.is_raised() {
turn_cancelled = true;
break;
}
if let Ok(mut buf) = streamed.lock() {
buf.clear();
}
let system = build_system_prompt(workspace.as_deref(), explain);
let turn_tools =
runtime::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,
budget::retain_tokens(prompt_window),
&agent_tx,
&mut log,
)
.await;
compaction_stalled = !shrank;
}
let _ = agent_tx.send(channels::AgentUpdate::Context {
used: budget.total(&history),
window: prompt_window,
});
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 do not fit in the \
{} tokens this Ollama server loaded {} with, and there is nothing \
left to trim. Ollama truncates silently rather than erroring, so this \
response may be based on an incomplete prompt. The window is the \
server's, not the model's — restart it with \
OLLAMA_CONTEXT_LENGTH=32768 (the model itself supports far more) and \
this stops.",
turn_tools.len(),
context_window,
cfg.default_model,
)));
}
barrier(&mut log, &agent_tx).await;
let sent = tokio::select! {
biased;
result = client.send_message_streaming(
&history,
Some(&turn_tools),
system.as_deref(),
&text_tx,
) => Some(result),
_ = cancel.wait() => None,
};
let Some(sent) = sent else {
let partial = streamed.lock().map(|b| b.clone()).unwrap_or_default();
let blocks = vec![agent::ContentPart::Text {
text: interrupted_text(&partial),
}];
record(
&mut log,
&agent_tx,
session::SessionEvent::AssistantMessage {
blocks: blocks.clone(),
},
)
.await;
history.push(agent::Message::assistant(blocks));
turn_cancelled = true;
break;
};
let outcome = match sent {
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,
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, recovered) = agent::recover_text_tool_calls(outcome.blocks);
if recovered {
let _ = agent_tx.send(channels::AgentUpdate::RetractResponse);
}
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 {
if cancel.is_raised() {
turn_cancelled = true;
let content = "Error: the user interrupted the turn before this tool \
ran."
.to_string();
record(
&mut log,
&agent_tx,
session::SessionEvent::ToolResult {
id: id.clone(),
content: content.clone(),
is_error: true,
},
)
.await;
results.push((id, content));
continue;
}
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 recorded_input = input.clone();
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();
operations
.record(&name, outcome.as_ref().map(|_| ()).map_err(|e| e.as_str()));
verify::session().record(&name, &recorded_input, !is_error);
let _ = agent_tx.send(channels::AgentUpdate::ToolFinished {
name: name.clone(),
ok: !is_error,
});
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));
if turn_cancelled {
break;
}
}
drop(text_tx);
let _ = forwarder.await;
if turn_cancelled {
let _ = agent_tx.send(channels::AgentUpdate::Notice(
"Turn interrupted. The conversation is kept, so you can carry on from \
here."
.to_string(),
));
}
record(
&mut log,
&agent_tx,
session::SessionEvent::TurnEnd {
reason: if turn_cancelled {
session::TurnEnd::Interrupted
} else 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::RunTool { name, input, label } => {
cancel.take();
let _ = agent_tx.send(channels::AgentUpdate::Status(format!(
"Using tool: {}",
name
)));
let recorded_input = input.clone();
let outcome = registry.execute(&name, input).await;
let ok = outcome.is_ok();
operations.record(&name, outcome.as_ref().map(|_| ()).map_err(|e| e.as_str()));
verify::session().record(&name, &recorded_input, ok);
let _ = agent_tx.send(channels::AgentUpdate::ToolFinished {
name: name.clone(),
ok,
});
let output = match outcome {
Ok(text) => text,
Err(e) => format!("Error: {}", e),
};
let output = agent::clamp_tool_result(output);
let _ = agent_tx.send(channels::AgentUpdate::Notice(format!(
"{} — {}\n{}",
label,
if ok { "done" } else { "failed" },
output.trim_end()
)));
let _ = agent_tx.send(channels::AgentUpdate::ResponseEnd);
quick_action_output.push(format!(
"[The user ran {} themselves and was shown this output:\n{}\n]",
name, output
));
}
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();
if let Some(installed) = llm::installed_models(&new_cfg).await {
if !llm::is_installed(&installed, &model) {
let _ = agent_tx.send(channels::AgentUpdate::Error(format!(
"{} has no model named '{}'. Installed: {}.",
provider,
model,
installed.join(", ")
)));
let _ = agent_tx.send(channels::AgentUpdate::Ready {
provider: cfg.provider.to_string(),
model: cfg.default_model.clone(),
credential: client.is_some(),
});
continue;
}
}
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();
match cfg.save() {
Ok(()) => {
let _ = agent_tx.send(channels::AgentUpdate::Notice(format!(
"Now on {} / {}, and saved as the default for new sessions in \
every directory.",
provider, model
)));
}
Err(e) => {
let _ = agent_tx.send(channels::AgentUpdate::Error(format!(
"Switched for this session, but could not save it as the \
default: {}",
e
)));
}
}
let _ = agent_tx.send(channels::AgentUpdate::Ready {
provider: provider.to_string(),
model,
credential: true,
});
let _ = agent_tx.send(channels::AgentUpdate::LocalModels(
llm::installed_models(&cfg).await.unwrap_or_default(),
));
}
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 transcript_for_summary(history: &[agent::Message]) -> String {
let mut out = String::new();
for message in history {
for part in &message.content {
match part {
agent::ContentPart::Text { text } if !text.trim().is_empty() => {
out.push_str(&format!("{}: {}\n\n", message.role, text.trim()));
}
agent::ContentPart::ToolUse { name, input, .. } => {
out.push_str(&format!("tool call: {}({})\n\n", name, input));
}
agent::ContentPart::ToolResult { content, .. } => {
out.push_str(&format!("tool result: {}\n\n", content.trim()));
}
agent::ContentPart::Text { .. } => {}
}
}
}
out
}
fn frame_summary(summary: &str) -> agent::Message {
agent::Message::user(&format!(
"{}\n\n<compacted-summary>\n{}\n</compacted-summary>",
CHECKPOINT_PREAMBLE, summary
))
}
async fn compact(
client: &llm::LlmClient,
history: &mut Vec<agent::Message>,
budget: &mut budget::Budget,
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 request = vec![agent::Message::user(&format!(
"{}\n\n---\n\n{}",
transcript_for_summary(&history[..cut]),
COMPACT_INSTRUCTION
))];
let blocks = match client.send_message(&request, None, None).await {
Ok(blocks) => blocks,
Err(e) => {
let _ = agent_tx.send(channels::AgentUpdate::Notice(format!(
"Could not compact the history, so this turn runs on it unchanged: {}",
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::Notice(
"The model returned no summary, so the history is unchanged and this turn runs on it \
as it is."
.to_string(),
));
return false;
}
let framed = frame_summary(&summary);
if budget::price_message(&framed) >= shadowed_tokens {
let _ = agent_tx.send(channels::AgentUpdate::Notice(
"The summary came back no smaller than what it would replace, so the history is \
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)));
}
}
}
async fn skill_names() -> Vec<String> {
registries::skills()
.await
.all()
.iter()
.map(|skill| skill.name.clone())
.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 crate::runtime::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 the_summary_request_keeps_what_a_handoff_needs() {
let history = vec![
agent::Message::user("deploy the counter"),
agent::Message::assistant(vec![
agent::ContentPart::Text {
text: "Deploying now.".to_string(),
},
agent::ContentPart::ToolUse {
id: "t1".to_string(),
name: "caatinga_deploy".to_string(),
input: serde_json::json!({"network": "testnet"}),
},
]),
agent::Message::tool_results(vec![("t1".to_string(), "contract CDLZ".to_string())]),
];
let text = super::transcript_for_summary(&history);
assert!(text.contains("deploy the counter"));
assert!(text.contains("Deploying now."));
assert!(text.contains("caatinga_deploy"), "got {}", text);
assert!(text.contains("testnet"), "got {}", text);
assert!(text.contains("CDLZ"), "got {}", text);
}
#[test]
fn the_summary_request_says_who_said_what() {
let history = vec![
agent::Message::user("oi"),
agent::Message::assistant(vec![agent::ContentPart::Text {
text: "ola".to_string(),
}]),
];
let text = super::transcript_for_summary(&history);
assert!(text.contains("user: oi"), "got {}", text);
assert!(text.contains("assistant: ola"), "got {}", text);
}
#[test]
fn empty_text_blocks_do_not_become_empty_turns() {
let history = vec![agent::Message::assistant(vec![agent::ContentPart::Text {
text: " \n ".to_string(),
}])];
assert!(super::transcript_for_summary(&history).is_empty());
}
#[test]
fn an_interrupted_reply_keeps_what_was_already_said() {
let text = super::interrupted_text("A Stellar é uma rede");
assert!(text.starts_with("A Stellar é uma rede"));
assert!(text.contains("interrupted"));
}
#[test]
fn an_interrupt_before_any_text_still_produces_a_block() {
assert!(!super::interrupted_text("").is_empty());
assert!(!super::interrupted_text(" \n").is_empty());
}
#[test]
fn taking_the_cancel_flag_clears_it() {
let flag = crate::channels::CancelFlag::default();
assert!(!flag.take());
flag.raise();
assert!(flag.is_raised());
assert!(flag.take(), "take reports the raise it consumed");
assert!(
!flag.is_raised(),
"a flag left raised would cancel the following turn on arrival"
);
}
#[test]
fn a_cancel_flag_is_shared_by_its_clones() {
let ui = crate::channels::CancelFlag::default();
let agent = ui.clone();
ui.raise();
assert!(agent.is_raised());
}
fn snapshot_context(
project: Option<crate::project::Project>,
) -> crate::context::WorkspaceContext {
crate::context::WorkspaceContext {
cwd: std::path::PathBuf::from("/w/demo"),
project,
accounts: Vec::new(),
stellar_cli: None,
npx: false,
mcp_servers: Vec::new(),
skills: Vec::new(),
operations: Vec::new(),
unverified: Vec::new(),
caatinga_config: false,
}
}
fn snapshot_config(default_network: &str) -> config::AppConfig {
config::AppConfig {
default_network: default_network.to_string(),
..config::AppConfig::default()
}
}
#[test]
fn a_discovered_project_reaches_the_status_line() {
let mut project = crate::project::Project::new("my-app");
project.source = crate::project::ProjectSource::Inferred;
project.contracts.push(crate::project::Contract {
name: "counter".to_string(),
address: None,
wasm_path: None,
});
let snap = super::workspace_snapshot(
&snapshot_context(Some(project)),
&snapshot_config("testnet"),
&[],
);
assert_eq!(snap.project_name, "my-app");
assert_eq!(snap.contract_name.as_deref(), Some("counter"));
}
#[test]
fn an_inferred_project_does_not_override_the_configured_network() {
let project = crate::project::Project {
source: crate::project::ProjectSource::Inferred,
..crate::project::Project::new("my-app")
};
let snap = super::workspace_snapshot(
&snapshot_context(Some(project)),
&snapshot_config("local"),
&[],
);
assert_eq!(snap.network, "local");
}
#[test]
fn a_configured_project_still_sets_the_network() {
let project = crate::project::Project {
default_network: crate::project::Network::Mainnet,
..crate::project::Project::new("my-app")
};
let snap = super::workspace_snapshot(
&snapshot_context(Some(project)),
&snapshot_config("local"),
&[],
);
assert_eq!(snap.network, "mainnet");
}
#[test]
fn no_project_falls_back_to_the_config() {
let snap =
super::workspace_snapshot(&snapshot_context(None), &snapshot_config("local"), &[]);
assert_eq!(snap.project_name, "No project");
assert!(snap.contract_name.is_none());
assert_eq!(snap.network, "local");
}
#[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 exec_takes_a_prompt_and_defaults_to_changing_nothing() {
match args(&["--exec", "what network am I on?"]) {
Startup::Exec {
prompt,
allow_changes,
} => {
assert_eq!(prompt, "what network am I on?");
assert!(
!allow_changes,
"an unattended run must not write by default"
);
}
other => panic!("expected an exec request, got {:?}", other),
}
}
#[test]
fn exec_accepts_the_flag_on_either_side_of_the_prompt() {
for argv in [
&["--exec", "deploy the counter", "--allow-changes"],
&["--exec", "--allow-changes", "deploy the counter"],
] {
match args(argv) {
Startup::Exec {
prompt,
allow_changes,
} => {
assert_eq!(prompt, "deploy the counter", "{:?}", argv);
assert!(allow_changes, "{:?}", argv);
}
other => panic!("expected an exec request, got {:?}", other),
}
}
}
#[test]
fn exec_without_a_prompt_is_a_usage_error_rather_than_an_empty_turn() {
assert!(matches!(args(&["--exec"]), Startup::ShowUsage));
assert!(matches!(args(&["--exec", " "]), Startup::ShowUsage));
assert!(matches!(
args(&["--exec", "--allow-changes"]),
Startup::ShowUsage
));
}
#[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"]);
}
#[test]
fn ollama_keeps_the_test_runner() {
let kept = tools_for_provider(config::Provider::Ollama, &[tool("run_tests")]);
assert_eq!(kept.len(), 1, "run_tests must survive the Ollama filter");
}
}
#[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"));
}
}