Skip to main content

sparrow/cmd_handlers/
handle_run_task_cmd.rs

1// src/cmd_handlers/handle_run_task_cmd.rs
2use super::prelude::*;
3
4/// Snapshot the relevant parts of the config for the recorder, with any
5/// inline secrets in provider entries replaced by "<redacted>". Lives here
6/// (rather than in main.rs) so any handler can reach it via the prelude.
7pub fn redacted_config_snapshot(config: &sparrow::config::Config) -> serde_json::Value {
8    let looks_like_inline_secret = super::handle_agent_cmd::looks_like_inline_secret;
9    serde_json::json!({
10        "theme": config.theme,
11        "autonomy": config.defaults.autonomy,
12        "sandbox": config.defaults.sandbox,
13        "budget": {
14            "daily": config.budget.daily_usd,
15            "session": config.budget.session_usd
16        },
17        "routing": {
18            "free_first": config.routing.free_first,
19            "policy": config.routing.policy,
20            "preferred_provider": config.routing.preferred_provider
21        },
22        "providers": config.providers.iter().map(|(k, v)| {
23            let api_key = match &v.api_key_env {
24                Some(env) if looks_like_inline_secret(env) => Some("<redacted>".to_string()),
25                Some(env) => Some(env.clone()),
26                None => None,
27            };
28            (k.clone(), serde_json::json!({
29                "adapter": v.adapter,
30                "models": v.models,
31                "api_key_env": api_key,
32                "has_key": v.api_key_env.as_ref()
33                    .map(|env| std::env::var(env).map(|v| !v.trim().is_empty()).unwrap_or(false))
34                    .unwrap_or(false)
35            }))
36        }).collect::<serde_json::Map<_, _>>()
37    })
38}
39
40pub async fn run_task(
41    task: &str,
42    config: &sparrow::config::Config,
43    memory: Arc<dyn Memory>,
44    skills: Arc<dyn SkillLibrary>,
45    recorder: Arc<FsRecorder>,
46    soul: Option<Soul>,
47    flags: RunFlags,
48) -> anyhow::Result<()> {
49    use sparrow::engine::Engine;
50    use sparrow::router::BasicRouter;
51    use std::sync::Arc;
52
53    let run_config = soul
54        .as_ref()
55        .map(|soul| config_for_soul(config, soul))
56        .unwrap_or_else(|| config.clone());
57
58    let providers = build_provider_brains(&run_config, &memory, true);
59
60    let router = Arc::new(BasicRouter::new(&run_config, providers));
61    let mut engine = Engine::new(router, run_config.clone())
62        .with_memory(memory.clone())
63        .with_skills(skills);
64    if let Some(soul) = &soul {
65        engine = engine.with_identity(soul.to_identity());
66    }
67
68    // ── Session continuity (§8) ───────────────────────────────────────────
69    // Load prior conversation so context follows the user across runs and
70    // surfaces. Key: $SPARROW_SESSION (set it to "user:<id>" to continue a
71    // Telegram/Slack thread) else a per-workspace CLI session.
72    let sessions =
73        sparrow::runtime::session::SessionStore::open(&run_config.state_dir.join("sessions.db"))
74            .ok()
75            .map(Arc::new);
76    let session_key = match flags.session_mode {
77        SessionMode::ContinueLast => {
78            // `sparrow --continue`: pick up the most recently updated session
79            // from ANY surface (CLI, console, gateway threads).
80            sessions
81                .as_ref()
82                .and_then(|s| s.list().into_iter().next())
83                .map(|s| s.id)
84                .unwrap_or_else(|| {
85                    std::env::var("SPARROW_SESSION").unwrap_or_else(|_| {
86                        format!(
87                            "cli:{}",
88                            std::env::current_dir()
89                                .map(|p| p.display().to_string())
90                                .unwrap_or_else(|_| "default".into())
91                        )
92                    })
93                })
94        }
95        _ => std::env::var("SPARROW_SESSION").unwrap_or_else(|_| {
96            format!(
97                "cli:{}",
98                std::env::current_dir()
99                    .map(|p| p.display().to_string())
100                    .unwrap_or_else(|_| "default".into())
101            )
102        }),
103    };
104    let prior_msgs: Vec<sparrow::provider::Msg> = if flags.session_mode == SessionMode::Fresh {
105        Vec::new()
106    } else {
107        sessions
108            .as_ref()
109            .and_then(|s| s.load(&session_key))
110            .and_then(|sess| match serde_json::from_str(&sess.messages_json) {
111                Ok(v) => Some(v),
112                Err(e) => {
113                    tracing::warn!("session '{}' deserialize failed: {}", session_key, e);
114                    None
115                }
116            })
117            .unwrap_or_default()
118    };
119    // Make continuity VISIBLE — silently carrying context surprises users.
120    if !prior_msgs.is_empty() {
121        eprintln!(
122            "\x1b[2m↩ continuing session ({} prior messages) — use --fresh to start clean\x1b[0m",
123            prior_msgs.len()
124        );
125    }
126
127    // ── Pre-run quote (estimate, then confirm) ─────────────────────────────
128    // Nobody else quotes BEFORE executing. Skipped with --yes or when stdin
129    // is not a TTY (CI, pipes) so automation never blocks.
130    let interactive = std::io::IsTerminal::is_terminal(&std::io::stdin());
131    if !flags.assume_yes && interactive {
132        let pf = engine.preflight(task);
133        let chain_disp = sparrow::engine::summarize_model_chain(&pf.chain, 3);
134        eprintln!(
135            "  plan: tier {} · est. {}–{}k tok · est. ${:.2}–${:.2} · route: {}",
136            pf.tier.as_str(),
137            (pf.est_input_range.0 + pf.est_output_range.0) / 1_000,
138            (pf.est_input_range.1 + pf.est_output_range.1) / 1_000,
139            pf.est_cost_range.0,
140            pf.est_cost_range.1,
141            chain_disp
142        );
143        let proceed = dialoguer::Confirm::new()
144            .with_prompt("  proceed?")
145            .default(true)
146            .interact()
147            .unwrap_or(true);
148        if !proceed {
149            eprintln!("  aborted — nothing was run, nothing was spent.");
150            return Ok(());
151        }
152    }
153
154    let task_obj = sparrow::engine::Task {
155        description: task.to_string(),
156        context: prior_msgs.clone(),
157    };
158
159    let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
160
161    let task_for_recording = task.to_string();
162    let config_snapshot = redacted_config_snapshot(&run_config);
163    let repo_head = current_repo_head();
164    let start_time = std::time::Instant::now();
165    eprintln!("\x1b[36m⚡ Sparrow running: {}\x1b[0m", task);
166    // v0.9 Pilier 2: in simple mode the status events render as plain-language
167    // lines via the humanize table; the technical labels are kept for pro mode.
168    let simple = run_config.experience.is_simple();
169    let lang = run_config.experience.lang();
170    let print_handle = tokio::spawn(async move {
171        let mut full_reply = String::new();
172        let mut reasoning_reply = String::new();
173        let mut think = sparrow::event::ThinkStripper::new();
174        use std::io::Write as _;
175        while let Some(event) = rx.recv().await {
176            if let sparrow::event::Event::ThinkingDelta { text, .. } = &event {
177                full_reply.push_str(text);
178            }
179            if let sparrow::event::Event::ReasoningDelta { text, .. } = &event {
180                reasoning_reply.push_str(text);
181            }
182            if let sparrow::event::Event::RunStarted { run, agent, .. } = &event {
183                recorder.start_run(
184                    run.0.clone(),
185                    RunInputs {
186                        task: task_for_recording.clone(),
187                        config_snapshot: config_snapshot.clone(),
188                        model_id: "router-selected".into(),
189                        repo_head: repo_head.clone(),
190                        timestamp: chrono::Utc::now().to_rfc3339(),
191                        agent: agent.clone(),
192                    },
193                );
194            }
195            recorder.record(&event);
196            if let sparrow::event::Event::RunFinished { run, .. } = &event {
197                let _ = recorder.finalize(&run.0);
198            }
199            match &event {
200                sparrow::event::Event::ThinkingDelta { text, .. } => {
201                    // Strip <think> reasoning blocks; stream the rest.
202                    let visible = think.feed(text);
203                    if !visible.is_empty() {
204                        print!("{}", visible);
205                        let _ = std::io::stdout().flush();
206                    }
207                }
208                sparrow::event::Event::ToolUseProposed { name, .. } => {
209                    if simple {
210                        if let Some(line) = sparrow::humanize::humanize(&event, lang) {
211                            println!("\n{}", line);
212                        }
213                    } else {
214                        println!("\n[Tool: {}]", name);
215                    }
216                }
217                sparrow::event::Event::ApprovalRequested { summary, .. } => {
218                    if simple {
219                        if let Some(line) = sparrow::humanize::humanize(&event, lang) {
220                            println!("\n{}", line);
221                        }
222                    } else {
223                        println!("\n[APPROVAL NEEDED: {}]", summary);
224                    }
225                }
226                sparrow::event::Event::CheckpointCreated { id, label, .. } => {
227                    if simple {
228                        if let Some(line) = sparrow::humanize::humanize(&event, lang) {
229                            println!("\n{}", line);
230                        }
231                    } else {
232                        println!("\n[Checkpoint: {} — {}]", id.0, label);
233                    }
234                }
235                sparrow::event::Event::ModelSwitched {
236                    from, to, reason, ..
237                } => {
238                    if simple {
239                        if let Some(line) = sparrow::humanize::humanize(&event, lang) {
240                            println!("\n{}", line);
241                        }
242                    } else {
243                        let clean = sparrow::event::friendly_model_switch_reason(reason);
244                        if sparrow::event::is_local_model_unavailable(reason) {
245                            println!(
246                                "\n[Routing] modèle local indisponible → routage modèle cloud ({})",
247                                to
248                            );
249                        } else {
250                            println!("\n[Routing] {} → {} ({})", from, to, clean);
251                        }
252                    }
253                }
254                // Cost is shown once at the end (no noisy inline $0.0000 prints).
255                sparrow::event::Event::RunFinished { outcome, .. } => {
256                    // Flush any text held back by the think-stripper (recovers an
257                    // unclosed <think> so the answer is never silently swallowed).
258                    let tail = think.flush();
259                    if !tail.trim().is_empty() {
260                        print!("{}", tail);
261                        let _ = std::io::stdout().flush();
262                    }
263                    if simple {
264                        // Plain-language wrap-up: one human sentence + one cost
265                        // line in centimes, no token jargon, no competitor table.
266                        if let Some(line) = sparrow::humanize::humanize(&event, lang) {
267                            println!("\n{}", line);
268                        }
269                        let usd = outcome.cost_usd;
270                        let cost_line = if usd <= 0.0 {
271                            "C'était gratuit.".to_string()
272                        } else if usd < 0.01 {
273                            "Coût : moins d'un centime.".to_string()
274                        } else {
275                            format!("Coût : environ {:.0} centimes.", usd * 100.0)
276                        };
277                        println!("{}", cost_line);
278                        if outcome.status == "completed" {
279                            println!("Pas convaincu ? « sparrow annule » remet tout comme avant.");
280                        }
281                    } else if outcome.status == "completed" {
282                        println!(
283                            "\nDone. Cost: ${:.4}, Tokens: {} in / {} out",
284                            outcome.cost_usd, outcome.tokens.input, outcome.tokens.output,
285                        );
286                        // Full cost comparison — Sparrow's competitive moat
287                        if outcome.tokens.input > 0 || outcome.tokens.output > 0 {
288                            println!(
289                                "{}",
290                                sparrow::cost::format_comparison(outcome.cost_usd, &outcome.tokens)
291                            );
292                        }
293                    } else {
294                        println!(
295                            "\nRun {}. Cost so far: ${:.4}, Tokens: {} in / {} out",
296                            outcome.status,
297                            outcome.cost_usd,
298                            outcome.tokens.input,
299                            outcome.tokens.output,
300                        );
301                    }
302                }
303                sparrow::event::Event::Error { message, .. }
304                    if !sparrow::event::is_local_model_unavailable(message) =>
305                {
306                    eprintln!("\nError: {}", message);
307                }
308                _ => {}
309            }
310        }
311        (full_reply, reasoning_reply)
312    });
313
314    println!("Running: {}", task);
315    let drive_result = engine.drive(task_obj, tx).await;
316    let (full_reply, reasoning_reply) = print_handle.await.unwrap_or_default();
317
318    // Persist the turn to the session BEFORE propagating any error, so a
319    // transient failure never erases the user's message from the conversation.
320    if let Some(store) = &sessions {
321        let mut updated = prior_msgs;
322        updated.push(sparrow::provider::Msg {
323            role: "user".into(),
324            content: vec![sparrow::provider::ContentBlock::Text {
325                text: task.to_string(),
326            }],
327        });
328        if !full_reply.trim().is_empty() {
329            let mut content = Vec::new();
330            if !reasoning_reply.trim().is_empty() {
331                content.push(sparrow::provider::ContentBlock::Reasoning {
332                    text: reasoning_reply,
333                });
334            }
335            content.push(sparrow::provider::ContentBlock::Text { text: full_reply });
336            updated.push(sparrow::provider::Msg {
337                role: "assistant".into(),
338                content,
339            });
340        }
341        let len = updated.len();
342        if len > 40 {
343            updated.drain(..len - 40);
344        }
345        let _ = store.save(&session_key, &updated, None);
346    }
347
348    let outcome = drive_result?;
349    let elapsed = start_time.elapsed();
350    println!(
351        "Status: {}  ⏱ {:.1}s",
352        outcome.status,
353        elapsed.as_secs_f64()
354    );
355    Ok(())
356}