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    /// One run is using the exact cognitive-package generation retained by
449    /// the surrounding session snapshot.
450    #[serde(rename = "cognitive_context_bound")]
451    CognitiveContextBound {
452        binding: crate::cognitive_context::CognitivePackageBindingV1,
453    },
454
455    // ========================================================================
456    // a3s-lane integration events
457    // ========================================================================
458    /// Command moved to dead letter queue after exhausting retries
459    #[serde(rename = "command_dead_lettered")]
460    CommandDeadLettered {
461        command_id: String,
462        command_type: String,
463        lane: String,
464        error: String,
465        attempts: u32,
466    },
467
468    /// Command retry attempt
469    #[serde(rename = "command_retry")]
470    CommandRetry {
471        command_id: String,
472        command_type: String,
473        lane: String,
474        attempt: u32,
475        delay_ms: u64,
476    },
477
478    /// Queue alert (depth warning, latency alert, etc.)
479    #[serde(rename = "queue_alert")]
480    QueueAlert {
481        level: String,
482        alert_type: String,
483        message: String,
484    },
485
486    // ========================================================================
487    // Task tracking events
488    // ========================================================================
489    /// Task list updated
490    #[serde(rename = "task_updated")]
491    TaskUpdated {
492        session_id: String,
493        tasks: Vec<crate::planning::Task>,
494    },
495
496    // ========================================================================
497    // Memory System events (Phase 3)
498    // ========================================================================
499    /// Memory stored
500    #[serde(rename = "memory_stored")]
501    MemoryStored {
502        memory_id: String,
503        memory_type: String,
504        importance: f32,
505        tags: Vec<String>,
506    },
507
508    /// Memory recalled
509    #[serde(rename = "memory_recalled")]
510    MemoryRecalled {
511        memory_id: String,
512        content: String,
513        relevance: f32,
514    },
515
516    /// Memories searched
517    #[serde(rename = "memories_searched")]
518    MemoriesSearched {
519        query: Option<String>,
520        tags: Vec<String>,
521        result_count: usize,
522    },
523
524    /// Memory cleared
525    #[serde(rename = "memory_cleared")]
526    MemoryCleared {
527        tier: String, // "long_term", "short_term", "working"
528        count: u64,
529    },
530
531    // ========================================================================
532    // Subagent events
533    // ========================================================================
534    /// Subagent task started
535    #[serde(rename = "subagent_start")]
536    SubagentStart {
537        /// Unique task identifier
538        task_id: String,
539        /// Child session ID
540        session_id: String,
541        /// Parent session ID
542        parent_session_id: String,
543        /// Agent type (e.g., "explore", "general")
544        agent: String,
545        /// Short description of the task
546        description: String,
547        /// Wall-clock start timestamp in milliseconds since Unix epoch.
548        #[serde(default)]
549        started_ms: u64,
550    },
551
552    /// Subagent task progress update
553    #[serde(rename = "subagent_progress")]
554    SubagentProgress {
555        /// Task identifier
556        task_id: String,
557        /// Child session ID
558        session_id: String,
559        /// Progress status message
560        status: String,
561        /// Additional metadata
562        metadata: serde_json::Value,
563    },
564
565    /// Subagent task completed
566    #[serde(rename = "subagent_end")]
567    SubagentEnd {
568        /// Task identifier
569        task_id: String,
570        /// Child session ID
571        session_id: String,
572        /// Agent type
573        agent: String,
574        /// Task output/result
575        output: String,
576        /// Whether the task succeeded
577        success: bool,
578        /// Wall-clock finish timestamp in milliseconds since Unix epoch.
579        #[serde(default)]
580        finished_ms: u64,
581    },
582
583    // ========================================================================
584    // Planning and Goal Tracking Events (Phase 1)
585    // ========================================================================
586    /// Planning phase started
587    #[serde(rename = "planning_start")]
588    PlanningStart { prompt: String },
589
590    /// Planning phase completed
591    #[serde(rename = "planning_end")]
592    PlanningEnd {
593        plan: ExecutionPlan,
594        estimated_steps: usize,
595    },
596
597    /// Step execution started
598    #[serde(rename = "step_start")]
599    StepStart {
600        step_id: String,
601        description: String,
602        step_number: usize,
603        total_steps: usize,
604    },
605
606    /// Step execution completed
607    #[serde(rename = "step_end")]
608    StepEnd {
609        step_id: String,
610        status: TaskStatus,
611        step_number: usize,
612        total_steps: usize,
613    },
614
615    /// Goal extracted from prompt
616    #[serde(rename = "goal_extracted")]
617    GoalExtracted { goal: AgentGoal },
618
619    /// Goal progress update
620    #[serde(rename = "goal_progress")]
621    GoalProgress {
622        goal: String,
623        progress: f32,
624        completed_steps: usize,
625        total_steps: usize,
626    },
627
628    /// Goal achieved
629    #[serde(rename = "goal_achieved")]
630    GoalAchieved {
631        goal: String,
632        total_steps: usize,
633        duration_ms: i64,
634    },
635
636    // ========================================================================
637    // Context Compaction events
638    // ========================================================================
639    /// Context automatically compacted due to high usage
640    #[serde(rename = "context_compacted")]
641    ContextCompacted {
642        session_id: String,
643        before_messages: usize,
644        after_messages: usize,
645        percent_before: f32,
646        /// Cumulative continuation summary when message history was reduced.
647        /// Prune-only compactions omit this field.
648        #[serde(default, skip_serializing_if = "Option::is_none")]
649        summary: Option<String>,
650    },
651
652    // ========================================================================
653    // Persistence events
654    // ========================================================================
655    /// Session persistence failed — SDK clients should handle this
656    #[serde(rename = "persistence_failed")]
657    PersistenceFailed {
658        session_id: String,
659        operation: String,
660        error: String,
661    },
662
663    // ========================================================================
664    // Cluster / platform events
665    //
666    // These variants are emitted by the host platform via
667    // `HookExecutor` and are not produced by the agent loop itself. They
668    // give in-session code a uniform way to observe platform-level
669    // decisions (budget exhaustion, scheduled passivation, peer
670    // invocations) without coupling to the host's transport.
671    // ========================================================================
672    /// A budget threshold was crossed for this session/tenant.
673    ///
674    /// Emitted by a host `BudgetGuard` impl when LLM/tool spend hits a
675    /// soft or hard threshold. The session is **not** automatically
676    /// halted — `kind` lets in-session policy decide (e.g. fast-compact
677    /// at "soft", refuse next LLM call at "hard").
678    #[serde(rename = "budget_threshold_hit")]
679    BudgetThresholdHit {
680        /// Logical resource: "llm_tokens", "tool_calls", "wall_time",
681        /// "usd_cost", or host-defined.
682        resource: String,
683        /// "soft" or "hard"; host-defined semantics beyond that.
684        kind: String,
685        /// Current consumed amount in the same unit as `limit`.
686        consumed: f64,
687        /// Threshold that was crossed.
688        limit: f64,
689        /// Optional explanation for logs / UI.
690        #[serde(default, skip_serializing_if = "Option::is_none")]
691        message: Option<String>,
692    },
693
694    /// The host is asking the session to release in-memory state.
695    ///
696    /// Emitted before the host calls `session.close()` or moves the
697    /// session to another node. Session code that holds large caches
698    /// can react (flush to memory store, drop derived state). The
699    /// framework does not act on this event itself.
700    #[serde(rename = "passivation_requested")]
701    PassivationRequested {
702        /// "idle_reaper", "node_drain", "migration", "manual", or
703        /// host-defined.
704        reason: String,
705        /// Optional deadline (Unix epoch ms) before forced close.
706        #[serde(default, skip_serializing_if = "Option::is_none")]
707        deadline_ms: Option<u64>,
708    },
709
710    /// Another session in the cluster has invoked this one.
711    ///
712    /// Lets in-session hooks distinguish "human-driven send" from
713    /// "peer-driven send" without inspecting prompts. The host routes
714    /// the actual prompt through the normal `send` / `stream` path;
715    /// this event is metadata only.
716    #[serde(rename = "peer_invocation")]
717    PeerInvocation {
718        /// Session id of the invoking peer (cluster-stable).
719        from_session_id: String,
720        /// Optional tenant of the invoking peer.
721        #[serde(default, skip_serializing_if = "Option::is_none")]
722        from_tenant_id: Option<String>,
723        /// Distributed-trace correlation id linking the two sessions.
724        #[serde(default, skip_serializing_if = "Option::is_none")]
725        correlation_id: Option<String>,
726    },
727}
728
729/// Result of agent execution
730#[derive(Debug, Clone)]
731pub struct AgentResult {
732    pub text: String,
733    pub messages: Vec<Message>,
734    pub usage: TokenUsage,
735    pub tool_calls_count: usize,
736    pub verification_reports: Vec<crate::verification::VerificationReport>,
737}
738
739impl AgentResult {
740    pub fn verification_summary(&self) -> crate::verification::VerificationSummary {
741        crate::verification::VerificationSummary::from_reports(&self.verification_reports)
742    }
743
744    pub fn verification_summary_text(&self) -> String {
745        crate::verification::format_verification_summary(&self.verification_summary())
746    }
747
748    pub fn has_pending_verification(&self) -> bool {
749        matches!(
750            self.verification_summary().status,
751            crate::verification::VerificationStatus::NeedsReview
752        )
753    }
754}
755
756// ============================================================================
757// ToolCommand — bridges ToolExecutor to SessionCommand for queue submission
758// ============================================================================
759
760/// Adapter that implements `SessionCommand` for tool execution via the queue.
761///
762/// Wraps a `ToolExecutor` call so it can be submitted to `SessionLaneQueue`.
763pub struct ToolCommand {
764    tool_executor: Arc<ToolExecutor>,
765    tool_name: String,
766    tool_args: Value,
767    tool_context: ToolContext,
768    tool_timeout_ms: Option<u64>,
769}
770
771impl ToolCommand {
772    /// Create a new ToolCommand
773    pub fn new(
774        tool_executor: Arc<ToolExecutor>,
775        tool_name: String,
776        tool_args: Value,
777        tool_context: ToolContext,
778        tool_timeout_ms: Option<u64>,
779    ) -> Self {
780        Self {
781            tool_executor,
782            tool_name,
783            tool_args,
784            tool_context,
785            tool_timeout_ms,
786        }
787    }
788}
789
790#[async_trait]
791impl SessionCommand for ToolCommand {
792    async fn execute(&self) -> Result<Value> {
793        if self.tool_context.is_cancelled() {
794            anyhow::bail!("Tool '{}' cancelled before queue execution", self.tool_name);
795        }
796
797        let result = tool_execution_runtime::execute_tool_with_deadline(
798            self.tool_executor.as_ref(),
799            &self.tool_name,
800            &self.tool_args,
801            &self.tool_context,
802            self.tool_timeout_ms,
803        )
804        .await?;
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    model_generation_admission: crate::llm::ModelGenerationAdmission,
842    tool_executor: Arc<ToolExecutor>,
843    tool_context: ToolContext,
844    config: AgentConfig,
845    /// Optional lane queue for priority-based tool execution
846    command_queue: Option<Arc<SessionLaneQueue>>,
847    /// Optional sink for per-tool-round checkpoints. Populated by
848    /// `build_agent_loop` when the session has a configured
849    /// `SessionStore`. The agent loop uses
850    /// [`AgentLoop::set_checkpoint_run`] to bind a run id before
851    /// `execute_with_session`, then persists a checkpoint after each
852    /// completed tool round.
853    pub(crate) checkpoint_sink: Option<Arc<dyn crate::loop_checkpoint::LoopCheckpointSink>>,
854    /// Run id under which checkpoints are stored. Reset per execution
855    /// via [`AgentLoop::set_checkpoint_run`].
856    pub(crate) checkpoint_run_id: Option<String>,
857}
858
859#[cfg(test)]
860pub(crate) mod tests;
861
862#[cfg(test)]
863mod extra_agent_tests;
864
865#[cfg(test)]
866mod agent_dir_governance_tests;