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 // In-flight run control events
700 // ========================================================================
701 /// A host steer/interrupt request was consumed at a loop safe point.
702 #[serde(rename = "run_control_applied")]
703 RunControlApplied {
704 request_id: String,
705 operation: crate::run_control::RunControlOperation,
706 turn_id: Option<String>,
707 turn_revision: u64,
708 #[serde(default, skip_serializing_if = "Option::is_none")]
709 input: Option<String>,
710 #[serde(default, skip_serializing_if = "Option::is_none")]
711 reason: Option<String>,
712 },
713
714 // ========================================================================
715 // Persistence events
716 // ========================================================================
717 /// Session persistence failed — SDK clients should handle this
718 #[serde(rename = "persistence_failed")]
719 PersistenceFailed {
720 session_id: String,
721 operation: String,
722 error: String,
723 },
724
725 // ========================================================================
726 // Cluster / platform events
727 //
728 // These variants are emitted by the host platform via
729 // `HookExecutor` and are not produced by the agent loop itself. They
730 // give in-session code a uniform way to observe platform-level
731 // decisions (budget exhaustion, scheduled passivation, peer
732 // invocations) without coupling to the host's transport.
733 // ========================================================================
734 /// A budget threshold was crossed for this session/tenant.
735 ///
736 /// Emitted by a host `BudgetGuard` impl when LLM/tool spend hits a
737 /// soft or hard threshold. The session is **not** automatically
738 /// halted — `kind` lets in-session policy decide (e.g. fast-compact
739 /// at "soft", refuse next LLM call at "hard").
740 #[serde(rename = "budget_threshold_hit")]
741 BudgetThresholdHit {
742 /// Logical resource: "llm_tokens", "tool_calls", "wall_time",
743 /// "usd_cost", or host-defined.
744 resource: String,
745 /// "soft" or "hard"; host-defined semantics beyond that.
746 kind: String,
747 /// Current consumed amount in the same unit as `limit`.
748 consumed: f64,
749 /// Threshold that was crossed.
750 limit: f64,
751 /// Optional explanation for logs / UI.
752 #[serde(default, skip_serializing_if = "Option::is_none")]
753 message: Option<String>,
754 },
755
756 /// The host is asking the session to release in-memory state.
757 ///
758 /// Emitted before the host calls `session.close()` or moves the
759 /// session to another node. Session code that holds large caches
760 /// can react (flush to memory store, drop derived state). The
761 /// framework does not act on this event itself.
762 #[serde(rename = "passivation_requested")]
763 PassivationRequested {
764 /// "idle_reaper", "node_drain", "migration", "manual", or
765 /// host-defined.
766 reason: String,
767 /// Optional deadline (Unix epoch ms) before forced close.
768 #[serde(default, skip_serializing_if = "Option::is_none")]
769 deadline_ms: Option<u64>,
770 },
771
772 /// Another session in the cluster has invoked this one.
773 ///
774 /// Lets in-session hooks distinguish "human-driven send" from
775 /// "peer-driven send" without inspecting prompts. The host routes
776 /// the actual prompt through the normal `send` / `stream` path;
777 /// this event is metadata only.
778 #[serde(rename = "peer_invocation")]
779 PeerInvocation {
780 /// Session id of the invoking peer (cluster-stable).
781 from_session_id: String,
782 /// Optional tenant of the invoking peer.
783 #[serde(default, skip_serializing_if = "Option::is_none")]
784 from_tenant_id: Option<String>,
785 /// Distributed-trace correlation id linking the two sessions.
786 #[serde(default, skip_serializing_if = "Option::is_none")]
787 correlation_id: Option<String>,
788 },
789}
790
791/// Result of agent execution
792#[derive(Debug, Clone)]
793pub struct AgentResult {
794 pub text: String,
795 pub messages: Vec<Message>,
796 pub usage: TokenUsage,
797 pub tool_calls_count: usize,
798 pub verification_reports: Vec<crate::verification::VerificationReport>,
799}
800
801/// An execution error paired with the usage and tool-call accounting that was
802/// completed before the failure.
803///
804/// Agent execution still returns an error: this type only prevents already
805/// incurred work from disappearing when a provider call, tool round, or
806/// capability boundary fails. Rust callers can downcast the returned
807/// `anyhow::Error` to this type when they need the partial accounting.
808#[derive(Debug)]
809pub struct AgentExecutionFailure {
810 source: anyhow::Error,
811 usage: TokenUsage,
812 tool_calls_count: usize,
813}
814
815impl AgentExecutionFailure {
816 pub(crate) fn new(source: anyhow::Error, usage: TokenUsage, tool_calls_count: usize) -> Self {
817 Self {
818 source,
819 usage,
820 tool_calls_count,
821 }
822 }
823
824 /// Usage reported by provider calls that completed before the failure.
825 pub fn usage(&self) -> &TokenUsage {
826 &self.usage
827 }
828
829 /// Tool calls admitted before the failure, including failed tool calls.
830 pub fn tool_calls_count(&self) -> usize {
831 self.tool_calls_count
832 }
833
834 pub(crate) fn add_tool_calls(&mut self, count: usize) {
835 self.tool_calls_count = self.tool_calls_count.saturating_add(count);
836 }
837}
838
839impl std::fmt::Display for AgentExecutionFailure {
840 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
841 self.source.fmt(f)
842 }
843}
844
845impl std::error::Error for AgentExecutionFailure {
846 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
847 Some(self.source.as_ref())
848 }
849}
850
851impl AgentResult {
852 pub fn verification_summary(&self) -> crate::verification::VerificationSummary {
853 crate::verification::VerificationSummary::from_reports(&self.verification_reports)
854 }
855
856 pub fn verification_summary_text(&self) -> String {
857 crate::verification::format_verification_summary(&self.verification_summary())
858 }
859
860 pub fn has_pending_verification(&self) -> bool {
861 matches!(
862 self.verification_summary().status,
863 crate::verification::VerificationStatus::NeedsReview
864 )
865 }
866}
867
868// ============================================================================
869// ToolCommand — bridges ToolExecutor to SessionCommand for queue submission
870// ============================================================================
871
872/// Adapter that implements `SessionCommand` for tool execution via the queue.
873///
874/// Wraps a `ToolExecutor` call so it can be submitted to `SessionLaneQueue`.
875pub struct ToolCommand {
876 tool_executor: Arc<ToolExecutor>,
877 tool_name: String,
878 tool_args: Value,
879 tool_context: ToolContext,
880 tool_timeout_ms: Option<u64>,
881}
882
883impl ToolCommand {
884 /// Create a new ToolCommand
885 pub fn new(
886 tool_executor: Arc<ToolExecutor>,
887 tool_name: String,
888 tool_args: Value,
889 tool_context: ToolContext,
890 tool_timeout_ms: Option<u64>,
891 ) -> Self {
892 Self {
893 tool_executor,
894 tool_name,
895 tool_args,
896 tool_context,
897 tool_timeout_ms,
898 }
899 }
900}
901
902#[async_trait]
903impl SessionCommand for ToolCommand {
904 async fn execute(&self) -> Result<Value> {
905 if self.tool_context.is_cancelled() {
906 anyhow::bail!("Tool '{}' cancelled before queue execution", self.tool_name);
907 }
908
909 let result = tool_execution_runtime::execute_tool_with_deadline(
910 self.tool_executor.as_ref(),
911 &self.tool_name,
912 &self.tool_args,
913 &self.tool_context,
914 self.tool_timeout_ms,
915 )
916 .await?;
917 let images = result
918 .images
919 .iter()
920 .map(|image| {
921 serde_json::json!({
922 "data": image.base64_data(),
923 "media_type": image.media_type,
924 })
925 })
926 .collect::<Vec<_>>();
927 Ok(serde_json::json!({
928 "output": result.output,
929 "exit_code": result.exit_code,
930 "metadata": result.metadata,
931 "images": images,
932 "error_kind": result.error_kind,
933 }))
934 }
935
936 fn command_type(&self) -> &str {
937 &self.tool_name
938 }
939
940 fn payload(&self) -> Value {
941 self.tool_args.clone()
942 }
943}
944
945// ============================================================================
946// AgentLoop
947// ============================================================================
948
949/// Internal agent loop executor.
950#[derive(Clone)]
951pub(crate) struct AgentLoop {
952 llm_client: Arc<dyn LlmClient>,
953 model_generation_admission: crate::llm::ModelGenerationAdmission,
954 tool_executor: Arc<ToolExecutor>,
955 tool_context: ToolContext,
956 config: AgentConfig,
957 /// Optional lane queue for priority-based tool execution
958 command_queue: Option<Arc<SessionLaneQueue>>,
959 /// Optional sink for per-tool-round checkpoints. Populated by
960 /// `build_agent_loop` when the session has a configured
961 /// `SessionStore`. The agent loop uses
962 /// [`AgentLoop::set_checkpoint_run`] to bind a run id before
963 /// `execute_with_session`, then persists a checkpoint after each
964 /// completed tool round.
965 pub(crate) checkpoint_sink: Option<Arc<dyn crate::loop_checkpoint::LoopCheckpointSink>>,
966 /// Run id under which checkpoints are stored. Reset per execution
967 /// via [`AgentLoop::set_checkpoint_run`].
968 pub(crate) checkpoint_run_id: Option<String>,
969 /// Complete immutable capability identity captured from the admitted Run.
970 /// Compatibility loops without a scoped Run leave this empty.
971 pub(crate) checkpoint_capability_binding: Option<crate::capability::RunCapabilityBindingV1>,
972 /// The invocation shared by every model helper in the currently scoped
973 /// run. Base session loops keep this empty and bind it on execution.
974 bound_invocation: Option<InvocationContext>,
975 /// Weak capability parent used to create and close one real Turn scope per
976 /// provider/tool iteration. It never extends the admitted Run lease.
977 capability_runtime: Option<crate::capability::AgentCapabilityRuntime>,
978}
979
980#[cfg(test)]
981pub(crate) mod tests;
982
983#[cfg(test)]
984mod extra_agent_tests;
985
986#[cfg(test)]
987mod agent_dir_governance_tests;