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