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