1use super::prelude::*;
3
4pub 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 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 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 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 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 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 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 sparrow::event::Event::RunFinished { outcome, .. } => {
256 let tail = think.flush();
259 if !tail.trim().is_empty() {
260 print!("{}", tail);
261 let _ = std::io::stdout().flush();
262 }
263 if simple {
264 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 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 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}