Skip to main content

a3s_code_core/
agent.rs

1//! Agent Loop Implementation
2//!
3//! The agent loop handles the core conversation cycle:
4//! 1. User sends a prompt
5//! 2. LLM generates a response (possibly with tool calls)
6//! 3. If tool calls present, execute them and send results back
7//! 4. Repeat until LLM returns without tool calls
8//!
9//! This implements agentic behavior where the LLM can use tools
10//! to accomplish tasks agentically.
11
12use crate::context::ContextProvider;
13use crate::hitl::ConfirmationProvider;
14use crate::hooks::HookExecutor;
15#[cfg(test)]
16use crate::llm::LlmResponse;
17use crate::llm::{LlmClient, Message, TokenUsage, ToolDefinition};
18use crate::permissions::{PermissionChecker, PermissionPolicy};
19use crate::planning::{AgentGoal, ExecutionPlan, TaskStatus};
20use crate::prompts::{PlanningMode, SystemPromptSlots};
21use crate::queue::{SessionCommand, SessionQueueConfig};
22use crate::session_lane_queue::SessionLaneQueue;
23use crate::subagent::AgentRegistry;
24use crate::tools::{ToolContext, ToolExecutor};
25use anyhow::Result;
26use async_trait::async_trait;
27use serde::{Deserialize, Serialize};
28use serde_json::Value;
29use std::sync::Arc;
30
31mod auto_delegation;
32mod completion_runtime;
33mod context_perception;
34mod execution_entry;
35mod execution_mode;
36mod execution_state;
37pub(crate) use execution_state::ExecutionSeed;
38mod hook_runtime;
39mod llm_turn;
40mod loop_builder;
41mod loop_runtime;
42mod parallel_tool_runtime;
43mod plan_execution;
44mod planning_runtime;
45mod project_context;
46mod prompt_runtime;
47mod queue_forwarder;
48mod telemetry_runtime;
49mod tool_completion_runtime;
50mod tool_execution_runtime;
51mod tool_gate_runtime;
52mod tool_guard_runtime;
53mod tool_memory_runtime;
54mod tool_result_runtime;
55mod tool_turn;
56mod turn_context;
57
58/// Maximum number of tool execution rounds before stopping
59pub(crate) const MAX_TOOL_ROUNDS: usize = 50;
60pub(crate) const DEFAULT_MAX_PARALLEL_TASKS: usize = 8;
61
62/// Internal agent loop configuration.
63#[derive(Clone)]
64pub(crate) struct AgentConfig {
65    /// Slot-based system prompt customization.
66    ///
67    /// Users can customize specific parts (role, guidelines, response style, extra)
68    /// without overriding the core agentic capabilities. The default agentic core
69    /// (tool usage, autonomous behavior, completion criteria) is always preserved.
70    pub prompt_slots: SystemPromptSlots,
71    pub tools: Vec<ToolDefinition>,
72    pub max_tool_rounds: usize,
73    /// Optional security provider for input taint tracking and output sanitization
74    pub security_provider: Option<Arc<dyn crate::security::SecurityProvider>>,
75    /// Optional permission checker for tool execution control
76    pub permission_checker: Option<Arc<dyn PermissionChecker>>,
77    /// Serializable permission policy used to build the checker, when available.
78    pub permission_policy: Option<PermissionPolicy>,
79    /// Optional confirmation manager for HITL (Human-in-the-Loop)
80    pub confirmation_manager: Option<Arc<dyn ConfirmationProvider>>,
81    /// Serializable confirmation policy used to build the manager, when available.
82    pub confirmation_policy: Option<crate::hitl::ConfirmationPolicy>,
83    /// Serializable queue configuration used to build the optional command queue.
84    pub queue_config: Option<SessionQueueConfig>,
85    /// Context providers for augmenting prompts with external context
86    pub context_providers: Vec<Arc<dyn ContextProvider>>,
87    /// Planning mode — Auto (detect from message), Enabled, or Disabled.
88    pub planning_mode: PlanningMode,
89    /// Enable goal tracking
90    pub goal_tracking: bool,
91    /// Optional hook engine for firing lifecycle events (PreToolUse, PostToolUse, etc.)
92    pub hook_engine: Option<Arc<dyn HookExecutor>>,
93    /// Optional skill registry for tool permission enforcement
94    pub skill_registry: Option<Arc<crate::skills::SkillRegistry>>,
95    /// Max consecutive malformed-tool-args errors before aborting (default: 2).
96    ///
97    /// When the LLM returns tool arguments with `__parse_error`, the error is
98    /// fed back as a tool result. After this many consecutive parse errors the
99    /// loop bails instead of retrying indefinitely.
100    pub max_parse_retries: u32,
101    /// Per-tool execution timeout in milliseconds (`None` = no timeout).
102    ///
103    /// When set, each tool execution is wrapped in `tokio::time::timeout`.
104    /// A timeout produces an error result sent back to the LLM rather than
105    /// crashing the session.
106    pub tool_timeout_ms: Option<u64>,
107    /// Maximum number of sibling branches/tools to run concurrently in bounded
108    /// parallel fan-out paths.
109    pub max_parallel_tasks: usize,
110    /// Runtime-driven automatic child-agent delegation.
111    pub auto_delegation: crate::config::AutoDelegationConfig,
112    /// Available child agents for automatic delegation.
113    pub agent_registry: Option<Arc<AgentRegistry>>,
114    /// Circuit-breaker threshold: max consecutive LLM API failures before
115    /// aborting (default: 3).
116    ///
117    /// In non-streaming mode, transient LLM failures are retried up to this
118    /// many times (with short exponential backoff) before the loop bails.
119    /// In streaming mode, any failure is fatal (events cannot be replayed).
120    pub circuit_breaker_threshold: u32,
121    /// Max consecutive identical tool signatures before aborting (default: 3).
122    ///
123    /// A tool signature is the exact combination of tool name + compact JSON
124    /// arguments. This prevents the agent from getting stuck repeating the same
125    /// tool call in a loop, for example repeatedly fetching the same URL.
126    pub duplicate_tool_call_threshold: u32,
127    /// Enable auto-compaction when context usage exceeds threshold.
128    pub auto_compact: bool,
129    /// Context usage percentage threshold to trigger auto-compaction (0.0 - 1.0).
130    /// Default: 0.80 (80%).
131    pub auto_compact_threshold: f32,
132    /// Maximum context window size in tokens (used for auto-compact calculation).
133    /// Default: 200_000.
134    pub max_context_tokens: usize,
135    /// Optional agent memory for auto-remember after tool execution and recall before prompts.
136    pub memory: Option<Arc<crate::memory::AgentMemory>>,
137    /// Inject a continuation message when the LLM stops calling tools before the
138    /// task is complete. Enabled by default. Set to `false` to disable.
139    ///
140    /// When enabled, if the LLM produces a response with no tool calls but the
141    /// response text looks like an intermediate step (not a final answer), the
142    /// loop injects [`crate::prompts::CONTINUATION`] as a user message and
143    /// continues for up to `max_continuation_turns` additional turns.
144    pub continuation_enabled: bool,
145    /// Maximum number of continuation injections per execution (default: 3).
146    ///
147    /// Prevents infinite loops when the LLM repeatedly stops without completing.
148    pub max_continuation_turns: u32,
149    /// Maximum execution time in milliseconds (`None` = no timeout).
150    ///
151    /// When set, the entire execution loop is wrapped in a timeout check.
152    /// If execution exceeds this duration, the loop bails with an error.
153    /// This prevents runaway executions that consume excessive API quota.
154    pub max_execution_time_ms: Option<u64>,
155    /// Host-supplied budget guard consulted before every LLM call (and
156    /// after, for usage accounting). `None` means no enforcement.
157    pub budget_guard: Option<Arc<dyn crate::budget::BudgetGuard>>,
158    /// Host-provided ID generator + clock. Defaults to wall-clock UUIDs.
159    /// Replace via [`SessionOptions::with_host_env`](crate::agent_api::SessionOptions::with_host_env)
160    /// when deterministic replay is needed.
161    pub host_env: Arc<crate::host_env::HostEnv>,
162}
163
164impl std::fmt::Debug for AgentConfig {
165    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
166        f.debug_struct("AgentConfig")
167            .field("prompt_slots", &self.prompt_slots)
168            .field("tools", &self.tools)
169            .field("max_tool_rounds", &self.max_tool_rounds)
170            .field("security_provider", &self.security_provider.is_some())
171            .field("permission_checker", &self.permission_checker.is_some())
172            .field("permission_policy", &self.permission_policy.is_some())
173            .field("confirmation_manager", &self.confirmation_manager.is_some())
174            .field("confirmation_policy", &self.confirmation_policy.is_some())
175            .field("queue_config", &self.queue_config.is_some())
176            .field("context_providers", &self.context_providers.len())
177            .field("planning_mode", &self.planning_mode)
178            .field("goal_tracking", &self.goal_tracking)
179            .field("hook_engine", &self.hook_engine.is_some())
180            .field(
181                "skill_registry",
182                &self.skill_registry.as_ref().map(|r| r.len()),
183            )
184            .field("max_parse_retries", &self.max_parse_retries)
185            .field("tool_timeout_ms", &self.tool_timeout_ms)
186            .field("max_parallel_tasks", &self.max_parallel_tasks)
187            .field("auto_delegation", &self.auto_delegation)
188            .field(
189                "agent_registry",
190                &self.agent_registry.as_ref().map(|registry| registry.len()),
191            )
192            .field("circuit_breaker_threshold", &self.circuit_breaker_threshold)
193            .field(
194                "duplicate_tool_call_threshold",
195                &self.duplicate_tool_call_threshold,
196            )
197            .field("auto_compact", &self.auto_compact)
198            .field("auto_compact_threshold", &self.auto_compact_threshold)
199            .field("max_context_tokens", &self.max_context_tokens)
200            .field("continuation_enabled", &self.continuation_enabled)
201            .field("max_continuation_turns", &self.max_continuation_turns)
202            .field("memory", &self.memory.is_some())
203            .finish()
204    }
205}
206
207impl Default for AgentConfig {
208    fn default() -> Self {
209        Self {
210            prompt_slots: SystemPromptSlots::default(),
211            tools: Vec::new(), // Tools are provided by ToolExecutor
212            max_tool_rounds: MAX_TOOL_ROUNDS,
213            security_provider: None,
214            permission_checker: None,
215            permission_policy: None,
216            confirmation_manager: None,
217            confirmation_policy: None,
218            queue_config: None,
219            context_providers: Vec::new(),
220            planning_mode: PlanningMode::default(),
221            goal_tracking: false,
222            hook_engine: None,
223            skill_registry: Some(Arc::new(crate::skills::SkillRegistry::with_builtins())),
224            max_parse_retries: 2,
225            tool_timeout_ms: None,
226            max_parallel_tasks: DEFAULT_MAX_PARALLEL_TASKS,
227            auto_delegation: crate::config::AutoDelegationConfig::default(),
228            agent_registry: None,
229            circuit_breaker_threshold: 3,
230            duplicate_tool_call_threshold: 3,
231            auto_compact: false,
232            auto_compact_threshold: 0.80,
233            max_context_tokens: 200_000,
234            memory: None,
235            continuation_enabled: true,
236            max_continuation_turns: 3,
237            max_execution_time_ms: None,
238            budget_guard: None,
239            host_env: Arc::new(crate::host_env::HostEnv::system()),
240        }
241    }
242}
243
244/// Events emitted during agent execution
245///
246/// Subscribe via [`crate::AgentSession::stream`].
247/// New variants may be added in minor releases — always include a wildcard arm
248/// (`_ => {}`) when matching.
249#[derive(Debug, Clone, Serialize, Deserialize)]
250#[serde(tag = "type")]
251#[non_exhaustive]
252pub enum AgentEvent {
253    /// Agent started processing
254    #[serde(rename = "agent_start")]
255    Start { prompt: String },
256
257    /// Runtime agent style/mode selected for the current execution.
258    #[serde(rename = "agent_mode_changed")]
259    AgentModeChanged {
260        /// Stable UI/runtime mode label, e.g. "general", "planning", "explore".
261        mode: String,
262        /// Canonical built-in agent name associated with this mode.
263        agent: String,
264        /// Human-readable explanation of the selected style.
265        description: String,
266    },
267
268    /// LLM turn started
269    #[serde(rename = "turn_start")]
270    TurnStart { turn: usize },
271
272    /// Text delta from streaming
273    #[serde(rename = "text_delta")]
274    TextDelta { text: String },
275
276    /// Reasoning/thinking delta from streaming (for models like kimi, deepseek)
277    #[serde(rename = "reasoning_delta")]
278    ReasoningDelta { text: String },
279
280    /// Tool execution started
281    #[serde(rename = "tool_start")]
282    ToolStart { id: String, name: String },
283
284    /// Tool input delta from streaming (partial JSON arguments)
285    #[serde(rename = "tool_input_delta")]
286    ToolInputDelta { delta: String },
287
288    /// Tool execution completed
289    #[serde(rename = "tool_end")]
290    ToolEnd {
291        id: String,
292        name: String,
293        output: String,
294        exit_code: i32,
295        #[serde(skip_serializing_if = "Option::is_none")]
296        metadata: Option<serde_json::Value>,
297        /// Structured discriminant set by tools that mapped their failure
298        /// into a typed [`ToolErrorKind`](crate::tools::ToolErrorKind)
299        /// (e.g. `edit` / `patch` on a `WorkspaceError::VersionConflict`).
300        /// `None` on success or untyped failure.
301        #[serde(skip_serializing_if = "Option::is_none")]
302        error_kind: Option<crate::tools::ToolErrorKind>,
303    },
304
305    /// Intermediate tool output (streaming delta)
306    #[serde(rename = "tool_output_delta")]
307    ToolOutputDelta {
308        id: String,
309        name: String,
310        delta: String,
311    },
312
313    /// LLM turn completed
314    #[serde(rename = "turn_end")]
315    TurnEnd { turn: usize, usage: TokenUsage },
316
317    /// Agent completed
318    #[serde(rename = "agent_end")]
319    End {
320        text: String,
321        usage: TokenUsage,
322        verification_summary: Box<crate::verification::VerificationSummary>,
323        #[serde(skip_serializing_if = "Option::is_none")]
324        meta: Option<crate::llm::LlmResponseMeta>,
325    },
326
327    /// Error occurred
328    #[serde(rename = "error")]
329    Error { message: String },
330
331    /// Tool execution requires confirmation (HITL)
332    #[serde(rename = "confirmation_required")]
333    ConfirmationRequired {
334        tool_id: String,
335        tool_name: String,
336        args: serde_json::Value,
337        timeout_ms: u64,
338    },
339
340    /// Confirmation received from user (HITL)
341    #[serde(rename = "confirmation_received")]
342    ConfirmationReceived {
343        tool_id: String,
344        approved: bool,
345        reason: Option<String>,
346    },
347
348    /// Confirmation timed out (HITL)
349    #[serde(rename = "confirmation_timeout")]
350    ConfirmationTimeout {
351        tool_id: String,
352        action_taken: String, // "rejected" or "auto_approved"
353    },
354
355    /// External task pending (needs SDK processing)
356    #[serde(rename = "external_task_pending")]
357    ExternalTaskPending {
358        task_id: String,
359        session_id: String,
360        lane: crate::queue::SessionLane,
361        command_type: String,
362        payload: serde_json::Value,
363        timeout_ms: u64,
364    },
365
366    /// External task completed
367    #[serde(rename = "external_task_completed")]
368    ExternalTaskCompleted {
369        task_id: String,
370        session_id: String,
371        success: bool,
372    },
373
374    /// Tool execution denied by permission policy
375    #[serde(rename = "permission_denied")]
376    PermissionDenied {
377        tool_id: String,
378        tool_name: String,
379        args: serde_json::Value,
380        reason: String,
381    },
382
383    /// Context resolution started
384    #[serde(rename = "context_resolving")]
385    ContextResolving { providers: Vec<String> },
386
387    /// Context resolution completed
388    #[serde(rename = "context_resolved")]
389    ContextResolved {
390        total_items: usize,
391        total_tokens: usize,
392    },
393
394    // ========================================================================
395    // a3s-lane integration events
396    // ========================================================================
397    /// Command moved to dead letter queue after exhausting retries
398    #[serde(rename = "command_dead_lettered")]
399    CommandDeadLettered {
400        command_id: String,
401        command_type: String,
402        lane: String,
403        error: String,
404        attempts: u32,
405    },
406
407    /// Command retry attempt
408    #[serde(rename = "command_retry")]
409    CommandRetry {
410        command_id: String,
411        command_type: String,
412        lane: String,
413        attempt: u32,
414        delay_ms: u64,
415    },
416
417    /// Queue alert (depth warning, latency alert, etc.)
418    #[serde(rename = "queue_alert")]
419    QueueAlert {
420        level: String,
421        alert_type: String,
422        message: String,
423    },
424
425    // ========================================================================
426    // Task tracking events
427    // ========================================================================
428    /// Task list updated
429    #[serde(rename = "task_updated")]
430    TaskUpdated {
431        session_id: String,
432        tasks: Vec<crate::planning::Task>,
433    },
434
435    // ========================================================================
436    // Memory System events (Phase 3)
437    // ========================================================================
438    /// Memory stored
439    #[serde(rename = "memory_stored")]
440    MemoryStored {
441        memory_id: String,
442        memory_type: String,
443        importance: f32,
444        tags: Vec<String>,
445    },
446
447    /// Memory recalled
448    #[serde(rename = "memory_recalled")]
449    MemoryRecalled {
450        memory_id: String,
451        content: String,
452        relevance: f32,
453    },
454
455    /// Memories searched
456    #[serde(rename = "memories_searched")]
457    MemoriesSearched {
458        query: Option<String>,
459        tags: Vec<String>,
460        result_count: usize,
461    },
462
463    /// Memory cleared
464    #[serde(rename = "memory_cleared")]
465    MemoryCleared {
466        tier: String, // "long_term", "short_term", "working"
467        count: u64,
468    },
469
470    // ========================================================================
471    // Subagent events
472    // ========================================================================
473    /// Subagent task started
474    #[serde(rename = "subagent_start")]
475    SubagentStart {
476        /// Unique task identifier
477        task_id: String,
478        /// Child session ID
479        session_id: String,
480        /// Parent session ID
481        parent_session_id: String,
482        /// Agent type (e.g., "explore", "general")
483        agent: String,
484        /// Short description of the task
485        description: String,
486    },
487
488    /// Subagent task progress update
489    #[serde(rename = "subagent_progress")]
490    SubagentProgress {
491        /// Task identifier
492        task_id: String,
493        /// Child session ID
494        session_id: String,
495        /// Progress status message
496        status: String,
497        /// Additional metadata
498        metadata: serde_json::Value,
499    },
500
501    /// Subagent task completed
502    #[serde(rename = "subagent_end")]
503    SubagentEnd {
504        /// Task identifier
505        task_id: String,
506        /// Child session ID
507        session_id: String,
508        /// Agent type
509        agent: String,
510        /// Task output/result
511        output: String,
512        /// Whether the task succeeded
513        success: bool,
514    },
515
516    // ========================================================================
517    // Planning and Goal Tracking Events (Phase 1)
518    // ========================================================================
519    /// Planning phase started
520    #[serde(rename = "planning_start")]
521    PlanningStart { prompt: String },
522
523    /// Planning phase completed
524    #[serde(rename = "planning_end")]
525    PlanningEnd {
526        plan: ExecutionPlan,
527        estimated_steps: usize,
528    },
529
530    /// Step execution started
531    #[serde(rename = "step_start")]
532    StepStart {
533        step_id: String,
534        description: String,
535        step_number: usize,
536        total_steps: usize,
537    },
538
539    /// Step execution completed
540    #[serde(rename = "step_end")]
541    StepEnd {
542        step_id: String,
543        status: TaskStatus,
544        step_number: usize,
545        total_steps: usize,
546    },
547
548    /// Goal extracted from prompt
549    #[serde(rename = "goal_extracted")]
550    GoalExtracted { goal: AgentGoal },
551
552    /// Goal progress update
553    #[serde(rename = "goal_progress")]
554    GoalProgress {
555        goal: String,
556        progress: f32,
557        completed_steps: usize,
558        total_steps: usize,
559    },
560
561    /// Goal achieved
562    #[serde(rename = "goal_achieved")]
563    GoalAchieved {
564        goal: String,
565        total_steps: usize,
566        duration_ms: i64,
567    },
568
569    // ========================================================================
570    // Context Compaction events
571    // ========================================================================
572    /// Context automatically compacted due to high usage
573    #[serde(rename = "context_compacted")]
574    ContextCompacted {
575        session_id: String,
576        before_messages: usize,
577        after_messages: usize,
578        percent_before: f32,
579    },
580
581    // ========================================================================
582    // Persistence events
583    // ========================================================================
584    /// Session persistence failed — SDK clients should handle this
585    #[serde(rename = "persistence_failed")]
586    PersistenceFailed {
587        session_id: String,
588        operation: String,
589        error: String,
590    },
591
592    // ========================================================================
593    // Cluster / platform events
594    //
595    // These variants are emitted by the host platform (e.g. 书安OS) via
596    // `HookExecutor` and are not produced by the agent loop itself. They
597    // give in-session code a uniform way to observe platform-level
598    // decisions (budget exhaustion, scheduled passivation, peer
599    // invocations) without coupling to the host's transport.
600    // ========================================================================
601    /// A budget threshold was crossed for this session/tenant.
602    ///
603    /// Emitted by a host `BudgetGuard` impl when LLM/tool spend hits a
604    /// soft or hard threshold. The session is **not** automatically
605    /// halted — `kind` lets in-session policy decide (e.g. fast-compact
606    /// at "soft", refuse next LLM call at "hard").
607    #[serde(rename = "budget_threshold_hit")]
608    BudgetThresholdHit {
609        /// Logical resource: "llm_tokens", "tool_calls", "wall_time",
610        /// "usd_cost", or host-defined.
611        resource: String,
612        /// "soft" or "hard"; host-defined semantics beyond that.
613        kind: String,
614        /// Current consumed amount in the same unit as `limit`.
615        consumed: f64,
616        /// Threshold that was crossed.
617        limit: f64,
618        /// Optional explanation for logs / UI.
619        #[serde(default, skip_serializing_if = "Option::is_none")]
620        message: Option<String>,
621    },
622
623    /// The host is asking the session to release in-memory state.
624    ///
625    /// Emitted before the host calls `session.close()` or moves the
626    /// session to another node. Session code that holds large caches
627    /// can react (flush to memory store, drop derived state). The
628    /// framework does not act on this event itself.
629    #[serde(rename = "passivation_requested")]
630    PassivationRequested {
631        /// "idle_reaper", "node_drain", "migration", "manual", or
632        /// host-defined.
633        reason: String,
634        /// Optional deadline (Unix epoch ms) before forced close.
635        #[serde(default, skip_serializing_if = "Option::is_none")]
636        deadline_ms: Option<u64>,
637    },
638
639    /// Another session in the cluster has invoked this one.
640    ///
641    /// Lets in-session hooks distinguish "human-driven send" from
642    /// "peer-driven send" without inspecting prompts. The host routes
643    /// the actual prompt through the normal `send` / `stream` path;
644    /// this event is metadata only.
645    #[serde(rename = "peer_invocation")]
646    PeerInvocation {
647        /// Session id of the invoking peer (cluster-stable).
648        from_session_id: String,
649        /// Optional tenant of the invoking peer.
650        #[serde(default, skip_serializing_if = "Option::is_none")]
651        from_tenant_id: Option<String>,
652        /// Distributed-trace correlation id linking the two sessions.
653        #[serde(default, skip_serializing_if = "Option::is_none")]
654        correlation_id: Option<String>,
655    },
656}
657
658/// Result of agent execution
659#[derive(Debug, Clone)]
660pub struct AgentResult {
661    pub text: String,
662    pub messages: Vec<Message>,
663    pub usage: TokenUsage,
664    pub tool_calls_count: usize,
665    pub verification_reports: Vec<crate::verification::VerificationReport>,
666}
667
668impl AgentResult {
669    pub fn verification_summary(&self) -> crate::verification::VerificationSummary {
670        crate::verification::VerificationSummary::from_reports(&self.verification_reports)
671    }
672
673    pub fn verification_summary_text(&self) -> String {
674        crate::verification::format_verification_summary(&self.verification_summary())
675    }
676
677    pub fn has_pending_verification(&self) -> bool {
678        matches!(
679            self.verification_summary().status,
680            crate::verification::VerificationStatus::NeedsReview
681        )
682    }
683}
684
685// ============================================================================
686// ToolCommand — bridges ToolExecutor to SessionCommand for queue submission
687// ============================================================================
688
689/// Adapter that implements `SessionCommand` for tool execution via the queue.
690///
691/// Wraps a `ToolExecutor` call so it can be submitted to `SessionLaneQueue`.
692pub struct ToolCommand {
693    tool_executor: Arc<ToolExecutor>,
694    tool_name: String,
695    tool_args: Value,
696    tool_context: ToolContext,
697    skill_registry: Option<Arc<crate::skills::SkillRegistry>>,
698}
699
700impl ToolCommand {
701    /// Create a new ToolCommand
702    pub fn new(
703        tool_executor: Arc<ToolExecutor>,
704        tool_name: String,
705        tool_args: Value,
706        tool_context: ToolContext,
707        skill_registry: Option<Arc<crate::skills::SkillRegistry>>,
708    ) -> Self {
709        Self {
710            tool_executor,
711            tool_name,
712            tool_args,
713            tool_context,
714            skill_registry,
715        }
716    }
717}
718
719#[async_trait]
720impl SessionCommand for ToolCommand {
721    async fn execute(&self) -> Result<Value> {
722        // Check skill-based tool permissions
723        if let Some(registry) = &self.skill_registry {
724            // If there are instruction skills with tool restrictions, check permissions
725            let restricting_skills = registry.global_tool_restricting_skills();
726
727            if !restricting_skills.is_empty() {
728                let mut allowed = false;
729
730                for skill in &restricting_skills {
731                    if skill.is_tool_allowed(&self.tool_name) {
732                        allowed = true;
733                        break;
734                    }
735                }
736
737                if !allowed {
738                    return Err(anyhow::anyhow!(
739                        "Tool '{}' is not allowed by any active skill. Active skills restrict tools to their allowed-tools lists.",
740                        self.tool_name
741                    ));
742                }
743            }
744        }
745
746        // Execute the tool
747        let result = self
748            .tool_executor
749            .execute_with_context(&self.tool_name, &self.tool_args, &self.tool_context)
750            .await?;
751        Ok(serde_json::json!({
752            "output": result.output,
753            "exit_code": result.exit_code,
754            "metadata": result.metadata,
755        }))
756    }
757
758    fn command_type(&self) -> &str {
759        &self.tool_name
760    }
761
762    fn payload(&self) -> Value {
763        self.tool_args.clone()
764    }
765}
766
767// ============================================================================
768// AgentLoop
769// ============================================================================
770
771/// Internal agent loop executor.
772#[derive(Clone)]
773pub(crate) struct AgentLoop {
774    llm_client: Arc<dyn LlmClient>,
775    tool_executor: Arc<ToolExecutor>,
776    tool_context: ToolContext,
777    config: AgentConfig,
778    /// Optional lane queue for priority-based tool execution
779    command_queue: Option<Arc<SessionLaneQueue>>,
780    /// Optional sink for per-tool-round checkpoints. Populated by
781    /// `build_agent_loop` when the session has a configured
782    /// `SessionStore`. The agent loop uses
783    /// [`AgentLoop::set_checkpoint_run`] to bind a run id before
784    /// `execute_with_session`, then persists a checkpoint after each
785    /// completed tool round.
786    pub(crate) checkpoint_sink: Option<Arc<dyn crate::loop_checkpoint::LoopCheckpointSink>>,
787    /// Run id under which checkpoints are stored. Reset per execution
788    /// via [`AgentLoop::set_checkpoint_run`].
789    pub(crate) checkpoint_run_id: Option<String>,
790}
791
792#[cfg(test)]
793pub(crate) mod tests;
794
795#[cfg(test)]
796mod extra_agent_tests;