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