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