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;