Skip to main content

rx4/
agent.rs

1//! Agent loop: event-driven turn cycling with tool execution, permissions, scopes,
2//! cancellation, caching, and parallel tool dispatch.
3//!
4//! Architecture informed by codex-rs (turn-based loop with CancellationToken),
5//! grok-build (moka cache, dashmap registry, parking_lot), and pi_agent_rust
6//! (stable event ordering, bounded tool recursion).
7
8use crate::compaction::{apply_compaction, estimate_messages, CompactionConfig};
9use crate::guardrails::plan_tool_effect_batches;
10use crate::hooks::HookRegistry;
11use crate::mode::{self, Profile, Scope};
12use crate::permissions::{self, Approver, Decision, Policy};
13use crate::provider::{Message, Provider, Role};
14use moka::future::Cache;
15use parking_lot::RwLock;
16use serde::{Deserialize, Serialize};
17use std::future::Future;
18use std::pin::Pin;
19use std::sync::Arc;
20#[cfg(feature = "providers")]
21use tracing::error;
22use tracing::{debug, info, warn};
23
24#[cfg(feature = "ipc")]
25use cancellation_token::{CancellationToken, CancellationTokenSource};
26
27/// Normalize a tool name, translating common pi-style aliases to rx4 native
28/// names. Unknown names pass through unchanged.
29pub fn normalize_tool_name(name: &str) -> &str {
30    match name {
31        "read_file" | "read" => "read",
32        "write_file" | "write" => "write",
33        "list_dir" | "ls" => "ls",
34        "run_command" | "bash" => "bash",
35        "find_files" | "find" => "find",
36        "code_intel" | "grep" => "grep",
37        "hashline_edit" | "search_replace" | "apply_patch" | "edit" => "edit",
38        "spawn_agent" | "agent" => "spawn_agent",
39        "web_fetch" | "fetch" | "fetch_url" => "web_fetch",
40        "todo" | "todo_write" | "todo_list" => "todo",
41        "enter_plan_mode" | "plan_mode" => "enter_plan_mode",
42        "exit_plan_mode" => "exit_plan_mode",
43        "lsp_diagnostics" | "diagnostics" => "lsp_diagnostics",
44        "lsp_definition" | "definition" | "go_to_definition" => "lsp_definition",
45        "lsp_references" | "references" | "find_references" => "lsp_references",
46        _ => name,
47    }
48}
49
50pub type ToolFuture = Pin<Box<dyn Future<Output = ToolResult> + Send>>;
51
52#[cfg(feature = "ipc")]
53#[derive(Clone)]
54pub struct CancellationHandle {
55    source: Arc<RwLock<CancellationTokenSource>>,
56}
57
58#[cfg(feature = "ipc")]
59impl CancellationHandle {
60    fn new() -> Self {
61        Self {
62            source: Arc::new(RwLock::new(CancellationTokenSource::new())),
63        }
64    }
65
66    pub fn cancel(&self) {
67        self.source.read().cancel();
68    }
69
70    fn reset(&self) -> CancellationToken {
71        let source = CancellationTokenSource::new();
72        let token = source.token();
73        *self.source.write() = source;
74        token
75    }
76}
77
78#[derive(Debug, Clone, Serialize, Deserialize)]
79pub struct ToolCall {
80    pub id: String,
81    pub name: String,
82    pub arguments: String,
83}
84
85#[derive(Debug, Clone, Serialize, Deserialize)]
86pub struct ToolResult {
87    pub id: String,
88    pub content: String,
89    pub is_error: bool,
90}
91
92impl ToolResult {
93    pub fn ok(id: impl Into<String>, content: impl Into<String>) -> Self {
94        Self {
95            id: id.into(),
96            content: content.into(),
97            is_error: false,
98        }
99    }
100    pub fn err(id: impl Into<String>, content: impl Into<String>) -> Self {
101        Self {
102            id: id.into(),
103            content: content.into(),
104            is_error: true,
105        }
106    }
107}
108
109/// Context passed to tool execution — provides workspace root, cancellation, etc.
110pub struct ToolContext {
111    pub workspace_root: std::path::PathBuf,
112    #[cfg(feature = "ipc")]
113    pub cancellation: CancellationToken,
114    pub sandbox: Option<std::sync::Arc<crate::sandbox::SandboxManager>>,
115    pub os_sandbox: Option<std::sync::Arc<crate::sandbox::OsSandboxRunner>>,
116    /// Optional provider so nested tools (e.g. spawn_agent) can run an agent loop.
117    pub provider: Option<Arc<dyn Provider>>,
118    /// Optional tool registry for nested agent runs.
119    pub tools: Option<Arc<ToolRegistry>>,
120    /// Tools may request a scope switch; Agent applies after the tool batch.
121    pub pending_scope: Option<Arc<parking_lot::Mutex<Option<Scope>>>>,
122    /// Optional LSP manager for diagnostics / navigation tools.
123    #[cfg(feature = "ipc")]
124    pub lsp: Option<Arc<crate::lsp::LspManager>>,
125}
126
127impl ToolContext {
128    pub fn new(workspace_root: impl Into<std::path::PathBuf>) -> Self {
129        Self {
130            workspace_root: workspace_root.into(),
131            #[cfg(feature = "ipc")]
132            cancellation: CancellationToken::new(false),
133            sandbox: None,
134            os_sandbox: None,
135            provider: None,
136            tools: None,
137            pending_scope: None,
138            #[cfg(feature = "ipc")]
139            lsp: None,
140        }
141    }
142
143    pub fn with_sandbox(mut self, sb: Arc<crate::sandbox::SandboxManager>) -> Self {
144        self.sandbox = Some(sb);
145        self
146    }
147
148    pub fn with_os_sandbox(mut self, os: Arc<crate::sandbox::OsSandboxRunner>) -> Self {
149        self.os_sandbox = Some(os);
150        self
151    }
152}
153
154/// Function-pointer tool (for simple builtins).
155pub type ToolExecuteFn = fn(Arc<ToolContext>, String) -> ToolFuture;
156
157/// Boxed-closure tool (for stateful tools that capture external state).
158pub type ToolExecuteBox = Box<dyn Fn(Arc<ToolContext>, String) -> ToolFuture + Send + Sync>;
159
160/// Tool executor — either a function pointer or a boxed closure.
161pub enum ToolExecutor {
162    Fn(ToolExecuteFn),
163    Boxed(ToolExecuteBox),
164}
165
166impl ToolExecutor {
167    /// Execute the tool, dispatching to the appropriate variant.
168    pub fn call(&self, ctx: Arc<ToolContext>, args: String) -> ToolFuture {
169        match self {
170            ToolExecutor::Fn(f) => f(ctx, args),
171            ToolExecutor::Boxed(b) => b(ctx, args),
172        }
173    }
174}
175
176/// Tool effect class — determines parallel execution eligibility (codex-rs pattern).
177/// Read-only tools can run in parallel; write/process tools are serialized.
178#[derive(Debug, Clone, Copy, PartialEq, Eq)]
179pub enum ToolEffect {
180    Read,
181    Write,
182    Network,
183    Process,
184}
185
186impl ToolEffect {
187    /// Returns true if this tool can run in parallel with other read tools.
188    pub fn supports_parallel(self) -> bool {
189        matches!(self, ToolEffect::Read | ToolEffect::Network)
190    }
191}
192
193pub struct ToolDefinition {
194    pub name: String,
195    pub description: String,
196    pub parameters_json: String,
197    pub execute: ToolExecutor,
198    pub effect: ToolEffect,
199}
200
201impl ToolDefinition {
202    pub fn new_fn(
203        name: impl Into<String>,
204        description: impl Into<String>,
205        parameters_json: impl Into<String>,
206        execute: ToolExecuteFn,
207    ) -> Self {
208        Self {
209            name: name.into(),
210            description: description.into(),
211            parameters_json: parameters_json.into(),
212            execute: ToolExecutor::Fn(execute),
213            effect: ToolEffect::Read,
214        }
215    }
216
217    /// Create a tool definition with a boxed closure executor (for stateful tools).
218    pub fn new_boxed(
219        name: impl Into<String>,
220        description: impl Into<String>,
221        parameters_json: impl Into<String>,
222        execute: ToolExecuteBox,
223    ) -> Self {
224        Self {
225            name: name.into(),
226            description: description.into(),
227            parameters_json: parameters_json.into(),
228            execute: ToolExecutor::Boxed(execute),
229            effect: ToolEffect::Read,
230        }
231    }
232
233    pub fn with_effect(mut self, effect: ToolEffect) -> Self {
234        self.effect = effect;
235        self
236    }
237}
238
239/// Concurrent tool registry using dashmap (grok pattern).
240pub struct ToolRegistry {
241    tools: dashmap::DashMap<String, ToolDefinition>,
242}
243
244impl ToolRegistry {
245    pub fn new() -> Self {
246        Self {
247            tools: dashmap::DashMap::new(),
248        }
249    }
250
251    pub fn register(&mut self, tool: ToolDefinition) {
252        info!("registered tool: {}", tool.name);
253        self.tools.insert(tool.name.clone(), tool);
254    }
255
256    pub fn count(&self) -> usize {
257        self.tools.len()
258    }
259
260    pub fn definitions(&self) -> Vec<serde_json::Value> {
261        self.tools.iter().map(|t| serde_json::json!({
262            "name": t.name,
263            "description": t.description,
264            "parameters": serde_json::from_str::<serde_json::Value>(&t.parameters_json).unwrap_or(serde_json::Value::Null),
265        })).collect()
266    }
267
268    pub async fn execute(
269        &self,
270        name: &str,
271        ctx: &Arc<ToolContext>,
272        arguments: &str,
273    ) -> Option<ToolResult> {
274        let entry = self.tools.get(name)?;
275        Some(entry.execute.call(ctx.clone(), arguments.to_string()).await)
276    }
277
278    /// Get the effect class for a tool (defaults to Read if not found).
279    pub fn effect_of(&self, name: &str) -> ToolEffect {
280        self.tools
281            .get(name)
282            .map(|e| e.effect)
283            .unwrap_or(ToolEffect::Read)
284    }
285}
286
287impl Default for ToolRegistry {
288    fn default() -> Self {
289        Self::new()
290    }
291}
292
293/// Stable event ordering (pi_agent_rust pattern).
294#[derive(Debug, Clone, Serialize)]
295#[serde(tag = "type")]
296pub enum Event {
297    AgentStart,
298    TurnStart {
299        turn: usize,
300    },
301    MessageStart {
302        role: Role,
303    },
304    MessageDelta {
305        delta: String,
306    },
307    MessageEnd {
308        role: Role,
309        content: String,
310    },
311    ToolCall(ToolCall),
312    /// Host UX: tool needs approval (Codex-style ask payload).
313    ApprovalRequired(crate::permissions::ApprovalRequest),
314    ToolExecutionStart(ToolCall),
315    ToolExecutionEnd(ToolResult),
316    TurnEnd {
317        turn: usize,
318    },
319    AgentEnd,
320    Error(String),
321}
322
323pub type Subscriber = Arc<dyn Fn(&Event) + Send + Sync>;
324
325/// The agent — owns the loop, tools, provider, policy, scope, hooks, cache.
326pub struct Agent {
327    pub model: String,
328    pub system_prompt: Option<String>,
329    pub tools: Arc<ToolRegistry>,
330    pub policy: Policy,
331    pub scope: Scope,
332    scope_profile: Option<Profile>,
333    pub hooks: Option<HookRegistry>,
334    pub approver: Option<Arc<dyn Approver>>,
335    pub provider: Option<Arc<dyn Provider>>,
336    pub max_tool_iterations: usize,
337    pub auto_compact_after: usize,
338    pub workspace_root: std::path::PathBuf,
339    pub sandbox: Option<Arc<crate::sandbox::SandboxManager>>,
340    pub os_sandbox: Option<Arc<crate::sandbox::OsSandboxRunner>>,
341    #[cfg(feature = "skills")]
342    pub skill_registry: Option<crate::skill_engine::SkillRegistry>,
343    #[cfg(feature = "skills")]
344    pub skill_engine: Option<crate::skill_engine::SkillEngine>,
345    #[cfg(feature = "graph-memory")]
346    pub graph_memory: Option<crate::graph_memory::GraphMemory>,
347    /// When true and graph_memory is set, run one dream consolidation after each prompt.
348    #[cfg(feature = "graph-memory")]
349    pub auto_dream: bool,
350    #[cfg(feature = "ipc")]
351    turn_cancellation: CancellationHandle,
352    subscribers: Vec<Subscriber>,
353    pub messages: RwLock<Vec<Message>>,
354    tool_cache: Cache<String, ToolResult>,
355}
356
357impl Agent {
358    pub fn new() -> Self {
359        let mut agent = Self {
360            model: "gpt-4o".into(),
361            system_prompt: None,
362            tools: Arc::new(ToolRegistry::new()),
363            policy: Policy::workspace_write(),
364            scope: Scope::Coding,
365            scope_profile: None,
366            hooks: None,
367            approver: None,
368            provider: None,
369            max_tool_iterations: 50,
370            auto_compact_after: 80,
371            workspace_root: std::env::current_dir().unwrap_or_else(|_| ".".into()),
372            sandbox: None,
373            os_sandbox: None,
374            #[cfg(feature = "skills")]
375            skill_registry: None,
376            #[cfg(feature = "skills")]
377            skill_engine: None,
378            #[cfg(feature = "graph-memory")]
379            graph_memory: None,
380            #[cfg(feature = "graph-memory")]
381            auto_dream: false,
382            #[cfg(feature = "ipc")]
383            turn_cancellation: CancellationHandle::new(),
384            subscribers: Vec::new(),
385            messages: RwLock::new(Vec::new()),
386            tool_cache: Cache::builder()
387                .max_capacity(10_000)
388                .time_to_live(std::time::Duration::from_secs(3600))
389                .time_to_idle(std::time::Duration::from_secs(900))
390                .build(),
391        };
392        // Policy plugin: workspace_write enables OS sandbox by default.
393        if agent.policy.enable_os_sandbox {
394            let _ = agent.enable_os_sandbox();
395        }
396        agent
397    }
398
399    pub fn set_model(&mut self, model: impl Into<String>) {
400        self.model = model.into();
401    }
402
403    pub fn set_system_prompt(&mut self, prompt: impl Into<String>) {
404        self.system_prompt = Some(prompt.into());
405    }
406
407    pub fn set_tools(&mut self, tools: ToolRegistry) {
408        self.tools = Arc::new(tools);
409    }
410
411    pub fn set_policy(&mut self, policy: Policy) {
412        self.policy = policy;
413        if self.policy.enable_os_sandbox && self.os_sandbox.is_none() {
414            let _ = self.enable_os_sandbox();
415        }
416    }
417
418    pub fn set_scope(&mut self, scope: Scope) {
419        self.scope = scope;
420        let profile = mode::profile(scope);
421        self.policy = profile.policy.clone();
422        let base = self.system_prompt.clone();
423        self.system_prompt = Some(mode::compose_prompt(base.as_deref(), &profile));
424        self.scope_profile = Some(profile);
425    }
426
427    pub fn set_hooks(&mut self, hooks: HookRegistry) {
428        self.hooks = Some(hooks);
429    }
430
431    pub fn set_approver(&mut self, approver: Arc<dyn Approver>) {
432        self.approver = Some(approver);
433    }
434
435    pub fn set_provider(&mut self, provider: Arc<dyn Provider>) {
436        self.provider = Some(provider);
437    }
438
439    pub fn set_workspace_root(&mut self, path: impl Into<std::path::PathBuf>) {
440        self.workspace_root = path.into();
441    }
442
443    #[cfg(feature = "ipc")]
444    pub fn cancel(&self) {
445        self.turn_cancellation.cancel();
446    }
447
448    #[cfg(feature = "ipc")]
449    pub fn cancellation_handle(&self) -> CancellationHandle {
450        self.turn_cancellation.clone()
451    }
452
453    /// Load project instruction files (AGENTS.md / CLAUDE.md / .cursor/rules)
454    /// from `workspace_root` and merge into the system prompt.
455    pub fn load_project_context(&mut self) {
456        if let Some(instr) = crate::context::load_project_instructions(&self.workspace_root) {
457            self.system_prompt = crate::context::compose_system_prompt(
458                self.system_prompt.as_deref(),
459                &instr.content,
460            );
461        }
462    }
463
464    pub fn set_sandbox(&mut self, sb: Arc<crate::sandbox::SandboxManager>) {
465        self.sandbox = Some(sb);
466    }
467
468    pub fn set_os_sandbox(&mut self, os: Arc<crate::sandbox::OsSandboxRunner>) {
469        self.os_sandbox = Some(os);
470    }
471
472    /// Enable OS sandbox for bash using auto-detected seatbelt/bwrap backend.
473    pub fn enable_os_sandbox(&mut self) -> Result<(), crate::sandbox::SandboxError> {
474        let mode = crate::sandbox::detect_sandbox();
475        let config = crate::sandbox::OsSandboxConfig::new(mode, self.workspace_root.clone());
476        let runner = crate::sandbox::OsSandboxRunner::new(config)?;
477        self.os_sandbox = Some(Arc::new(runner));
478        Ok(())
479    }
480
481    #[cfg(feature = "skills")]
482    pub fn set_skill_registry(&mut self, registry: crate::skill_engine::SkillRegistry) {
483        self.skill_registry = Some(registry);
484    }
485
486    /// Attach a skill engine for post-prompt background review.
487    #[cfg(feature = "skills")]
488    pub fn set_skill_engine(&mut self, engine: crate::skill_engine::SkillEngine) {
489        self.skill_engine = Some(engine);
490    }
491
492    #[cfg(feature = "graph-memory")]
493    pub fn set_graph_memory(&mut self, graph: crate::graph_memory::GraphMemory) {
494        self.graph_memory = Some(graph);
495    }
496
497    /// Run dream consolidation after each prompt when graph_memory is set.
498    #[cfg(feature = "graph-memory")]
499    pub fn enable_auto_dream(&mut self, enabled: bool) {
500        self.auto_dream = enabled;
501    }
502
503    pub fn subscribe(&mut self, callback: impl Fn(&Event) + Send + Sync + 'static) {
504        self.subscribers.push(Arc::new(callback));
505    }
506
507    fn emit(&self, event: Event) {
508        if self.subscribers.is_empty() {
509            return;
510        }
511        for sub in &self.subscribers {
512            sub(&event);
513        }
514    }
515
516    pub fn clear_messages(&self) {
517        self.messages.write().clear();
518    }
519
520    pub fn message_count(&self) -> usize {
521        self.messages.read().len()
522    }
523
524    /// Run a prompt through the agent loop.
525    /// Streams events to subscribers, executes tools, cycles turns.
526    pub async fn prompt(&mut self, text: &str) -> Result<(), AgentError> {
527        let tokens = estimate_messages(&self.messages.read());
528        if tokens >= self.auto_compact_after {
529            self.compact("auto-compact before prompt");
530        }
531
532        // Inject activated skill instructions into system prompt for this turn.
533        #[cfg(feature = "skills")]
534        if let Some(reg) = &self.skill_registry {
535            let activated = reg.auto_activate(text);
536            if !activated.is_empty() {
537                let block = activated.join("\n\n---\n\n");
538                let base = self.system_prompt.as_deref();
539                let merged = match base {
540                    Some(b) => format!("{b}\n\n# Active Skills\n\n{block}"),
541                    None => format!("# Active Skills\n\n{block}"),
542                };
543                self.system_prompt = Some(merged);
544            }
545        }
546
547        self.messages.write().push(Message::user(text));
548        self.emit(Event::AgentStart);
549
550        let provider = self.provider.clone().ok_or(AgentError::NoProvider)?;
551        let mut tool_ctx = ToolContext::new(self.workspace_root.clone());
552        #[cfg(feature = "ipc")]
553        {
554            tool_ctx.cancellation = self.turn_cancellation.reset();
555        }
556        if let Some(sb) = self.sandbox.clone() {
557            tool_ctx = tool_ctx.with_sandbox(sb);
558        }
559        if let Some(os) = self.os_sandbox.clone() {
560            tool_ctx = tool_ctx.with_os_sandbox(os);
561        }
562        tool_ctx.provider = Some(provider.clone());
563        tool_ctx.tools = Some(Arc::clone(&self.tools));
564        let pending_scope = Arc::new(parking_lot::Mutex::new(None));
565        tool_ctx.pending_scope = Some(Arc::clone(&pending_scope));
566        let ctx = Arc::new(tool_ctx);
567
568        for iteration in 0..self.max_tool_iterations {
569            self.emit(Event::TurnStart { turn: iteration });
570
571            let messages: Vec<Message> = self.messages.read().clone();
572            let system = self.system_prompt.clone();
573
574            #[allow(unused_mut)]
575            let mut tool_calls: Vec<ToolCall> = Vec::new();
576            #[allow(unused_assignments)]
577            let mut assistant_content = String::new();
578
579            self.emit(Event::MessageStart {
580                role: Role::Assistant,
581            });
582
583            #[cfg(feature = "providers")]
584            {
585                use crate::provider::StreamEvent;
586                use futures::StreamExt;
587                let mut attempts = 0;
588                let stream = loop {
589                    #[cfg(feature = "ipc")]
590                    let result = ctx
591                        .cancellation
592                        .run(provider.stream(
593                            &messages,
594                            &system,
595                            &self.model,
596                            &self.tools.definitions(),
597                        ))
598                        .await
599                        .map_err(|_| AgentError::Cancelled)?;
600                    #[cfg(not(feature = "ipc"))]
601                    let result = provider
602                        .stream(&messages, &system, &self.model, &self.tools.definitions())
603                        .await;
604                    match result {
605                        Ok(stream) => break stream,
606                        Err(e) if e.is_transient() && attempts < 2 => {
607                            attempts += 1;
608                            #[cfg(feature = "ipc")]
609                            ctx.cancellation
610                                .run(tokio::time::sleep(std::time::Duration::from_millis(
611                                    250 * (1 << attempts),
612                                )))
613                                .await
614                                .map_err(|_| AgentError::Cancelled)?;
615                            #[cfg(not(feature = "ipc"))]
616                            tokio::time::sleep(std::time::Duration::from_millis(
617                                250 * (1 << attempts),
618                            ))
619                            .await;
620                        }
621                        Err(e) => {
622                            error!("provider stream error: {e}");
623                            self.emit(Event::Error(e.to_string()));
624                            return Err(AgentError::Provider(e.to_string()));
625                        }
626                    }
627                };
628
629                let mut stream = stream;
630                loop {
631                    #[cfg(feature = "ipc")]
632                    let next = ctx
633                        .cancellation
634                        .run(stream.next())
635                        .await
636                        .map_err(|_| AgentError::Cancelled)?;
637                    #[cfg(not(feature = "ipc"))]
638                    let next = stream.next().await;
639                    let Some(event_result) = next else {
640                        break;
641                    };
642                    match event_result {
643                        Ok(StreamEvent::Delta(delta)) => {
644                            assistant_content.push_str(&delta);
645                            self.emit(Event::MessageDelta { delta });
646                        }
647                        Ok(StreamEvent::ToolCall(call)) => {
648                            tool_calls.push(call.clone());
649                            self.emit(Event::ToolCall(call));
650                        }
651                        Ok(StreamEvent::Done) => break,
652                        Err(e) => {
653                            error!("stream error: {e}");
654                            self.emit(Event::Error(e.to_string()));
655                            return Err(AgentError::Provider(e.to_string()));
656                        }
657                    }
658                }
659            }
660
661            #[cfg(not(feature = "providers"))]
662            {
663                let _ = (&provider, &messages, &system);
664                assistant_content =
665                    "[providers feature not enabled — enable with --features providers]"
666                        .to_string();
667            }
668
669            self.emit(Event::MessageEnd {
670                role: Role::Assistant,
671                content: assistant_content.clone(),
672            });
673
674            if !assistant_content.is_empty() {
675                self.messages
676                    .write()
677                    .push(Message::assistant(assistant_content));
678            }
679
680            if tool_calls.is_empty() {
681                self.emit(Event::TurnEnd { turn: iteration });
682                break;
683            }
684
685            let results = self.execute_tools_parallel(&tool_calls, &ctx).await;
686            for result in &results {
687                self.messages
688                    .write()
689                    .push(Message::tool(&result.id, &result.content));
690            }
691            if let Some(scope) = pending_scope.lock().take() {
692                self.set_scope(scope);
693            }
694
695            self.emit(Event::TurnEnd { turn: iteration });
696        }
697
698        #[cfg(feature = "graph-memory")]
699        if let Some(graph) = self.graph_memory.as_mut() {
700            let turns: Vec<crate::graph_memory::ConversationTurn> = self
701                .messages
702                .read()
703                .iter()
704                .map(|m| crate::graph_memory::ConversationTurn {
705                    role: m.role.to_string(),
706                    content: m.content.clone(),
707                })
708                .collect();
709            let extracted = crate::graph_memory::ConversationExtractor::new().extract(&turns);
710            for node in extracted.nodes {
711                graph.add_node(node);
712            }
713            for edge in extracted.edges {
714                let _ = graph.add_edge(edge);
715            }
716            if self.auto_dream {
717                let _ = crate::dream_scheduler::DreamScheduler::new().run_cycle(graph);
718            }
719        }
720
721        // Background skill review when a SkillEngine is attached (host opt-in).
722        #[cfg(feature = "skills")]
723        if let Some(engine) = self.skill_engine.as_mut() {
724            let turns: Vec<crate::skill_engine::ConversationTurn> = self
725                .messages
726                .read()
727                .iter()
728                .map(|m| crate::skill_engine::ConversationTurn {
729                    role: m.role.to_string(),
730                    content: m.content.clone(),
731                    tool_calls: Vec::new(),
732                })
733                .collect();
734            let mut reviewer = crate::background_review::BackgroundReviewer::new(engine);
735            if let Ok(reviews) =
736                reviewer.review_conversation(&turns, crate::skill_engine::SkillOutcome::Success)
737            {
738                let _ = reviewer.apply_review(&reviews);
739            }
740        }
741
742        self.emit(Event::AgentEnd);
743        Ok(())
744    }
745
746    /// Execute tool calls: parallel batches for Read/Network, serial for Write/Process.
747    async fn execute_tools_parallel(
748        &self,
749        calls: &[ToolCall],
750        ctx: &Arc<ToolContext>,
751    ) -> Vec<ToolResult> {
752        let effects: Vec<ToolEffect> = calls
753            .iter()
754            .map(|c| {
755                let name = normalize_tool_name(&c.name);
756                self.tools.effect_of(name)
757            })
758            .collect();
759        let batches = plan_tool_effect_batches(&effects);
760        let mut results: Vec<Option<ToolResult>> = vec![None; calls.len()];
761
762        for batch in batches {
763            if batch.len() == 1 {
764                let idx = batch[0];
765                let original = &calls[idx];
766                self.emit(Event::ToolExecutionStart(original.clone()));
767                let (call, result) = self.execute_single_tool(original, ctx).await;
768                if result.is_error && result.content == "approval required" {
769                    self.emit(Event::ApprovalRequired(
770                        crate::permissions::ApprovalRequest::from_call(&call, &self.policy),
771                    ));
772                }
773                self.emit(Event::ToolExecutionEnd(result.clone()));
774                results[idx] = Some(result);
775                continue;
776            }
777
778            let tools = Arc::clone(&self.tools);
779            let policy = self.policy.clone();
780            let scope_profile = self.scope_profile.clone();
781            let approver = self.approver.clone();
782            let tool_cache = self.tool_cache.clone();
783            let mut join_set = tokio::task::JoinSet::new();
784
785            for idx in batch {
786                let original = &calls[idx];
787                let call = match self.apply_before_tool_hooks(original) {
788                    Ok(c) => c,
789                    Err(reason) => {
790                        self.emit(Event::ToolExecutionStart(original.clone()));
791                        let result = ToolResult::err(&original.id, reason);
792                        self.emit(Event::ToolExecutionEnd(result.clone()));
793                        results[idx] = Some(result);
794                        continue;
795                    }
796                };
797                self.emit(Event::ToolExecutionStart(call.clone()));
798                let ctx = Arc::clone(ctx);
799                let tools = Arc::clone(&tools);
800                let policy = policy.clone();
801                let scope_profile = scope_profile.clone();
802                let approver = approver.clone();
803                let tool_cache = tool_cache.clone();
804                join_set.spawn(async move {
805                    let result = Agent::run_tool_call(
806                        &tools,
807                        &policy,
808                        scope_profile.as_ref(),
809                        approver.as_deref(),
810                        &tool_cache,
811                        &call,
812                        &ctx,
813                    )
814                    .await;
815                    (idx, call, result)
816                });
817            }
818
819            while let Some(joined) = join_set.join_next().await {
820                match joined {
821                    Ok((idx, call, result)) => {
822                        if result.is_error && result.content == "approval required" {
823                            self.emit(Event::ApprovalRequired(
824                                crate::permissions::ApprovalRequest::from_call(&call, &self.policy),
825                            ));
826                        }
827                        self.emit(Event::ToolExecutionEnd(result.clone()));
828                        results[idx] = Some(result);
829                    }
830                    Err(e) => {
831                        warn!("parallel tool task join error: {e}");
832                    }
833                }
834            }
835        }
836
837        results
838            .into_iter()
839            .enumerate()
840            .map(|(i, r)| {
841                r.unwrap_or_else(|| {
842                    ToolResult::err(
843                        calls.get(i).map(|c| c.id.as_str()).unwrap_or(""),
844                        "tool execution failed",
845                    )
846                })
847            })
848            .collect()
849    }
850
851    fn apply_before_tool_hooks(&self, call: &ToolCall) -> Result<ToolCall, String> {
852        match &self.hooks {
853            Some(hooks) => hooks.run_before_tool(call),
854            None => Ok(call.clone()),
855        }
856    }
857
858    async fn execute_single_tool(
859        &self,
860        call: &ToolCall,
861        ctx: &Arc<ToolContext>,
862    ) -> (ToolCall, ToolResult) {
863        let call = match self.apply_before_tool_hooks(call) {
864            Ok(c) => c,
865            Err(reason) => {
866                let id = call.id.clone();
867                return (call.clone(), ToolResult::err(&id, reason));
868            }
869        };
870        let result = Self::run_tool_call(
871            self.tools.as_ref(),
872            &self.policy,
873            self.scope_profile.as_ref(),
874            self.approver.as_deref(),
875            &self.tool_cache,
876            &call,
877            ctx,
878        )
879        .await;
880        (call, result)
881    }
882
883    async fn run_tool_call(
884        tools: &ToolRegistry,
885        policy: &Policy,
886        scope_profile: Option<&Profile>,
887        approver: Option<&dyn Approver>,
888        tool_cache: &Cache<String, ToolResult>,
889        call: &ToolCall,
890        ctx: &Arc<ToolContext>,
891    ) -> ToolResult {
892        let resolved_name = normalize_tool_name(&call.name).to_string();
893
894        if let Some(profile) = scope_profile {
895            if !mode::tool_allowed(profile, &call.name)
896                && !mode::tool_allowed(profile, &resolved_name)
897            {
898                let msg = format!("tool not in scope {}: {}", profile.scope.name(), call.name);
899                return ToolResult::err(&call.id, msg);
900            }
901        }
902
903        let decision = permissions::authorize_with_workspace(
904            policy,
905            &resolved_name,
906            &call.arguments,
907            approver,
908            Some(ctx.workspace_root.as_path()),
909        );
910
911        match decision {
912            Decision::Deny => ToolResult::err(&call.id, "denied by policy"),
913            Decision::Ask => {
914                // Rich payload is emitted by callers that have Agent self; parallel path
915                // only returns the error string — serial path re-emits below when possible.
916                ToolResult::err(&call.id, "approval required")
917            }
918            Decision::Allow => {
919                let effect = tools.effect_of(&resolved_name);
920                let cache_key = format!("{}:{}", resolved_name, call.arguments);
921                if effect == ToolEffect::Read {
922                    if let Some(cached) = tool_cache.get(&cache_key).await {
923                        debug!("tool cache hit: {}", resolved_name);
924                        return ToolResult::ok(&call.id, cached.content);
925                    }
926                }
927
928                let mut result = match tools.execute(&resolved_name, ctx, &call.arguments).await {
929                    Some(r) => r,
930                    None => ToolResult::err(&call.id, format!("unknown tool: {}", call.name)),
931                };
932
933                result.content = crate::secrets::Redactor::new().redact(&result.content);
934
935                if !result.is_error {
936                    match effect {
937                        ToolEffect::Read => {
938                            tool_cache.insert(cache_key, result.clone()).await;
939                        }
940                        ToolEffect::Write | ToolEffect::Process => {
941                            tool_cache.invalidate_all();
942                        }
943                        ToolEffect::Network => {}
944                    }
945                }
946
947                result
948            }
949        }
950    }
951
952    pub fn compact(&self, reason: &str) {
953        info!("compacting context: {reason}");
954        let mut msgs = self.messages.write();
955        if msgs.len() <= 2 {
956            return;
957        }
958        let trigger = self.auto_compact_after.max(64);
959        let reserve = (trigger / 4).max(32);
960        let keep_recent = (trigger / 4).max(32);
961        let config = CompactionConfig::new(trigger + reserve, reserve, keep_recent);
962        let result = apply_compaction(&mut msgs, &config);
963        if !result.summary.is_empty() {
964            msgs.push(Message::system(format!("[compact reason: {reason}]")));
965        }
966    }
967}
968
969#[cfg(test)]
970mod tests {
971    use super::*;
972    use std::sync::atomic::{AtomicUsize, Ordering};
973    use std::time::Duration;
974
975    static PARALLEL_DELAY_CALLS: AtomicUsize = AtomicUsize::new(0);
976
977    fn delay_read_tool(name: &str) -> ToolDefinition {
978        ToolDefinition::new_boxed(
979            name,
980            "delay read",
981            "{}",
982            Box::new(|_ctx, _args| {
983                Box::pin(async {
984                    PARALLEL_DELAY_CALLS.fetch_add(1, Ordering::SeqCst);
985                    tokio::time::sleep(Duration::from_millis(40)).await;
986                    ToolResult::ok("id", "ok")
987                })
988            }),
989        )
990        .with_effect(ToolEffect::Read)
991    }
992
993    #[tokio::test]
994    async fn parallel_read_tools_run_concurrently() {
995        PARALLEL_DELAY_CALLS.store(0, Ordering::SeqCst);
996        let mut registry = ToolRegistry::new();
997        registry.register(delay_read_tool("a"));
998        registry.register(delay_read_tool("b"));
999        let mut agent = Agent::new();
1000        agent.set_tools(registry);
1001        agent.set_policy(Policy::full_access());
1002        let ctx = Arc::new(ToolContext::new("."));
1003        let calls = vec![
1004            ToolCall {
1005                id: "1".into(),
1006                name: "a".into(),
1007                arguments: "{}".into(),
1008            },
1009            ToolCall {
1010                id: "2".into(),
1011                name: "b".into(),
1012                arguments: "{}".into(),
1013            },
1014        ];
1015        let start = std::time::Instant::now();
1016        let results = agent.execute_tools_parallel(&calls, &ctx).await;
1017        assert_eq!(results.len(), 2);
1018        assert!(results.iter().all(|r| !r.is_error));
1019        assert_eq!(PARALLEL_DELAY_CALLS.load(Ordering::SeqCst), 2);
1020        assert!(start.elapsed() < Duration::from_millis(70));
1021    }
1022
1023    static CACHE_READ_CALLS: AtomicUsize = AtomicUsize::new(0);
1024    static CACHE_WRITE_CALLS: AtomicUsize = AtomicUsize::new(0);
1025
1026    #[tokio::test]
1027    async fn cache_not_used_for_write_effect() {
1028        CACHE_READ_CALLS.store(0, Ordering::SeqCst);
1029        CACHE_WRITE_CALLS.store(0, Ordering::SeqCst);
1030        let mut registry = ToolRegistry::new();
1031        registry.register(
1032            ToolDefinition::new_boxed(
1033                "r",
1034                "read",
1035                "{}",
1036                Box::new(|_ctx, _args| {
1037                    Box::pin(async {
1038                        CACHE_READ_CALLS.fetch_add(1, Ordering::SeqCst);
1039                        ToolResult::ok("id", "data")
1040                    })
1041                }),
1042            )
1043            .with_effect(ToolEffect::Read),
1044        );
1045        registry.register(
1046            ToolDefinition::new_boxed(
1047                "w",
1048                "write",
1049                "{}",
1050                Box::new(|_ctx, _args| {
1051                    Box::pin(async {
1052                        CACHE_WRITE_CALLS.fetch_add(1, Ordering::SeqCst);
1053                        ToolResult::ok("id", "wrote")
1054                    })
1055                }),
1056            )
1057            .with_effect(ToolEffect::Write),
1058        );
1059        let mut agent = Agent::new();
1060        agent.set_tools(registry);
1061        agent.set_policy(Policy::full_access());
1062        let ctx = Arc::new(ToolContext::new("."));
1063        let read_call = ToolCall {
1064            id: "1".into(),
1065            name: "r".into(),
1066            arguments: "{}".into(),
1067        };
1068        let write_call = ToolCall {
1069            id: "2".into(),
1070            name: "w".into(),
1071            arguments: "{}".into(),
1072        };
1073
1074        agent.execute_single_tool(&read_call, &ctx).await;
1075        agent.execute_single_tool(&read_call, &ctx).await;
1076        assert_eq!(CACHE_READ_CALLS.load(Ordering::SeqCst), 1);
1077
1078        agent.execute_single_tool(&write_call, &ctx).await;
1079        agent.execute_single_tool(&write_call, &ctx).await;
1080        assert_eq!(CACHE_WRITE_CALLS.load(Ordering::SeqCst), 2);
1081
1082        agent.execute_single_tool(&read_call, &ctx).await;
1083        assert_eq!(CACHE_READ_CALLS.load(Ordering::SeqCst), 2);
1084    }
1085
1086    #[test]
1087    fn compact_uses_token_aware_compaction() {
1088        let mut agent = Agent::new();
1089        agent.auto_compact_after = 50;
1090        {
1091            let mut msgs = agent.messages.write();
1092            msgs.push(Message::system("sys"));
1093            for i in 0..20 {
1094                msgs.push(Message::user(
1095                    format!("old message {i} ",) + &"x".repeat(80),
1096                ));
1097                msgs.push(Message::assistant("reply".repeat(40)));
1098            }
1099            msgs.push(Message::user("recent tail"));
1100        }
1101        agent.compact("test");
1102        let msgs = agent.messages.read();
1103        assert!(msgs.len() < 42);
1104        assert!(msgs.iter().any(|m| m.content.contains("context compacted")));
1105        assert!(msgs.iter().any(|m| m.content.contains("recent tail")));
1106    }
1107
1108    #[cfg(feature = "ipc")]
1109    #[test]
1110    fn cancellation_handle_cancels_reset_turn() {
1111        let handle = CancellationHandle::new();
1112        let external = handle.clone();
1113        let token = handle.reset();
1114        external.cancel();
1115        assert!(token.is_canceled());
1116    }
1117}
1118
1119impl Default for Agent {
1120    fn default() -> Self {
1121        Self::new()
1122    }
1123}
1124
1125#[derive(Debug, thiserror::Error)]
1126pub enum AgentError {
1127    #[error("provider error: {0}")]
1128    Provider(String),
1129    #[error("tool error: {0}")]
1130    Tool(String),
1131    #[error("no provider configured")]
1132    NoProvider,
1133    #[error("agent cancelled")]
1134    Cancelled,
1135}