brokecrew/src/tui/mod.rs

1255 lines
47 KiB
Rust

pub mod app;
pub mod ui;
use app::{AppState, CandidateAttemptInfo, TuiMode};
use brokecrew::budget::BudgetTracker;
use brokecrew::client::OpenRouterClient;
use brokecrew::config::AppConfig;
use brokecrew::persona::Tier;
use brokecrew::pipeline::{PipelineEvent, PipelineExecutor};
use brokecrew::registry::ModelRegistry;
use brokecrew::router::{RouteStage, Router};
use brokecrew::session::Session;
use brokecrew::tool::ToolEngine;
use crossterm::{
event::{Event, EventStream, KeyCode, KeyEvent, KeyModifiers},
execute,
terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
};
use futures_util::StreamExt;
use ratatui::{backend::CrosstermBackend, Terminal};
use std::io;
use std::sync::Arc;
use std::time::Duration;
use tokio::sync::mpsc;
struct TerminalGuard;
impl Drop for TerminalGuard {
fn drop(&mut self) {
let _ = disable_raw_mode();
let _ = execute!(io::stdout(), LeaveAlternateScreen, crossterm::cursor::Show);
}
}
pub enum WorkerEvent {
PipelineStarted {
pipeline_name: String,
steps: Vec<String>,
},
RouterResolved {
persona: String,
stage: RouteStage,
details: String,
complexity_score: u8,
},
CandidateAttempt {
model: String,
attempt: usize,
total: usize,
tier: Tier,
},
CandidateFailed {
model: String,
error: String,
},
ToolExecutionStarted {
command: String,
},
ToolExecutionCompleted {
command: String,
exit_code: i32,
output: String,
duration_ms: u64,
},
StepStarted {
step_index: usize,
total_steps: usize,
persona: String,
model: String,
tier: Tier,
},
Chunk {
persona: String,
model: String,
tier: Tier,
delta: String,
},
StepCompleted {
fell_back: bool,
},
Finished {
fell_back: bool,
model_used: String,
error: Option<String>,
calls_made: usize,
paid_calls_made: usize,
tokens_in: usize,
tokens_out: usize,
},
}
pub async fn run_tui(
config: AppConfig,
mut budget_tracker: BudgetTracker,
model_registry: Arc<ModelRegistry>,
allow_stealth: bool,
escalate: bool,
) -> Result<(), Box<dyn std::error::Error>> {
// Enable raw mode and alternate screen
enable_raw_mode()?;
let mut stdout = io::stdout();
execute!(stdout, EnterAlternateScreen, crossterm::cursor::Show)?;
let _guard = TerminalGuard;
let backend = CrosstermBackend::new(stdout);
let mut terminal = Terminal::new(backend)?;
// Prepare personas list for AppState
let personas_summary: Vec<(String, String)> = config
.persona_registry
.list()
.iter()
.map(|p| {
let desc = if !p.trigger_words.is_empty() {
format!("triggers: {}", p.trigger_words.join(", "))
} else {
p.system_prompt.chars().take(50).collect::<String>()
};
(p.name.clone(), desc)
})
.collect();
let (minute_limit, daily_limit) = match budget_tracker.check_budget() {
Ok(s) => (s.minute_limit, s.daily_limit),
Err(_) => (20, 50),
};
let mut app = AppState::new(
personas_summary,
config.pipelines.clone(),
minute_limit,
daily_limit,
config.config_dir.clone(),
config.theme.clone(),
);
app.allow_stealth = allow_stealth || config.allow_stealth;
app.escalate = escalate;
// Refresh model catalog in background
let reg_clone = Arc::clone(&model_registry);
let url_clone = config.openrouter_base_url.clone();
tokio::spawn(async move {
let _ = reg_clone.fetch_live(&url_clone).await;
});
let client = Arc::new(OpenRouterClient::with_base_url(
config.api_key.clone().unwrap_or_default(),
config.openrouter_base_url.clone(),
));
let router = Arc::new(Router::new(
config.default_persona.clone(),
config.router_model.clone(),
config.complexity_threshold,
));
let (worker_tx, mut worker_rx) = mpsc::channel::<WorkerEvent>(100);
let mut event_stream = EventStream::new();
let mut tick_interval = tokio::time::interval(Duration::from_millis(80));
loop {
// Update budget counters in AppState
if let Ok(st) = budget_tracker.check_budget() {
app.minute_requests = st.minute_requests;
app.daily_requests = st.daily_requests;
app.minute_limit = st.minute_limit;
app.daily_limit = st.daily_limit;
}
app.models_cached = model_registry.model_count().await;
// Render UI
terminal.draw(|f| ui::render(f, &mut app))?;
tokio::select! {
_ = tick_interval.tick() => {
if app.is_generating {
app.spinner_tick = app.spinner_tick.wrapping_add(1);
}
}
Some(event) = worker_rx.recv() => {
handle_worker_event(&mut app, &mut budget_tracker, event);
}
maybe_event = event_stream.next() => {
match maybe_event {
Some(Ok(Event::Key(key_event))) => {
let should_quit = handle_key_event(
&mut app,
key_event,
&config,
&model_registry,
&client,
&router,
worker_tx.clone(),
);
if should_quit {
break;
}
}
Some(Ok(Event::Resize(_, _))) => {
terminal.autoresize()?;
}
_ => {}
}
}
}
}
Ok(())
}
fn handle_worker_event(app: &mut AppState, budget_tracker: &mut BudgetTracker, event: WorkerEvent) {
match event {
WorkerEvent::PipelineStarted {
pipeline_name,
steps,
} => {
app.workings.active_pipeline = Some(pipeline_name.clone());
app.workings.pipeline_steps = steps
.into_iter()
.map(|p| app::PipelineStepTelemetry {
persona: p,
model: "evaluating...".to_string(),
tier: Tier::Free,
is_active: false,
is_completed: false,
fell_back: false,
})
.collect();
if let Some(first) = app.workings.pipeline_steps.first_mut() {
first.is_active = true;
}
app.log_working_event(&format!(
"[pipeline:start] '{}' with {} steps",
pipeline_name,
app.workings.pipeline_steps.len()
));
}
WorkerEvent::RouterResolved {
persona,
stage,
details,
complexity_score,
} => {
let stage_name = match stage {
RouteStage::StageAKeyword => "Stage A (Keyword Filter)",
RouteStage::StageBLlm => "Stage B (LLM Classifier)",
};
app.workings.route_stage = Some(stage_name.to_string());
app.workings.route_details = Some(details.clone());
app.workings.complexity_score = Some(complexity_score);
app.log_working_event(&format!(
"[route] {} ➔ {} (complexity: {}/10)",
stage_name, persona, complexity_score
));
}
WorkerEvent::CandidateAttempt {
model,
attempt,
total,
tier,
} => {
app.workings.candidate_attempts.push(CandidateAttemptInfo {
model: model.clone(),
tier,
status: format!("Attempting ({attempt}/{total})..."),
is_error: false,
is_success: false,
});
app.log_working_event(&format!(
"[candidate] trying {} ({}/{})",
model, attempt, total
));
}
WorkerEvent::CandidateFailed { model, error } => {
if let Some(att) = app
.workings
.candidate_attempts
.iter_mut()
.find(|a| a.model == model)
{
att.status = error.clone();
att.is_error = true;
}
app.log_working_event(&format!("[fallback] failed {}: {}", model, error));
}
WorkerEvent::ToolExecutionStarted { command } => {
app.status_message = format!("Running tool: {command}");
app.log_working_event(&format!("[tool:start] $ {command}"));
}
WorkerEvent::ToolExecutionCompleted {
command,
exit_code,
output,
duration_ms,
} => {
app.add_tool_result_message(command.clone(), exit_code, output, duration_ms);
let dur_s = (duration_ms as f64) / 1000.0;
app.log_working_event(&format!(
"[tool:done] $ {command} -> exit {exit_code} ({dur_s:.1}s)"
));
}
WorkerEvent::StepStarted {
step_index,
total_steps,
persona,
model,
tier,
} => {
if step_index < app.workings.pipeline_steps.len() {
for (i, st) in app.workings.pipeline_steps.iter_mut().enumerate() {
if i < step_index {
st.is_completed = true;
st.is_active = false;
} else if i == step_index {
st.is_active = true;
st.model = model.clone();
st.tier = tier;
} else {
st.is_active = false;
}
}
} else if app.workings.pipeline_steps.is_empty() {
app.workings
.pipeline_steps
.push(app::PipelineStepTelemetry {
persona: persona.clone(),
model: model.clone(),
tier,
is_active: true,
is_completed: false,
fell_back: false,
});
}
if total_steps > 1 {
app.start_pipeline_step_message(step_index, total_steps, &persona, &model, tier);
app.log_working_event(&format!(
"[pipeline] step {}/{}: {} ({})",
step_index + 1,
total_steps,
persona,
model
));
} else {
app.log_working_event(&format!("[dispatch] {} ({})", persona, model));
}
}
WorkerEvent::Chunk {
persona,
model,
tier,
delta,
} => {
// Lazily ensure assistant message exists so no empty boxes exist
let needs_start = match app.messages.last() {
Some(msg) => matches!(
msg.sender,
app::MessageSender::User | app::MessageSender::System
),
None => true,
};
if needs_start {
app.start_assistant_message(&persona, &model, tier);
}
app.append_to_current_message(&delta);
}
WorkerEvent::StepCompleted { fell_back } => {
if let Some(st) = app.workings.pipeline_steps.iter_mut().find(|s| s.is_active) {
st.is_completed = true;
st.is_active = false;
st.fell_back = fell_back;
}
if fell_back {
app.mark_current_message_fallback();
}
}
WorkerEvent::Finished {
fell_back,
model_used,
error,
calls_made,
paid_calls_made,
tokens_in,
tokens_out,
} => {
app.is_generating = false;
app.status_message = "Ready".to_string();
app.tokens_in += tokens_in;
app.tokens_out += tokens_out;
for st in app.workings.pipeline_steps.iter_mut() {
if !st.is_completed {
st.is_completed = true;
st.is_active = false;
}
}
if !model_used.is_empty() && model_used != "none" {
app.update_current_message_model(&model_used);
if let Some(att) = app
.workings
.candidate_attempts
.iter_mut()
.find(|a| a.model == model_used)
{
att.status = "Connected & Active".to_string();
att.is_success = true;
}
}
if fell_back {
app.mark_current_message_fallback();
}
if let Some(err) = error {
app.add_system_message(format!("! error: {err}"));
app.log_working_event(&format!("[error] {err}"));
} else {
app.log_working_event(&format!("[complete] response finished ({model_used})"));
}
let free_calls = calls_made.saturating_sub(paid_calls_made);
for _ in 0..free_calls {
let _ = budget_tracker.record_call(false);
}
for _ in 0..paid_calls_made {
let _ = budget_tracker.record_call(true);
}
// Auto-save session state
app.save_current_session();
}
}
}
fn handle_key_event(
app: &mut AppState,
key: KeyEvent,
config: &AppConfig,
model_registry: &Arc<ModelRegistry>,
client: &Arc<OpenRouterClient>,
router: &Arc<Router>,
worker_tx: mpsc::Sender<WorkerEvent>,
) -> bool {
// Global quit on Ctrl+C
if key.modifiers.contains(KeyModifiers::CONTROL) && key.code == KeyCode::Char('c') {
return true;
}
match app.mode {
TuiMode::PersonaSelect => match key.code {
KeyCode::Esc => {
app.mode = TuiMode::Normal;
}
KeyCode::Up => {
app.selected_persona_idx = app.selected_persona_idx.saturating_sub(1);
}
KeyCode::Down => {
if app.selected_persona_idx + 1 < app.persona_items.len() {
app.selected_persona_idx += 1;
}
}
KeyCode::Enter => {
app.active_pipeline_for_prompt = None;
app.mode = TuiMode::Normal;
app.log_working_event(&format!(
"[persona] switched to {}",
app.current_persona_name()
));
}
_ => {}
},
TuiMode::PipelineSelect => match key.code {
KeyCode::Esc => {
app.mode = TuiMode::Normal;
}
KeyCode::Up => {
app.selected_pipeline_idx = app.selected_pipeline_idx.saturating_sub(1);
}
KeyCode::Down => {
if app.selected_pipeline_idx + 1 < app.pipelines.len() {
app.selected_pipeline_idx += 1;
}
}
KeyCode::Enter => {
if let Some(pipe) = app.pipelines.get(app.selected_pipeline_idx) {
app.active_pipeline_for_prompt = Some(pipe.name.clone());
app.log_working_event(&format!("[pipeline] selected {}", pipe.name));
}
app.mode = TuiMode::Normal;
}
_ => {}
},
TuiMode::SessionSelect => match key.code {
KeyCode::Esc => {
app.mode = TuiMode::Normal;
}
KeyCode::Up => {
app.selected_session_idx = app.selected_session_idx.saturating_sub(1);
}
KeyCode::Down => {
if app.selected_session_idx + 1 < app.session_summaries.len() {
app.selected_session_idx += 1;
}
}
KeyCode::Enter => {
if let Some(summary) = app.session_summaries.get(app.selected_session_idx).cloned()
{
let _ = app.load_session_by_id(&summary.id);
}
app.mode = TuiMode::Normal;
}
KeyCode::Char('d') => {
if let Some(summary) = app.session_summaries.get(app.selected_session_idx).cloned()
{
let _ = Session::delete(&app.config_dir, &summary.id);
app.session_summaries = Session::list(&app.config_dir).unwrap_or_default();
if app.selected_session_idx >= app.session_summaries.len() {
app.selected_session_idx = app.session_summaries.len().saturating_sub(1);
}
app.log_working_event(&format!("[session] deleted {}", summary.id));
}
}
_ => {}
},
TuiMode::ThemeSelect => match key.code {
KeyCode::Esc => {
app.mode = TuiMode::Normal;
}
KeyCode::Up => {
app.selected_theme_idx = app.selected_theme_idx.saturating_sub(1);
}
KeyCode::Down => {
if app.selected_theme_idx + 1 < app::THEMES.len() {
app.selected_theme_idx += 1;
}
}
KeyCode::Enter => {
if let Some((name, _)) = app::THEMES.get(app.selected_theme_idx) {
app.current_theme = name.to_string();
let _ = AppConfig::save_theme(&app.config_dir, name);
app.log_working_event(&format!("[theme] selected {}", name));
}
app.mode = TuiMode::Normal;
}
_ => {}
},
TuiMode::Normal => {
// Mode switching and shortcuts
if key.modifiers.contains(KeyModifiers::CONTROL) {
app.esc_pressed_at = None;
match key.code {
KeyCode::Char('p') => {
app.mode = TuiMode::PipelineSelect;
return false;
}
KeyCode::Char('s') => {
app.session_summaries = Session::list(&app.config_dir).unwrap_or_default();
app.selected_session_idx = 0;
app.mode = TuiMode::SessionSelect;
return false;
}
KeyCode::Char('n') => {
app.start_new_session();
return false;
}
KeyCode::Char('o') => {
app.expand_tool_outputs = !app.expand_tool_outputs;
app.log_working_event(if app.expand_tool_outputs {
"[tools] output expanded"
} else {
"[tools] output collapsed"
});
return false;
}
KeyCode::Char('l') => {
app.clear_chat();
return false;
}
_ => {}
}
}
match key.code {
KeyCode::Esc => {
let now = std::time::Instant::now();
if let Some(prev) = app.esc_pressed_at {
if now.duration_since(prev) <= Duration::from_millis(2000) {
return true;
}
}
app.esc_pressed_at = Some(now);
app.status_message = "Press Esc again to quit".to_string();
app.log_working_event("[esc] press Esc again to quit");
return false;
}
_ => {
app.esc_pressed_at = None;
}
}
// Command suggestions navigation when typing slash command
if app.input.starts_with('/') {
let suggestions = app.get_matching_suggestions();
if !suggestions.is_empty() {
match key.code {
KeyCode::Up => {
app.selected_suggestion_idx =
app.selected_suggestion_idx.saturating_sub(1);
return false;
}
KeyCode::Down => {
if app.selected_suggestion_idx + 1 < suggestions.len() {
app.selected_suggestion_idx += 1;
}
return false;
}
KeyCode::Tab => {
if let Some(sug) = suggestions.get(app.selected_suggestion_idx) {
app.input = format!("{} ", sug.name);
app.cursor_pos = app.input.len();
app.selected_suggestion_idx = 0;
}
return false;
}
KeyCode::Enter if !app.input.contains(' ') => {
// If input is purely a command without arguments, select the highlighted suggestion
if let Some(sug) = suggestions.get(app.selected_suggestion_idx) {
let cmd = sug.name.to_string();
app.input.clear();
app.cursor_pos = 0;
app.selected_suggestion_idx = 0;
handle_slash_command(app, config, &cmd);
return false;
}
}
_ => {}
}
}
}
match key.code {
KeyCode::Esc => unreachable!(),
KeyCode::Tab => {
app.mode = TuiMode::PersonaSelect;
}
KeyCode::PageUp => {
app.scroll_up(5);
}
KeyCode::PageDown => {
app.scroll_down(5);
}
KeyCode::Left => {
app.move_cursor_left();
}
KeyCode::Right => {
app.move_cursor_right();
}
KeyCode::Home => {
app.move_cursor_start();
}
KeyCode::End => {
app.move_cursor_end();
}
KeyCode::Backspace => {
app.delete_char_before_cursor();
app.selected_suggestion_idx = 0;
}
KeyCode::Delete => {
app.delete_char_under_cursor();
app.selected_suggestion_idx = 0;
}
KeyCode::Enter => {
let prompt = app.input.trim().to_string();
if !prompt.is_empty() && !app.is_generating {
app.input.clear();
app.cursor_pos = 0;
app.selected_suggestion_idx = 0;
if prompt.starts_with('/') {
handle_slash_command(app, config, &prompt);
return false;
}
app.add_user_message(prompt.clone());
app.is_generating = true;
app.status_message = "Dispatching...".to_string();
let target_persona = app.current_persona_name().to_string();
let pipe_name = app.active_pipeline_for_prompt.clone();
app.reset_turn_telemetry(&target_persona, pipe_name);
app.log_working_event(&format!("[prompt] \"{prompt}\""));
spawn_dispatch_task(
prompt,
app,
config,
model_registry,
client,
router,
worker_tx,
);
}
}
KeyCode::Char(c) => {
app.insert_char(c);
app.selected_suggestion_idx = 0;
}
_ => {}
}
}
}
false
}
fn handle_slash_command(app: &mut AppState, config: &AppConfig, input: &str) {
let parts: Vec<&str> = input.split_whitespace().collect();
let cmd = parts.first().copied().unwrap_or("");
match cmd {
"/new" => {
app.start_new_session();
}
"/session" | "/sessions" => {
app.session_summaries = Session::list(&app.config_dir).unwrap_or_default();
app.selected_session_idx = 0;
app.mode = TuiMode::SessionSelect;
}
"/config" => {
let key_status = if config.api_key.is_some() {
"configured"
} else {
"not configured"
};
app.add_system_message(format!(
"Config Overview:\n Router Model: {}\n Default Persona: {}\n Stealth Candidates: {}\n Complexity Escalation: {}\n OpenRouter Key: {}\n Base URL: {}\n Config Dir: {}",
config.router_model,
config.default_persona,
app.allow_stealth,
app.escalate,
key_status,
config.openrouter_base_url,
config.config_dir.display()
));
}
"/usage" => {
app.add_system_message(format!(
"Rate Limit & Budget Usage:\n 1-Minute Rate: {}/{} requests\n 24-Hour Budget: {}/{} requests\n Tokens In: {} (est)\n Tokens Out: {} (est)\n Cached Models: {} available",
app.minute_requests,
app.minute_limit,
app.daily_requests,
app.daily_limit,
app::AppState::format_tokens(app.tokens_in),
app::AppState::format_tokens(app.tokens_out),
app.models_cached
));
}
"/theme" => {
if let Some(arg) = parts.get(1) {
if *arg == "next" {
app.cycle_theme();
let theme_name = app.current_theme.clone();
let _ = AppConfig::save_theme(&app.config_dir, &theme_name);
app.add_system_message(format!("Theme set to: {theme_name}"));
return;
} else if let Some((name, _)) = app::THEMES.iter().find(|(n, _)| n == arg) {
app.current_theme = name.to_string();
let _ = AppConfig::save_theme(&app.config_dir, name);
app.add_system_message(format!("Theme set to: {name}"));
return;
}
}
if let Some(pos) = app::THEMES
.iter()
.position(|(name, _)| *name == app.current_theme)
{
app.selected_theme_idx = pos;
}
app.mode = TuiMode::ThemeSelect;
}
"/settings" => {
app.add_system_message(format!(
"Runtime Settings:\n Stealth Models: {} (toggle with /stealth)\n Complexity Escalate: {} (toggle with /escalate)\n Active Persona: {}\n Active Pipeline: {}",
if app.allow_stealth { "enabled" } else { "disabled" },
if app.escalate { "enabled" } else { "disabled" },
app.current_persona_name(),
app.active_pipeline_for_prompt.as_deref().unwrap_or("none")
));
}
"/stealth" => {
app.allow_stealth = !app.allow_stealth;
app.add_system_message(format!(
"Stealth candidate models are now: {}",
if app.allow_stealth {
"ENABLED"
} else {
"DISABLED"
}
));
}
"/escalate" => {
app.escalate = !app.escalate;
app.add_system_message(format!(
"Complexity escalation is now: {}",
if app.escalate { "ENABLED" } else { "DISABLED" }
));
}
"/clear" => {
app.clear_chat();
}
"/persona" | "/personas" => {
app.mode = TuiMode::PersonaSelect;
}
"/pipeline" | "/pipelines" => {
app.mode = TuiMode::PipelineSelect;
}
"/help" => {
app.add_system_message(
"Available Commands:\n /new - Start a fresh session\n /session - Open session switcher modal\n /config - View active system configuration\n /usage - View budget and rate-limit counters\n /theme - Cycle color theme (default, warm, forest, mono)\n /settings - View and adjust runtime settings\n /stealth - Toggle stealth model eligibility\n /escalate - Toggle complexity escalation\n /persona - Open persona selection modal\n /pipeline - Open pipeline selection modal\n /clear - Clear chat screen\n /help - Show this help summary"
.to_string(),
);
}
other => {
app.add_system_message(format!(
"Unknown command '{other}'. Type /help to view available commands."
));
}
}
}
fn spawn_dispatch_task(
prompt: String,
app: &AppState,
config: &AppConfig,
model_registry: &Arc<ModelRegistry>,
client: &Arc<OpenRouterClient>,
router: &Arc<Router>,
worker_tx: mpsc::Sender<WorkerEvent>,
) {
let pipeline_name = app.active_pipeline_for_prompt.clone();
let is_auto = app.is_auto_route();
let direct_persona = app.current_persona_name().to_string();
let allow_stealth = app.allow_stealth;
let escalate = app.escalate;
let persona_registry = config.persona_registry.clone();
let pipelines = config.pipelines.clone();
let default_persona = config.default_persona.clone();
let model_registry = Arc::clone(model_registry);
let client = Arc::clone(client);
let router = Arc::clone(router);
tokio::spawn(async move {
if let Some(pipe_name) = pipeline_name {
if let Some(pipe) = pipelines.iter().find(|p| p.name == pipe_name) {
let steps: Vec<String> = pipe.steps.iter().map(|s| s.persona.clone()).collect();
let _ = worker_tx
.send(WorkerEvent::PipelineStarted {
pipeline_name: pipe_name.clone(),
steps,
})
.await;
let (event_tx, mut event_rx) = mpsc::channel::<PipelineEvent>(50);
let w_tx = worker_tx.clone();
let forwarder = tokio::spawn(async move {
while let Some(event) = event_rx.recv().await {
match event {
PipelineEvent::StepStarted {
step_index,
total_steps,
persona,
model,
tier,
} => {
let _ = w_tx
.send(WorkerEvent::StepStarted {
step_index,
total_steps,
persona,
model,
tier,
})
.await;
}
PipelineEvent::CandidateAttempt {
model,
attempt,
total_candidates,
tier,
..
} => {
let _ = w_tx
.send(WorkerEvent::CandidateAttempt {
model,
attempt,
total: total_candidates,
tier,
})
.await;
}
PipelineEvent::CandidateFailed { model, error, .. } => {
let _ = w_tx
.send(WorkerEvent::CandidateFailed { model, error })
.await;
}
PipelineEvent::Chunk { delta, .. } => {
let _ = w_tx
.send(WorkerEvent::Chunk {
persona: "pipeline".to_string(),
model: "pipeline".to_string(),
tier: Tier::Free,
delta,
})
.await;
}
PipelineEvent::StepCompleted { fell_back, .. } => {
let _ = w_tx.send(WorkerEvent::StepCompleted { fell_back }).await;
}
}
}
});
let res = PipelineExecutor::execute(
pipe,
&persona_registry,
&model_registry,
client.as_ref(),
&prompt,
allow_stealth,
escalate,
Some(event_tx),
)
.await;
let _ = forwarder.await;
match res {
Ok(results) => {
let total_calls: usize = results.iter().map(|r| r.attempts.len()).sum();
let paid_calls: usize =
results.iter().filter(|r| r.tier_used == Tier::Paid).count();
let fell_back = results.iter().any(|r| r.fell_back);
let tok_in = brokecrew::estimate_tokens(&prompt);
let tok_out: usize = results
.iter()
.map(|r| brokecrew::estimate_tokens(&r.reply))
.sum();
let _ = worker_tx
.send(WorkerEvent::Finished {
fell_back,
model_used: "pipeline completed".to_string(),
error: None,
calls_made: total_calls + results.len(),
paid_calls_made: paid_calls,
tokens_in: tok_in,
tokens_out: tok_out,
})
.await;
}
Err(e) => {
let _ = worker_tx
.send(WorkerEvent::Finished {
fell_back: false,
model_used: "none".to_string(),
error: Some(e.to_string()),
calls_made: 1,
paid_calls_made: 0,
tokens_in: brokecrew::estimate_tokens(&prompt),
tokens_out: 0,
})
.await;
}
}
return;
}
}
// Determine target persona via router
let (target_persona, route_stage, details, complexity_score) = if is_auto {
match router
.route(&persona_registry, client.as_ref(), &prompt, escalate)
.await
{
Ok(decision) => {
let d = match decision.stage_used {
RouteStage::StageAKeyword => "Matched keyword in prompt".to_string(),
RouteStage::StageBLlm => {
format!("LLM classifier (model: {})", router.router_model)
}
};
(
decision.persona,
Some(decision.stage_used),
d,
decision.complexity_score,
)
}
Err(err) => (
default_persona,
None,
format!("Router fallback to default: {err}"),
1,
),
}
} else {
(
direct_persona,
None,
"Direct persona selected by user".to_string(),
1,
)
};
if let Some(stage) = route_stage {
let _ = worker_tx
.send(WorkerEvent::RouterResolved {
persona: target_persona.clone(),
stage,
details,
complexity_score,
})
.await;
}
let (chunk_tx, mut chunk_rx) = mpsc::channel::<String>(100);
let (event_tx, mut event_rx) = mpsc::channel::<PipelineEvent>(50);
let w_tx = worker_tx.clone();
let target_clone = target_persona.clone();
// Forward candidate events from dispatch_persona_with_events
let event_forwarder = tokio::spawn(async move {
while let Some(event) = event_rx.recv().await {
match event {
PipelineEvent::CandidateAttempt {
model,
attempt,
total_candidates,
tier,
..
} => {
let _ = w_tx
.send(WorkerEvent::CandidateAttempt {
model,
attempt,
total: total_candidates,
tier,
})
.await;
}
PipelineEvent::CandidateFailed { model, error, .. } => {
let _ = w_tx
.send(WorkerEvent::CandidateFailed { model, error })
.await;
}
_ => {}
}
}
});
let w_tx2 = worker_tx.clone();
let chunk_forwarder = tokio::spawn(async move {
while let Some(chunk) = chunk_rx.recv().await {
let _ = w_tx2
.send(WorkerEvent::Chunk {
persona: target_clone.clone(),
model: "streaming".to_string(),
tier: Tier::Free,
delta: chunk,
})
.await;
}
});
let persona_registry_tools = persona_registry.with_tool_capabilities();
let res = PipelineExecutor::dispatch_persona_with_events(
&target_persona,
&persona_registry_tools,
&model_registry,
client.as_ref(),
&prompt,
allow_stealth,
escalate,
Some(chunk_tx),
Some(event_tx),
)
.await;
let _ = chunk_forwarder.await;
let _ = event_forwarder.await;
match res {
Ok(disp) => {
let commands = ToolEngine::extract_command_calls(&disp.reply);
if !commands.is_empty() {
let mut tool_results_summary = String::new();
for cmd in commands {
let _ = worker_tx
.send(WorkerEvent::ToolExecutionStarted {
command: cmd.clone(),
})
.await;
let (exit_code, formatted, duration_ms) =
match ToolEngine::execute_bash(&cmd, None, Duration::from_secs(30))
.await
{
Ok(out) => (out.exit_code, out.format_for_model(), out.duration_ms),
Err(e) => (-1, format!("[Command: `{cmd}` failed: {e}]\n"), 0),
};
let _ = worker_tx
.send(WorkerEvent::ToolExecutionCompleted {
command: cmd,
exit_code,
output: formatted.clone(),
duration_ms,
})
.await;
tool_results_summary.push_str(&formatted);
tool_results_summary.push('\n');
}
let followup_prompt = format!(
"{prompt}\n\n[Tool Output]:\n{tool_results_summary}\nPlease analyze the tool results above and provide your response to the user."
);
let (chunk_tx2, mut chunk_rx2) = mpsc::channel::<String>(100);
let w_tx3 = worker_tx.clone();
let target_clone2 = target_persona.clone();
let chunk_forwarder2 = tokio::spawn(async move {
while let Some(chunk) = chunk_rx2.recv().await {
let _ = w_tx3
.send(WorkerEvent::Chunk {
persona: target_clone2.clone(),
model: "streaming".to_string(),
tier: Tier::Free,
delta: chunk,
})
.await;
}
});
let followup_res = PipelineExecutor::dispatch_persona_with_events(
&target_persona,
&persona_registry_tools,
&model_registry,
client.as_ref(),
&followup_prompt,
allow_stealth,
escalate,
Some(chunk_tx2),
None,
)
.await;
let _ = chunk_forwarder2.await;
match followup_res {
Ok(f_disp) => {
let tok_in = brokecrew::estimate_tokens(&prompt)
+ brokecrew::estimate_tokens(&tool_results_summary);
let tok_out = brokecrew::estimate_tokens(&disp.reply)
+ brokecrew::estimate_tokens(&f_disp.reply);
let paid =
if disp.tier_used == Tier::Paid || f_disp.tier_used == Tier::Paid {
1
} else {
0
};
let _ = worker_tx
.send(WorkerEvent::Finished {
fell_back: disp.fell_back || f_disp.fell_back,
model_used: f_disp.model_used,
error: None,
calls_made: disp.attempts.len() + f_disp.attempts.len() + 2,
paid_calls_made: paid,
tokens_in: tok_in,
tokens_out: tok_out,
})
.await;
}
Err(e) => {
let tok_in = brokecrew::estimate_tokens(&prompt)
+ brokecrew::estimate_tokens(&tool_results_summary);
let tok_out = brokecrew::estimate_tokens(&disp.reply);
let _ = worker_tx
.send(WorkerEvent::Finished {
fell_back: disp.fell_back,
model_used: disp.model_used,
error: Some(format!("Tool synthesis failed: {e}")),
calls_made: disp.attempts.len() + 2,
paid_calls_made: if disp.tier_used == Tier::Paid {
1
} else {
0
},
tokens_in: tok_in,
tokens_out: tok_out,
})
.await;
}
}
} else {
let tok_in = brokecrew::estimate_tokens(&prompt);
let tok_out = brokecrew::estimate_tokens(&disp.reply);
let _ = worker_tx
.send(WorkerEvent::Finished {
fell_back: disp.fell_back,
model_used: disp.model_used,
error: None,
calls_made: disp.attempts.len() + 1,
paid_calls_made: if disp.tier_used == Tier::Paid { 1 } else { 0 },
tokens_in: tok_in,
tokens_out: tok_out,
})
.await;
}
}
Err(e) => {
let _ = worker_tx
.send(WorkerEvent::Finished {
fell_back: false,
model_used: "none".to_string(),
error: Some(e.to_string()),
calls_made: 1,
paid_calls_made: 0,
tokens_in: brokecrew::estimate_tokens(&prompt),
tokens_out: 0,
})
.await;
}
}
});
}