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