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;