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;