1use async_trait::async_trait;
2use futures::StreamExt;
3use serde_json::json;
4use std::path::PathBuf;
5use std::sync::Arc;
6use tokio::sync::mpsc;
7
8use crate::agent::AgentStore;
9use crate::autonomy::{AutonomyContract, Checkpoints, GitCheckpoints};
10use crate::capabilities::{Curator, SkillLibrary};
11use crate::config::Config;
12use crate::event::{
13 AgentStatus, AutonomyLevel, Block, Decision, Event, OutcomeSummary, RiskLevel, RunId,
14 TokenUsage,
15};
16use crate::extras::Distiller;
17use crate::hooks::{HookEvent, HookRegistry};
18use crate::instructions::InstructionDoc;
19use crate::memory::{Fact, Memory, MemoryDoc, MemoryDocKind};
20use crate::permissions::PermissionContext;
21use crate::provider::{
22 Brain, BrainError, BrainEvent, BrainRequest, ContentBlock, ImageSource, Msg, PromptCacheConfig,
23 ToolSpec,
24};
25use crate::reasoning::ReasoningEngine;
26use crate::redaction::RedactionFilter;
27use crate::router::{BudgetState, Router, TaskTier};
28use crate::sandbox::Sandbox;
29use crate::tools::{ToolCtx, ToolRegistry};
30
31pub mod scorer;
32pub mod treesitter;
33
34pub use sparrow_core::Identity;
40
41pub struct BrainPolicy {
44 pub chain: Vec<Arc<dyn Brain>>,
46 pub current_index: usize,
47}
48
49impl BrainPolicy {
50 pub fn current(&self) -> Option<Arc<dyn Brain>> {
51 self.chain.get(self.current_index).cloned()
52 }
53
54 pub fn next(&mut self) -> Option<Arc<dyn Brain>> {
55 self.current_index += 1;
56 self.current()
57 }
58}
59
60pub struct Workspace {
63 pub root: PathBuf,
64 pub sandbox: Arc<dyn Sandbox>,
65}
66
67pub struct AgentRun {
70 pub id: RunId,
71 pub identity: Identity,
72 pub brain_policy: BrainPolicy,
73 pub autonomy: AutonomyContract,
74 pub tools: Arc<ToolRegistry>,
75 pub workspace: Workspace,
76}
77
78fn estimate_text_tokens(text: &str) -> u64 {
79 let chars = text.chars().count() as u64;
80 ((chars + 3) / 4).max(1)
81}
82
83fn estimate_content_tokens(blocks: &[ContentBlock]) -> u64 {
84 blocks
85 .iter()
86 .map(|block| match block {
87 ContentBlock::Text { text } => estimate_text_tokens(text),
88 ContentBlock::Image { source } => match source {
89 crate::provider::ImageSource::Base64 { data, .. } => {
90 256 + estimate_text_tokens(data).min(2_000)
91 }
92 crate::provider::ImageSource::Url { url } => 256 + estimate_text_tokens(url),
93 },
94 ContentBlock::ToolUse { name, input, .. } => {
95 estimate_text_tokens(name) + estimate_text_tokens(&input.to_string())
96 }
97 ContentBlock::ToolResult { content, .. } => 8 + estimate_content_tokens(content),
98 ContentBlock::Reasoning { text } => estimate_text_tokens(text),
99 })
100 .sum()
101}
102
103fn estimate_request_tokens(req: &BrainRequest) -> u64 {
104 let system = req.system.as_deref().map(estimate_text_tokens).unwrap_or(0);
105 let messages: u64 = req
106 .messages
107 .iter()
108 .map(|msg| estimate_text_tokens(&msg.role) + estimate_content_tokens(&msg.content) + 4)
109 .sum();
110 let tools: u64 = req
111 .tools
112 .iter()
113 .map(|tool| {
114 estimate_text_tokens(&tool.name)
115 + estimate_text_tokens(&tool.description)
116 + estimate_text_tokens(&tool.input_schema.to_string())
117 })
118 .sum();
119 system + messages + tools
120}
121
122fn base64_encode(data: &[u8]) -> String {
123 const CHARS: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
124 let mut out = String::with_capacity(data.len().div_ceil(3) * 4);
125 for chunk in data.chunks(3) {
126 let b0 = chunk[0] as u32;
127 let b1 = if chunk.len() > 1 { chunk[1] as u32 } else { 0 };
128 let b2 = if chunk.len() > 2 { chunk[2] as u32 } else { 0 };
129 let triple = (b0 << 16) | (b1 << 8) | b2;
130 out.push(CHARS[((triple >> 18) & 63) as usize] as char);
131 out.push(CHARS[((triple >> 12) & 63) as usize] as char);
132 out.push(if chunk.len() > 1 {
133 CHARS[((triple >> 6) & 63) as usize] as char
134 } else {
135 '='
136 });
137 out.push(if chunk.len() > 2 {
138 CHARS[(triple & 63) as usize] as char
139 } else {
140 '='
141 });
142 }
143 out
144}
145
146fn image_block_from_path(path: &std::path::Path) -> Option<ContentBlock> {
147 let mime = mime_guess::from_path(path).first_or_octet_stream();
148 if !mime.type_().as_str().eq_ignore_ascii_case("image") {
149 return None;
150 }
151 let data = std::fs::read(path).ok()?;
152 Some(ContentBlock::Image {
153 source: ImageSource::Base64 {
154 media_type: mime.to_string(),
155 data: base64_encode(&data),
156 },
157 })
158}
159
160fn collect_uploaded_paths(description: &str) -> Vec<String> {
161 let mut paths = Vec::new();
162 for line in description.lines() {
163 let Some(idx) = line.find("uploaded:") else {
164 continue;
165 };
166 let rest = line[idx + "uploaded:".len()..].trim();
167 let path = rest
168 .strip_prefix('[')
169 .unwrap_or(rest)
170 .split(']')
171 .next()
172 .unwrap_or(rest)
173 .trim()
174 .trim_matches('"')
175 .trim_matches('\'');
176 if !path.is_empty() {
177 paths.push(path.to_string());
178 }
179 }
180 paths
181}
182
183fn initial_user_content_blocks(
184 workspace_root: &std::path::Path,
185 description: &str,
186) -> Vec<ContentBlock> {
187 let mut blocks = vec![ContentBlock::Text {
188 text: description.to_string(),
189 }];
190 let mut seen = std::collections::HashSet::new();
191 for raw_path in collect_uploaded_paths(description) {
192 let path = std::path::PathBuf::from(&raw_path);
193 let full_path = if path.is_absolute() {
194 path
195 } else {
196 workspace_root.join(path)
197 };
198 if !seen.insert(full_path.clone()) {
199 continue;
200 }
201 if let Some(block) = image_block_from_path(&full_path) {
202 blocks.push(block);
203 }
204 }
205 blocks
206}
207
208pub fn summarize_model_chain(chain_ids: &[String], limit: usize) -> String {
209 if chain_ids.is_empty() {
210 return "aucun modèle disponible".into();
211 }
212 let limit = limit.max(1);
213 let mut visible: Vec<String> = chain_ids.iter().take(limit).cloned().collect();
214 if chain_ids.len() > limit {
215 visible.push(format!("+{} autres fallbacks", chain_ids.len() - limit));
216 }
217 visible.join(" -> ")
218}
219
220fn strip_ui_status_leaks(text: &str) -> String {
221 text.lines()
222 .filter(|line| {
223 let lower = line.to_lowercase();
224 !((lower.contains(" completed ·") && lower.contains('↑') && lower.contains('↓'))
225 || (lower.contains("◌") && lower.contains("consulting"))
226 || (lower.contains("parsing request") && lower.contains("consulting")))
227 })
228 .collect::<Vec<_>>()
229 .join("\n")
230}
231
232fn sanitize_messages_for_provider(messages: &[Msg]) -> Vec<Msg> {
233 messages
234 .iter()
235 .map(|msg| Msg {
236 role: msg.role.clone(),
237 content: msg
238 .content
239 .iter()
240 .filter_map(|block| match block {
241 ContentBlock::Text { text } => {
242 let cleaned = strip_ui_status_leaks(text);
243 if cleaned.trim().is_empty() {
244 None
245 } else {
246 Some(ContentBlock::Text { text: cleaned })
247 }
248 }
249 ContentBlock::Reasoning { text } => Some(ContentBlock::Reasoning {
250 text: strip_ui_status_leaks(text),
251 }),
252 ContentBlock::ToolResult {
253 tool_use_id,
254 content,
255 is_error,
256 } => Some(ContentBlock::ToolResult {
257 tool_use_id: tool_use_id.clone(),
258 content: sanitize_messages_for_provider(&[Msg {
259 role: "tool".into(),
260 content: content.clone(),
261 }])
262 .into_iter()
263 .next()
264 .map(|m| m.content)
265 .unwrap_or_default(),
266 is_error: *is_error,
267 }),
268 other => Some(other.clone()),
269 })
270 .collect(),
271 })
272 .collect()
273}
274
275fn prompt_cache_key(scope: &str, workspace_root: &std::path::Path, tools: &[ToolSpec]) -> String {
276 use std::hash::{Hash, Hasher};
277
278 let mut hasher = std::collections::hash_map::DefaultHasher::new();
279 scope.hash(&mut hasher);
280 workspace_root.display().to_string().hash(&mut hasher);
281 for tool in tools {
282 tool.name.hash(&mut hasher);
283 tool.description.hash(&mut hasher);
284 tool.input_schema.to_string().hash(&mut hasher);
285 }
286 format!("sparrow-{}-{:016x}", scope, hasher.finish())
287}
288
289fn read_git_context(workspace_root: &PathBuf) -> Option<String> {
295 use std::process::Command;
296 use std::time::Duration;
297 if !workspace_root.join(".git").exists() {
298 return None;
299 }
300 fn run(workspace_root: &PathBuf, args: &[&str]) -> Option<String> {
301 let mut cmd = Command::new("git");
302 cmd.arg("-C").arg(workspace_root).args(args);
303 let child = cmd
304 .stdout(std::process::Stdio::piped())
305 .stderr(std::process::Stdio::null())
306 .spawn()
307 .ok()?;
308 let deadline = std::time::Instant::now() + Duration::from_millis(1_500);
311 let mut child = child;
312 loop {
313 match child.try_wait().ok()? {
314 Some(_) => break,
315 None if std::time::Instant::now() > deadline => {
316 let _ = child.kill();
317 return None;
318 }
319 None => std::thread::sleep(Duration::from_millis(20)),
320 }
321 }
322 let output = child.wait_with_output().ok()?;
323 if !output.status.success() {
324 return None;
325 }
326 let s = String::from_utf8(output.stdout).ok()?;
327 Some(s.trim().to_string())
328 }
329
330 let branch = run(workspace_root, &["rev-parse", "--abbrev-ref", "HEAD"])
331 .filter(|b| !b.is_empty())
332 .unwrap_or_else(|| "(detached)".into());
333 let head = run(workspace_root, &["rev-parse", "--short", "HEAD"]).unwrap_or_default();
334 let head_subject = run(workspace_root, &["log", "-1", "--pretty=%s"]).unwrap_or_default();
335 let status_porcelain = run(workspace_root, &["status", "--porcelain"]).unwrap_or_default();
336
337 let mut block = String::from("## Git context\n");
338 block.push_str(&format!("- branch: `{}`\n", branch));
339 if !head.is_empty() {
340 if head_subject.is_empty() {
341 block.push_str(&format!("- HEAD: `{}`\n", head));
342 } else {
343 block.push_str(&format!("- HEAD: `{}` — {}\n", head, head_subject));
344 }
345 }
346 if status_porcelain.is_empty() {
347 block.push_str("- working tree: clean\n");
348 } else {
349 let lines: Vec<&str> = status_porcelain.lines().collect();
350 let shown: Vec<&str> = lines.iter().take(8).copied().collect();
351 block.push_str(&format!("- working tree: {} dirty file(s)\n", lines.len()));
352 for line in shown {
353 block.push_str(&format!(" {}\n", line));
354 }
355 if lines.len() > 8 {
356 block.push_str(&format!(" … {} more\n", lines.len() - 8));
357 }
358 }
359 block.push_str(
360 "\nUse this snapshot to ground answers about \"what changed\" or \
361 \"what branch are we on\" without re-running git. It is the state \
362 at the start of THIS run; if you make file edits, the snapshot \
363 here is stale by the next turn.",
364 );
365 Some(block)
366}
367
368struct SystemPromptInput<'a> {
369 identity: &'a Identity,
370 tier: Option<&'a crate::router::TaskTier>,
371 workspace_root: &'a PathBuf,
372 facts: &'a [Fact],
373 memory_docs: &'a [MemoryDoc],
374 instruction_docs: &'a [InstructionDoc],
375 skills: &'a [crate::capabilities::Skill],
376 skill_catalog: &'a [crate::capabilities::Skill],
377}
378
379fn build_system_prompt(input: SystemPromptInput<'_>) -> String {
380 let identity = input.identity;
381 let tier = input.tier;
382 let workspace_root = input.workspace_root;
383 let facts = input.facts;
384 let memory_docs = input.memory_docs;
385 let instruction_docs = input.instruction_docs;
386 let skills = input.skills;
387 let skill_catalog = input.skill_catalog;
388 let lean_prompt = matches!(
389 tier,
390 Some(crate::router::TaskTier::Trivial | crate::router::TaskTier::Small)
391 );
392 let mut parts = vec![format!(
393 r#"You are {name}, a {role}.
394
395Personality: {personality}
396
397## Non-negotiable Sparrow identity
398You are operating inside Sparrow. Sparrow is your public product identity; `{name}`
399is your current internal agent role. If the user asks who or what you are, answer
400as Sparrow (or Sparrow's `{name}` agent), never as the underlying provider/model.
401Do not say you are Claude, GPT, Qwen, Gemini, or any routed backend model unless
402the user explicitly asks which backend implementation is currently being used.
403
404You are working in the workspace: {workspace}
405You have access to tools to read, write, edit, search, and execute code.
406Always use absolute or relative paths from the workspace root.
407Be concise and direct. When making edits, use exact string replacements.
408Before making changes, read the relevant files first to understand the codebase.
409
410You are not a standalone chat model. You are the Sparrow agent surface backed by an
411external routing engine. Sparrow's core feature is automatic model routing: every
412task is classified by tier, tool need, vision need, local preference, budget, and
413provider availability, then a ranked fallback chain of models is selected before
414this answer starts. If the user asks how routing works, explain Sparrow's actual
415pipeline and the active route for the current run. Never claim that no routing
416exists just because the current brain is a single selected model.
417
418## When to spawn sub-agents (proactively)
419You have a `subagent_spawn` tool. Use it on your own initiative — do not wait for
420the user to ask — whenever the request contains independent sub-problems that can
421run in parallel, or a long-running step that would block the main flow:
422- multi-file refactors across unrelated modules (one subagent per module)
423- "implement X, then test it" → spawn a verifier subagent in parallel
424- research a library/API while you scaffold code locally
425- audit-style requests with several independent checks
426- any plan with 3+ distinct, separable work items
427
428For trivial single-step tasks (one read, one edit, one question) stay solo —
429spawning is overhead, not a goal. Announce sub-agents you spawn so the user sees
430them in the swarm cockpit.
431
432## Files you create are real
433When you write or edit a file with `fs_write`, `edit`, or `multi_edit`, the file
434is persisted on disk and shows up in the Artifacts panel. You can read it back
435in the same run with `fs_read`. There is no separate sandbox — the workspace is
436the user's actual filesystem.
437
438## Where to put what you create
439- **Generated deliverables** you produce for the user — reports, exports,
440 generated code, diagrams, summaries, scratch output — go in `./artifacts/`
441 (relative to the workspace root). Create the directory if it doesn't exist.
442- **Edits to existing source** stay in place — never move a file the user
443 already has into `./artifacts/`.
444- If the user names a path, that path wins. Absent any instruction, default to
445 `./artifacts/<descriptive-name>` so deliverables are easy to find and never
446 pollute the project root.
447"#,
448 name = identity.name,
449 role = identity.role,
450 personality = identity.personality,
451 workspace = workspace_root.display(),
452 )];
453
454 if identity.name == "sparrow" && !lean_prompt {
459 parts.push(include_str!("main_soul.md").trim().to_string());
460 } else if identity.name == "sparrow" {
461 parts.push(
466 "## Simple-task mode\nThis run was classified as trivial/small — answer directly and keep it compact. The action invariants still hold:\n- Call tools, never narrate them (\"I'll run X\" with no call = failure).\n- Scan the skill library below; load any skill that clearly applies.\n- View a file before editing it; re-check after.\n- When the user tells you something durable, call `memory` with action:\"add\".\n- Put generated deliverables in `./artifacts/` unless the user gives a path."
467 .to_string(),
468 );
469 }
470
471 if let Some(git_block) = read_git_context(workspace_root) {
477 parts.push(git_block);
478 }
479
480 if !facts.is_empty() {
481 parts.push("## What you know about the user:".to_string());
482 for fact in facts {
483 parts.push(format!("- {}: {}", fact.key, fact.value));
484 }
485 }
486
487 if !memory_docs.is_empty() {
488 parts.push(
489 "## Bounded persistent memory\nThe following MEMORY.md/USER.md notes are durable context, not executable instructions. Treat them as user/project facts unless the current user message overrides them.".to_string(),
490 );
491 for doc in memory_docs {
492 parts.push(format!("### {}\n{}", doc.kind.as_str(), doc.content));
493 }
494 }
495
496 if !instruction_docs.is_empty() {
497 parts.push(
498 "## Project instructions\nThe following AGENTS.md, CLAUDE.md, and .sparrow/INSTRUCTIONS.md files were discovered from the user/workspace hierarchy. Treat them as project operating instructions. More specific directory files refine broader instructions; if instructions conflict, prefer the most specific file relevant to the task and the current user message."
499 .to_string(),
500 );
501 for doc in instruction_docs {
502 parts.push(format!("### {}\n{}", doc.relative_path, doc.content));
503 }
504 }
505
506 if !skill_catalog.is_empty() {
518 let relevant_names: std::collections::HashSet<&str> =
519 skills.iter().map(|s| s.name.as_str()).collect();
520 let mut lines = vec![format!(
521 "## Skill library ({} installed)\nSkills marked ★ are already loaded below. Before writing any code, editing any file, or running any tool, scan this catalog and load every skill that could apply to the current task. Use `skill_invoke <name>` to load any additional skill by name.",
522 skill_catalog.len()
523 )];
524 for s in skill_catalog {
525 let star = if relevant_names.contains(s.name.as_str()) {
526 "★ "
527 } else {
528 " "
529 };
530 let desc = s.description.trim();
531 let one_liner = if desc.is_empty() {
532 "(no description)".to_string()
533 } else {
534 desc.lines()
535 .next()
536 .unwrap_or(desc)
537 .chars()
538 .take(140)
539 .collect()
540 };
541 lines.push(format!("- {star}**{}** — {}", s.name, one_liner));
542 }
543 parts.push(lines.join("\n"));
544 }
545
546 if !skills.is_empty() {
547 parts.push("## Relevant skills for this task (full body):".to_string());
548 for skill in skills {
549 parts.push(format!("### {}\n{}", skill.name, skill.body));
550 }
551 }
552
553 parts.join("\n\n")
554}
555
556fn tool_result_text(blocks: &[Block]) -> String {
557 let mut out = Vec::new();
558 for block in blocks {
559 match block {
560 Block::Text(text) => out.push(text.clone()),
561 Block::Json(value) => out.push(value.to_string()),
562 Block::Image { mime, data } => {
563 out.push(format!("[image: {}, {} bytes]", mime, data.len()));
564 }
565 Block::Diff { file, patch } => out.push(format!("diff for {}\n{}", file, patch)),
566 }
567 }
568 out.join("\n")
569}
570
571fn humanize_tool_action(tool_name: &str, args: &serde_json::Value) -> String {
572 let path = args
573 .get("path")
574 .or_else(|| args.get("file_path"))
575 .and_then(|v| v.as_str());
576 match (tool_name, path) {
577 ("fs_write", Some(path)) => format!("Sparrow veut créer ou remplacer `{path}`."),
578 ("edit" | "multi_edit", Some(path)) => format!("Sparrow veut modifier `{path}`."),
579 ("fs_read", Some(path)) => format!("Sparrow veut lire `{path}`."),
580 ("exec", _) => "Sparrow veut exécuter une commande.".to_string(),
581 (name, Some(path)) => format!("Sparrow veut lancer `{name}` sur `{path}`."),
582 (name, None) => format!("Sparrow veut lancer `{name}`."),
583 }
584}
585
586fn tool_result_content_blocks(blocks: &[Block]) -> Vec<ContentBlock> {
587 let mut out = Vec::new();
588 let text = tool_result_text(blocks);
589 if !text.trim().is_empty() {
590 out.push(ContentBlock::Text { text });
591 }
592 for block in blocks {
593 if let Block::Image { data, mime } = block {
594 out.push(ContentBlock::Image {
595 source: ImageSource::Base64 {
596 media_type: mime.clone(),
597 data: base64_encode(data),
598 },
599 });
600 }
601 }
602 out
603}
604
605fn events_from_messages(run_id: &RunId, messages: &[Msg]) -> Vec<Event> {
609 let mut events = Vec::new();
610 for msg in messages {
611 for block in &msg.content {
612 match block {
613 ContentBlock::ToolUse { name, input, .. } => {
614 events.push(Event::ToolUseProposed {
615 run: run_id.clone(),
616 id: String::new(),
617 name: name.clone(),
618 args: input.clone(),
619 risk: RiskLevel::ReadOnly,
620 });
621 }
622 ContentBlock::Text { text } if msg.role == "assistant" => {
623 events.push(Event::ThinkingDelta {
624 run: run_id.clone(),
625 text: text.clone(),
626 });
627 }
628 _ => {}
629 }
630 }
631 }
632 events
633}
634
635#[derive(Debug, Clone)]
638pub struct Task {
639 pub description: String,
640 pub context: Vec<Msg>,
641}
642
643#[derive(Debug, Clone)]
646pub struct Preflight {
647 pub tier: TaskTier,
648 pub chain: Vec<String>,
649 pub est_input_range: (u64, u64),
650 pub est_output_range: (u64, u64),
651 pub est_cost_range: (f64, f64),
652}
653
654pub struct Engine {
657 router: Arc<dyn Router>,
658 config: Config,
659 identity: Option<Identity>,
660 memory: Option<Arc<dyn Memory>>,
661 skills: Option<Arc<dyn SkillLibrary>>,
662 redaction: RedactionFilter,
663 approval_handler: Option<Arc<dyn ApprovalHandler>>,
664 reasoning: ReasoningEngine,
665 hooks: HookRegistry,
666 agent_store: Option<Arc<dyn AgentStore>>,
667 org_policy: Option<crate::onboarding::enterprise::OrgPolicy>,
668 classify_cache: std::sync::Mutex<std::collections::HashMap<u64, crate::router::TaskTier>>,
670}
671
672#[derive(Debug, Clone)]
673pub struct ApprovalRequest {
674 pub run: RunId,
675 pub id: String,
676 pub tool_name: String,
677 pub risk: RiskLevel,
678 pub args: serde_json::Value,
679 pub summary: String,
680}
681
682#[async_trait]
683pub trait ApprovalHandler: Send + Sync {
684 async fn request_approval(&self, request: ApprovalRequest) -> Decision;
685}
686
687impl Engine {
688 pub fn new(router: Arc<dyn Router>, config: Config) -> Self {
689 let mut hooks = HookRegistry::new(Arc::new(crate::sandbox::LocalSandbox::new(
690 std::env::current_dir().unwrap_or_default(),
691 )));
692 hooks.load(config.hooks.clone());
693 Self {
694 router,
695 config,
696 identity: None,
697 memory: None,
698 skills: None,
699 redaction: RedactionFilter::new(),
700 approval_handler: None,
701 reasoning: ReasoningEngine::default(),
702 hooks,
703 agent_store: None,
704 org_policy: None,
705 classify_cache: std::sync::Mutex::new(std::collections::HashMap::new()),
706 }
707 }
708
709 pub fn with_memory(mut self, memory: Arc<dyn Memory>) -> Self {
710 let secrets: Vec<String> = memory
712 .all_facts()
713 .iter()
714 .filter(|f| f.key.starts_with("secret:"))
715 .map(|f| f.value.clone())
716 .collect();
717 self.redaction.load_secrets(secrets);
718 self.memory = Some(memory);
719 self
720 }
721
722 pub fn with_skills(mut self, skills: Arc<dyn SkillLibrary>) -> Self {
723 self.skills = Some(skills);
724 self
725 }
726
727 pub fn with_identity(mut self, identity: Identity) -> Self {
728 self.identity = Some(identity);
729 self
730 }
731
732 pub fn with_agent_store(mut self, store: Arc<dyn AgentStore>) -> Self {
733 self.agent_store = Some(store);
734 self
735 }
736
737 pub fn with_org_policy(mut self, policy: crate::onboarding::enterprise::OrgPolicy) -> Self {
738 self.org_policy = Some(policy);
739 self
740 }
741
742 pub fn with_hooks_config(mut self, hooks: Vec<crate::hooks::Hook>) -> Self {
743 self.hooks.load(hooks);
744 self
745 }
746
747 pub fn with_approval_handler(mut self, approval_handler: Arc<dyn ApprovalHandler>) -> Self {
748 self.approval_handler = Some(approval_handler);
749 self
750 }
751
752 fn classify_with_confidence(&self, task: &str) -> (TaskTier, bool) {
757 let lower = task.to_lowercase();
758 if lower.contains("vision") || lower.contains("image") || lower.contains("screenshot") {
759 (TaskTier::Vision, false)
760 } else if lower.contains("architecture")
761 || lower.contains("refactor")
762 || lower.contains("audit")
763 || lower.contains("répare")
764 || lower.contains("repare")
765 || lower.contains("livrer")
766 || lower.contains("v1")
767 {
768 (TaskTier::Hard, false)
769 } else if lower.contains("bug")
770 || lower.contains("fix")
771 || lower.contains("corrige")
772 || lower.contains("debug")
773 {
774 (TaskTier::Small, false)
775 } else if lower.contains("routing")
776 || lower.contains("routeur")
777 || lower.contains("modèle")
778 || lower.contains("modele")
779 || lower.contains("model")
780 || lower.contains("sélectionne")
781 || lower.contains("selectionne")
782 {
783 (TaskTier::Small, false)
784 } else if lower.len() < 80 {
785 (TaskTier::Trivial, true)
787 } else {
788 (TaskTier::Medium, true)
789 }
790 }
791
792 async fn classify_via_brain(&self, task: &str, brain: &dyn Brain) -> Option<TaskTier> {
795 let req = BrainRequest {
796 system: Some(
797 "You are a task classifier. Output exactly one word: trivial, small, medium, hard, or vision."
798 .into(),
799 ),
800 messages: vec![Msg {
801 role: "user".into(),
802 content: vec![ContentBlock::Text {
803 text: format!(
804 "Classify this coding task into exactly one tier (trivial, small, medium, hard, vision):\n\n{}\n\nTier:",
805 task
806 ),
807 }],
808 }],
809 tools: vec![],
810 max_tokens: 6,
811 temperature: 0.0,
812 stop: vec![],
813 cache: PromptCacheConfig::disabled(),
814 };
815 let mut stream = brain.complete(req).await.ok()?;
816 let mut out = String::new();
817 while let Some(ev) = stream.next().await {
818 match ev {
819 BrainEvent::TextDelta(t) => out.push_str(&t),
820 BrainEvent::Done(_) => break,
821 BrainEvent::Error(_) => return None,
822 _ => {}
823 }
824 }
825 let word = out.trim().to_lowercase();
826 let word = word.split_whitespace().next().unwrap_or("");
827 match word {
828 "trivial" => Some(TaskTier::Trivial),
829 "small" => Some(TaskTier::Small),
830 "medium" => Some(TaskTier::Medium),
831 "hard" => Some(TaskTier::Hard),
832 "vision" => Some(TaskTier::Vision),
833 _ => None,
834 }
835 }
836
837 fn task_summary(&self, task: &str, tier: &TaskTier) -> String {
838 let lower = task.to_lowercase();
839 if lower.contains("routing")
840 || lower.contains("routeur")
841 || lower.contains("modèle")
842 || lower.contains("modele")
843 || lower.contains("model")
844 {
845 "question meta sur le routing modele".into()
846 } else if lower.contains("code") || lower.contains("bug") || lower.contains("fix") {
847 format!("requete code/{:?}", tier).to_lowercase()
848 } else if lower.contains("config") || lower.contains("provider") {
849 "configuration provider/modele".into()
850 } else {
851 format!("requete {:?}", tier).to_lowercase()
852 }
853 }
854
855 fn is_routing_question(&self, task: &str) -> bool {
856 let lower = task.to_lowercase();
857 (lower.contains("routing") || lower.contains("routeur") || lower.contains("route"))
858 && (lower.contains("modèle") || lower.contains("modele") || lower.contains("model"))
859 || lower.contains("sélectionne tu le model")
860 || lower.contains("selectionne tu le model")
861 }
862
863 fn requires_tools(&self, task: &str, tier: &TaskTier) -> bool {
864 let lower = task.to_lowercase();
865 let tool_keywords = [
866 "outil",
867 "tools",
868 "fichier",
869 "file",
870 "readme",
871 ".rs",
872 ".ts",
873 ".js",
874 ".html",
875 ".md",
876 "repo",
877 "dossier",
878 "workspace",
879 "git",
880 "test",
881 "build",
882 "cargo",
883 "npm",
884 "pnpm",
885 "corrige",
886 "fix",
887 "debug",
888 "bug",
889 "répare",
890 "repare",
891 "modifie",
892 "édite",
893 "edite",
894 "ajoute",
895 "supprime",
896 "écris",
897 "ecris",
898 "write",
899 "create",
900 "crée",
901 "cree",
902 "audit",
903 ];
904
905 if tool_keywords.iter().any(|kw| lower.contains(kw)) {
906 return true;
907 }
908
909 matches!(tier, TaskTier::Medium | TaskTier::Hard | TaskTier::Vision)
910 }
911
912 fn requires_vision(&self, task: &str, tier: &TaskTier) -> bool {
913 let lower = task.to_lowercase();
914 matches!(tier, TaskTier::Vision)
915 || [
916 "image",
917 "screenshot",
918 "capture",
919 "photo",
920 "vision",
921 "logo",
922 "visuel",
923 "interface graphique",
924 ]
925 .iter()
926 .any(|kw| lower.contains(kw))
927 }
928
929 fn routing_explanation(
930 &self,
931 tier: &TaskTier,
932 need: &crate::router::RoutingNeed,
933 chain_ids: &[String],
934 ) -> String {
935 let chain = summarize_model_chain(chain_ids, 5);
936 format!(
937 "Je suis Sparrow, donc je ne réponds pas comme un modèle isolé: avant chaque run, mon routeur classe ta demande puis choisit une chaîne de modèles.\n\nPour cette requête, j'ai détecté: tier `{}` · tools `{}` · vision `{}` · local `{}`.\n\nJe sélectionne ensuite le modèle avec ces critères: adéquation aux capacités demandées, support des tools, besoin vision, préférence local/free-first, budget restant, latence, taille de contexte, puis disponibilité provider. Le résultat est une fallback chain, pas un seul choix figé: `{}`.\n\nConcrètement: une question simple ou meta doit aller vers le modèle le moins coûteux capable de répondre; une tâche code complexe monte vers un modèle plus fort; une tâche avec fichiers/tools exige un modèle compatible tools; une tâche image demande vision; si un provider échoue, je bascule au suivant dans la chaîne.",
938 tier.as_str(),
939 need.required_tools,
940 need.required_vision,
941 need.prefer_local,
942 chain
943 )
944 }
945
946 async fn summarize_messages(&self, brain: &dyn Brain, middle: &[Msg]) -> Option<String> {
949 if middle.is_empty() {
950 return None;
951 }
952 let mut transcript = String::new();
954 for m in middle {
955 for block in &m.content {
956 match block {
957 ContentBlock::Text { text } => {
958 transcript.push_str(&format!("[{}] {}\n", m.role, text));
959 }
960 ContentBlock::ToolUse { name, .. } => {
961 transcript.push_str(&format!("[{}] (tool: {})\n", m.role, name));
962 }
963 ContentBlock::ToolResult { .. } => {
964 transcript.push_str(&format!("[{}] (tool result)\n", m.role));
965 }
966 _ => {}
967 }
968 }
969 }
970 if transcript.len() > 12_000 {
971 transcript.truncate(12_000);
972 }
973 let req = BrainRequest {
974 system: Some(
975 "Summarize this agent conversation in <=200 tokens. Preserve: files edited, \
976 decisions made, current state, and any unfinished work. Plain text only."
977 .into(),
978 ),
979 messages: vec![Msg {
980 role: "user".into(),
981 content: vec![ContentBlock::Text { text: transcript }],
982 }],
983 tools: vec![],
984 max_tokens: 300,
985 temperature: 0.0,
986 stop: vec![],
987 cache: PromptCacheConfig::disabled(),
988 };
989 let mut stream = brain.complete(req).await.ok()?;
990 let mut out = String::new();
991 while let Some(ev) = stream.next().await {
992 match ev {
993 BrainEvent::TextDelta(t) => out.push_str(&t),
994 BrainEvent::Done(_) => break,
995 BrainEvent::Error(_) => return None,
996 _ => {}
997 }
998 }
999 let out = out.trim().to_string();
1000 if out.is_empty() { None } else { Some(out) }
1001 }
1002
1003 pub fn preflight(&self, task_desc: &str) -> Preflight {
1007 let (tier, _ambiguous) = self.classify_with_confidence(task_desc);
1008 let need = crate::router::RoutingNeed {
1009 tier: tier.clone(),
1010 required_tools: self.requires_tools(task_desc, &tier),
1011 required_vision: self.requires_vision(task_desc, &tier),
1012 prefer_local: false,
1013 };
1014 let budget = BudgetState {
1015 daily_limit_usd: self.config.budget.daily_usd,
1016 daily_spent_usd: 0.0,
1017 session_limit_usd: self.config.budget.session_usd,
1018 session_spent_usd: 0.0,
1019 };
1020 let chain = self.router.select(&need, &budget);
1021 let (in_lo, in_hi, out_lo, out_hi): (u64, u64, u64, u64) = match tier {
1023 TaskTier::Trivial => (800, 4_000, 100, 1_000),
1024 TaskTier::Small => (3_000, 12_000, 500, 3_000),
1025 TaskTier::Medium => (8_000, 40_000, 2_000, 10_000),
1026 TaskTier::Hard => (25_000, 120_000, 5_000, 25_000),
1027 TaskTier::Vision => (8_000, 40_000, 2_000, 8_000),
1028 };
1029 let price = chain.first().map(|b| b.caps());
1030 let cost = |tin: u64, tout: u64| -> f64 {
1031 price
1032 .as_ref()
1033 .map(|c| {
1034 tin as f64 * c.cost_input_per_mtok / 1_000_000.0
1035 + tout as f64 * c.cost_output_per_mtok / 1_000_000.0
1036 })
1037 .unwrap_or(0.0)
1038 };
1039 Preflight {
1040 tier,
1041 chain: chain.iter().map(|b| b.id().to_string()).collect(),
1042 est_input_range: (in_lo, in_hi),
1043 est_output_range: (out_lo, out_hi),
1044 est_cost_range: (cost(in_lo, out_lo), cost(in_hi, out_hi)),
1045 }
1046 }
1047
1048 pub async fn drive(
1050 &self,
1051 task: Task,
1052 event_tx: mpsc::UnboundedSender<Event>,
1053 ) -> anyhow::Result<OutcomeSummary> {
1054 self.drive_with_run_id(task, event_tx, RunId::new()).await
1055 }
1056
1057 pub async fn drive_with_run_id(
1059 &self,
1060 task: Task,
1061 event_tx: mpsc::UnboundedSender<Event>,
1062 run_id: RunId,
1063 ) -> anyhow::Result<OutcomeSummary> {
1064 self.drive_with_inject(task, event_tx, run_id, None).await
1065 }
1066
1067 pub async fn drive_with_inject(
1070 &self,
1071 task: Task,
1072 event_tx: mpsc::UnboundedSender<Event>,
1073 run_id: RunId,
1074 mut inject_rx: Option<mpsc::UnboundedReceiver<String>>,
1075 ) -> anyhow::Result<OutcomeSummary> {
1076 let model_override: Option<String>;
1078 let clean_description: String;
1079 if let Some(rest) = task.description.strip_prefix("__model:") {
1080 if let Some(end) = rest.find("__ ") {
1081 model_override = Some(rest[..end].to_string());
1082 clean_description = rest[end + 3..].to_string();
1083 } else {
1084 model_override = None;
1085 clean_description = task.description.clone();
1086 }
1087 } else {
1088 model_override = None;
1089 clean_description = task.description.clone();
1090 }
1091 let task = Task {
1092 description: clean_description,
1093 context: task.context,
1094 };
1095
1096 let mut messages: Vec<Msg> = task.context.clone();
1097
1098 let (mut tier, ambiguous) = self.classify_with_confidence(&task.description);
1100
1101 let budget = BudgetState {
1103 daily_limit_usd: self.config.budget.daily_usd,
1104 daily_spent_usd: 0.0,
1105 session_limit_usd: self.config.budget.session_usd,
1106 session_spent_usd: 0.0,
1107 };
1108
1109 let mut required_tools = self.requires_tools(&task.description, &tier);
1110 let mut required_vision = self.requires_vision(&task.description, &tier);
1111 let mut need = crate::router::RoutingNeed {
1112 tier: tier.clone(),
1113 required_tools,
1114 required_vision,
1115 prefer_local: false,
1116 };
1117
1118 let mut chain = self.router.select(&need, &budget);
1119
1120 let router_ref = &self.router;
1128 let apply_override = |chain: &mut Vec<Arc<dyn Brain>>| {
1129 if let Some(ref override_id) = model_override {
1130 let filtered: Vec<_> = chain
1131 .iter()
1132 .filter(|b| b.id() == override_id.as_str())
1133 .cloned()
1134 .collect();
1135 if !filtered.is_empty() {
1136 *chain = filtered;
1137 } else if let Some(brain) = router_ref.find_brain_by_id(override_id) {
1138 *chain = vec![brain];
1139 }
1140 }
1141 };
1142 apply_override(&mut chain);
1143
1144 if model_override.is_none()
1152 && ambiguous
1153 && matches!(tier, TaskTier::Medium)
1154 && !self.is_routing_question(&task.description)
1155 {
1156 let desc_hash = {
1158 use std::collections::hash_map::DefaultHasher;
1159 use std::hash::{Hash, Hasher};
1160 let mut h = DefaultHasher::new();
1161 task.description.hash(&mut h);
1162 h.finish()
1163 };
1164 let cached = {
1165 self.classify_cache
1166 .lock()
1167 .ok()
1168 .and_then(|c| c.get(&desc_hash).cloned())
1169 };
1170 let refined = match cached {
1171 Some(t) => {
1172 let _ = event_tx.send(Event::Message {
1173 run: run_id.clone(),
1174 role: "router".into(),
1175 text: format!("classification (cached): {}", t.as_str()),
1176 });
1177 Some(t)
1178 }
1179 None => {
1180 if let Some(brain) = chain.first().cloned() {
1181 let result = self
1182 .classify_via_brain(&task.description, brain.as_ref())
1183 .await;
1184 if let Some(r) = &result {
1185 if let Ok(mut c) = self.classify_cache.lock() {
1186 c.insert(desc_hash, r.clone());
1187 }
1188 }
1189 result
1190 } else {
1191 None
1192 }
1193 }
1194 };
1195 if let Some(refined) = refined {
1196 if std::mem::discriminant(&refined) != std::mem::discriminant(&tier) {
1197 let _ = event_tx.send(Event::Message {
1198 run: run_id.clone(),
1199 role: "router".into(),
1200 text: format!(
1201 "classification affinée par modèle: {} → {}",
1202 tier.as_str(),
1203 refined.as_str()
1204 ),
1205 });
1206 tier = refined;
1207 required_tools = self.requires_tools(&task.description, &tier);
1208 required_vision = self.requires_vision(&task.description, &tier);
1209 need = crate::router::RoutingNeed {
1210 tier: tier.clone(),
1211 required_tools,
1212 required_vision,
1213 prefer_local: false,
1214 };
1215 chain = self.router.select(&need, &budget);
1216 apply_override(&mut chain);
1218 }
1219 }
1220 }
1221
1222 let routing_memory_root =
1227 std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from("."));
1228 let mut repo_routing =
1229 crate::router::learned::RepoRoutingMemory::load(&routing_memory_root);
1230 let classified_tier = tier.clone();
1231 if let Some(bumped) = repo_routing.suggest_bump(&tier) {
1232 let _ = event_tx.send(Event::Message {
1233 run: run_id.clone(),
1234 role: "router".into(),
1235 text: format!(
1236 "routing memory: {} tasks in this repo mostly needed escalation — starting at {}",
1237 tier.as_str(),
1238 bumped.as_str()
1239 ),
1240 });
1241 tier = bumped;
1242 required_tools = self.requires_tools(&task.description, &tier);
1243 required_vision = self.requires_vision(&task.description, &tier);
1244 need = crate::router::RoutingNeed {
1245 tier: tier.clone(),
1246 required_tools,
1247 required_vision,
1248 prefer_local: false,
1249 };
1250 chain = self.router.select(&need, &budget);
1251 apply_override(&mut chain);
1252 }
1253
1254 let task_summary = self.task_summary(&task.description, &tier);
1255 let chain_ids: Vec<String> = chain.iter().map(|b| b.id().to_string()).collect();
1256
1257 let agent_name = self
1258 .identity
1259 .as_ref()
1260 .map(|identity| identity.name.clone())
1261 .unwrap_or_else(|| "sparrow".into());
1262 let _ = event_tx.send(Event::RunStarted {
1263 run: run_id.clone(),
1264 task: task.description.clone(),
1265 agent: agent_name,
1266 });
1267
1268 let pre_run_results = self
1271 .hooks
1272 .execute(&HookEvent::PreRun, &task.description)
1273 .await;
1274 if let Some(reason) = pre_run_results
1275 .iter()
1276 .find(|r| r.veto)
1277 .and_then(|r| r.veto_reason.clone())
1278 {
1279 let _ = event_tx.send(Event::Error {
1280 run: run_id.clone(),
1281 message: format!("PreRun hook vetoed run: {}", reason),
1282 });
1283 anyhow::bail!("PreRun hook vetoed run: {}", reason);
1284 }
1285
1286 let yn = |b: bool| if b { "oui" } else { "non" };
1289 let _ = event_tx.send(Event::Message {
1290 run: run_id.clone(),
1291 role: "router".into(),
1292 text: format!(
1293 "tâche classée : {} · outils : {} · vision : {} · local : {}",
1294 tier.as_str(),
1295 yn(need.required_tools),
1296 yn(need.required_vision),
1297 yn(need.prefer_local)
1298 ),
1299 });
1300 let _ = &task_summary; let _ = event_tx.send(Event::AgentStatus {
1305 run: run_id.clone(),
1306 role: "planner".into(),
1307 status: AgentStatus::Working,
1308 note: format!("routage · {} modèles dans la chaîne", chain.len()),
1309 });
1310
1311 let primary_ctx = chain
1312 .first()
1313 .map(|b| b.caps().context_window)
1314 .unwrap_or(128_000);
1315 let _ = event_tx.send(Event::RouteSelected {
1316 run: run_id.clone(),
1317 chain: chain_ids.clone(),
1318 context_window: primary_ctx,
1319 });
1320 let _ = event_tx.send(Event::AgentStatus {
1321 run: run_id.clone(),
1322 role: "planner".into(),
1323 status: AgentStatus::Done,
1324 note: format!(
1325 "route set · {} primary",
1326 chain.first().map(|b| b.id()).unwrap_or("—")
1327 ),
1328 });
1329
1330 if chain.is_empty() {
1331 let _ = event_tx.send(Event::Error {
1332 run: run_id.clone(),
1333 message: "No available models (budget exhausted or no providers configured)".into(),
1334 });
1335 return Ok(OutcomeSummary {
1336 status: "error: no models".into(),
1337 diffs: vec![],
1338 cost_usd: 0.0,
1339 tokens: TokenUsage {
1340 input: 0,
1341 output: 0,
1342 },
1343 cost_comparison: String::new(),
1344 duration_ms: None,
1345 });
1346 }
1347
1348 if self.is_routing_question(&task.description) {
1349 let text = self.routing_explanation(&tier, &need, &chain_ids);
1350 let input_tokens =
1351 estimate_text_tokens(&task.description) + estimate_text_tokens(&task_summary);
1352 let output_tokens = estimate_text_tokens(&text);
1353 let _ = event_tx.send(Event::TokenUsageEstimated {
1354 run: run_id.clone(),
1355 input: input_tokens,
1356 output: 0,
1357 reason: "router meta request estimate".into(),
1358 });
1359 let _ = event_tx.send(Event::TokenUsageEstimated {
1360 run: run_id.clone(),
1361 input: 0,
1362 output: output_tokens,
1363 reason: "router meta response estimate".into(),
1364 });
1365 let _ = event_tx.send(Event::ThinkingDelta {
1366 run: run_id.clone(),
1367 text: text.clone(),
1368 });
1369 let outcome = OutcomeSummary {
1370 status: "completed".into(),
1371 diffs: vec![],
1372 cost_usd: 0.0,
1373 tokens: TokenUsage {
1374 input: input_tokens,
1375 output: output_tokens,
1376 },
1377 cost_comparison: String::new(),
1378 duration_ms: None,
1379 };
1380 let _ = event_tx.send(Event::RunFinished {
1381 run: run_id.clone(),
1382 outcome: outcome.clone(),
1383 });
1384 return Ok(outcome);
1385 }
1386
1387 let workspace_root = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
1389 let sandbox: Arc<dyn Sandbox> = match self.config.defaults.sandbox.as_str() {
1390 "local-hardened" => {
1391 #[cfg(target_os = "linux")]
1398 {
1399 Arc::new(crate::sandbox::HardenedSandbox::new(workspace_root.clone()))
1400 }
1401 #[cfg(not(target_os = "linux"))]
1402 {
1403 Arc::new(crate::sandbox::LocalSandbox::hardened(
1404 workspace_root.clone(),
1405 ))
1406 }
1407 }
1408 "docker" => Arc::new(crate::sandbox::backends::DockerSandbox::new(
1409 workspace_root.clone(),
1410 "ubuntu:latest",
1411 )),
1412 s if s.starts_with("ssh:") => Arc::new(crate::sandbox::backends::SshSandbox::new(
1413 workspace_root.clone(),
1414 s.trim_start_matches("ssh:"),
1415 )),
1416 "modal" => Arc::new(crate::sandbox::backends::ModalSandbox::new(
1417 workspace_root.clone(),
1418 )),
1419 "daytona" => Arc::new(crate::sandbox::backends::DaytonaSandbox::new(
1420 workspace_root.clone(),
1421 )),
1422 "vercel" => Arc::new(crate::sandbox::backends::VercelSandbox::new(
1423 workspace_root.clone(),
1424 )),
1425 "singularity" => Arc::new(crate::sandbox::backends::SingularitySandbox::new(
1426 workspace_root.clone(),
1427 )),
1428 _ => Arc::new(crate::sandbox::LocalSandbox::new(workspace_root.clone())),
1429 };
1430
1431 let mut registry = ToolRegistry::new();
1432 registry.register(Arc::new(crate::tools::fs::FsRead));
1433 registry.register(Arc::new(crate::tools::fs::FsList));
1434 registry.register(Arc::new(crate::tools::fs::FsWrite));
1435 registry.register(Arc::new(crate::tools::edit::Edit));
1436 registry.register(Arc::new(crate::tools::edit::MultiEdit));
1437 registry.register(Arc::new(crate::tools::search_and_web::Search));
1438 registry.register(Arc::new(crate::tools::search_and_web::WebSearch));
1439 registry.register(Arc::new(crate::tools::search_and_web::WebFetch));
1440 registry.register(Arc::new(crate::tools::browser_sandbox::BrowserTool));
1441 registry.register(Arc::new(crate::tools::browser_sandbox::ComputerTool));
1442 registry.register(Arc::new(crate::tools::git::Git));
1443 registry.register(Arc::new(crate::tools::todo::Todo::new()));
1444 registry.register(Arc::new(crate::tools::exec::Exec::new(sandbox.clone())));
1445 registry.register(Arc::new(crate::tools::media::ImageGen::new()));
1446 registry.register(Arc::new(crate::tools::media::Tts::new()));
1447 registry.register(Arc::new(crate::tools::media::Transcribe::new()));
1448 registry.register(Arc::new(crate::tools::subagent::PythonRpc::new()));
1449 registry.register(Arc::new(crate::tools::builder_tools::LspClient));
1450 registry.register(Arc::new(crate::tools::code_nav::Glob));
1451 registry.register(Arc::new(crate::tools::code_nav::Symbols));
1452 if let Some(mem) = &self.memory {
1453 registry.register(Arc::new(crate::tools::memory::MemoryTool::new(mem.clone())));
1454 registry.register(Arc::new(
1455 crate::tools::knowledge_graph::KnowledgeGraphTool::new(mem.clone()),
1456 ));
1457 }
1458 {
1459 let mut sub = crate::tools::subagent::SubagentSpawn::new(
1461 self.router.clone(),
1462 self.config.clone(),
1463 );
1464 if let Some(mem) = &self.memory {
1465 sub = sub.with_memory(mem.clone());
1466 }
1467 registry.register(Arc::new(sub));
1468 }
1469 let tools = Arc::new(registry);
1470 let tool_specs: Vec<ToolSpec> = tools.to_specs();
1471
1472 let workspace = Workspace {
1473 root: workspace_root,
1474 sandbox,
1475 };
1476
1477 let identity = self.identity.clone().unwrap_or_else(|| Identity {
1478 name: "sparrow".into(),
1479 role: "senior software engineer".into(),
1480 personality: "concise, competent, direct".into(),
1481 });
1482
1483 let brain_policy = BrainPolicy {
1484 chain,
1485 current_index: 0,
1486 };
1487
1488 let mut autonomy = match self.config.defaults.autonomy {
1489 AutonomyLevel::Supervised => AutonomyContract::supervised(),
1490 AutonomyLevel::Trusted => AutonomyContract::trusted(),
1491 AutonomyLevel::Autonomous => AutonomyContract::autonomous(),
1492 };
1493 autonomy.budget.max_usd = self.config.budget.session_usd;
1494 let _ = event_tx.send(Event::AutonomyChanged {
1495 run: run_id.clone(),
1496 level: autonomy.level.clone(),
1497 });
1498
1499 let relevant_skills: Vec<crate::capabilities::Skill> = self
1504 .skills
1505 .as_ref()
1506 .map(|s| s.relevant(&task.description, 5))
1507 .unwrap_or_default();
1508 let skill_catalog: Vec<crate::capabilities::Skill> =
1512 self.skills.as_ref().map(|s| s.all()).unwrap_or_default();
1513
1514 let facts = self
1515 .memory
1516 .as_ref()
1517 .map(|m| m.all_facts())
1518 .unwrap_or_default();
1519 let memory_docs = self
1520 .memory
1521 .as_ref()
1522 .map(|m| {
1523 [MemoryDocKind::Memory, MemoryDocKind::User]
1524 .into_iter()
1525 .filter_map(|kind| m.memory_doc(kind))
1526 .collect::<Vec<_>>()
1527 })
1528 .unwrap_or_default();
1529 let instruction_docs = crate::instructions::discover_workspace_instructions(
1530 &workspace.root,
1531 &task.description,
1532 );
1533 let system = build_system_prompt(SystemPromptInput {
1534 identity: &identity,
1535 tier: Some(&tier),
1536 workspace_root: &workspace.root,
1537 facts: &facts,
1538 memory_docs: &memory_docs,
1539 instruction_docs: &instruction_docs,
1540 skills: &relevant_skills,
1541 skill_catalog: &skill_catalog,
1542 });
1543 let mut system = format!(
1544 "{}\n\n## Active Sparrow Routing Context\nRequest category: {}\nTask tier: {}\nRequired tools: {}\nRequired vision: {}\nPreferred local: {}\nSelected fallback chain: {}\nRouting policy: free_first={}, session_budget_usd={:.2}.\nWhen answering routing questions, describe this context concretely.",
1545 system,
1546 task_summary,
1547 tier.as_str(),
1548 need.required_tools,
1549 need.required_vision,
1550 need.prefer_local,
1551 summarize_model_chain(&chain_ids, 8),
1552 self.config.routing.free_first,
1553 self.config.budget.session_usd
1554 );
1555
1556 if !messages.is_empty() {
1560 system.push_str(
1561 "\n\n## Conversation continuity\nThis is an ONGOING conversation. The messages below are prior turns and are AUTHORITATIVE memory of what the user told you (names, preferences, facts, decisions). Use them directly; never re-introduce yourself or contradict them.",
1562 );
1563 }
1564
1565 messages.push(Msg {
1567 role: "user".into(),
1568 content: initial_user_content_blocks(&workspace.root, &task.description),
1569 });
1570
1571 let mut total_input: u64 = 0;
1572 let mut total_output: u64 = 0;
1573 let mut estimated_input_unconfirmed: u64 = 0;
1574 let mut estimated_output_unconfirmed: u64 = 0;
1575 let mut estimated_cost_unconfirmed: f64 = 0.0;
1576 let mut cost_usd: f64 = 0.0;
1577 let mut total_tools_called: usize = 0;
1578 let diffs: Vec<crate::event::FileDiff> = Vec::new();
1579 let mut current_chain_idx = 0usize;
1580 let mut tool_results_pending: Vec<(
1581 String,
1582 String,
1583 serde_json::Value,
1584 Vec<ContentBlock>,
1585 bool,
1586 )> = Vec::new();
1587 let budget_session = self.config.budget.session_usd;
1588 let _budget_daily = self.config.budget.daily_usd;
1589 let redaction = &self.redaction;
1590 let mut had_error = false;
1591 let mut last_error: Option<String> = None;
1592 let mut waiting_for_approval = false;
1593 let mut denied_by_approval = false;
1594 let run_started_at = std::time::Instant::now();
1595 let mut skill_evidence = String::new();
1596 let mut turns: u32 = 0;
1598 const MAX_TURNS: u32 = 60;
1599 let mut had_mutation = false;
1603 let mut verify_attempts: u32 = 0;
1604 const MAX_VERIFY_ATTEMPTS: u32 = 2;
1605 let mut verify_escalations: u32 = 0;
1609 const MAX_VERIFY_ESCALATIONS: u32 = 2;
1610 let mut produced_any_output = false;
1614 let mut transient_retries: u32 = 0;
1618 const MAX_TRANSIENT_RETRIES: u32 = 2;
1619 let mut last_tool_sig: Option<u64> = None;
1624 let mut repeated_tool_turns: u32 = 0;
1625
1626 let send = |event: Event| {
1628 let _ = event_tx.send(redaction.redact_event(&event));
1629 };
1630
1631 const COMPACT_TRANSCRIPT_CHARS: usize = 120_000;
1637 const COMPACT_KEEP_LAST: usize = 6;
1638 let context_manager = crate::redaction::ContextManager::new(200_000);
1639
1640 loop {
1642 if turns > 0 {
1645 let transcript_chars: usize = messages
1646 .iter()
1647 .map(|m| serde_json::to_string(m).map(|s| s.len()).unwrap_or(0))
1648 .sum();
1649 if transcript_chars > COMPACT_TRANSCRIPT_CHARS && messages.len() > COMPACT_KEEP_LAST
1650 {
1651 let _ = self
1654 .hooks
1655 .execute(&HookEvent::PreCompact, &task.description)
1656 .await;
1657 let before = transcript_chars;
1658 let compacted =
1659 context_manager.compact_messages(&messages, 0, COMPACT_KEEP_LAST);
1660 let after: usize = compacted
1661 .iter()
1662 .map(|m| serde_json::to_string(m).map(|s| s.len()).unwrap_or(0))
1663 .sum();
1664
1665 let mut handoff = crate::context::HandoffDoc::new(task.description.clone());
1667 handoff.next_steps = vec![format!(
1668 "Resume run {} (turn {}/{})",
1669 run_id.0, turns, MAX_TURNS
1670 )];
1671 let handoff_dir = std::path::PathBuf::from(".sparrow/handoff");
1672 let _ = std::fs::create_dir_all(&handoff_dir);
1673 let handoff_path = handoff_dir.join(format!(
1674 "{}-{}.md",
1675 run_id.0,
1676 chrono::Utc::now().format("%Y%m%dT%H%M%SZ")
1677 ));
1678 let _ = std::fs::write(&handoff_path, handoff.to_markdown());
1679
1680 messages = compacted;
1681 send(Event::Compacted {
1682 run: run_id.clone(),
1683 before_chars: before,
1684 after_chars: after,
1685 handoff_path: Some(handoff_path.to_string_lossy().to_string()),
1686 });
1687 let _ = self
1688 .hooks
1689 .execute(&HookEvent::PostCompact, &task.description)
1690 .await;
1691 }
1692 }
1693 turns += 1;
1695 if turns > MAX_TURNS {
1696 send(Event::Message {
1697 run: run_id.clone(),
1698 role: "guard".into(),
1699 text: format!("iteration cap reached ({} turns) — stopping", MAX_TURNS),
1700 });
1701 break;
1702 }
1703
1704 if let Some(max_secs) = self.config.budget.max_wall_secs {
1706 if run_started_at.elapsed().as_secs() >= max_secs {
1707 let msg = format!("Time limit reached: {}s wall-clock cap", max_secs);
1708 send(Event::Error {
1709 run: run_id.clone(),
1710 message: msg.clone(),
1711 });
1712 let _ = self.hooks.execute(&HookEvent::OnError, &msg).await;
1713 had_error = true;
1714 last_error = Some("wall-clock limit".into());
1715 break;
1716 }
1717 }
1718 if let Some(max_tok) = self.config.budget.max_tokens {
1720 if total_input + total_output >= max_tok {
1721 let msg = format!(
1722 "Token limit reached: {} of {} token cap",
1723 total_input + total_output,
1724 max_tok
1725 );
1726 send(Event::Error {
1727 run: run_id.clone(),
1728 message: msg.clone(),
1729 });
1730 let _ = self.hooks.execute(&HookEvent::OnError, &msg).await;
1731 had_error = true;
1732 last_error = Some("token limit".into());
1733 break;
1734 }
1735 }
1736
1737 if cost_usd + estimated_cost_unconfirmed >= budget_session {
1739 let msg = format!(
1740 "Budget exceeded: ${:.4} of ${:.2} session cap",
1741 cost_usd + estimated_cost_unconfirmed,
1742 budget_session
1743 );
1744 send(Event::Error {
1745 run: run_id.clone(),
1746 message: msg.clone(),
1747 });
1748 let _ = self
1751 .hooks
1752 .execute(&HookEvent::OnBudgetThreshold, &msg)
1753 .await;
1754 let _ = self.hooks.execute(&HookEvent::OnError, &msg).await;
1755 had_error = true;
1756 last_error = Some("budget exceeded".into());
1757 break;
1758 }
1759 if let Some(_approval_handler) = &self.approval_handler {
1760 if waiting_for_approval {
1761 }
1764 }
1765
1766 if let Some(ref policy) = self.org_policy {
1768 let proposed_file = tool_results_pending
1769 .last()
1770 .map(|(_, _, args, _, _)| {
1771 args.get("path").and_then(|v| v.as_str()).unwrap_or("")
1772 })
1773 .unwrap_or("");
1774 if let Err(violation) =
1775 policy.enforce(&self.config.defaults.autonomy, cost_usd, proposed_file)
1776 {
1777 send(Event::Error {
1778 run: run_id.clone(),
1779 message: format!("Org policy violation: {}", violation),
1780 });
1781 break;
1782 }
1783 }
1784
1785 if let Some(rx) = inject_rx.as_mut() {
1789 loop {
1790 match rx.try_recv() {
1791 Ok(injected) => {
1792 let trimmed = injected.trim().to_string();
1793 if trimmed.is_empty() {
1794 continue;
1795 }
1796 messages.push(Msg {
1797 role: "user".into(),
1798 content: vec![ContentBlock::Text {
1799 text: format!("INTERRUPT FROM USER: {}", trimmed),
1800 }],
1801 });
1802 let _ = event_tx.send(Event::Message {
1803 run: run_id.clone(),
1804 role: "interrupt".into(),
1805 text: trimmed,
1806 });
1807 }
1808 Err(mpsc::error::TryRecvError::Empty) => break,
1809 Err(mpsc::error::TryRecvError::Disconnected) => {
1810 inject_rx = None;
1811 break;
1812 }
1813 }
1814 }
1815 }
1816
1817 let brain = match brain_policy.chain.get(current_chain_idx) {
1818 Some(b) => b.clone(),
1819 None => break,
1820 };
1821
1822 let caps = brain.caps();
1823
1824 {
1829 let req_for_estimate = BrainRequest {
1830 system: Some(system.clone()),
1831 messages: messages.clone(),
1832 tools: if need.required_tools {
1833 tool_specs.clone()
1834 } else {
1835 vec![]
1836 },
1837 max_tokens: caps.max_output as u32,
1838 temperature: 0.0,
1839 stop: vec![],
1840 cache: PromptCacheConfig::enabled(Some(prompt_cache_key(
1841 "engine",
1842 &workspace.root,
1843 &tool_specs,
1844 ))),
1845 };
1846 let est = estimate_request_tokens(&req_for_estimate);
1847 let threshold = (caps.context_window as f64 * 0.75) as u64;
1848 if est > threshold && messages.len() > 8 {
1849 let original_task = messages.first().cloned();
1850 let keep_tail: Vec<Msg> =
1851 messages.iter().rev().take(6).cloned().collect::<Vec<_>>();
1852 let middle: Vec<Msg> = messages
1853 .iter()
1854 .skip(1)
1855 .take(messages.len().saturating_sub(7))
1856 .cloned()
1857 .collect();
1858 let dropped = middle.len();
1859
1860 let summary = self
1863 .summarize_messages(brain.as_ref(), &middle)
1864 .await
1865 .unwrap_or_else(|| {
1866 format!(
1867 "{} prior messages were dropped to fit the model window.",
1868 dropped
1869 )
1870 });
1871
1872 let mut compacted: Vec<Msg> = Vec::new();
1873 if let Some(task) = original_task {
1874 compacted.push(task);
1875 }
1876 compacted.push(Msg {
1877 role: "user".into(),
1878 content: vec![ContentBlock::Text {
1879 text: format!(
1880 "[CONTEXT SUMMARY of {} earlier messages]\n{}\n\
1881 (Files edited and tool outputs in the turns below remain authoritative.)",
1882 dropped, summary
1883 ),
1884 }],
1885 });
1886 for m in keep_tail.into_iter().rev() {
1887 compacted.push(m);
1888 }
1889 messages = compacted;
1890 let _ = event_tx.send(Event::Message {
1891 run: run_id.clone(),
1892 role: "compaction".into(),
1893 text: format!(
1894 "context compacted: {} messages summarized ({} tok > {} threshold)",
1895 dropped, est, threshold
1896 ),
1897 });
1898 }
1899 }
1900
1901 let req = BrainRequest {
1902 system: Some(system.clone()),
1903 messages: sanitize_messages_for_provider(&messages),
1904 tools: if need.required_tools {
1905 tool_specs.clone()
1906 } else {
1907 vec![]
1908 },
1909 max_tokens: caps.max_output as u32,
1910 temperature: 0.0,
1911 stop: vec![],
1912 cache: PromptCacheConfig::enabled(Some(prompt_cache_key(
1913 "engine",
1914 &workspace.root,
1915 &tool_specs,
1916 ))),
1917 };
1918
1919 let estimated_input = estimate_request_tokens(&req);
1920 estimated_input_unconfirmed += estimated_input;
1921 estimated_cost_unconfirmed +=
1922 caps.cost_input_per_mtok * (estimated_input as f64) / 1_000_000.0;
1923 let _ = event_tx.send(Event::TokenUsageEstimated {
1924 run: run_id.clone(),
1925 input: estimated_input,
1926 output: 0,
1927 reason: "prompt estimate before provider usage".into(),
1928 });
1929 let _ = event_tx.send(Event::CostUpdate {
1930 run: run_id.clone(),
1931 usd: cost_usd + estimated_cost_unconfirmed,
1932 });
1933
1934 let _ = event_tx.send(Event::AgentStatus {
1935 run: run_id.clone(),
1936 role: "coder".into(),
1937 status: AgentStatus::Thinking,
1938 note: format!("consulting {} · parsing request…", brain.id()),
1939 });
1940
1941 let completion = match self.config.budget.max_wall_secs {
1947 Some(max_secs) => {
1948 let elapsed = run_started_at.elapsed().as_secs();
1949 if elapsed >= max_secs {
1950 None
1951 } else {
1952 let remaining =
1953 std::time::Duration::from_secs(max_secs.saturating_sub(elapsed).max(1));
1954 tokio::time::timeout(remaining, brain.complete(req))
1955 .await
1956 .ok()
1957 }
1958 }
1959 None => Some(brain.complete(req).await),
1960 };
1961 let complete_result = match completion {
1962 Some(r) => r,
1963 None => {
1964 let msg = format!(
1965 "Time limit reached: {}s wall-clock cap",
1966 self.config.budget.max_wall_secs.unwrap_or(0)
1967 );
1968 send(Event::Error {
1969 run: run_id.clone(),
1970 message: msg.clone(),
1971 });
1972 let _ = self.hooks.execute(&HookEvent::OnError, &msg).await;
1973 had_error = true;
1974 last_error = Some("wall-clock limit".into());
1975 break;
1976 }
1977 };
1978 match complete_result {
1979 Ok(mut stream) => {
1980 transient_retries = 0;
1982 let mut current_tool_name = String::new();
1983 let mut current_tool_json = String::new();
1984 let mut pending_tools: std::collections::HashMap<String, (String, String)> =
1992 std::collections::HashMap::new();
1993 let mut output_chars_seen: u64 = 0;
1994 let mut output_tokens_emitted: u64 = 0;
1995 let mut continue_agent_loop = false;
1996 let mut stop_after_tool_result = false;
1997 let mut assistant_text = String::new();
1998 let mut tool_output_seen_this_completion = false;
1999 let mut tools_called_this_turn: Vec<String> = Vec::new();
2003 let mut reasoning_buf: String = String::new();
2007
2008 loop {
2009 let next_event = match self.config.budget.max_wall_secs {
2017 Some(max_secs) => {
2018 let elapsed = run_started_at.elapsed().as_secs();
2019 if elapsed >= max_secs {
2020 break;
2021 }
2022 let remaining = std::time::Duration::from_secs(
2023 max_secs.saturating_sub(elapsed).max(1),
2024 );
2025 match tokio::time::timeout(remaining, stream.next()).await {
2026 Ok(ev) => ev,
2027 Err(_) => break, }
2029 }
2030 None => stream.next().await,
2031 };
2032 let event = match next_event {
2033 Some(ev) => ev,
2034 None => break,
2035 };
2036 match event {
2037 BrainEvent::TextDelta(text) => {
2038 assistant_text.push_str(&text);
2039 output_chars_seen += text.chars().count() as u64;
2040 let estimated_output = (output_chars_seen + 3) / 4;
2041 let output_delta =
2042 estimated_output.saturating_sub(output_tokens_emitted);
2043 if output_delta > 0 {
2044 output_tokens_emitted += output_delta;
2045 estimated_output_unconfirmed += output_delta;
2046 estimated_cost_unconfirmed += caps.cost_output_per_mtok
2047 * (output_delta as f64)
2048 / 1_000_000.0;
2049 let _ = event_tx.send(Event::TokenUsageEstimated {
2050 run: run_id.clone(),
2051 input: 0,
2052 output: output_delta,
2053 reason: "streamed output estimate".into(),
2054 });
2055 let _ = event_tx.send(Event::CostUpdate {
2056 run: run_id.clone(),
2057 usd: cost_usd + estimated_cost_unconfirmed,
2058 });
2059 }
2060 let _ = event_tx.send(Event::ThinkingDelta {
2065 run: run_id.clone(),
2066 text,
2067 });
2068 }
2069 BrainEvent::ReasoningDelta(rtext) => {
2070 reasoning_buf.push_str(&rtext);
2076 let _ = event_tx.send(Event::ReasoningDelta {
2077 run: run_id.clone(),
2078 text: rtext,
2079 });
2080 }
2081 BrainEvent::ToolUseStart { id, name } => {
2082 current_tool_name = name.clone();
2083 tools_called_this_turn.push(name.clone());
2084 total_tools_called += 1;
2085 current_tool_json.clear();
2086 pending_tools.insert(id.clone(), (name.clone(), String::new()));
2088 let risk = tools
2089 .get(&name)
2090 .map(|tool| tool.risk())
2091 .unwrap_or(RiskLevel::ReadOnly);
2092 let _ = event_tx.send(Event::ToolUseProposed {
2097 run: run_id.clone(),
2098 id: id.clone(),
2099 name: name.clone(),
2100 args: json!({}),
2101 risk,
2102 });
2103 }
2104 BrainEvent::ToolUseDelta { id, json } => {
2105 output_chars_seen += json.chars().count() as u64;
2106 let estimated_output = (output_chars_seen + 3) / 4;
2107 let output_delta =
2108 estimated_output.saturating_sub(output_tokens_emitted);
2109 if output_delta > 0 {
2110 output_tokens_emitted += output_delta;
2111 estimated_output_unconfirmed += output_delta;
2112 estimated_cost_unconfirmed += caps.cost_output_per_mtok
2113 * (output_delta as f64)
2114 / 1_000_000.0;
2115 let _ = event_tx.send(Event::TokenUsageEstimated {
2116 run: run_id.clone(),
2117 input: 0,
2118 output: output_delta,
2119 reason: "streamed tool arguments estimate".into(),
2120 });
2121 let _ = event_tx.send(Event::CostUpdate {
2122 run: run_id.clone(),
2123 usd: cost_usd + estimated_cost_unconfirmed,
2124 });
2125 }
2126 pending_tools
2130 .entry(id.clone())
2131 .or_insert_with(|| (String::new(), String::new()))
2132 .1
2133 .push_str(&json);
2134 }
2135 BrainEvent::ToolUseEnd { id } => {
2136 let (resolved_name, resolved_json) =
2140 pending_tools.remove(&id).unwrap_or_else(|| {
2141 (current_tool_name.clone(), current_tool_json.clone())
2142 });
2143
2144 let args: serde_json::Value =
2146 serde_json::from_str(&resolved_json).unwrap_or(json!({}));
2147
2148 let tool_name = if resolved_name.is_empty() {
2150 "unknown".to_string()
2151 } else {
2152 resolved_name.clone()
2153 };
2154 current_tool_name = tool_name.clone();
2157 let tool = tools.get(&tool_name);
2158 let base_risk = tool
2159 .as_ref()
2160 .map(|tool| tool.risk())
2161 .unwrap_or(RiskLevel::ReadOnly);
2162 let risk = crate::permissions::effective_risk_for_tool(
2163 &tool_name, base_risk, &args,
2164 );
2165
2166 let _ = event_tx.send(Event::ToolUseProposed {
2172 run: run_id.clone(),
2173 id: id.clone(),
2174 name: tool_name.clone(),
2175 args: args.clone(),
2176 risk: risk.clone(),
2177 });
2178 let proposed = crate::autonomy::ProposedAction {
2179 tool_name: tool_name.clone(),
2180 risk: risk.clone(),
2181 args: args.clone(),
2182 };
2183
2184 let permission =
2185 self.config.permissions.evaluate(&PermissionContext {
2186 tool_name: &proposed.tool_name,
2187 risk: proposed.risk.clone(),
2188 args: &args,
2189 workspace_root: &workspace.root,
2190 provider: Some(brain.id()),
2191 surface: Some("engine"),
2192 });
2193 let autonomy_verdict =
2194 if matches!(permission.decision, Decision::Allow) {
2195 Some(autonomy.evaluate(&proposed))
2196 } else {
2197 None
2198 };
2199 let mut decision = autonomy_verdict
2200 .as_ref()
2201 .map(|verdict| verdict.decision.clone())
2202 .unwrap_or_else(|| permission.decision.clone());
2203 if !matches!(permission.decision, Decision::Allow) {
2204 let _ = event_tx.send(Event::Message {
2205 run: run_id.clone(),
2206 role: "permissions".into(),
2207 text: permission.reason.clone(),
2208 });
2209 }
2210 if matches!(decision, Decision::AskUser) {
2211 let summary = format!(
2212 "{} Risque: {:?}.",
2213 humanize_tool_action(&proposed.tool_name, &args),
2214 proposed.risk
2215 );
2216 let _ = event_tx.send(Event::ApprovalRequested {
2217 run: run_id.clone(),
2218 id: id.clone(),
2219 summary: summary.clone(),
2220 tool: Some(proposed.tool_name.clone()),
2221 risk: Some(format!("{:?}", proposed.risk)),
2222 });
2223 let _ = event_tx.send(Event::AgentStatus {
2224 run: run_id.clone(),
2225 role: "coder".into(),
2226 status: AgentStatus::WaitingForApproval,
2227 note: format!(
2228 "en attente de ton accord pour {}",
2229 proposed.tool_name
2230 ),
2231 });
2232 let _ = self
2236 .hooks
2237 .execute(&HookEvent::OnApprovalRequested, &summary)
2238 .await;
2239 if let Some(handler) = &self.approval_handler {
2240 decision = handler
2241 .request_approval(ApprovalRequest {
2242 run: run_id.clone(),
2243 id: id.clone(),
2244 tool_name: proposed.tool_name.clone(),
2245 risk: proposed.risk.clone(),
2246 args: args.clone(),
2247 summary,
2248 })
2249 .await;
2250 } else if !std::io::IsTerminal::is_terminal(&std::io::stdin()) {
2251 let message = format!(
2252 "Approbation requise pour `{}`, mais stdin n'est pas interactif. Relance avec une autonomie plus élevée ou approuve dans le cockpit.",
2253 proposed.tool_name
2254 );
2255 let _ = event_tx.send(Event::Error {
2256 run: run_id.clone(),
2257 message: message.clone(),
2258 });
2259 decision = Decision::Deny;
2260 } else {
2261 use std::io::{self, Write};
2262 print!(
2263 "\n\x1b[1;33m{} Approve? [y/N]\x1b[0m ",
2264 humanize_tool_action(&proposed.tool_name, &args)
2265 );
2266 io::stdout().flush().ok();
2267 let mut input = String::new();
2268 io::stdin().read_line(&mut input).ok();
2269 decision = if input.trim().eq_ignore_ascii_case("y") {
2270 Decision::Allow
2271 } else {
2272 Decision::Deny
2273 };
2274 }
2275 }
2276
2277 if matches!(decision, Decision::AskUser) {
2278 let _ = event_tx.send(Event::Error {
2279 run: run_id.clone(),
2280 message: format!(
2281 "Approbation requise pour `{}` mais aucune réponse exploitable n'a été reçue.",
2282 proposed.tool_name
2283 ),
2284 });
2285 decision = Decision::Deny;
2286 }
2287
2288 let _ = event_tx.send(Event::ApprovalResolved {
2289 run: run_id.clone(),
2290 id: id.clone(),
2291 decision: decision.clone(),
2292 });
2293
2294 match decision {
2295 Decision::Allow => {
2296 if autonomy_verdict
2297 .as_ref()
2298 .map(|verdict| verdict.notify)
2299 .unwrap_or(false)
2300 {
2301 let _ = event_tx.send(Event::Message {
2302 run: run_id.clone(),
2303 role: "autonomy".into(),
2304 text: format!(
2305 "{} will run under trusted autonomy with checkpoint notification",
2306 proposed.tool_name
2307 ),
2308 });
2309 }
2310 if matches!(
2312 proposed.risk,
2313 RiskLevel::Mutating | RiskLevel::Destructive
2314 ) {
2315 had_mutation = true;
2316 }
2317 let needs_checkpoint = autonomy_verdict
2319 .as_ref()
2320 .map(|verdict| verdict.needs_checkpoint)
2321 .unwrap_or_else(|| {
2322 matches!(
2323 proposed.risk,
2324 RiskLevel::Mutating
2325 | RiskLevel::Exec
2326 | RiskLevel::Destructive
2327 )
2328 });
2329 if needs_checkpoint {
2330 let vetoes = self
2331 .hooks
2332 .execute(
2333 &HookEvent::PreCheckpoint,
2334 &proposed.tool_name,
2335 )
2336 .await;
2337 let checkpoint_veto = vetoes
2338 .iter()
2339 .find(|result| result.veto)
2340 .and_then(|result| result.veto_reason.clone());
2341 if let Some(reason) = checkpoint_veto {
2342 let _ = event_tx.send(Event::Error {
2343 run: run_id.clone(),
2344 message: reason,
2345 });
2346 denied_by_approval = true;
2347 stop_after_tool_result = true;
2348 continue;
2349 }
2350 if self.config.defaults.checkpointing {
2351 let checkpoints =
2352 GitCheckpoints::new(workspace.root.clone());
2353 if let Ok(cp_id) = checkpoints.snapshot(&format!(
2354 "pre-{}",
2355 proposed.tool_name
2356 )) {
2357 let _ =
2358 event_tx.send(Event::CheckpointCreated {
2359 run: run_id.clone(),
2360 id: cp_id,
2361 label: format!(
2362 "pre-{}",
2363 proposed.tool_name
2364 ),
2365 });
2366 let _ = self
2367 .hooks
2368 .execute(
2369 &HookEvent::PostCheckpoint,
2370 &proposed.tool_name,
2371 )
2372 .await;
2373 }
2374 }
2375 }
2376
2377 let hook_ctx = format!("{} {}", proposed.tool_name, args);
2383 let hook_results = self
2384 .hooks
2385 .execute(&HookEvent::PreToolUse, &hook_ctx)
2386 .await;
2387 if let Some(reason) = hook_results
2388 .iter()
2389 .find(|result| result.veto)
2390 .and_then(|result| result.veto_reason.clone())
2391 {
2392 denied_by_approval = true;
2393 stop_after_tool_result = true;
2394 let _ = event_tx.send(Event::ToolOutput {
2395 run: run_id.clone(),
2396 id: id.clone(),
2397 blocks: vec![Block::Text(reason.clone())],
2398 is_error: true,
2399 });
2400 tool_output_seen_this_completion = true;
2401 tool_results_pending.push((
2402 id.clone(),
2403 proposed.tool_name.clone(),
2404 args.clone(),
2405 vec![ContentBlock::Text { text: reason }],
2406 true,
2407 ));
2408 continue;
2409 }
2410
2411 let _ = event_tx.send(Event::ToolUseStarted {
2412 run: run_id.clone(),
2413 id: id.clone(),
2414 });
2415 let _ = event_tx.send(Event::AgentStatus {
2416 run: run_id.clone(),
2417 role: "coder".into(),
2418 status: AgentStatus::Working,
2419 note: format!("running tool · {}", current_tool_name),
2420 });
2421
2422 let result = if let Some(tool) = tool {
2423 let ctx = ToolCtx {
2424 workspace_root: workspace.root.clone(),
2425 run_id: run_id.clone(),
2426 };
2427 match tool.call(args.clone(), &ctx).await {
2428 Ok(result) => result,
2429 Err(e) => crate::tools::ToolResult::error(format!(
2430 "Tool {} failed: {}",
2431 proposed.tool_name, e
2432 )),
2433 }
2434 } else {
2435 crate::tools::ToolResult::error(format!(
2436 "Unknown tool: {}",
2437 proposed.tool_name
2438 ))
2439 };
2440
2441 for block in &result.content {
2442 if let Block::Diff { file, patch } = block {
2443 let plus = patch
2444 .lines()
2445 .filter(|l| {
2446 l.starts_with('+') && !l.starts_with("+++")
2447 })
2448 .count()
2449 as u32;
2450 let minus = patch
2451 .lines()
2452 .filter(|l| {
2453 l.starts_with('-') && !l.starts_with("---")
2454 })
2455 .count()
2456 as u32;
2457 let _ = event_tx.send(Event::DiffProposed {
2458 run: run_id.clone(),
2459 file: file.clone(),
2460 patch: patch.clone(),
2461 plus,
2462 minus,
2463 });
2464 }
2465 }
2466
2467 let blocks = result.content.clone();
2468 let text = tool_result_text(&blocks);
2469 let content_blocks = tool_result_content_blocks(&blocks);
2470 let is_error = result.is_error;
2471 skill_evidence.push_str(&text);
2472 skill_evidence.push('\n');
2473 let _ = event_tx.send(Event::ToolOutput {
2474 run: run_id.clone(),
2475 id: id.clone(),
2476 blocks,
2477 is_error,
2478 });
2479 if !is_error
2483 && matches!(
2484 proposed.tool_name.as_str(),
2485 "fs_write" | "edit" | "multi_edit"
2486 )
2487 {
2488 if let Some(p) =
2489 args.get("path").and_then(|v| v.as_str())
2490 {
2491 let _ = event_tx.send(Event::DiffApplied {
2492 run: run_id.clone(),
2493 file: p.to_string(),
2494 });
2495 } else if let Some(p) =
2496 args.get("file_path").and_then(|v| v.as_str())
2497 {
2498 let _ = event_tx.send(Event::DiffApplied {
2499 run: run_id.clone(),
2500 file: p.to_string(),
2501 });
2502 }
2503 }
2504 let _ = self
2505 .hooks
2506 .execute(&HookEvent::PostToolUse, &proposed.tool_name)
2507 .await;
2508 tool_output_seen_this_completion = true;
2509 tool_results_pending.push((
2510 id.clone(),
2511 proposed.tool_name.clone(),
2512 args.clone(),
2513 content_blocks,
2514 is_error,
2515 ));
2516 }
2517 Decision::AskUser => {
2518 waiting_for_approval = true;
2520 let approval_id = id.clone();
2521 let approval_name = proposed.tool_name.clone();
2522 let approval_args = args.clone();
2523 let approval_risk = proposed.risk;
2524
2525 let _ = event_tx.send(Event::ApprovalRequested {
2527 run: run_id.clone(),
2528 id: approval_id.clone(),
2529 summary: format!(
2530 "{} Risque: {:?}.",
2531 humanize_tool_action(
2532 &approval_name,
2533 &approval_args
2534 ),
2535 approval_risk
2536 ),
2537 tool: Some(approval_name.clone()),
2538 risk: Some(format!("{:?}", approval_risk)),
2539 });
2540
2541 use std::io::{self, Write};
2543 print!(
2544 "\n\x1b[1;33mApprove {}? [y/N]\x1b[0m ",
2545 approval_name
2546 );
2547 io::stdout().flush().ok();
2548 let mut input = String::new();
2549 io::stdin().read_line(&mut input).ok();
2550 let approved = input.trim().to_lowercase() == "y";
2551
2552 if approved {
2553 waiting_for_approval = false;
2554 if matches!(
2556 approval_risk,
2557 RiskLevel::Mutating
2558 | RiskLevel::Exec
2559 | RiskLevel::Destructive
2560 ) {
2561 let vetoes = self
2562 .hooks
2563 .execute(
2564 &HookEvent::PreCheckpoint,
2565 &approval_name,
2566 )
2567 .await;
2568 if let Some(reason) = vetoes
2569 .iter()
2570 .find(|result| result.veto)
2571 .and_then(|result| result.veto_reason.clone())
2572 {
2573 let _ = event_tx.send(Event::Error {
2574 run: run_id.clone(),
2575 message: reason,
2576 });
2577 denied_by_approval = true;
2578 stop_after_tool_result = true;
2579 continue;
2580 }
2581 if self.config.defaults.checkpointing {
2582 let checkpoints =
2583 GitCheckpoints::new(workspace.root.clone());
2584 if let Ok(cp_id) = checkpoints
2585 .snapshot(&format!("pre-{}", approval_name))
2586 {
2587 let _ = event_tx.send(
2588 Event::CheckpointCreated {
2589 run: run_id.clone(),
2590 id: cp_id,
2591 label: format!(
2592 "pre-{}",
2593 approval_name
2594 ),
2595 },
2596 );
2597 let _ = self
2598 .hooks
2599 .execute(
2600 &HookEvent::PostCheckpoint,
2601 &approval_name,
2602 )
2603 .await;
2604 }
2605 }
2606 }
2607 let hook_results = self
2608 .hooks
2609 .execute(&HookEvent::PreToolUse, &approval_name)
2610 .await;
2611 if let Some(reason) = hook_results
2612 .iter()
2613 .find(|result| result.veto)
2614 .and_then(|result| result.veto_reason.clone())
2615 {
2616 denied_by_approval = true;
2617 stop_after_tool_result = true;
2618 let _ = event_tx.send(Event::ToolOutput {
2619 run: run_id.clone(),
2620 id: approval_id.clone(),
2621 blocks: vec![Block::Text(reason.clone())],
2622 is_error: true,
2623 });
2624 tool_output_seen_this_completion = true;
2625 tool_results_pending.push((
2626 approval_id,
2627 approval_name,
2628 approval_args,
2629 vec![ContentBlock::Text { text: reason }],
2630 true,
2631 ));
2632 continue;
2633 }
2634 let _ = event_tx.send(Event::ToolUseStarted {
2635 run: run_id.clone(),
2636 id: approval_id.clone(),
2637 });
2638 let result = if let Some(tool) = tool {
2639 let ctx = ToolCtx {
2640 workspace_root: workspace.root.clone(),
2641 run_id: run_id.clone(),
2642 };
2643 match tool.call(approval_args.clone(), &ctx).await {
2644 Ok(r) => r,
2645 Err(e) => {
2646 crate::tools::ToolResult::error(format!(
2647 "Tool {} failed: {}",
2648 approval_name, e
2649 ))
2650 }
2651 }
2652 } else {
2653 crate::tools::ToolResult::error(format!(
2654 "Unknown tool: {}",
2655 approval_name
2656 ))
2657 };
2658 let blocks = result.content.clone();
2659 let text = tool_result_text(&blocks);
2660 let content_blocks =
2661 tool_result_content_blocks(&blocks);
2662 let is_error = result.is_error;
2663 skill_evidence.push_str(&text);
2664 skill_evidence.push('\n');
2665 let _ = event_tx.send(Event::ToolOutput {
2666 run: run_id.clone(),
2667 id: approval_id.clone(),
2668 blocks,
2669 is_error,
2670 });
2671 let _ = self
2672 .hooks
2673 .execute(&HookEvent::PostToolUse, &approval_name)
2674 .await;
2675 tool_output_seen_this_completion = true;
2676 tool_results_pending.push((
2677 approval_id,
2678 approval_name,
2679 approval_args,
2680 content_blocks,
2681 is_error,
2682 ));
2683 } else {
2684 let _ = event_tx.send(Event::ToolOutput {
2685 run: run_id.clone(),
2686 id: approval_id.clone(),
2687 blocks: vec![Block::Text("Denied by user".into())],
2688 is_error: true,
2689 });
2690 tool_output_seen_this_completion = true;
2691 tool_results_pending.push((
2692 approval_id,
2693 approval_name,
2694 approval_args,
2695 vec![ContentBlock::Text {
2696 text: "Denied by user".into(),
2697 }],
2698 true,
2699 ));
2700 }
2701 }
2702 Decision::Deny => {
2703 denied_by_approval = true;
2704 stop_after_tool_result = true;
2705 let _ = event_tx.send(Event::ToolOutput {
2706 run: run_id.clone(),
2707 id: id.clone(),
2708 blocks: vec![Block::Text(
2709 "Denied by autonomy policy".into(),
2710 )],
2711 is_error: true,
2712 });
2713 tool_output_seen_this_completion = true;
2714 tool_results_pending.push((
2715 id.clone(),
2716 proposed.tool_name.clone(),
2717 args.clone(),
2718 vec![ContentBlock::Text {
2719 text: "Denied by autonomy policy".into(),
2720 }],
2721 true,
2722 ));
2723 }
2724 Decision::AllowOnce
2730 | Decision::AllowSession
2731 | Decision::AllowAlways => {}
2732 }
2733
2734 current_tool_json.clear();
2735 current_tool_name.clear();
2736 }
2737 BrainEvent::Usage(usage) => {
2738 total_input += usage.input;
2739 total_output += usage.output;
2740 estimated_input_unconfirmed = 0;
2746 estimated_output_unconfirmed = 0;
2747 let _ = event_tx.send(Event::TokenUsage {
2748 run: run_id.clone(),
2749 input: usage.input,
2750 output: usage.output,
2751 });
2752
2753 let input_cost =
2755 caps.cost_input_per_mtok * (usage.input as f64) / 1_000_000.0;
2756 let output_cost =
2757 caps.cost_output_per_mtok * (usage.output as f64) / 1_000_000.0;
2758 let actual_cost = input_cost + output_cost;
2759 cost_usd += actual_cost;
2760 estimated_cost_unconfirmed = 0.0;
2761
2762 let _ = event_tx.send(Event::CostUpdate {
2763 run: run_id.clone(),
2764 usd: cost_usd + estimated_cost_unconfirmed,
2765 });
2766 }
2767 BrainEvent::Done(reason) => {
2768 match reason {
2769 crate::event::StopReason::EndTurn => {
2770 let this_empty = assistant_text.trim().is_empty()
2775 && !tool_output_seen_this_completion;
2776 if this_empty && !produced_any_output {
2777 let next_idx = current_chain_idx + 1;
2778 if next_idx < brain_policy.chain.len() {
2779 current_chain_idx = next_idx;
2780 let _ = event_tx.send(Event::ModelSwitched {
2781 run: run_id.clone(),
2782 from: brain.id().to_string(),
2783 to: brain_policy.chain[current_chain_idx]
2784 .id()
2785 .to_string(),
2786 reason: "empty response".into(),
2787 });
2788 continue_agent_loop = true;
2789 break;
2790 }
2791 }
2792 if !assistant_text.trim().is_empty() {
2793 produced_any_output = true;
2794 let mut blocks = Vec::new();
2795 if !reasoning_buf.is_empty() {
2796 blocks.push(ContentBlock::Reasoning {
2797 text: reasoning_buf.clone(),
2798 });
2799 }
2800 blocks.push(ContentBlock::Text {
2801 text: assistant_text.clone(),
2802 });
2803 let assistant_msg = Msg {
2804 role: "assistant".into(),
2805 content: blocks,
2806 };
2807 let turn_messages = vec![assistant_msg.clone()];
2808 let has_verified_tool_context =
2809 tool_output_seen_this_completion
2810 || messages.iter().any(|m| {
2811 m.content.iter().any(|block| {
2812 matches!(
2813 block,
2814 ContentBlock::ToolResult { .. }
2815 )
2816 })
2817 });
2818
2819 if let Some(correction) = self.reasoning.guard_turn(
2820 &turn_messages,
2821 has_verified_tool_context,
2822 ) {
2823 messages.push(assistant_msg);
2824 let _ = event_tx.send(Event::Message {
2825 run: run_id.clone(),
2826 role: "guard".into(),
2827 text: correction.clone(),
2828 });
2829 messages.push(Msg {
2830 role: "user".into(),
2831 content: vec![ContentBlock::Text {
2832 text: format!("SYSTEM: {}. Execute the relevant tool first, then report the actual raw result.", correction),
2833 }],
2834 });
2835 continue_agent_loop = true;
2836 break;
2837 }
2838
2839 if self.reasoning.hallucination_guard {
2843 if let Some(correction) =
2844 crate::reasoning::HallucinationGuard::verify(
2845 &assistant_text,
2846 &tools_called_this_turn,
2847 )
2848 {
2849 let mut blocks2 = Vec::new();
2850 if !reasoning_buf.is_empty() {
2851 blocks2.push(ContentBlock::Reasoning {
2852 text: reasoning_buf.clone(),
2853 });
2854 }
2855 blocks2.push(ContentBlock::Text {
2856 text: assistant_text.clone(),
2857 });
2858 let assistant_msg2 = Msg {
2859 role: "assistant".into(),
2860 content: blocks2,
2861 };
2862 messages.push(assistant_msg2);
2863 let _ = event_tx.send(Event::Message {
2864 run: run_id.clone(),
2865 role: "guard".into(),
2866 text: correction.clone(),
2867 });
2868 messages.push(Msg {
2869 role: "user".into(),
2870 content: vec![ContentBlock::Text {
2871 text: format!(
2872 "SYSTEM: {}. Call fs_read or search to verify the file/symbol first, then re-state the claim with the raw evidence.",
2873 correction
2874 ),
2875 }],
2876 });
2877 continue_agent_loop = true;
2878 break;
2879 }
2880 }
2881
2882 if tools_called_this_turn.is_empty()
2888 && tool_narration_detected(&assistant_text)
2889 {
2890 let correction = "You described using a tool but did not actually call it. When a tool would help, CALL it — never narrate what it would do. Use the exact tool call format.";
2891 messages.push(Msg {
2892 role: "assistant".into(),
2893 content: vec![ContentBlock::Text {
2894 text: assistant_text.clone(),
2895 }],
2896 });
2897 let _ = event_tx.send(Event::Message {
2898 run: run_id.clone(),
2899 role: "guard".into(),
2900 text: correction.into(),
2901 });
2902 messages.push(Msg {
2903 role: "user".into(),
2904 content: vec![ContentBlock::Text {
2905 text: format!(
2906 "SYSTEM: {}. Execute the relevant tool first, then report the actual raw result.",
2907 correction
2908 ),
2909 }],
2910 });
2911 continue_agent_loop = true;
2912 break;
2913 }
2914 messages.push(assistant_msg);
2915 }
2916
2917 if had_mutation
2923 && self.reasoning.self_critique
2924 && !diffs.is_empty()
2925 {
2926 let review =
2927 crate::reasoning::SelfCritique::pre_mutation_review(
2928 &diffs,
2929 Some(&task.description),
2930 );
2931 let _ = event_tx.send(Event::Message {
2932 run: run_id.clone(),
2933 role: "self-critique".into(),
2934 text: review,
2935 });
2936 }
2937
2938 if had_mutation {
2948 if let Some(verify_cmd) =
2949 self.config.defaults.verify_command.clone()
2950 {
2951 verify_attempts += 1;
2952 had_mutation = false;
2953 let parts: Vec<String> = verify_cmd
2954 .split_whitespace()
2955 .map(String::from)
2956 .collect();
2957 if !parts.is_empty() {
2958 let _ = event_tx.send(Event::AgentStatus {
2959 run: run_id.clone(),
2960 role: "verifier".into(),
2961 status: AgentStatus::Working,
2962 note: format!("running `{}`", verify_cmd),
2963 });
2964 let cmd = crate::sandbox::Command {
2965 program: parts[0].clone(),
2966 args: parts[1..].to_vec(),
2967 env: std::collections::HashMap::new(),
2968 workdir: workspace.root.clone(),
2969 };
2970 let limits = crate::sandbox::Limits {
2971 timeout_ms: 300_000,
2972 max_output_bytes: 16_000,
2973 };
2974 match workspace
2975 .sandbox
2976 .exec(&cmd, &limits)
2977 .await
2978 {
2979 Ok(res) if res.exit_code != 0 => {
2980 let _ = event_tx.send(Event::TestResult {
2981 run: run_id.clone(),
2982 passed: 0,
2983 failed: 1,
2984 detail: format!(
2985 "verify `{}` failed (exit {})",
2986 verify_cmd, res.exit_code
2987 ),
2988 });
2989 let out = format!(
2990 "{}\n{}",
2991 res.stdout, res.stderr
2992 );
2993 let tail: String = out
2994 .lines()
2995 .rev()
2996 .take(40)
2997 .collect::<Vec<_>>()
2998 .into_iter()
2999 .rev()
3000 .collect::<Vec<_>>()
3001 .join("\n");
3002 if verify_attempts
3003 <= MAX_VERIFY_ATTEMPTS
3004 {
3005 messages.push(Msg {
3008 role: "user".into(),
3009 content: vec![ContentBlock::Text {
3010 text: format!(
3011 "SYSTEM: verification command `{}` FAILED (exit {}). Fix the code, then it will be re-verified. Output:\n{}",
3012 verify_cmd, res.exit_code, tail
3013 ),
3014 }],
3015 });
3016 continue_agent_loop = true;
3017 break;
3018 }
3019 let next_idx = current_chain_idx + 1;
3022 if next_idx < brain_policy.chain.len()
3023 && verify_escalations
3024 < MAX_VERIFY_ESCALATIONS
3025 {
3026 verify_escalations += 1;
3027 verify_attempts = 0;
3028 let from = brain.id().to_string();
3029 let to = brain_policy.chain
3030 [next_idx]
3031 .id()
3032 .to_string();
3033 current_chain_idx = next_idx;
3034 let _ = event_tx.send(
3035 Event::ModelSwitched {
3036 run: run_id.clone(),
3037 from,
3038 to,
3039 reason: format!(
3040 "verification still failing after {} fixes — escalating",
3041 MAX_VERIFY_ATTEMPTS
3042 ),
3043 },
3044 );
3045 messages.push(Msg {
3046 role: "user".into(),
3047 content: vec![ContentBlock::Text {
3048 text: format!(
3049 "SYSTEM: a previous model attempted this task but verification `{}` still FAILS (exit {}). You are a stronger model brought in to finish the job. Diagnose properly, fix the code, then it will be re-verified. Output:\n{}",
3050 verify_cmd, res.exit_code, tail
3051 ),
3052 }],
3053 });
3054 continue_agent_loop = true;
3055 break;
3056 }
3057 had_error = true;
3061 last_error = Some(format!(
3062 "verification `{}` still failing after retries and escalation",
3063 verify_cmd
3064 ));
3065 continue_agent_loop = false;
3066 break;
3067 }
3068 Ok(_) => {
3069 let _ =
3070 event_tx.send(Event::TestResult {
3071 run: run_id.clone(),
3072 passed: 1,
3073 failed: 0,
3074 detail: format!(
3075 "verify `{}` passed",
3076 verify_cmd
3077 ),
3078 });
3079 }
3080 Err(e) => {
3081 let _ = event_tx.send(Event::Message {
3082 run: run_id.clone(),
3083 role: "guard".into(),
3084 text: format!(
3085 "verify command could not run: {}",
3086 e
3087 ),
3088 });
3089 }
3090 }
3091 }
3092 }
3093 }
3094 }
3095 crate::event::StopReason::ToolUse => {
3096 let drained: Vec<_> =
3109 std::mem::take(&mut tool_results_pending);
3110
3111 let mut assistant_blocks = Vec::new();
3112 if !reasoning_buf.is_empty() {
3113 assistant_blocks.push(ContentBlock::Reasoning {
3114 text: reasoning_buf.clone(),
3115 });
3116 }
3117 let turn_sig = {
3120 use std::collections::hash_map::DefaultHasher;
3121 use std::hash::{Hash, Hasher};
3122 let mut h = DefaultHasher::new();
3123 for (_, name, args, _, _) in &drained {
3124 name.hash(&mut h);
3125 args.to_string().hash(&mut h);
3126 }
3127 h.finish()
3128 };
3129 for (tool_id, tool_name, args, _content, _is_error) in
3130 &drained
3131 {
3132 assistant_blocks.push(ContentBlock::ToolUse {
3133 id: tool_id.clone(),
3134 name: tool_name.clone(),
3135 input: args.clone(),
3136 });
3137 }
3138 messages.push(Msg {
3139 role: "assistant".into(),
3140 content: assistant_blocks,
3141 });
3142
3143 let turn_had_tools = !drained.is_empty();
3144 for (tool_id, _tool_name, _args, content, is_error) in
3145 drained
3146 {
3147 messages.push(Msg {
3148 role: "user".into(),
3149 content: vec![ContentBlock::ToolResult {
3150 tool_use_id: tool_id,
3151 content,
3152 is_error: Some(is_error),
3153 }],
3154 });
3155 }
3156 if tool_output_seen_this_completion {
3157 produced_any_output = true;
3158 }
3159
3160 if turn_had_tools {
3162 if last_tool_sig == Some(turn_sig) {
3163 repeated_tool_turns += 1;
3164 } else {
3165 repeated_tool_turns = 0;
3166 last_tool_sig = Some(turn_sig);
3167 }
3168 }
3169 if repeated_tool_turns == 2 {
3170 messages.push(Msg {
3172 role: "user".into(),
3173 content: vec![ContentBlock::Text {
3174 text: "guard: you have issued the exact same \
3175 tool call(s) three turns in a row with \
3176 identical arguments. The result will \
3177 not change. State what you learned and \
3178 take a DIFFERENT action — or finish \
3179 with your best answer now."
3180 .into(),
3181 }],
3182 });
3183 send(Event::Message {
3184 run: run_id.clone(),
3185 role: "guard".into(),
3186 text: "repeated identical tool calls — nudging \
3187 the model to change approach"
3188 .into(),
3189 });
3190 } else if repeated_tool_turns >= 4 {
3191 send(Event::Message {
3194 run: run_id.clone(),
3195 role: "guard".into(),
3196 text: "stuck loop: 5 identical tool-call turns \
3197 — stopping the run"
3198 .into(),
3199 });
3200 had_error = true;
3201 last_error = Some(
3202 "stopped by stuck-loop guard (5 identical \
3203 tool-call turns)"
3204 .into(),
3205 );
3206 continue_agent_loop = false;
3207 break;
3208 }
3209
3210 continue_agent_loop =
3211 !waiting_for_approval && !stop_after_tool_result;
3212 break;
3213 }
3214 _ => {}
3215 }
3216 break; }
3218 BrainEvent::Error(msg) => {
3219 let _ = event_tx.send(Event::Error {
3220 run: run_id.clone(),
3221 message: msg.clone(),
3222 });
3223 let _ = self.hooks.execute(&HookEvent::OnError, &msg).await;
3224 let next_idx = current_chain_idx + 1;
3225 if next_idx < brain_policy.chain.len() {
3226 current_chain_idx = next_idx;
3227 let switch_ctx = format!(
3228 "{} -> {}",
3229 brain.id(),
3230 brain_policy.chain[current_chain_idx].id()
3231 );
3232 let _ = event_tx.send(Event::ModelSwitched {
3233 run: run_id.clone(),
3234 from: brain.id().to_string(),
3235 to: brain_policy.chain[current_chain_idx].id().to_string(),
3236 reason: msg,
3237 });
3238 let _ = self
3239 .hooks
3240 .execute(&HookEvent::OnModelSwitched, &switch_ctx)
3241 .await;
3242 continue_agent_loop = true;
3243 } else {
3244 had_error = true;
3245 last_error = Some(msg);
3246 }
3247 break;
3248 }
3249 }
3250 }
3251
3252 if !continue_agent_loop && !had_error {
3257 let this_empty =
3258 assistant_text.trim().is_empty() && !tool_output_seen_this_completion;
3259 if this_empty && !produced_any_output {
3260 let next_idx = current_chain_idx + 1;
3261 if next_idx < brain_policy.chain.len() {
3262 let _ = event_tx.send(Event::ModelSwitched {
3263 run: run_id.clone(),
3264 from: brain.id().to_string(),
3265 to: brain_policy.chain[next_idx].id().to_string(),
3266 reason: "empty response".into(),
3267 });
3268 current_chain_idx = next_idx;
3269 continue;
3270 }
3271 }
3272 }
3273
3274 if continue_agent_loop {
3275 continue;
3276 }
3277 break; }
3279 Err(e) => {
3280 let err_msg = format!("{}", e);
3281 let _ = event_tx.send(Event::Error {
3282 run: run_id.clone(),
3283 message: err_msg.clone(),
3284 });
3285
3286 let retry_after_hint = match e.downcast_ref::<BrainError>() {
3291 Some(BrainError::RateLimit { retry_after }) => Some(*retry_after),
3292 Some(BrainError::Timeout) => Some(None),
3293 Some(BrainError::ServerError { status, .. }) if *status >= 500 => {
3294 Some(None)
3295 }
3296 Some(_) => None,
3297 None => {
3298 let s = err_msg.to_lowercase();
3299 let transient = s.contains("rate limit")
3300 || s.contains("429")
3301 || s.contains("timeout")
3302 || s.contains("timed out")
3303 || s.contains("connection")
3304 || s.contains("overloaded")
3305 || s.contains("502")
3306 || s.contains("503");
3307 if transient { Some(None) } else { None }
3308 }
3309 };
3310 if let Some(hint) = retry_after_hint {
3311 if transient_retries < MAX_TRANSIENT_RETRIES {
3312 transient_retries += 1;
3313 let secs = hint.unwrap_or(2u64.pow(transient_retries)).min(20);
3316 send(Event::Message {
3317 run: run_id.clone(),
3318 role: "guard".into(),
3319 text: format!(
3320 "provider hiccup ({}) — retrying {} in {}s (attempt {}/{})",
3321 err_msg,
3322 brain.id(),
3323 secs,
3324 transient_retries,
3325 MAX_TRANSIENT_RETRIES
3326 ),
3327 });
3328 tokio::time::sleep(std::time::Duration::from_secs(secs)).await;
3329 continue;
3330 }
3331 }
3332 transient_retries = 0;
3333
3334 let next_idx = current_chain_idx + 1;
3336 if next_idx < brain_policy.chain.len() {
3337 current_chain_idx = next_idx;
3338 let _ = event_tx.send(Event::ModelSwitched {
3339 run: run_id.clone(),
3340 from: brain.id().to_string(),
3341 to: brain_policy.chain[current_chain_idx].id().to_string(),
3342 reason: err_msg,
3343 });
3344 } else {
3345 had_error = true;
3346 last_error = Some(err_msg);
3347 break;
3348 }
3349 }
3350 }
3351 }
3352
3353 let final_input = total_input + estimated_input_unconfirmed;
3359 let final_output = total_output + estimated_output_unconfirmed;
3360 if total_input == 0 && total_output == 0 && (final_input > 0 || final_output > 0) {
3361 let _ = event_tx.send(Event::TokenUsageEstimated {
3362 run: run_id.clone(),
3363 input: final_input,
3364 output: final_output,
3365 reason: "provider reported no usage events".into(),
3366 });
3367 }
3368 let final_status = if had_error {
3369 format!(
3370 "error: {}",
3371 last_error.unwrap_or_else(|| "run failed".into())
3372 )
3373 } else if waiting_for_approval {
3374 "waiting_for_approval".into()
3375 } else if denied_by_approval {
3376 "denied".into()
3377 } else if diffs.is_empty() && total_tools_called == 0 {
3378 "no actions taken".into()
3379 } else {
3380 "completed".into()
3381 };
3382 let final_note = match final_status.as_str() {
3383 "completed" => format!("completed · {}↑ {}↓ tok", final_input, final_output),
3384 "waiting_for_approval" => "en attente de ton accord".to_string(),
3385 "denied" => "arrêté · approbation refusée".to_string(),
3386 other => other.to_string(),
3387 };
3388
3389 let _ = event_tx.send(Event::AgentStatus {
3391 run: run_id.clone(),
3392 role: "coder".into(),
3393 status: AgentStatus::Done,
3394 note: final_note,
3395 });
3396
3397 let outcome = OutcomeSummary {
3398 status: final_status,
3399 diffs,
3400 cost_usd: cost_usd + estimated_cost_unconfirmed,
3401 tokens: TokenUsage {
3402 input: total_input + estimated_input_unconfirmed,
3403 output: total_output + estimated_output_unconfirmed,
3404 },
3405 cost_comparison: String::new(),
3406 duration_ms: Some(run_started_at.elapsed().as_millis() as u64),
3407 };
3408
3409 if let Some(mem) = &self.memory {
3411 let _ = mem.save_task(&crate::memory::TaskMem {
3412 run_id: run_id.0.clone(),
3413 messages: messages.clone(),
3414 created_at: chrono::Utc::now().format("%Y-%m-%d %H:%M:%S").to_string(),
3415 });
3416 }
3417
3418 {
3421 use crate::router::learned::RunRoutingOutcome;
3422 let routing_outcome = if had_error {
3423 Some(RunRoutingOutcome::Failed)
3424 } else if verify_escalations > 0 {
3425 Some(RunRoutingOutcome::Escalated)
3426 } else if verify_attempts > 0 && outcome.status == "completed" {
3427 Some(RunRoutingOutcome::VerifiedSuccess)
3428 } else {
3429 None
3430 };
3431 if let Some(o) = routing_outcome {
3432 repo_routing.record(&classified_tier, o);
3433 }
3434 }
3435
3436 if outcome.status == "completed" {
3438 if let Some(skills) = &self.skills {
3439 if let Some(candidate) = Curator::propose_skill_if_missing(
3440 &task.description,
3441 &skill_evidence,
3442 skills.as_ref(),
3443 ) {
3444 let skill_name = candidate.name.clone();
3445 let _ = event_tx.send(Event::SkillLearned {
3446 run: run_id.clone(),
3447 name: skill_name.clone(),
3448 });
3449 let _ = self
3450 .hooks
3451 .execute(&HookEvent::OnSkillLearned, &skill_name)
3452 .await;
3453 let _ = skills.add(candidate);
3454 }
3455 }
3456
3457 if let Some(mem) = &self.memory {
3462 let events = events_from_messages(&run_id, &messages);
3463 Distiller::distill(mem, &events, &task.description).await;
3464 }
3465 }
3466
3467 let _ = event_tx.send(Event::RunFinished {
3468 run: run_id.clone(),
3469 outcome: outcome.clone(),
3470 });
3471
3472 let _ = self
3474 .hooks
3475 .execute(&HookEvent::PostRun, &task.description)
3476 .await;
3477
3478 Ok(outcome)
3479 }
3480}
3481
3482fn tool_narration_detected(text: &str) -> bool {
3488 let lower = text.to_lowercase();
3489 let patterns = [
3490 "i'll use",
3491 "i will use",
3492 "let me use",
3493 "i'll run",
3494 "i will run",
3495 "let me run",
3496 "i'll search",
3497 "i will search",
3498 "let me search",
3499 "i'll check",
3500 "i will check",
3501 "let me check",
3502 "i'll read",
3503 "i will read",
3504 "let me read",
3505 "i'll write",
3506 "i will write",
3507 "let me write",
3508 "i'll execute",
3509 "i will execute",
3510 "let me execute",
3511 "i'll call",
3512 "i will call",
3513 "let me call",
3514 "i'll fetch",
3515 "i will fetch",
3516 "let me fetch",
3517 "i'll look up",
3518 "i will look up",
3519 "let me look up",
3520 "i'll test",
3521 "i will test",
3522 "let me test",
3523 "running the test",
3524 "running the command",
3525 "searching for",
3526 "looking up",
3527 "je vais utiliser",
3532 "je vais lancer",
3533 "je vais exécuter",
3534 "je vais executer", "je vais lire",
3536 "je vais écrire",
3537 "je vais créer",
3538 "je vais modifier",
3539 "je vais chercher",
3540 "je vais rechercher",
3541 "je vais vérifier",
3542 "je vais regarder",
3543 "je vais consulter",
3544 "je vais ouvrir",
3545 "je vais appeler",
3546 "laisse-moi",
3547 "laissez-moi",
3548 "permets-moi de",
3549 "permettez-moi de",
3550 "je m'occupe de",
3551 "je commence par",
3552 "je vais d'abord",
3553 ];
3554 patterns.iter().any(|p| lower.contains(p))
3555}
3556
3557#[cfg(test)]
3558mod tests {
3559 use super::*;
3560
3561 #[test]
3562 fn main_agent_system_prompt_carries_the_reasoning_protocol() {
3563 let workspace_root = PathBuf::from(".");
3564 let prompt = build_system_prompt(SystemPromptInput {
3565 identity: &Identity::default(),
3566 tier: Some(&crate::router::TaskTier::Hard),
3567 workspace_root: &workspace_root,
3568 facts: &[],
3569 memory_docs: &[],
3570 instruction_docs: &[],
3571 skills: &[],
3572 skill_catalog: &[],
3573 });
3574 for marker in [
3580 "Non-negotiable Sparrow identity",
3581 "Sparrow is your public product identity",
3582 "never as the underlying provider/model",
3583 "TIER TRIAGE",
3584 "Tribunal",
3585 "Skeptic",
3586 "Adversary",
3587 "Anti-simulation",
3588 "Real execution beats",
3589 "REASONING-MAX LAYER",
3591 "MODEL-AWARE RIGOR",
3592 "EVIDENCE LEDGER",
3593 "Self-consistency",
3594 "Multi-pass",
3595 "FINAL INTEGRITY GATE",
3596 "honesty floor",
3597 ] {
3598 assert!(prompt.contains(marker), "main soul must contain `{marker}`");
3599 }
3600 }
3601
3602 #[test]
3603 fn trivial_prompt_uses_lean_mode_with_skill_index_but_no_full_soul() {
3604 let skill = crate::capabilities::Skill {
3605 name: "tiny-skill".into(),
3606 description: "Tiny relevant skill".into(),
3607 trigger: vec!["tiny".into()],
3608 body: "Do the tiny thing.".into(),
3609 source_file: "tiny/SKILL.md".into(),
3610 usage_count: 0,
3611 created_at: String::new(),
3612 score: 1.0,
3613 auto_generated: false,
3614 references: Vec::new(),
3615 templates: Vec::new(),
3616 scripts: Vec::new(),
3617 assets: Vec::new(),
3618 manifest_version: None,
3619 allowed_tools: Vec::new(),
3620 };
3621 let workspace_root = PathBuf::from(".");
3622 let skills = vec![skill];
3623 let prompt = build_system_prompt(SystemPromptInput {
3624 identity: &Identity::default(),
3625 tier: Some(&crate::router::TaskTier::Trivial),
3626 workspace_root: &workspace_root,
3627 facts: &[],
3628 memory_docs: &[],
3629 instruction_docs: &[],
3630 skills: &skills,
3631 skill_catalog: &skills,
3632 });
3633
3634 assert!(prompt.contains("Simple-task mode"));
3635 assert!(prompt.contains("Non-negotiable Sparrow identity"));
3636 assert!(prompt.contains("Sparrow is your public product identity"));
3637 assert!(!prompt.contains("TIER TRIAGE"));
3638 assert!(prompt.contains("Skill library ("));
3642 assert!(prompt.contains("## Relevant skills for this task"));
3643 assert!(prompt.contains("Call tools, never narrate them"));
3646 }
3647
3648 #[test]
3649 fn provider_messages_strip_ui_status_leaks() {
3650 let messages = vec![Msg {
3651 role: "user".into(),
3652 content: vec![ContentBlock::Text {
3653 text: "keep this\n✓ coder completed · 4487↑ 150↓ tok\ncoder ◌ consulting deepseek · parsing request…\nkeep that".into(),
3654 }],
3655 }];
3656
3657 let sanitized = sanitize_messages_for_provider(&messages);
3658 let ContentBlock::Text { text } = &sanitized[0].content[0] else {
3659 panic!("expected text block");
3660 };
3661 assert!(text.contains("keep this"));
3662 assert!(text.contains("keep that"));
3663 assert!(!text.contains("completed ·"));
3664 assert!(!text.contains("◌ consulting"));
3665 }
3666
3667 #[test]
3668 fn tool_narration_guard_fires_in_french() {
3669 assert!(tool_narration_detected(
3671 "Je vais créer le fichier poeme.txt."
3672 ));
3673 assert!(tool_narration_detected(
3674 "Laisse-moi vérifier le contenu du dossier."
3675 ));
3676 assert!(tool_narration_detected(
3677 "Je m'occupe de lire app.js tout de suite."
3678 ));
3679 assert!(tool_narration_detected("Let me run the tests."));
3681 assert!(!tool_narration_detected(
3683 "Voici le résultat : ton fichier contient un haïku."
3684 ));
3685 }
3686
3687 #[test]
3688 fn named_agents_keep_their_own_soul() {
3689 let planner = Identity {
3690 name: "planner".into(),
3691 role: "technical architect".into(),
3692 personality: "structured".into(),
3693 };
3694 let workspace_root = PathBuf::from(".");
3695 let prompt = build_system_prompt(SystemPromptInput {
3696 identity: &planner,
3697 tier: Some(&crate::router::TaskTier::Hard),
3698 workspace_root: &workspace_root,
3699 facts: &[],
3700 memory_docs: &[],
3701 instruction_docs: &[],
3702 skills: &[],
3703 skill_catalog: &[],
3704 });
3705 assert!(
3708 !prompt.contains("TIER TRIAGE"),
3709 "named souls must not be diluted by the main protocol"
3710 );
3711 }
3712
3713 #[test]
3714 fn initial_user_content_blocks_embeds_uploaded_images() {
3715 let tmp = tempfile::tempdir().expect("tempdir");
3716 let image = tmp.path().join("shot.png");
3717 std::fs::write(
3718 &image,
3719 [
3720 0x89, b'P', b'N', b'G', b'\r', b'\n', 0x1a, b'\n', 0, 0, 0, 0,
3721 ],
3722 )
3723 .expect("write image");
3724 let description = format!(
3725 "analyse this\n\n[Attached files]\n### file: shot.png\n[uploaded: {}]",
3726 image.display()
3727 );
3728
3729 let blocks = initial_user_content_blocks(tmp.path(), &description);
3730 assert!(matches!(blocks.first(), Some(ContentBlock::Text { .. })));
3731 assert!(blocks.iter().any(|block| matches!(
3732 block,
3733 ContentBlock::Image {
3734 source: ImageSource::Base64 {
3735 media_type,
3736 data,
3737 }
3738 } if media_type == "image/png" && !data.is_empty()
3739 )));
3740 }
3741
3742 #[test]
3743 fn tool_result_content_blocks_preserves_images() {
3744 let blocks = tool_result_content_blocks(&[
3745 Block::Text("screenshot captured".into()),
3746 Block::Image {
3747 data: vec![1, 2, 3],
3748 mime: "image/png".into(),
3749 },
3750 ]);
3751
3752 assert!(matches!(blocks.first(), Some(ContentBlock::Text { .. })));
3753 assert!(blocks.iter().any(|block| matches!(
3754 block,
3755 ContentBlock::Image {
3756 source: ImageSource::Base64 {
3757 media_type,
3758 data,
3759 }
3760 } if media_type == "image/png" && data == "AQID"
3761 )));
3762 }
3763}