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