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