Skip to main content

everruns_core/
in_memory_loop.rs

1// In-Memory Agentic Loop
2//
3// Convenience helpers for running full agentic loops in memory without
4// external dependencies (database, real LLM, etc.). Perfect for:
5// - Unit and integration tests
6// - Prototyping and experimentation
7// - Examples and documentation
8//
9// The `InMemoryAgenticLoop` bundles all in-memory stores and atoms,
10// providing a simple API for executing agent turns.
11
12use std::sync::Arc;
13
14use async_trait::async_trait;
15use chrono::Utc;
16
17use crate::agent::{Agent, AgentStatus};
18use crate::atoms::{
19    ActAtom, ActInput, Atom, AtomContext, InputAtom, InputAtomInput, ReasonAtom, ReasonInput,
20};
21use crate::capabilities::{AgentCapabilityConfig, Capability, CapabilityRegistry};
22use crate::driver_registry::{DriverId, DriverRegistry};
23use crate::error::Result;
24use crate::events::{Event, EventData, EventRequest, OUTPUT_MESSAGE_COMPLETED};
25use crate::in_memory::{
26    InMemoryAgentStore, InMemoryEventEmitter, InMemoryHarnessStore, InMemoryMessageRetriever,
27    InMemoryProviderStore, InMemorySessionStore,
28};
29use crate::llmsim_driver::{LlmSimConfig, LlmSimDriver};
30use crate::message::Message;
31use crate::message_retriever::{InputMessage, MessageRetriever};
32use crate::session::{Session, SessionStatus};
33use crate::tool_types::ToolCall;
34use crate::tools::{Tool, ToolRegistry, ToolRegistryBuilder};
35use crate::traits::{EventEmitter, ResolvedModel};
36use crate::turn::{TurnAction, TurnContext, TurnOutcome, TurnStateMachine, TurnStopReason};
37use crate::typed_id::{AgentId, HarnessId, SessionId, TurnId};
38
39// ============================================================================
40// Bridging Event Emitter
41// ============================================================================
42
43/// Event emitter that bridges events to message storage
44///
45/// When a `output.message.completed` event is emitted, it also stores the message
46/// in the provided message retriever. This enables full agentic loops
47/// in memory without the database layer.
48#[derive(Clone)]
49struct BridgingEventEmitter {
50    inner: InMemoryEventEmitter,
51    message_retriever: InMemoryMessageRetriever,
52}
53
54impl BridgingEventEmitter {
55    fn new(message_retriever: InMemoryMessageRetriever) -> Self {
56        Self {
57            inner: InMemoryEventEmitter::new(),
58            message_retriever,
59        }
60    }
61
62    async fn events(&self) -> Vec<Event> {
63        self.inner.events().await
64    }
65
66    async fn events_by_type(&self, event_type: &str) -> Vec<Event> {
67        self.inner.events_by_type(event_type).await
68    }
69
70    async fn event_count(&self) -> usize {
71        self.inner.event_count().await
72    }
73
74    async fn clear(&self) {
75        self.inner.clear().await;
76    }
77}
78
79#[async_trait]
80impl EventEmitter for BridgingEventEmitter {
81    async fn emit(&self, request: EventRequest) -> Result<Event> {
82        // If this is an output.message.completed event, also store the message
83        if request.data.event_type() == OUTPUT_MESSAGE_COMPLETED
84            && let EventData::OutputMessageCompleted(data) = &request.data
85        {
86            // Store the message in the retriever
87            let _ = self
88                .message_retriever
89                .store(request.session_id, data.message.clone())
90                .await;
91        }
92
93        // Delegate to the inner emitter
94        self.inner.emit(request).await
95    }
96}
97
98// ============================================================================
99// Turn Result
100// ============================================================================
101
102/// Result of executing a turn
103#[derive(Debug, Clone)]
104pub struct TurnResult {
105    /// Final text response from the agent
106    pub response: String,
107    /// Number of reasoning iterations (Reason → Act cycles)
108    pub iterations: usize,
109    /// Total tool calls made during the turn
110    pub tool_calls_count: usize,
111    /// Whether the turn completed successfully
112    pub success: bool,
113    /// Error message if the turn failed
114    pub error: Option<String>,
115    /// Structured reason the turn stopped.
116    pub stop_reason: TurnStopReason,
117    /// Turn ID for this turn
118    pub turn_id: TurnId,
119}
120
121impl TurnResult {
122    /// Check if the response contains a specific substring
123    pub fn contains(&self, text: &str) -> bool {
124        self.response.contains(text)
125    }
126
127    /// Create a TurnResult from a TurnOutcome and turn_id.
128    fn from_outcome(outcome: TurnOutcome, turn_id: TurnId) -> Self {
129        let stop_reason = outcome.stop_reason();
130        match outcome {
131            TurnOutcome::Success {
132                response,
133                iterations,
134                tool_calls_count,
135                ..
136            } => Self {
137                response,
138                iterations,
139                tool_calls_count,
140                success: true,
141                error: None,
142                stop_reason,
143                turn_id,
144            },
145            TurnOutcome::Failed {
146                error, iterations, ..
147            } => Self {
148                response: String::new(),
149                iterations,
150                tool_calls_count: 0,
151                success: false,
152                error: Some(error),
153                stop_reason,
154                turn_id,
155            },
156            TurnOutcome::MaxIterationsReached {
157                response,
158                iterations,
159                tool_calls_count,
160            } => Self {
161                response,
162                iterations,
163                tool_calls_count,
164                success: true, // Max iterations is not a failure
165                error: None,
166                stop_reason,
167                turn_id,
168            },
169            // A sealed turn was deliberately stopped (EVE-534). Surface it as a
170            // non-success with the seal reason so in-memory callers can observe
171            // it distinctly from a normal completion.
172            TurnOutcome::Sealed {
173                reason,
174                response,
175                iterations,
176                tool_calls_count,
177            } => Self {
178                response,
179                iterations,
180                tool_calls_count,
181                success: false,
182                error: Some(format!("turn sealed: {reason}")),
183                stop_reason,
184                turn_id,
185            },
186        }
187    }
188}
189
190// ============================================================================
191// Builder
192// ============================================================================
193
194/// Builder for creating an `InMemoryAgenticLoop`
195pub struct InMemoryAgenticLoopBuilder {
196    agent_name: String,
197    system_prompt: String,
198    model: Option<ResolvedModel>,
199    driver_registry: Option<DriverRegistry>,
200    llm_sim_config: Option<LlmSimConfig>,
201    tools: Vec<Box<dyn Tool>>,
202    capabilities: Vec<Box<dyn Capability>>,
203    max_iterations: usize,
204    parallel_tool_calls: Option<bool>,
205    reasoning_effort_handle: Option<crate::traits::ReasoningEffortHandle>,
206}
207
208impl Default for InMemoryAgenticLoopBuilder {
209    fn default() -> Self {
210        Self::new()
211    }
212}
213
214impl InMemoryAgenticLoopBuilder {
215    /// Create a new builder with defaults (uses simulated LLM)
216    pub fn new() -> Self {
217        Self {
218            agent_name: "Test Agent".to_string(),
219            system_prompt: "You are a helpful assistant.".to_string(),
220            model: None,
221            driver_registry: None,
222            llm_sim_config: Some(LlmSimConfig::default()),
223            tools: vec![],
224            capabilities: vec![],
225            max_iterations: 10,
226            parallel_tool_calls: None,
227            reasoning_effort_handle: None,
228        }
229    }
230
231    /// Share a live reasoning-effort handle (EVE-595) across the loop's
232    /// `ReasonAtom` and `ActAtom`. Tools receive a clone via their
233    /// `ToolContext` and can mutate it mid-turn so subsequent LLM steps in the
234    /// same `run_turn` observe the new effort.
235    pub fn reasoning_effort_handle(mut self, handle: crate::traits::ReasoningEffortHandle) -> Self {
236        self.reasoning_effort_handle = Some(handle);
237        self
238    }
239
240    /// Set the agent name
241    pub fn agent_name(mut self, name: impl Into<String>) -> Self {
242        self.agent_name = name.into();
243        self
244    }
245
246    /// Set the system prompt
247    pub fn system_prompt(mut self, prompt: impl Into<String>) -> Self {
248        self.system_prompt = prompt.into();
249        self
250    }
251
252    /// Use a simulated LLM with a fixed response (no real API calls)
253    pub fn with_simulated_response(mut self, response: impl Into<String>) -> Self {
254        self.llm_sim_config = Some(LlmSimConfig::fixed(response));
255        self.model = None;
256        self.driver_registry = None;
257        self
258    }
259
260    /// Use a simulated LLM with custom configuration
261    pub fn with_llm_sim(mut self, config: LlmSimConfig) -> Self {
262        self.llm_sim_config = Some(config);
263        self.model = None;
264        self.driver_registry = None;
265        self
266    }
267
268    /// Set the LLM model to use
269    ///
270    /// # Example
271    ///
272    /// ```ignore
273    /// use everruns_core::traits::ResolvedModel;
274    /// use everruns_core::provider::DriverId;
275    ///
276    /// let model = ResolvedModel {
277    ///     model: "claude-sonnet-4-20250514".to_string(),
278    ///     provider_type: DriverId::Anthropic,
279    ///     api_key: Some(std::env::var("ANTHROPIC_API_KEY").unwrap()),
280    ///     base_url: None,
281    /// };
282    ///
283    /// let runner = InMemoryAgenticLoop::builder()
284    ///     .model(model)
285    ///     .driver_registry(driver_registry)
286    ///     .build()
287    ///     .await?;
288    /// ```
289    pub fn model(mut self, model: ResolvedModel) -> Self {
290        self.model = Some(model);
291        self.llm_sim_config = None;
292        self
293    }
294
295    /// Set the driver registry for LLM providers
296    ///
297    /// # Example
298    ///
299    /// ```ignore
300    /// use everruns_core::driver_registry::DriverRegistry;
301    ///
302    /// let mut driver_registry = DriverRegistry::new();
303    /// everruns_anthropic::register_driver(&mut driver_registry);
304    ///
305    /// let runner = InMemoryAgenticLoop::builder()
306    ///     .model(model)
307    ///     .driver_registry(driver_registry)
308    ///     .build()
309    ///     .await?;
310    /// ```
311    pub fn driver_registry(mut self, driver_registry: DriverRegistry) -> Self {
312        self.driver_registry = Some(driver_registry);
313        self.llm_sim_config = None;
314        self
315    }
316
317    /// Add a tool
318    pub fn tool<T: Tool + 'static>(mut self, tool: T) -> Self {
319        self.tools.push(Box::new(tool));
320        self
321    }
322
323    /// Add a capability (which may provide tools and system prompt additions)
324    ///
325    /// Capabilities provide a way to bundle related tools and functionality.
326    /// For example, the `current_time` capability provides a `get_current_time` tool.
327    ///
328    /// # Example
329    ///
330    /// ```ignore
331    /// use everruns_core::capabilities::current_time::CurrentTimeCapability;
332    ///
333    /// let runner = InMemoryAgenticLoop::builder()
334    ///     .capability(CurrentTimeCapability)
335    ///     .build()
336    ///     .await?;
337    /// ```
338    pub fn capability<C: Capability + 'static>(mut self, capability: C) -> Self {
339        self.capabilities.push(Box::new(capability));
340        self
341    }
342
343    /// Set maximum iterations per turn
344    pub fn max_iterations(mut self, max: usize) -> Self {
345        self.max_iterations = max;
346        self
347    }
348
349    /// Set the request-level parallel tool calling preference (EVE-598).
350    ///
351    /// `Some(true)` signals the provider that parallel tool calls are wanted;
352    /// `Some(false)` requests at most one tool call per turn and forces serial
353    /// execution. `None` (default) preserves provider defaults.
354    pub fn parallel_tool_calls(mut self, parallel_tool_calls: Option<bool>) -> Self {
355        self.parallel_tool_calls = parallel_tool_calls;
356        self
357    }
358
359    /// Build the agentic loop
360    pub async fn build(self) -> Result<InMemoryAgenticLoop> {
361        // Create stores
362        let harness_store = InMemoryHarnessStore::new();
363        let agent_store = InMemoryAgentStore::new();
364        let session_store = InMemorySessionStore::new();
365        let message_retriever = InMemoryMessageRetriever::new();
366        let event_emitter = BridgingEventEmitter::new(message_retriever.clone());
367
368        // Build capability configs for the agent from capabilities
369        let agent_capability_configs: Vec<AgentCapabilityConfig> = self
370            .capabilities
371            .iter()
372            .map(|cap| AgentCapabilityConfig::new(cap.id()))
373            .collect();
374
375        // Create harness
376        let harness_id = HarnessId::new();
377        let now = Utc::now();
378        let harness = crate::harness::Harness {
379            id: harness_id,
380            name: "in-memory".to_string(),
381            display_name: Some("In-Memory Harness".to_string()),
382            description: None,
383            system_prompt: Some(self.system_prompt.clone()),
384            parent_harness_id: None,
385            default_model_id: None,
386            tags: vec![],
387            capabilities: vec![],
388            mcp_servers: Default::default(),
389            initial_files: vec![],
390            network_access: None,
391            parallel_tool_calls: None,
392            embedder_metadata: Default::default(),
393            is_built_in: false,
394            status: crate::harness::HarnessStatus::Active,
395            created_at: now,
396            updated_at: now,
397            archived_at: None,
398            deleted_at: None,
399        };
400        harness_store.add_harness(harness).await;
401
402        // Surface explicitly-added tools (via `.tool(...)`) as agent tool
403        // definitions so ReasonAtom returns them and ActAtom executes them
404        // (rather than treating them as unknown). Capability-provided tools are
405        // already surfaced through the capability registry.
406        let explicit_tool_definitions: Vec<crate::tool_types::ToolDefinition> =
407            self.tools.iter().map(|tool| tool.to_definition()).collect();
408
409        // Create agent
410        let agent_id = AgentId::new();
411        let agent = Agent {
412            public_id: agent_id,
413            internal_id: agent_id.uuid(),
414            name: "in-memory".to_string(),
415            display_name: Some(self.agent_name),
416            description: None,
417            system_prompt: self.system_prompt,
418            default_model_id: None,
419            harness_id,
420            default_version_id: None,
421            forked_from_agent_id: None,
422            forked_from_version_id: None,
423            root_agent_id: None,
424            tags: vec![],
425            capabilities: agent_capability_configs,
426            mcp_servers: Default::default(),
427            initial_files: vec![],
428            network_access: None,
429            max_iterations: None,
430            parallel_tool_calls: self.parallel_tool_calls,
431            tools: explicit_tool_definitions,
432            status: AgentStatus::Active,
433            created_at: now,
434            updated_at: now,
435            archived_at: None,
436            deleted_at: None,
437            usage: None,
438        };
439        agent_store.add_agent(agent).await;
440
441        // Create session
442        let session_id = SessionId::new();
443        let session = Session {
444            id: session_id,
445            workspace_id: crate::WorkspaceId::from_uuid((session_id).uuid()),
446            organization_id: crate::DEFAULT_ORG_PUBLIC_ID.to_string(),
447            harness_id,
448            agent_id: Some(agent_id),
449            agent_version_id: None,
450            agent_identity_id: None,
451            owner_principal_id: crate::PrincipalId::from_seed(1),
452            resolved_owner_user_id: None,
453            owner: None,
454            effective_owner: None,
455            title: Some("In-Memory Session".to_string()),
456            goal: None,
457            locale: None,
458            preview: None,
459            output_preview: None,
460            tags: vec![],
461            model_id: None,
462            capabilities: vec![],
463            tools: vec![],
464            mcp_servers: Default::default(),
465            system_prompt: None,
466            initial_files: vec![],
467            hints: None,
468            network_access: None,
469            max_iterations: None,
470            parallel_tool_calls: None,
471            status: SessionStatus::Started,
472            created_at: now,
473            updated_at: now,
474            started_at: None,
475            finished_at: None,
476            usage: None,
477            is_pinned: None,
478            active_schedule_count: None,
479            features: vec![],
480            parent_session_id: None,
481            forked_from_session_id: None,
482            forked_from_sequence: None,
483            blueprint_id: None,
484            blueprint_config: None,
485        };
486        session_store.add_session(session).await;
487
488        // Capture the configured model name before the if-let consumes self.model.
489        // Used below to resolve model-adaptive capabilities against the right variant.
490        let configured_model = self.model.as_ref().map(|m| m.model.clone());
491
492        // Create provider store and driver registry
493        let provider_store = InMemoryProviderStore::new();
494        let driver_registry =
495            if let (Some(model), Some(registry)) = (self.model, self.driver_registry) {
496                // Use provided model and driver registry
497                provider_store.set_default_model(model).await;
498                registry
499            } else {
500                // Use LlmSim (default or explicitly configured)
501                let config = self.llm_sim_config.unwrap_or_default();
502                let model = ResolvedModel {
503                    model: "llmsim-model".to_string(),
504                    provider_type: DriverId::LlmSim,
505                    api_key: Some("fake-key".to_string()),
506                    base_url: None,
507                    provider_metadata: None,
508                };
509                provider_store.set_default_model(model).await;
510
511                // Create the driver once and share it across calls.
512                // This ensures sequence-based responses work correctly
513                // because the Arc counters are shared.
514                let driver = LlmSimDriver::new(config);
515                let mut registry = DriverRegistry::new();
516                registry.register(DriverId::LlmSim, move |_config| Box::new(driver.clone()));
517                registry
518            };
519
520        // Build tool registry - include tools from capabilities. Resolve each
521        // capability against the configured model so model-adaptive capabilities
522        // (e.g. auto_tool_search) contribute the right variant for this harness.
523        let configured_model_ref = configured_model.as_deref();
524        let mut tool_builder = ToolRegistryBuilder::new();
525        for capability in &self.capabilities {
526            let effective: &dyn crate::Capability = capability
527                .resolve_for_model(configured_model_ref)
528                .unwrap_or_else(|| capability.as_ref());
529            for tool in effective.tools() {
530                tool_builder = tool_builder.tool_boxed(tool);
531            }
532        }
533
534        // Add explicit tools (can override capability tools)
535        for tool in self.tools {
536            tool_builder = tool_builder.tool_boxed(tool);
537        }
538        let tool_registry = tool_builder.build();
539
540        // Create capability registry with added capabilities
541        let mut capability_registry = CapabilityRegistry::new();
542        for capability in self.capabilities {
543            capability_registry.register_boxed(capability);
544        }
545
546        let input_atom = InputAtom::new(message_retriever.clone());
547        let mut reason_atom = ReasonAtom::new(
548            harness_store.clone(),
549            agent_store.clone(),
550            session_store.clone(),
551            message_retriever.clone(),
552            provider_store.clone(),
553            capability_registry,
554            driver_registry,
555            event_emitter.clone(),
556        );
557        let mut act_atom = ActAtom::new(tool_registry.clone(), event_emitter.clone())
558            .with_tool_registry(Arc::new(tool_registry.clone()));
559        if let Some(handle) = &self.reasoning_effort_handle {
560            reason_atom = reason_atom.with_reasoning_effort_handle(handle.clone());
561            act_atom = act_atom.with_reasoning_effort_handle(handle.clone());
562        }
563
564        Ok(InMemoryAgenticLoop {
565            harness_id,
566            agent_id,
567            session_id,
568            harness_store,
569            agent_store,
570            session_store,
571            message_retriever,
572            provider_store,
573            event_emitter,
574            tool_registry,
575            input_atom: Arc::new(input_atom),
576            reason_atom: Arc::new(reason_atom),
577            act_atom: Arc::new(act_atom),
578            max_iterations: self.max_iterations,
579            reasoning_effort_handle: self.reasoning_effort_handle,
580        })
581    }
582}
583
584// ============================================================================
585// InMemoryAgenticLoop
586// ============================================================================
587
588/// In-memory agentic loop for testing and prototyping
589///
590/// Bundles all in-memory stores and atoms into a convenient interface
591/// for running agent turns without external dependencies.
592///
593/// # Example
594///
595/// ```ignore
596/// use everruns_core::in_memory_loop::InMemoryAgenticLoop;
597///
598/// // Simple usage with simulated LLM
599/// let mut loop_runner = InMemoryAgenticLoop::builder()
600///     .system_prompt("You are a helpful assistant.")
601///     .with_simulated_response("Hello! I can help you with that.")
602///     .build()
603///     .await?;
604///
605/// let result = loop_runner.run_turn("Hi there!").await?;
606/// assert!(result.success);
607/// println!("Response: {}", result.response);
608///
609/// // With real LLM (requires API key)
610/// let mut loop_runner = InMemoryAgenticLoop::builder()
611///     .with_real_llm()
612///     .tool(MyCustomTool)
613///     .build()
614///     .await?;
615/// ```
616pub struct InMemoryAgenticLoop {
617    harness_id: HarnessId,
618    agent_id: AgentId,
619    session_id: SessionId,
620    #[allow(dead_code)]
621    harness_store: InMemoryHarnessStore,
622    #[allow(dead_code)]
623    agent_store: InMemoryAgentStore,
624    #[allow(dead_code)]
625    session_store: InMemorySessionStore,
626    message_retriever: InMemoryMessageRetriever,
627    #[allow(dead_code)]
628    provider_store: InMemoryProviderStore,
629    event_emitter: BridgingEventEmitter,
630    tool_registry: ToolRegistry,
631    input_atom: Arc<InputAtom<InMemoryMessageRetriever>>,
632    reason_atom: Arc<ReasonAtom>,
633    act_atom: Arc<ActAtom<ToolRegistry, BridgingEventEmitter>>,
634    max_iterations: usize,
635    reasoning_effort_handle: Option<crate::traits::ReasoningEffortHandle>,
636}
637
638impl InMemoryAgenticLoop {
639    /// Create a new builder
640    pub fn builder() -> InMemoryAgenticLoopBuilder {
641        InMemoryAgenticLoopBuilder::new()
642    }
643
644    /// Get the agent ID
645    pub fn agent_id(&self) -> AgentId {
646        self.agent_id
647    }
648
649    /// Get the session ID
650    pub fn session_id(&self) -> SessionId {
651        self.session_id
652    }
653
654    /// Run a turn with the given user input
655    ///
656    /// Accepts either a string or an `InputMessage` for full control over
657    /// message options like reasoning effort.
658    ///
659    /// This executes the full agentic loop using the TurnStateMachine:
660    /// 1. Add user message
661    /// 2. Record input (InputAtom)
662    /// 3. Reason loop (ReasonAtom → ActAtom → repeat until done)
663    ///
664    /// The TurnStateMachine ensures consistent orchestration logic,
665    /// proper error handling (checking success flag), and turn ID management.
666    ///
667    /// # Examples
668    ///
669    /// ```ignore
670    /// // Simple string input
671    /// let result = runner.run_turn("Hello").await?;
672    ///
673    /// // Full InputMessage with controls
674    /// let input = InputMessage {
675    ///     role: MessageRole::User,
676    ///     content: vec![ContentPart::text("What is 2+2?")],
677    ///     controls: Some(Controls {
678    ///         model_id: None,
679    ///         reasoning: Some(ReasoningConfig { effort: Some("medium".into()) }),
680    ///     }),
681    ///     metadata: None,
682    ///     tags: vec![],
683    /// };
684    /// let result = runner.run_turn(input).await?;
685    /// ```
686    pub async fn run_turn(&self, input: impl Into<InputMessage>) -> Result<TurnResult> {
687        // The live effort override is turn-scoped: tools may set it for later
688        // LLM steps in this turn, but stale values must not override the next
689        // turn's message controls.
690        if let Some(handle) = &self.reasoning_effort_handle {
691            handle.set(None);
692        }
693
694        // Add user message
695        let message = self
696            .message_retriever
697            .add(self.session_id, input.into())
698            .await?;
699
700        // Create turn context and state machine
701        let turn_context = TurnContext::new(self.session_id, message.id, self.agent_id, 0);
702        let mut state_machine = TurnStateMachine::new(turn_context, self.max_iterations);
703
704        // Track last reason result for ActAtom
705        let mut last_reason_result: Option<crate::atoms::ReasonResult> = None;
706        // Track response_id from last reason call for chaining
707        let mut previous_response_id: Option<String> = None;
708
709        // Execute the turn using the state machine
710        loop {
711            match state_machine.next_action() {
712                TurnAction::ExecuteInput => {
713                    let base_context = AtomContext::new(
714                        state_machine.context().session_id,
715                        state_machine.context().turn_id,
716                        state_machine.context().input_message_id,
717                    );
718                    self.input_atom
719                        .execute(InputAtomInput {
720                            context: base_context,
721                        })
722                        .await?;
723                    state_machine.on_input_completed();
724                }
725
726                TurnAction::ExecuteReason => {
727                    let base_context = AtomContext::new(
728                        state_machine.context().session_id,
729                        state_machine.context().turn_id,
730                        state_machine.context().input_message_id,
731                    );
732                    let reason_result = self
733                        .reason_atom
734                        .execute(ReasonInput {
735                            context: base_context.next_exec(),
736                            harness_id: self.harness_id,
737                            agent_id: Some(self.agent_id),
738                            org_id: 0,
739                            mcp_tool_definitions: vec![],
740                            previous_response_id: previous_response_id.take(),
741                            iteration: state_machine.current_iteration() as u32 + 1,
742                        })
743                        .await?;
744
745                    let tool_call_count = reason_result.tool_calls.len();
746                    previous_response_id = reason_result.response_id.clone();
747                    // In-memory loop has no signal mechanism, so
748                    // has_pending_user_messages is always false.
749                    state_machine.on_reason_completed(
750                        reason_result.text.clone(),
751                        tool_call_count,
752                        reason_result.success,
753                        reason_result.error.clone(),
754                        reason_result.finish_reason.clone(),
755                        false,
756                    );
757
758                    // Store for ActAtom if needed
759                    if reason_result.has_tool_calls {
760                        last_reason_result = Some(reason_result);
761                    }
762                }
763
764                TurnAction::ExecuteAct => {
765                    let reason_result = last_reason_result
766                        .take()
767                        .expect("ExecuteAct requires prior ReasonResult with tool calls");
768                    let base_context = AtomContext::new(
769                        state_machine.context().session_id,
770                        state_machine.context().turn_id,
771                        state_machine.context().input_message_id,
772                    );
773                    self.act_atom
774                        .execute(ActInput {
775                            org_id: Some(0),
776                            context: base_context.next_exec(),
777                            harness_id: self.harness_id,
778                            agent_id: Some(self.agent_id),
779                            tool_calls: reason_result.tool_calls,
780                            tool_definitions: reason_result.tool_definitions,
781                            locale: reason_result.locale,
782                            blueprint_id: None,
783                            network_access: reason_result.network_access,
784                            // Request-level parallel tool calling preference,
785                            // carried from agent config through reason (EVE-598).
786                            parallel_tool_calls: reason_result.parallel_tool_calls,
787                        })
788                        .await?;
789                    state_machine.on_act_completed();
790                }
791
792                TurnAction::Complete(outcome) => {
793                    return Ok(TurnResult::from_outcome(
794                        outcome,
795                        state_machine.context().turn_id,
796                    ));
797                }
798            }
799        }
800    }
801
802    /// Run multiple turns in sequence
803    pub async fn run_conversation(&self, messages: &[&str]) -> Result<Vec<TurnResult>> {
804        let mut results = Vec::with_capacity(messages.len());
805        for msg in messages {
806            results.push(self.run_turn(*msg).await?);
807        }
808        Ok(results)
809    }
810
811    /// Get all messages in the session
812    pub async fn messages(&self) -> Result<Vec<Message>> {
813        self.message_retriever.load(self.session_id).await
814    }
815
816    /// Get all emitted events
817    pub async fn events(&self) -> Vec<Event> {
818        self.event_emitter.events().await
819    }
820
821    /// Get events of a specific type
822    pub async fn events_by_type(&self, event_type: &str) -> Vec<Event> {
823        self.event_emitter.events_by_type(event_type).await
824    }
825
826    /// Get the count of messages
827    pub async fn message_count(&self) -> Result<usize> {
828        self.message_retriever.count(self.session_id).await
829    }
830
831    /// Get the count of events
832    pub async fn event_count(&self) -> usize {
833        self.event_emitter.event_count().await
834    }
835
836    /// Clear all events (useful between tests)
837    pub async fn clear_events(&self) {
838        self.event_emitter.clear().await;
839    }
840
841    /// Clear all messages (starts a fresh conversation)
842    pub async fn clear_messages(&self) {
843        self.message_retriever.clear_session(self.session_id).await;
844    }
845
846    /// Reset the loop (clear messages and events)
847    pub async fn reset(&self) {
848        self.clear_messages().await;
849        self.clear_events().await;
850    }
851
852    /// Get conversation as a formatted string
853    pub async fn conversation_string(&self) -> Result<String> {
854        let messages = self.messages().await?;
855        let mut result = String::new();
856        for msg in messages {
857            let role = format!("{:?}", msg.role);
858            let text = msg.text().unwrap_or("[non-text content]");
859            result.push_str(&format!("[{}] {}\n", role, text));
860        }
861        Ok(result)
862    }
863
864    /// Access the message retriever directly
865    pub fn message_retriever(&self) -> &InMemoryMessageRetriever {
866        &self.message_retriever
867    }
868
869    /// Access the tool registry directly
870    pub fn tool_registry(&self) -> &ToolRegistry {
871        &self.tool_registry
872    }
873}
874
875// ============================================================================
876// Quick constructors
877// ============================================================================
878
879impl InMemoryAgenticLoop {
880    /// Create a simple loop with a fixed simulated response
881    ///
882    /// # Example
883    ///
884    /// ```ignore
885    /// let runner = InMemoryAgenticLoop::with_fixed_response("Hello!").await?;
886    /// let result = runner.run_turn("Hi").await?;
887    /// assert_eq!(result.response, "Hello!");
888    /// ```
889    pub async fn with_fixed_response(response: impl Into<String>) -> Result<Self> {
890        Self::builder()
891            .with_simulated_response(response)
892            .build()
893            .await
894    }
895
896    /// Create a loop that echoes user input
897    ///
898    /// # Example
899    ///
900    /// ```ignore
901    /// let runner = InMemoryAgenticLoop::with_echo().await?;
902    /// let result = runner.run_turn("Hello").await?;
903    /// assert!(result.response.contains("Hello"));
904    /// ```
905    pub async fn with_echo() -> Result<Self> {
906        Self::builder()
907            .with_llm_sim(LlmSimConfig::echo())
908            .build()
909            .await
910    }
911
912    /// Create a loop with sequence of responses
913    ///
914    /// # Example
915    ///
916    /// ```ignore
917    /// let runner = InMemoryAgenticLoop::with_sequence(vec![
918    ///     "First response",
919    ///     "Second response",
920    /// ]).await?;
921    ///
922    /// let r1 = runner.run_turn("msg1").await?;
923    /// let r2 = runner.run_turn("msg2").await?;
924    /// assert_eq!(r1.response, "First response");
925    /// assert_eq!(r2.response, "Second response");
926    /// ```
927    pub async fn with_sequence(responses: Vec<impl Into<String>>) -> Result<Self> {
928        let responses: Vec<String> = responses.into_iter().map(|s| s.into()).collect();
929        Self::builder()
930            .with_llm_sim(LlmSimConfig::sequence(responses))
931            .build()
932            .await
933    }
934
935    /// Create a loop with tool call simulation
936    ///
937    /// # Example
938    ///
939    /// ```ignore
940    /// use everruns_core::ToolCall;
941    /// use serde_json::json;
942    ///
943    /// let tool_call = ToolCall {
944    ///     id: "call_1".to_string(),
945    ///     name: "get_weather".to_string(),
946    ///     arguments: json!({"city": "NYC"}),
947    /// };
948    ///
949    /// let runner = InMemoryAgenticLoop::with_tool_calls(
950    ///     "Let me check that.",
951    ///     vec![tool_call],
952    /// ).await?;
953    /// ```
954    pub async fn with_tool_calls(
955        response: impl Into<String>,
956        tool_calls: Vec<ToolCall>,
957    ) -> Result<Self> {
958        Self::builder()
959            .with_llm_sim(LlmSimConfig::fixed(response).with_tool_calls(tool_calls))
960            .build()
961            .await
962    }
963}
964
965// ============================================================================
966// Tests
967// ============================================================================
968
969#[cfg(test)]
970mod tests {
971    use super::*;
972
973    #[tokio::test]
974    async fn test_simple_turn() {
975        let runner = InMemoryAgenticLoop::with_fixed_response("Hello from the assistant!")
976            .await
977            .unwrap();
978
979        let result = runner.run_turn("Hi there").await.unwrap();
980
981        assert!(result.success);
982        assert_eq!(result.response, "Hello from the assistant!");
983        assert_eq!(result.iterations, 1);
984        assert_eq!(result.tool_calls_count, 0);
985    }
986
987    #[tokio::test]
988    async fn test_echo_turn() {
989        let runner = InMemoryAgenticLoop::with_echo().await.unwrap();
990
991        let result = runner.run_turn("Test message").await.unwrap();
992
993        assert!(result.success);
994        assert!(result.response.contains("Test message"));
995    }
996
997    #[tokio::test]
998    async fn test_sequence_turns() {
999        let runner = InMemoryAgenticLoop::with_sequence(vec!["First", "Second", "Third"])
1000            .await
1001            .unwrap();
1002
1003        let r1 = runner.run_turn("msg1").await.unwrap();
1004        let r2 = runner.run_turn("msg2").await.unwrap();
1005        let r3 = runner.run_turn("msg3").await.unwrap();
1006
1007        assert_eq!(r1.response, "First");
1008        assert_eq!(r2.response, "Second");
1009        assert_eq!(r3.response, "Third");
1010    }
1011
1012    #[tokio::test]
1013    async fn test_conversation() {
1014        let runner = InMemoryAgenticLoop::with_sequence(vec!["Hello!", "How can I help?"])
1015            .await
1016            .unwrap();
1017
1018        let results = runner
1019            .run_conversation(&["Hi", "I need help"])
1020            .await
1021            .unwrap();
1022
1023        assert_eq!(results.len(), 2);
1024        assert_eq!(results[0].response, "Hello!");
1025        assert_eq!(results[1].response, "How can I help?");
1026
1027        // Check message history
1028        let messages = runner.messages().await.unwrap();
1029        assert_eq!(messages.len(), 4); // 2 user + 2 assistant
1030    }
1031
1032    #[tokio::test]
1033    async fn test_events_captured() {
1034        let runner = InMemoryAgenticLoop::with_fixed_response("Response")
1035            .await
1036            .unwrap();
1037
1038        runner.run_turn("Test").await.unwrap();
1039
1040        let events = runner.events().await;
1041        assert!(!events.is_empty());
1042
1043        // Should have reason.* events (input.message is emitted by API layer, not InputAtom)
1044        let reason_events = runner.events_by_type("reason.started").await;
1045        assert_eq!(reason_events.len(), 1);
1046    }
1047
1048    #[tokio::test]
1049    async fn test_reset() {
1050        let runner = InMemoryAgenticLoop::with_fixed_response("Response")
1051            .await
1052            .unwrap();
1053
1054        runner.run_turn("Test").await.unwrap();
1055        assert!(runner.message_count().await.unwrap() > 0);
1056        assert!(runner.event_count().await > 0);
1057
1058        runner.reset().await;
1059        assert_eq!(runner.message_count().await.unwrap(), 0);
1060        assert_eq!(runner.event_count().await, 0);
1061    }
1062
1063    #[tokio::test]
1064    async fn test_builder_with_custom_config() {
1065        let runner = InMemoryAgenticLoop::builder()
1066            .agent_name("Custom Agent")
1067            .system_prompt("You are a custom assistant.")
1068            .with_simulated_response("Custom response")
1069            .max_iterations(5)
1070            .build()
1071            .await
1072            .unwrap();
1073
1074        let result = runner.run_turn("Test").await.unwrap();
1075        assert_eq!(result.response, "Custom response");
1076    }
1077
1078    #[tokio::test]
1079    async fn test_conversation_string() {
1080        let runner = InMemoryAgenticLoop::with_fixed_response("Hello!")
1081            .await
1082            .unwrap();
1083
1084        runner.run_turn("Hi").await.unwrap();
1085
1086        let conv = runner.conversation_string().await.unwrap();
1087        assert!(conv.contains("[User]"));
1088        assert!(conv.contains("[Agent]"));
1089        assert!(conv.contains("Hi"));
1090        assert!(conv.contains("Hello!"));
1091    }
1092}