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 // ========================================================================
449 // a3s-lane integration events
450 // ========================================================================
451 /// Command moved to dead letter queue after exhausting retries
452 #[serde(rename = "command_dead_lettered")]
453 CommandDeadLettered {
454 command_id: String,
455 command_type: String,
456 lane: String,
457 error: String,
458 attempts: u32,
459 },
460
461 /// Command retry attempt
462 #[serde(rename = "command_retry")]
463 CommandRetry {
464 command_id: String,
465 command_type: String,
466 lane: String,
467 attempt: u32,
468 delay_ms: u64,
469 },
470
471 /// Queue alert (depth warning, latency alert, etc.)
472 #[serde(rename = "queue_alert")]
473 QueueAlert {
474 level: String,
475 alert_type: String,
476 message: String,
477 },
478
479 // ========================================================================
480 // Task tracking events
481 // ========================================================================
482 /// Task list updated
483 #[serde(rename = "task_updated")]
484 TaskUpdated {
485 session_id: String,
486 tasks: Vec<crate::planning::Task>,
487 },
488
489 // ========================================================================
490 // Memory System events (Phase 3)
491 // ========================================================================
492 /// Memory stored
493 #[serde(rename = "memory_stored")]
494 MemoryStored {
495 memory_id: String,
496 memory_type: String,
497 importance: f32,
498 tags: Vec<String>,
499 },
500
501 /// Memory recalled
502 #[serde(rename = "memory_recalled")]
503 MemoryRecalled {
504 memory_id: String,
505 content: String,
506 relevance: f32,
507 },
508
509 /// Memories searched
510 #[serde(rename = "memories_searched")]
511 MemoriesSearched {
512 query: Option<String>,
513 tags: Vec<String>,
514 result_count: usize,
515 },
516
517 /// Memory cleared
518 #[serde(rename = "memory_cleared")]
519 MemoryCleared {
520 tier: String, // "long_term", "short_term", "working"
521 count: u64,
522 },
523
524 // ========================================================================
525 // Subagent events
526 // ========================================================================
527 /// Subagent task started
528 #[serde(rename = "subagent_start")]
529 SubagentStart {
530 /// Unique task identifier
531 task_id: String,
532 /// Child session ID
533 session_id: String,
534 /// Parent session ID
535 parent_session_id: String,
536 /// Agent type (e.g., "explore", "general")
537 agent: String,
538 /// Short description of the task
539 description: String,
540 /// Wall-clock start timestamp in milliseconds since Unix epoch.
541 #[serde(default)]
542 started_ms: u64,
543 },
544
545 /// Subagent task progress update
546 #[serde(rename = "subagent_progress")]
547 SubagentProgress {
548 /// Task identifier
549 task_id: String,
550 /// Child session ID
551 session_id: String,
552 /// Progress status message
553 status: String,
554 /// Additional metadata
555 metadata: serde_json::Value,
556 },
557
558 /// Subagent task completed
559 #[serde(rename = "subagent_end")]
560 SubagentEnd {
561 /// Task identifier
562 task_id: String,
563 /// Child session ID
564 session_id: String,
565 /// Agent type
566 agent: String,
567 /// Task output/result
568 output: String,
569 /// Whether the task succeeded
570 success: bool,
571 /// Wall-clock finish timestamp in milliseconds since Unix epoch.
572 #[serde(default)]
573 finished_ms: u64,
574 },
575
576 // ========================================================================
577 // Planning and Goal Tracking Events (Phase 1)
578 // ========================================================================
579 /// Planning phase started
580 #[serde(rename = "planning_start")]
581 PlanningStart { prompt: String },
582
583 /// Planning phase completed
584 #[serde(rename = "planning_end")]
585 PlanningEnd {
586 plan: ExecutionPlan,
587 estimated_steps: usize,
588 },
589
590 /// Step execution started
591 #[serde(rename = "step_start")]
592 StepStart {
593 step_id: String,
594 description: String,
595 step_number: usize,
596 total_steps: usize,
597 },
598
599 /// Step execution completed
600 #[serde(rename = "step_end")]
601 StepEnd {
602 step_id: String,
603 status: TaskStatus,
604 step_number: usize,
605 total_steps: usize,
606 },
607
608 /// Goal extracted from prompt
609 #[serde(rename = "goal_extracted")]
610 GoalExtracted { goal: AgentGoal },
611
612 /// Goal progress update
613 #[serde(rename = "goal_progress")]
614 GoalProgress {
615 goal: String,
616 progress: f32,
617 completed_steps: usize,
618 total_steps: usize,
619 },
620
621 /// Goal achieved
622 #[serde(rename = "goal_achieved")]
623 GoalAchieved {
624 goal: String,
625 total_steps: usize,
626 duration_ms: i64,
627 },
628
629 // ========================================================================
630 // Context Compaction events
631 // ========================================================================
632 /// Context automatically compacted due to high usage
633 #[serde(rename = "context_compacted")]
634 ContextCompacted {
635 session_id: String,
636 before_messages: usize,
637 after_messages: usize,
638 percent_before: f32,
639 /// Cumulative continuation summary when message history was reduced.
640 /// Prune-only compactions omit this field.
641 #[serde(default, skip_serializing_if = "Option::is_none")]
642 summary: Option<String>,
643 },
644
645 // ========================================================================
646 // Persistence events
647 // ========================================================================
648 /// Session persistence failed — SDK clients should handle this
649 #[serde(rename = "persistence_failed")]
650 PersistenceFailed {
651 session_id: String,
652 operation: String,
653 error: String,
654 },
655
656 // ========================================================================
657 // Cluster / platform events
658 //
659 // These variants are emitted by the host platform via
660 // `HookExecutor` and are not produced by the agent loop itself. They
661 // give in-session code a uniform way to observe platform-level
662 // decisions (budget exhaustion, scheduled passivation, peer
663 // invocations) without coupling to the host's transport.
664 // ========================================================================
665 /// A budget threshold was crossed for this session/tenant.
666 ///
667 /// Emitted by a host `BudgetGuard` impl when LLM/tool spend hits a
668 /// soft or hard threshold. The session is **not** automatically
669 /// halted — `kind` lets in-session policy decide (e.g. fast-compact
670 /// at "soft", refuse next LLM call at "hard").
671 #[serde(rename = "budget_threshold_hit")]
672 BudgetThresholdHit {
673 /// Logical resource: "llm_tokens", "tool_calls", "wall_time",
674 /// "usd_cost", or host-defined.
675 resource: String,
676 /// "soft" or "hard"; host-defined semantics beyond that.
677 kind: String,
678 /// Current consumed amount in the same unit as `limit`.
679 consumed: f64,
680 /// Threshold that was crossed.
681 limit: f64,
682 /// Optional explanation for logs / UI.
683 #[serde(default, skip_serializing_if = "Option::is_none")]
684 message: Option<String>,
685 },
686
687 /// The host is asking the session to release in-memory state.
688 ///
689 /// Emitted before the host calls `session.close()` or moves the
690 /// session to another node. Session code that holds large caches
691 /// can react (flush to memory store, drop derived state). The
692 /// framework does not act on this event itself.
693 #[serde(rename = "passivation_requested")]
694 PassivationRequested {
695 /// "idle_reaper", "node_drain", "migration", "manual", or
696 /// host-defined.
697 reason: String,
698 /// Optional deadline (Unix epoch ms) before forced close.
699 #[serde(default, skip_serializing_if = "Option::is_none")]
700 deadline_ms: Option<u64>,
701 },
702
703 /// Another session in the cluster has invoked this one.
704 ///
705 /// Lets in-session hooks distinguish "human-driven send" from
706 /// "peer-driven send" without inspecting prompts. The host routes
707 /// the actual prompt through the normal `send` / `stream` path;
708 /// this event is metadata only.
709 #[serde(rename = "peer_invocation")]
710 PeerInvocation {
711 /// Session id of the invoking peer (cluster-stable).
712 from_session_id: String,
713 /// Optional tenant of the invoking peer.
714 #[serde(default, skip_serializing_if = "Option::is_none")]
715 from_tenant_id: Option<String>,
716 /// Distributed-trace correlation id linking the two sessions.
717 #[serde(default, skip_serializing_if = "Option::is_none")]
718 correlation_id: Option<String>,
719 },
720}
721
722/// Result of agent execution
723#[derive(Debug, Clone)]
724pub struct AgentResult {
725 pub text: String,
726 pub messages: Vec<Message>,
727 pub usage: TokenUsage,
728 pub tool_calls_count: usize,
729 pub verification_reports: Vec<crate::verification::VerificationReport>,
730}
731
732impl AgentResult {
733 pub fn verification_summary(&self) -> crate::verification::VerificationSummary {
734 crate::verification::VerificationSummary::from_reports(&self.verification_reports)
735 }
736
737 pub fn verification_summary_text(&self) -> String {
738 crate::verification::format_verification_summary(&self.verification_summary())
739 }
740
741 pub fn has_pending_verification(&self) -> bool {
742 matches!(
743 self.verification_summary().status,
744 crate::verification::VerificationStatus::NeedsReview
745 )
746 }
747}
748
749// ============================================================================
750// ToolCommand — bridges ToolExecutor to SessionCommand for queue submission
751// ============================================================================
752
753/// Adapter that implements `SessionCommand` for tool execution via the queue.
754///
755/// Wraps a `ToolExecutor` call so it can be submitted to `SessionLaneQueue`.
756pub struct ToolCommand {
757 tool_executor: Arc<ToolExecutor>,
758 tool_name: String,
759 tool_args: Value,
760 tool_context: ToolContext,
761 tool_timeout_ms: Option<u64>,
762}
763
764impl ToolCommand {
765 /// Create a new ToolCommand
766 pub fn new(
767 tool_executor: Arc<ToolExecutor>,
768 tool_name: String,
769 tool_args: Value,
770 tool_context: ToolContext,
771 tool_timeout_ms: Option<u64>,
772 ) -> Self {
773 Self {
774 tool_executor,
775 tool_name,
776 tool_args,
777 tool_context,
778 tool_timeout_ms,
779 }
780 }
781}
782
783#[async_trait]
784impl SessionCommand for ToolCommand {
785 async fn execute(&self) -> Result<Value> {
786 if self.tool_context.is_cancelled() {
787 anyhow::bail!("Tool '{}' cancelled before queue execution", self.tool_name);
788 }
789
790 let result = tool_execution_runtime::execute_tool_with_deadline(
791 self.tool_executor.as_ref(),
792 &self.tool_name,
793 &self.tool_args,
794 &self.tool_context,
795 self.tool_timeout_ms,
796 )
797 .await?;
798 let images = result
799 .images
800 .iter()
801 .map(|image| {
802 serde_json::json!({
803 "data": image.base64_data(),
804 "media_type": image.media_type,
805 })
806 })
807 .collect::<Vec<_>>();
808 Ok(serde_json::json!({
809 "output": result.output,
810 "exit_code": result.exit_code,
811 "metadata": result.metadata,
812 "images": images,
813 "error_kind": result.error_kind,
814 }))
815 }
816
817 fn command_type(&self) -> &str {
818 &self.tool_name
819 }
820
821 fn payload(&self) -> Value {
822 self.tool_args.clone()
823 }
824}
825
826// ============================================================================
827// AgentLoop
828// ============================================================================
829
830/// Internal agent loop executor.
831#[derive(Clone)]
832pub(crate) struct AgentLoop {
833 llm_client: Arc<dyn LlmClient>,
834 model_generation_admission: crate::llm::ModelGenerationAdmission,
835 tool_executor: Arc<ToolExecutor>,
836 tool_context: ToolContext,
837 config: AgentConfig,
838 /// Optional lane queue for priority-based tool execution
839 command_queue: Option<Arc<SessionLaneQueue>>,
840 /// Optional sink for per-tool-round checkpoints. Populated by
841 /// `build_agent_loop` when the session has a configured
842 /// `SessionStore`. The agent loop uses
843 /// [`AgentLoop::set_checkpoint_run`] to bind a run id before
844 /// `execute_with_session`, then persists a checkpoint after each
845 /// completed tool round.
846 pub(crate) checkpoint_sink: Option<Arc<dyn crate::loop_checkpoint::LoopCheckpointSink>>,
847 /// Run id under which checkpoints are stored. Reset per execution
848 /// via [`AgentLoop::set_checkpoint_run`].
849 pub(crate) checkpoint_run_id: Option<String>,
850}
851
852#[cfg(test)]
853pub(crate) mod tests;
854
855#[cfg(test)]
856mod extra_agent_tests;