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