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