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