Skip to main content

everruns_runtime/
runtime.rs

1// In-process runtime builder and runner.
2// Decision: the public runtime is in-memory today, but uses the same core atoms
3// and capability resolution path as the durable worker so behavior stays close.
4
5use crate::backends::{
6    EventBus, RuntimeAgentStore, RuntimeBackends, RuntimeHarnessStore, RuntimeMessageStore,
7    RuntimeProviderStore, RuntimeSessionStore,
8};
9use crate::builders::SingleSessionBuilder;
10use crate::host::{
11    RuntimeHostAdapter, RuntimeHostTurnContext, RuntimeSessionLifecycle, execute_act_activity,
12    execute_input_activity, execute_reason_activity,
13};
14use crate::in_memory::{InMemorySessionFileStore, InMemorySessionFileSystemFactory};
15use async_trait::async_trait;
16use everruns_core::agent::Agent;
17use everruns_core::atoms::{ActInput, AtomContext, InputAtomInput, ReasonInput};
18use everruns_core::capabilities::{Capability, CapabilityRegistry};
19use everruns_core::config_layer::AgentConfigOverlay;
20use everruns_core::driver_registry::{DriverId, DriverRegistry};
21use everruns_core::error::{AgentLoopError, Result};
22use everruns_core::events::{
23    Event, EventContext, EventData, EventRequest, InputMessageData, OutputMessageCompletedData,
24    ToolCompletedData,
25};
26use everruns_core::harness::Harness;
27use everruns_core::llmsim_driver::{LlmSimConfig, LlmSimDriver};
28use everruns_core::message::{ContentPart, Message};
29use everruns_core::platform_definition::PlatformDefinition;
30use everruns_core::plugins::{PluginFileSet, compile_plugin};
31use everruns_core::runtime_context::{AssembledTurnContext, inspect_turn_context};
32use everruns_core::session::{Session, SessionStatus};
33use everruns_core::session_file::{InitialFile, SessionFile};
34use everruns_core::tools::ToolResultImage;
35use everruns_core::traits::{
36    AgentStore, EventEmitter, HarnessStore, ProviderStore, ResolvedModel, SessionMutator,
37    SessionStorageStore, SessionStore, UserConnectionResolver,
38};
39use everruns_core::turn::{TurnAction, TurnContext, TurnOutcome, TurnStateMachine};
40use everruns_core::typed_id::{AgentId, HarnessId, OrgId, SessionId};
41use everruns_core::{
42    AgentCapabilityConfig, InputMessage, MessageRetriever, SessionFileSystem,
43    SessionFileSystemFactoryContext, plugin_capability_id,
44};
45use sha2::{Digest, Sha256};
46use std::path::Path;
47use std::sync::Arc;
48
49/// Cap on the input length hashed by [`hash_public_org_id`].
50///
51/// Legitimate org public ids are `org_<32hex>` (36 bytes). Bounding the
52/// hashed prefix keeps worst-case cost predictable when an attacker-controlled
53/// session carries an oversize string.
54const HASH_INPUT_CAP_BYTES: usize = 128;
55
56/// Derive an internal `i64` org id from the public `org_<32hex>` form on a
57/// [`Session`].
58///
59/// Round-trip with [`everruns_core::org_public_id_from_internal`]: when the
60/// public id was produced by that helper (i.e. the upper bits are zero and
61/// the value fits in a positive `i64`), this returns the original internal
62/// id unchanged. Other values are mapped into `[2, i64::MAX]` by hashing the
63/// original public id string so runtime namespaces do not fail open to the
64/// shared default org and avoid arithmetic collision gadgets.
65///
66/// Exposed so embedders (e.g. `everruns-local`) can scope per-org stores to the
67/// same internal id the act path resolves a session's org to.
68pub fn in_process_internal_org_id(public_org_id: &str) -> i64 {
69    if public_org_id == everruns_core::DEFAULT_ORG_PUBLIC_ID {
70        return everruns_core::DEFAULT_ORG_ID;
71    }
72
73    let Ok(parsed) = public_org_id.parse::<OrgId>() else {
74        return hash_public_org_id(public_org_id);
75    };
76    let raw: u128 = parsed.uuid().as_u128();
77    if raw == 0 {
78        return hash_public_org_id(public_org_id);
79    }
80
81    // Synthetic ids from `org_public_id_from_internal(i64)` always fit here,
82    // so the in-process runtime sees the same `org_id` the server used.
83    if raw <= i64::MAX as u128 {
84        return raw as i64;
85    }
86
87    hash_public_org_id(public_org_id)
88}
89
90// Use SHA-256 with a fixed truncation scheme so the mapping is stable across
91// Rust/binary upgrades and predictable for any embedder. Input is bounded to
92// `HASH_INPUT_CAP_BYTES` so attacker-controlled oversize org strings cannot
93// drive unbounded hashing work.
94fn hash_public_org_id(public_org_id: &str) -> i64 {
95    let bytes = public_org_id.as_bytes();
96    let bounded = &bytes[..bytes.len().min(HASH_INPUT_CAP_BYTES)];
97    let digest = Sha256::digest(bounded);
98    let mut buf = [0u8; 8];
99    buf.copy_from_slice(&digest[..8]);
100    let raw = u64::from_be_bytes(buf);
101    ((raw % ((i64::MAX - 1) as u64)) as i64) + 2
102}
103
104#[derive(Debug, Clone)]
105pub struct TurnResult {
106    /// Final text response produced by the turn.
107    pub response: String,
108    /// Number of reason iterations executed.
109    pub iterations: usize,
110    /// Total number of tool calls executed during the turn.
111    pub tool_calls_count: usize,
112    /// Whether the turn completed without an unrecoverable failure.
113    pub success: bool,
114    /// Failure message when `success` is false.
115    pub error: Option<String>,
116    /// Turn identifier used to correlate emitted events.
117    pub turn_id: everruns_core::typed_id::TurnId,
118}
119
120impl TurnResult {
121    fn from_outcome(outcome: TurnOutcome, turn_id: everruns_core::typed_id::TurnId) -> Self {
122        match outcome {
123            TurnOutcome::Success {
124                response,
125                iterations,
126                tool_calls_count,
127            } => Self {
128                response,
129                iterations,
130                tool_calls_count,
131                success: true,
132                error: None,
133                turn_id,
134            },
135            TurnOutcome::Failed { error, iterations } => Self {
136                response: String::new(),
137                iterations,
138                tool_calls_count: 0,
139                success: false,
140                error: Some(error),
141                turn_id,
142            },
143            TurnOutcome::MaxIterationsReached {
144                response,
145                iterations,
146                tool_calls_count,
147            } => Self {
148                response,
149                iterations,
150                tool_calls_count,
151                success: true,
152                error: None,
153                turn_id,
154            },
155            // A sealed turn was deliberately stopped (EVE-534) — distinct from a
156            // success. Report it as non-success carrying the seal reason.
157            TurnOutcome::Sealed {
158                reason,
159                response,
160                iterations,
161                tool_calls_count,
162            } => Self {
163                response,
164                iterations,
165                tool_calls_count,
166                success: false,
167                error: Some(format!("turn sealed: {reason}")),
168                turn_id,
169            },
170        }
171    }
172}
173
174/// Builder for the public in-process runtime.
175///
176/// The builder owns a standalone runtime bundle:
177/// - `PlatformDefinition` for capabilities and drivers
178/// - in-memory stores for sessions, files, storage, memory, and messages
179/// - seeded harness/agent/session entities
180///
181/// `build()` returns an [`InProcessRuntime`] that can execute turns in-process
182/// without the durable engine or the control-plane server.
183pub struct InProcessRuntimeBuilder {
184    platform_definition: PlatformDefinition,
185    llm_sim_config: Option<LlmSimConfig>,
186    default_model: Option<ResolvedModel>,
187    backends: Option<RuntimeBackends>,
188    session_file_system_factory_context: SessionFileSystemFactoryContext,
189    harnesses: Vec<Harness>,
190    agents: Vec<Agent>,
191    sessions: Vec<Session>,
192    default_session_id: Option<SessionId>,
193    seeded_files: Vec<(SessionId, InitialFile)>,
194    mcp_auth_provider: Option<Arc<dyn everruns_mcp::McpAuthProvider>>,
195    /// Hydrated capability configs for plugins loaded via [`Self::with_plugin_dir`].
196    ///
197    /// Keyed by `plugin:{name}`. Agents and harnesses reference these by the
198    /// same `plugin:{name}` capability ref; the hydrated config carries the
199    /// compiled `DeclarativeCapabilityDefinition` so no registry entry is needed.
200    plugin_capability_configs: Vec<AgentCapabilityConfig>,
201    /// Non-fatal warnings collected during plugin compilation.
202    plugin_warnings: Vec<String>,
203}
204
205impl Default for InProcessRuntimeBuilder {
206    fn default() -> Self {
207        Self::new()
208    }
209}
210
211impl InProcessRuntimeBuilder {
212    /// Create a builder with runtime-safe built-in capabilities and no implicit
213    /// LLM driver.
214    ///
215    /// Embedders must either:
216    /// - call [`Self::llm_sim`] for deterministic local examples/tests, or
217    /// - register their own driver(s) on the platform definition and set a
218    ///   default model via [`Self::default_model`].
219    pub fn new() -> Self {
220        Self {
221            platform_definition: PlatformDefinition::builder()
222                .capability_registry(CapabilityRegistry::runtime_builtins())
223                .driver_registry(DriverRegistry::new())
224                .session_file_system_factory(Arc::new(InMemorySessionFileSystemFactory))
225                .build(),
226            llm_sim_config: None,
227            default_model: None,
228            backends: None,
229            session_file_system_factory_context: SessionFileSystemFactoryContext::new(),
230            harnesses: Vec::new(),
231            agents: Vec::new(),
232            sessions: Vec::new(),
233            default_session_id: None,
234            seeded_files: Vec::new(),
235            mcp_auth_provider: None,
236            plugin_capability_configs: Vec::new(),
237            plugin_warnings: Vec::new(),
238        }
239    }
240
241    /// Set the auth provider used to acquire credentials for scoped MCP
242    /// servers (specs/runtime-mcp.md D3). Defaults to no credentials, suitable
243    /// for unauthenticated servers or servers carrying literal auth headers.
244    pub fn mcp_auth_provider(mut self, provider: Arc<dyn everruns_mcp::McpAuthProvider>) -> Self {
245        self.mcp_auth_provider = Some(provider);
246        self
247    }
248
249    /// Replace the platform definition used by the runtime.
250    pub fn platform_definition(mut self, platform_definition: PlatformDefinition) -> Self {
251        self.platform_definition = platform_definition;
252        self
253    }
254
255    /// Register an additional capability on the runtime platform.
256    pub fn capability<C: Capability + 'static>(mut self, capability: C) -> Self {
257        self.platform_definition
258            .capability_registry_mut()
259            .register(capability);
260        self
261    }
262
263    /// Replace the platform driver registry.
264    pub fn driver_registry(mut self, driver_registry: DriverRegistry) -> Self {
265        *self.platform_definition.driver_registry_mut() = driver_registry;
266        self
267    }
268
269    /// Register the built-in `llmsim` driver for deterministic local execution.
270    pub fn llm_sim(mut self, config: LlmSimConfig) -> Self {
271        self.llm_sim_config = Some(config);
272        self
273    }
274
275    /// Set the runtime default model used when sessions/agents do not override it.
276    pub fn default_model(mut self, model: ResolvedModel) -> Self {
277        self.default_model = Some(model);
278        self
279    }
280
281    /// Supply a custom backend bundle instead of the built-in in-memory stores.
282    pub fn backends(mut self, backends: RuntimeBackends) -> Self {
283        self.backends = Some(backends);
284        self
285    }
286
287    /// Inject a session-task registry. Convenience over `backends(...)` that
288    /// initializes an in-memory backend bundle on first use, so embedders can
289    /// add the registry without assembling a full `RuntimeBackends`.
290    pub fn with_session_task_registry(
291        mut self,
292        registry: Arc<dyn everruns_core::session_task::SessionTaskRegistry>,
293    ) -> Self {
294        let backends = self
295            .backends
296            .take()
297            .unwrap_or_else(RuntimeBackends::in_memory);
298        self.backends = Some(backends.with_session_task_registry(registry));
299        self
300    }
301
302    /// Inject a per-org schedule store factory (see [`RuntimeBackends`]).
303    pub fn with_schedule_store_factory(
304        mut self,
305        factory: crate::backends::ScheduleStoreFactory,
306    ) -> Self {
307        let backends = self
308            .backends
309            .take()
310            .unwrap_or_else(RuntimeBackends::in_memory);
311        self.backends = Some(backends.with_schedule_store_factory(factory));
312        self
313    }
314
315    /// Inject a per-(org, session) platform store factory (see [`RuntimeBackends`]).
316    pub fn with_platform_store_factory(
317        mut self,
318        factory: crate::backends::PlatformStoreFactory,
319    ) -> Self {
320        let backends = self
321            .backends
322            .take()
323            .unwrap_or_else(RuntimeBackends::in_memory);
324        self.backends = Some(backends.with_platform_store_factory(factory));
325        self
326    }
327
328    /// Supply host dependencies needed by the platform session filesystem factory.
329    pub fn session_file_system_factory_context(
330        mut self,
331        context: SessionFileSystemFactoryContext,
332    ) -> Self {
333        self.session_file_system_factory_context = context;
334        self
335    }
336
337    /// Seed a harness into the runtime store.
338    pub fn harness(mut self, harness: Harness) -> Self {
339        self.harnesses.push(harness);
340        self
341    }
342
343    /// Seed an agent into the runtime store.
344    pub fn agent(mut self, agent: Agent) -> Self {
345        self.agents.push(agent);
346        self
347    }
348
349    /// Seed a session into the runtime store.
350    pub fn session(mut self, session: Session) -> Self {
351        self.sessions.push(session);
352        self
353    }
354
355    /// Seed one harness, one agent, and one session with a compact sub-builder.
356    ///
357    /// The generated session id is exposed from the built runtime via
358    /// [`InProcessRuntime::default_session_id`].
359    pub fn single_session<F>(mut self, configure: F) -> Self
360    where
361        F: FnOnce(SingleSessionBuilder) -> SingleSessionBuilder,
362    {
363        let (harness, agent, session, session_id) =
364            configure(SingleSessionBuilder::default()).build();
365        self.harnesses.push(harness);
366        self.agents.push(agent);
367        self.sessions.push(session);
368        self.default_session_id = Some(session_id);
369        self
370    }
371
372    /// Seed an additional text file directly into a session workspace.
373    ///
374    /// This is applied after harness/agent/session `initial_files` are merged.
375    pub fn seed_text_file(
376        mut self,
377        session_id: SessionId,
378        path: impl Into<String>,
379        content: impl Into<String>,
380    ) -> Self {
381        self.seeded_files.push((
382            session_id,
383            InitialFile {
384                path: path.into(),
385                content: content.into(),
386                encoding: "text".to_string(),
387                is_readonly: false,
388            },
389        ));
390        self
391    }
392
393    /// Load a plugin from a local directory and make it available as a
394    /// `plugin:{name}` capability.
395    ///
396    /// Reads the plugin directory via [`PluginFileSet::from_dir`] and compiles
397    /// it with [`compile_plugin`] at call time. A compilation failure is
398    /// surfaced immediately as a configuration error so the problem is visible
399    /// before the runtime is built. Non-fatal compilation warnings are logged
400    /// via `tracing::warn!` and also collected so they can be inspected on the
401    /// built runtime via [`InProcessRuntime::plugin_warnings`].
402    ///
403    /// After loading, agents and harnesses can reference the plugin by its
404    /// `plugin:{name}` capability ref. The hydrated config carries the compiled
405    /// `DeclarativeCapabilityDefinition`, which the core capability resolution
406    /// path recognises without a registry entry (same path as declarative
407    /// capabilities).
408    ///
409    /// When using [`Self::single_session`], call
410    /// [`SingleSessionBuilder::agent_plugin`] to add the capability ref to the
411    /// seeded agent, or use [`AgentBuilder::capability`] / `with_capability`
412    /// directly.
413    pub fn with_plugin_dir(mut self, path: &Path) -> Result<Self> {
414        let file_set = PluginFileSet::from_dir(path)
415            .map_err(|e| AgentLoopError::config(format!("plugin directory load failed: {e}")))?;
416        let compiled = compile_plugin(&file_set)
417            .map_err(|e| AgentLoopError::config(format!("plugin compilation failed: {e}")))?;
418
419        for warning in &compiled.warnings {
420            tracing::warn!(plugin = %compiled.definition.name, warning = %warning, "plugin compile warning");
421        }
422        self.plugin_warnings.extend(compiled.warnings);
423
424        let cap_id = plugin_capability_id(&compiled.definition.name);
425        let hydrated_config = serde_json::to_value(&compiled.definition)
426            .unwrap_or(serde_json::Value::Object(serde_json::Map::new()));
427        self.plugin_capability_configs
428            .push(AgentCapabilityConfig::with_config(cap_id, hydrated_config));
429
430        Ok(self)
431    }
432
433    /// Return a hydrated `AgentCapabilityConfig` for a previously loaded plugin.
434    ///
435    /// Returns `None` when no plugin with that name was loaded via
436    /// [`Self::with_plugin_dir`]. Primarily used by callers that need the
437    /// hydrated config to seed it onto a harness or agent before building.
438    pub fn plugin_capability(&self, name: &str) -> Option<AgentCapabilityConfig> {
439        let cap_id = plugin_capability_id(name);
440        self.plugin_capability_configs
441            .iter()
442            .find(|c| c.capability_id() == cap_id)
443            .cloned()
444    }
445
446    /// Build the in-process runtime.
447    ///
448    /// Returns a configuration error when no default model is available after
449    /// applying explicit configuration and any requested `llmsim` setup.
450    pub async fn build(mut self) -> Result<InProcessRuntime> {
451        let backends = match self.backends.take() {
452            Some(backends) => backends,
453            None => RuntimeBackends::in_memory(),
454        };
455        let file_store = resolve_session_file_system(
456            &self.platform_definition,
457            self.session_file_system_factory_context.clone(),
458        )
459        .await?;
460
461        if let Some(config) = self.llm_sim_config.take() {
462            let driver = LlmSimDriver::new(config);
463            // Replace intentionally: the platform may already register a
464            // built-in LlmSim driver, and the builder's config takes precedence.
465            self.platform_definition
466                .driver_registry_mut()
467                .register_or_replace(DriverId::LlmSim, move |_config| Box::new(driver.clone()));
468
469            if self.default_model.is_none() {
470                self.default_model = Some(ResolvedModel {
471                    model: "llmsim-model".to_string(),
472                    provider_type: DriverId::LlmSim,
473                    api_key: Some("fake-key".to_string()),
474                    base_url: None,
475                    provider_metadata: None,
476                });
477            }
478        }
479
480        let default_model = self.default_model.ok_or_else(|| {
481            AgentLoopError::config(
482                "in-process runtime requires a default model; call \
483                 InProcessRuntimeBuilder::default_model(...) or \
484                 InProcessRuntimeBuilder::llm_sim(...)",
485            )
486        })?;
487
488        backends
489            .provider_store
490            .set_default_model(default_model)
491            .await?;
492
493        // Hydrate bare plugin: refs in harnesses/agents/sessions with the
494        // compiled definition config so the capability resolution path can
495        // deserialise them without a registry entry (same path as declarative:).
496        for harness in &mut self.harnesses {
497            hydrate_plugin_refs(&mut harness.capabilities, &self.plugin_capability_configs);
498        }
499        for agent in &mut self.agents {
500            hydrate_plugin_refs(&mut agent.capabilities, &self.plugin_capability_configs);
501        }
502        for session in &mut self.sessions {
503            hydrate_plugin_refs(&mut session.capabilities, &self.plugin_capability_configs);
504        }
505
506        for harness in &self.harnesses {
507            backends.harness_store.add_harness(harness.clone()).await?;
508        }
509        for agent in &self.agents {
510            backends.agent_store.add_agent(agent.clone()).await?;
511        }
512        for session in &self.sessions {
513            backends.session_store.add_session(session.clone()).await?;
514        }
515
516        for session in &self.sessions {
517            seed_runtime_initial_files(
518                backends.harness_store.as_ref(),
519                backends.agent_store.as_ref(),
520                file_store.as_ref(),
521                session,
522            )
523            .await?;
524        }
525
526        for (session_id, file) in &self.seeded_files {
527            file_store.seed_initial_file(*session_id, file).await?;
528        }
529
530        let persisting_emitter =
531            PersistingEventEmitter::new(backends.event_bus.clone(), backends.message_store.clone());
532
533        // Mid-turn wake delivery (EVE-681, part A): when a task registry is
534        // present, wrap it so qualifying task transitions fan out to a
535        // per-session `SessionWakeQueue`. The turn loop drains that queue at
536        // each reason iteration boundary. Without a registry there is no
537        // background work to wake on, so the queue is left absent (inert).
538        let (session_task_registry, session_wake_queue) = match backends.session_task_registry {
539            Some(inner) => {
540                let wake_queue = Arc::new(everruns_core::SessionWakeQueue::new());
541                let observing = everruns_core::ObservingTaskRegistry::new(inner)
542                    .with_observer(wake_queue.clone());
543                let wrapped: Arc<dyn everruns_core::session_task::SessionTaskRegistry> =
544                    Arc::new(observing);
545                (Some(wrapped), Some(wake_queue))
546            }
547            None => (None, None),
548        };
549
550        Ok(InProcessRuntime {
551            platform_definition: Arc::new(self.platform_definition),
552            harness_store: backends.harness_store,
553            agent_store: backends.agent_store,
554            session_store: backends.session_store,
555            default_session_id: self.default_session_id,
556            message_store: backends.message_store,
557            provider_store: backends.provider_store,
558            event_bus: backends.event_bus,
559            persisting_emitter,
560            file_store,
561            storage_store: backends.storage_store,
562            connection_resolver: backends.connection_resolver,
563            session_task_registry,
564            session_wake_queue,
565            schedule_store_factory: backends.schedule_store_factory,
566            platform_store_factory: backends.platform_store_factory,
567            mcp_auth_provider: self
568                .mcp_auth_provider
569                .unwrap_or_else(|| Arc::new(everruns_mcp::NoAuthProvider)),
570            mcp_discovery_cache: Arc::new(crate::mcp_cache::McpDiscoveryCache::new()),
571            plugin_warnings: self.plugin_warnings,
572        })
573    }
574}
575
576async fn resolve_session_file_system(
577    platform_definition: &PlatformDefinition,
578    file_system_factory_context: SessionFileSystemFactoryContext,
579) -> Result<Arc<dyn SessionFileSystem>> {
580    let file_system_factory = platform_definition.session_file_system_factory();
581    if file_system_factory.is_disabled() {
582        Ok(Arc::new(InMemorySessionFileStore::new()))
583    } else {
584        Ok(file_system_factory
585            .create_session_file_system(file_system_factory_context)
586            .await?)
587    }
588}
589
590#[derive(Clone)]
591/// Public in-process runtime backed by either in-memory or custom stores.
592///
593/// This runtime is intended for embedders who want to execute Everruns
594/// harnesses inside their own process while controlling capabilities,
595/// harness definitions, and driver registrations directly in Rust.
596pub struct InProcessRuntime {
597    platform_definition: Arc<PlatformDefinition>,
598    harness_store: Arc<dyn RuntimeHarnessStore>,
599    agent_store: Arc<dyn RuntimeAgentStore>,
600    session_store: Arc<dyn RuntimeSessionStore>,
601    default_session_id: Option<SessionId>,
602    message_store: Arc<dyn RuntimeMessageStore>,
603    provider_store: Arc<dyn RuntimeProviderStore>,
604    event_bus: Arc<dyn EventBus>,
605    persisting_emitter: PersistingEventEmitter,
606    file_store: Arc<dyn SessionFileSystem>,
607    storage_store: Arc<dyn SessionStorageStore>,
608    connection_resolver: Option<Arc<dyn UserConnectionResolver>>,
609    session_task_registry: Option<Arc<dyn everruns_core::session_task::SessionTaskRegistry>>,
610    /// Mid-turn wake queue fed by `session_task_registry` transitions and
611    /// drained at each reason iteration boundary (EVE-681, part A). Present iff
612    /// a task registry was configured.
613    session_wake_queue: Option<Arc<everruns_core::SessionWakeQueue>>,
614    schedule_store_factory: Option<crate::backends::ScheduleStoreFactory>,
615    platform_store_factory: Option<crate::backends::PlatformStoreFactory>,
616    mcp_auth_provider: Arc<dyn everruns_mcp::McpAuthProvider>,
617    mcp_discovery_cache: Arc<crate::mcp_cache::McpDiscoveryCache>,
618    /// Non-fatal warnings collected during plugin compilation (see
619    /// [`InProcessRuntimeBuilder::with_plugin_dir`]).
620    plugin_warnings: Vec<String>,
621}
622
623impl InProcessRuntime {
624    /// Build the shared MCP client over the platform egress boundary and the
625    /// configured auth provider.
626    fn mcp_client(&self) -> Arc<everruns_mcp::McpClient> {
627        Arc::new(everruns_mcp::McpClient::new(
628            self.platform_definition.egress_service(),
629            self.mcp_auth_provider.clone(),
630        ))
631    }
632
633    /// Resolve the effective scoped MCP servers for a session.
634    async fn session_mcp_servers(
635        &self,
636        session: &Session,
637        agent: Option<&Agent>,
638    ) -> everruns_core::ScopedMcpServers {
639        let harness_chain = self
640            .harness_store
641            .get_harness_chain(session.harness_id)
642            .await
643            .unwrap_or_default();
644        crate::mcp::merge_session_scoped_servers(&harness_chain, agent, session)
645    }
646    /// Create a builder for the in-process runtime.
647    pub fn builder() -> InProcessRuntimeBuilder {
648        InProcessRuntimeBuilder::new()
649    }
650
651    /// Return the default session id seeded by
652    /// [`InProcessRuntimeBuilder::single_session`], if one was configured.
653    pub fn default_session_id(&self) -> Option<SessionId> {
654        self.default_session_id
655    }
656
657    /// Return non-fatal warnings collected during plugin compilation.
658    ///
659    /// Warnings are also emitted at `tracing::warn!` level when
660    /// [`InProcessRuntimeBuilder::with_plugin_dir`] is called.
661    pub fn plugin_warnings(&self) -> &[String] {
662        &self.plugin_warnings
663    }
664
665    /// Execute one turn for an existing session.
666    ///
667    /// The input message is stored in the runtime history, an `input.message`
668    /// event is emitted, and the turn then executes the shared core
669    /// `input -> reason -> act` state machine.
670    pub async fn run_turn(
671        &self,
672        session_id: SessionId,
673        input: impl Into<InputMessage>,
674    ) -> Result<TurnResult> {
675        let session = self
676            .session_store
677            .get_session(session_id)
678            .await?
679            .ok_or_else(|| AgentLoopError::store(format!("session not found: {session_id}")))?;
680
681        // Input message is recorded directly (and emitted via the raw bus so
682        // that PersistingEventEmitter does not double-store it). All
683        // subsequent activity-emitted events flow through the persisting
684        // emitter the adapter hands out.
685        let input_message = self
686            .message_store
687            .add_input_message(session_id, input.into())
688            .await?;
689        self.event_bus
690            .emit(EventRequest::new(
691                session_id,
692                EventContext::empty(),
693                InputMessageData::new(input_message.clone()),
694            ))
695            .await?;
696
697        let assembled = self
698            .inspect_context_with_ids(session_id, session.harness_id, session.agent_id)
699            .await?;
700        let synthetic_agent_id = session
701            .agent_id
702            .unwrap_or_else(|| AgentId::from_uuid(session.id.uuid()));
703        let org_id = in_process_internal_org_id(&session.organization_id);
704        let mut state_machine = TurnStateMachine::new(
705            TurnContext::new(session_id, input_message.id, synthetic_agent_id, org_id),
706            assembled.runtime_agent.max_iterations,
707        );
708
709        let mut previous_response_id: Option<String> = None;
710        let mut last_reason_result: Option<everruns_core::ReasonResult> = None;
711
712        loop {
713            match state_machine.next_action() {
714                TurnAction::ExecuteInput => {
715                    let ctx = state_machine.context();
716                    let base_context =
717                        AtomContext::new(ctx.session_id, ctx.turn_id, ctx.input_message_id)
718                            .with_workspace_id(session.workspace_id);
719                    execute_input_activity(
720                        self,
721                        org_id,
722                        InputAtomInput {
723                            context: base_context,
724                        },
725                    )
726                    .await?;
727                    state_machine.on_input_completed();
728                }
729                TurnAction::ExecuteReason => {
730                    let ctx = state_machine.context();
731                    let session_id = ctx.session_id;
732                    // Iteration boundary: drain queued task wakes and inject
733                    // them before the LLM call so this reason reacts to them
734                    // (EVE-681, part A). Draining here also delivers wakes that
735                    // arrived while the session was idle, on the next turn's
736                    // first iteration (between-turn fallback).
737                    self.drain_and_inject_wakes(session_id).await?;
738                    let base_context =
739                        AtomContext::new(ctx.session_id, ctx.turn_id, ctx.input_message_id)
740                            .with_workspace_id(session.workspace_id);
741                    let reason_result = execute_reason_activity(
742                        self,
743                        org_id,
744                        ReasonInput {
745                            context: base_context.next_exec(),
746                            harness_id: session.harness_id,
747                            agent_id: session.agent_id,
748                            org_id,
749                            mcp_tool_definitions: vec![],
750                            previous_response_id: previous_response_id.take(),
751                            iteration: state_machine.current_iteration() as u32 + 1,
752                        },
753                    )
754                    .await?;
755                    previous_response_id = reason_result.response_id.clone();
756                    // If a wake landed during this reason (e.g. a background
757                    // task settling on another task), continue a would-idle turn
758                    // so it is delivered on the very next iteration rather than
759                    // after the session idles.
760                    let has_pending_wakes = self
761                        .session_wake_queue
762                        .as_ref()
763                        .is_some_and(|q| q.has_pending(session_id));
764                    state_machine.on_reason_completed(
765                        reason_result.text.clone(),
766                        reason_result.has_tool_calls,
767                        reason_result.tool_calls.len(),
768                        reason_result.success,
769                        reason_result.error.clone(),
770                        has_pending_wakes,
771                    );
772                    if reason_result.has_tool_calls {
773                        last_reason_result = Some(reason_result);
774                    }
775                }
776                TurnAction::ExecuteAct => {
777                    let reason_result = last_reason_result
778                        .take()
779                        .expect("ExecuteAct requires a prior ReasonResult");
780                    let ctx = state_machine.context();
781                    let base_context =
782                        AtomContext::new(ctx.session_id, ctx.turn_id, ctx.input_message_id)
783                            .with_workspace_id(session.workspace_id);
784                    execute_act_activity(
785                        self,
786                        ActInput {
787                            org_id: Some(org_id),
788                            context: base_context.next_exec(),
789                            harness_id: session.harness_id,
790                            agent_id: session.agent_id,
791                            tool_calls: reason_result.tool_calls,
792                            tool_definitions: reason_result.tool_definitions,
793                            locale: reason_result.locale,
794                            blueprint_id: None,
795                            network_access: reason_result.network_access,
796                            // Request-level parallel tool calling preference,
797                            // carried from agent config through reason (EVE-598).
798                            parallel_tool_calls: reason_result.parallel_tool_calls,
799                        },
800                    )
801                    .await?;
802                    state_machine.on_act_completed();
803                }
804                TurnAction::Complete(outcome) => {
805                    let ctx = state_machine.context();
806                    let lifecycle =
807                        RuntimeSessionLifecycle::new(self.clone(), org_id, ctx.session_id);
808                    let turn_succeeded = matches!(
809                        &outcome,
810                        TurnOutcome::Success { .. } | TurnOutcome::MaxIterationsReached { .. }
811                    );
812                    match &outcome {
813                        TurnOutcome::Success { iterations, .. }
814                        | TurnOutcome::MaxIterationsReached { iterations, .. } => {
815                            lifecycle
816                                .turn_completed(
817                                    ctx.turn_id,
818                                    ctx.input_message_id,
819                                    *iterations as u32,
820                                    None,
821                                    None,
822                                )
823                                .await;
824                        }
825                        TurnOutcome::Failed { error, .. } => {
826                            lifecycle
827                                .turn_failed(ctx.turn_id, ctx.input_message_id, error, None)
828                                .await;
829                        }
830                        TurnOutcome::Sealed {
831                            reason, iterations, ..
832                        } => {
833                            lifecycle
834                                .turn_sealed(
835                                    ctx.turn_id,
836                                    ctx.input_message_id,
837                                    reason.as_str(),
838                                    *iterations as u32,
839                                    None,
840                                )
841                                .await;
842                        }
843                    }
844                    // turn_end lifecycle hooks (advisory). Fired after the
845                    // terminal turn event for both success and failure.
846                    lifecycle
847                        .fire_turn_end_hooks(
848                            session.harness_id,
849                            session.agent_id,
850                            ctx.turn_id,
851                            turn_succeeded,
852                        )
853                        .await;
854                    return Ok(TurnResult::from_outcome(outcome, ctx.turn_id));
855                }
856            }
857        }
858    }
859
860    pub async fn run_text_turn(
861        &self,
862        session_id: SessionId,
863        text: impl Into<String>,
864    ) -> Result<TurnResult> {
865        self.run_turn(session_id, InputMessage::user(text)).await
866    }
867
868    /// Drain any queued task wakes for `session_id` and inject them into the
869    /// conversation as user messages so the next reason reacts to them
870    /// (EVE-681, part A). Returns the number of wakes injected.
871    ///
872    /// Called at the top of every reason iteration — before the LLM call — so a
873    /// task completion that landed during the previous act (or while idle) is
874    /// visible to the very next iteration. `SessionWakeQueue::drain` is the
875    /// exactly-once claim point: a drained wake is removed and never delivered
876    /// twice, so a wake is delivered mid-turn XOR on the next turn's first
877    /// drain, never both.
878    async fn drain_and_inject_wakes(&self, session_id: SessionId) -> Result<usize> {
879        let Some(queue) = &self.session_wake_queue else {
880            return Ok(0);
881        };
882        let wakes = queue.drain(session_id);
883        if wakes.is_empty() {
884            return Ok(0);
885        }
886        let count = wakes.len();
887        for wake in wakes {
888            // Persist the wake as a user message (history reload picks it up)
889            // and emit the input event on the raw bus so it appears in the turn
890            // span without the persisting emitter double-storing it — mirroring
891            // how `run_turn` records the initial input message.
892            let message = self
893                .message_store
894                .add_input_message(session_id, InputMessage::user(wake.text))
895                .await?;
896            self.event_bus
897                .emit(EventRequest::new(
898                    session_id,
899                    EventContext::empty(),
900                    InputMessageData::new(message),
901                ))
902                .await?;
903        }
904        Ok(count)
905    }
906
907    /// Load the current message history for a session.
908    pub async fn messages(&self, session_id: SessionId) -> Result<Vec<Message>> {
909        self.message_store.load(session_id).await
910    }
911
912    /// Read a file from the in-memory session filesystem.
913    pub async fn read_file(
914        &self,
915        session_id: SessionId,
916        path: &str,
917    ) -> Result<Option<SessionFile>> {
918        self.file_store.read_file(session_id, path).await
919    }
920
921    /// Assemble the current runtime context for a session without executing a turn.
922    pub async fn load_context(&self, session_id: SessionId) -> Result<AssembledTurnContext> {
923        let session = self
924            .session_store
925            .get_session(session_id)
926            .await?
927            .ok_or_else(|| AgentLoopError::store(format!("session not found: {session_id}")))?;
928        self.inspect_context_with_ids(session_id, session.harness_id, session.agent_id)
929            .await
930    }
931
932    /// Return all collected events from the runtime event bus.
933    ///
934    /// Event buses that do not retain events return an empty `Vec` (see
935    /// [`EventBus::collected_events`]).
936    pub async fn events(&self) -> Result<Vec<Event>> {
937        Ok(self.event_bus.collected_events().await)
938    }
939
940    /// Execute a system command declared by a registered capability.
941    ///
942    /// Looks up the first capability whose `commands()` includes the named
943    /// command (in capability-resolution order) and delegates to its
944    /// `execute_command`. Returns an error if no capability declares the
945    /// requested name. The coding-CLI example uses this for `/model`
946    /// (provided by `ModelSwitcherCapability`) so the dispatch path stays
947    /// inside the capability instead of the TUI's local `handle_command`
948    /// branches.
949    pub async fn execute_command(
950        &self,
951        session_id: SessionId,
952        request: everruns_core::command::ExecuteCommandRequest,
953    ) -> Result<everruns_core::command::CommandResult> {
954        let ctx = self.load_context(session_id).await?;
955        let registry = self.platform_definition.capability_registry();
956        // Context-aware commands (e.g. /btw) get the same store-backed host
957        // facilities the server provides; the already-assembled context seeds
958        // the host so dispatch and execution assemble it once.
959        let host = everruns_core::command_host::StoreCommandHost::new(
960            session_id,
961            self.harness_store.clone(),
962            self.agent_store.clone(),
963            self.session_store.clone(),
964            self.message_store.clone(),
965            self.provider_store.clone(),
966            registry.clone(),
967            self.platform_definition.driver_registry().clone(),
968        )
969        .with_file_store(self.file_store.clone())
970        .with_assembled_context(ctx.clone());
971        let exec_ctx =
972            everruns_core::command::CommandExecutionContext::new(session_id, Arc::new(host));
973        for config in &ctx.resolved_capability_configs {
974            let Some(capability) = registry.get(config.capability_id()) else {
975                continue;
976            };
977            if capability.commands().iter().any(|c| c.name == request.name) {
978                return capability.execute_command(&request, &exec_ctx).await;
979            }
980        }
981        Err(AgentLoopError::config(format!(
982            "no capability declares command /{}",
983            request.name
984        )))
985    }
986
987    /// List slash commands available for a session.
988    ///
989    /// Resolves the session's harness/agent capability chain and aggregates
990    /// commands declared via [`Capability::commands`], deduplicated by name
991    /// (first occurrence wins, matching the order of resolved capabilities).
992    /// This is the embedded equivalent of the server's
993    /// `GET /v1/sessions/{id}/commands` system-commands list — skill
994    /// commands are not included here because skills are discovered via the
995    /// platform filesystem rather than the capability registry.
996    pub async fn list_commands(
997        &self,
998        session_id: SessionId,
999    ) -> Result<Vec<everruns_core::command::CommandDescriptor>> {
1000        let ctx = self.load_context(session_id).await?;
1001        let registry = self.platform_definition.capability_registry();
1002        let mut seen = std::collections::HashSet::new();
1003        let mut commands = Vec::new();
1004        for config in &ctx.resolved_capability_configs {
1005            let Some(capability) = registry.get(config.capability_id()) else {
1006                continue;
1007            };
1008            for command in capability.commands() {
1009                if seen.insert(command.name.clone()) {
1010                    commands.push(command);
1011                }
1012            }
1013        }
1014        Ok(commands)
1015    }
1016
1017    async fn inspect_context_with_ids(
1018        &self,
1019        session_id: SessionId,
1020        harness_id: everruns_core::HarnessId,
1021        agent_id: Option<AgentId>,
1022    ) -> Result<AssembledTurnContext> {
1023        inspect_turn_context(
1024            self.harness_store.as_ref(),
1025            self.agent_store.as_ref(),
1026            self.session_store.as_ref(),
1027            self.message_store.as_ref(),
1028            self.provider_store.as_ref(),
1029            self.platform_definition.capability_registry(),
1030            session_id,
1031            harness_id,
1032            agent_id,
1033            &[],
1034            Some(self.file_store.clone()),
1035        )
1036        .await
1037    }
1038}
1039
1040#[async_trait]
1041impl RuntimeHostAdapter for InProcessRuntime {
1042    async fn get_agent(&self, _org_id: i64, agent_id: AgentId) -> Result<Option<Agent>> {
1043        self.agent_store.get_agent(agent_id).await
1044    }
1045
1046    async fn get_harness(&self, _org_id: i64, harness_id: HarnessId) -> Result<Option<Harness>> {
1047        let chain = self.harness_store.get_harness_chain(harness_id).await?;
1048        Ok(chain.into_iter().last())
1049    }
1050
1051    async fn set_session_status(
1052        &self,
1053        _org_id: i64,
1054        session_id: SessionId,
1055        _status: SessionStatus,
1056    ) -> Result<Session> {
1057        // The in-process runtime does not persist status. Lifecycle callers
1058        // still emit their events; downstream consumers in-process don't
1059        // observe session.status.
1060        self.session_store
1061            .get_session(session_id)
1062            .await?
1063            .ok_or_else(|| AgentLoopError::store(format!("session not found: {session_id}")))
1064    }
1065
1066    async fn load_turn_context(
1067        &self,
1068        _org_id: i64,
1069        session_id: SessionId,
1070    ) -> Result<RuntimeHostTurnContext> {
1071        let mut session = self
1072            .session_store
1073            .get_session(session_id)
1074            .await?
1075            .ok_or_else(|| AgentLoopError::store(format!("session not found: {session_id}")))?;
1076        // Fold runtime ARD attachments into the session config layer before
1077        // scoped MCP servers / capabilities are resolved (resource_discovery).
1078        everruns_core::ard_attachment::apply_session_attachments(
1079            self.storage_store.as_ref(),
1080            &mut session,
1081        )
1082        .await;
1083        let agent = match session.agent_id {
1084            Some(agent_id) => self.agent_store.get_agent(agent_id).await?,
1085            None => None,
1086        };
1087        let messages = self.message_store.load(session_id).await?;
1088        let model = self.provider_store.get_default_model().await?;
1089
1090        // Discover tools from the session's scoped MCP servers so they appear
1091        // to the LLM alongside built-in tools (specs/runtime-mcp.md D4).
1092        let scoped_servers = self.session_mcp_servers(&session, agent.as_ref()).await;
1093        let mcp_tool_definitions = if scoped_servers.is_empty() {
1094            vec![]
1095        } else {
1096            crate::mcp::discover_tool_definitions(
1097                &self.mcp_discovery_cache,
1098                self.mcp_client(),
1099                session_id.uuid(),
1100                &scoped_servers,
1101            )
1102            .await
1103        };
1104
1105        Ok(RuntimeHostTurnContext {
1106            agent,
1107            session,
1108            messages,
1109            model,
1110            mcp_tool_definitions,
1111        })
1112    }
1113
1114    async fn mcp_executor(
1115        &self,
1116        _org_id: i64,
1117        session_id: SessionId,
1118    ) -> Option<Arc<everruns_mcp::McpExecutor>> {
1119        let session = self.session_store.get_session(session_id).await.ok()??;
1120        let agent = match session.agent_id {
1121            Some(agent_id) => self.agent_store.get_agent(agent_id).await.ok().flatten(),
1122            None => None,
1123        };
1124        let scoped_servers = self.session_mcp_servers(&session, agent.as_ref()).await;
1125        crate::mcp::build_executor(self.mcp_client(), &scoped_servers)
1126    }
1127
1128    fn capability_registry(&self) -> CapabilityRegistry {
1129        self.platform_definition.capability_registry().clone()
1130    }
1131
1132    fn driver_registry(&self) -> DriverRegistry {
1133        self.platform_definition.driver_registry().clone()
1134    }
1135
1136    fn harness_store(&self, _org_id: i64) -> Arc<dyn HarnessStore> {
1137        self.harness_store.clone()
1138    }
1139
1140    fn agent_store(&self, _org_id: i64) -> Arc<dyn AgentStore> {
1141        self.agent_store.clone()
1142    }
1143
1144    fn session_store(&self, _org_id: i64) -> Arc<dyn SessionStore> {
1145        self.session_store.clone()
1146    }
1147
1148    fn session_mutator(&self, _org_id: i64) -> Arc<dyn SessionMutator> {
1149        self.session_store.clone()
1150    }
1151
1152    fn provider_store(&self, _org_id: i64) -> Arc<dyn ProviderStore> {
1153        self.provider_store.clone()
1154    }
1155
1156    fn message_store(&self) -> Arc<dyn MessageRetriever> {
1157        self.message_store.clone()
1158    }
1159
1160    fn event_emitter(&self) -> Arc<dyn EventEmitter> {
1161        Arc::new(self.persisting_emitter.clone())
1162    }
1163
1164    fn file_store(&self) -> Arc<dyn SessionFileSystem> {
1165        self.file_store.clone()
1166    }
1167
1168    fn storage_store(&self) -> Option<Arc<dyn SessionStorageStore>> {
1169        Some(self.storage_store.clone())
1170    }
1171
1172    fn connection_resolver(&self) -> Option<Arc<dyn UserConnectionResolver>> {
1173        self.connection_resolver.clone()
1174    }
1175
1176    fn session_task_registry(
1177        &self,
1178    ) -> Option<Arc<dyn everruns_core::session_task::SessionTaskRegistry>> {
1179        self.session_task_registry.clone()
1180    }
1181
1182    fn schedule_store(
1183        &self,
1184        org_id: i64,
1185    ) -> Option<Arc<dyn everruns_core::traits::SessionScheduleStore>> {
1186        self.schedule_store_factory
1187            .as_ref()
1188            .map(|factory| factory(org_id))
1189    }
1190
1191    fn platform_store(
1192        &self,
1193        org_id: i64,
1194        session_id: SessionId,
1195    ) -> Option<Arc<dyn everruns_core::platform_store::PlatformStore>> {
1196        self.platform_store_factory
1197            .as_ref()
1198            .map(|factory| factory(org_id, session_id))
1199    }
1200
1201    fn utility_llm_service(&self) -> Option<Arc<dyn everruns_core::UtilityLlmService>> {
1202        Some(self.platform_definition.utility_llm_service())
1203    }
1204
1205    fn egress_service(&self) -> Option<Arc<dyn everruns_core::EgressService>> {
1206        Some(self.platform_definition.egress_service())
1207    }
1208}
1209
1210#[derive(Clone)]
1211struct PersistingEventEmitter {
1212    inner: Arc<dyn EventBus>,
1213    message_store: Arc<dyn RuntimeMessageStore>,
1214}
1215
1216impl PersistingEventEmitter {
1217    fn new(inner: Arc<dyn EventBus>, message_store: Arc<dyn RuntimeMessageStore>) -> Self {
1218        Self {
1219            inner,
1220            message_store,
1221        }
1222    }
1223}
1224
1225#[async_trait]
1226impl EventEmitter for PersistingEventEmitter {
1227    async fn emit(&self, request: EventRequest) -> Result<Event> {
1228        let event = self.inner.emit(request.clone()).await?;
1229        if let Some(message) = message_from_event(&event.data) {
1230            self.message_store
1231                .store_message(request.session_id, message)
1232                .await?;
1233        }
1234        Ok(event)
1235    }
1236}
1237
1238fn effective_overlay(
1239    harness_chain: &[Harness],
1240    agent: Option<&Agent>,
1241    session: &Session,
1242) -> AgentConfigOverlay {
1243    let harness_layers = harness_chain.iter().map(AgentConfigOverlay::from);
1244    let agent_layers = agent.into_iter().map(AgentConfigOverlay::from);
1245    AgentConfigOverlay::fold(
1246        harness_layers
1247            .chain(agent_layers)
1248            .chain([AgentConfigOverlay::from(session)]),
1249    )
1250}
1251
1252/// Replace bare `plugin:{name}` refs (empty config) with the hydrated version
1253/// (config = serialised `DeclarativeCapabilityDefinition`) so that the core
1254/// capability resolution path can deserialise them without a registry entry.
1255///
1256/// Only replaces entries whose config is empty / `null`; entries that already
1257/// carry a non-empty config are left unchanged so explicit overrides are honoured.
1258fn hydrate_plugin_refs(
1259    capabilities: &mut [AgentCapabilityConfig],
1260    plugin_configs: &[AgentCapabilityConfig],
1261) {
1262    for cap in capabilities.iter_mut() {
1263        let cap_id = cap.capability_id();
1264        if !everruns_core::is_plugin_capability(cap_id) {
1265            continue;
1266        }
1267        // Only replace if the config is missing / empty so explicit overrides are honoured.
1268        let is_bare = cap.config.is_null()
1269            || cap
1270                .config
1271                .as_object()
1272                .map(|o| o.is_empty())
1273                .unwrap_or(false);
1274        if !is_bare {
1275            continue;
1276        }
1277        if let Some(hydrated) = plugin_configs.iter().find(|c| c.capability_id() == cap_id) {
1278            cap.config = hydrated.config.clone();
1279        }
1280    }
1281}
1282
1283async fn seed_runtime_initial_files(
1284    harness_store: &dyn RuntimeHarnessStore,
1285    agent_store: &dyn RuntimeAgentStore,
1286    file_store: &dyn SessionFileSystem,
1287    session: &Session,
1288) -> Result<()> {
1289    let harness_chain = harness_store.get_harness_chain(session.harness_id).await?;
1290    if harness_chain.is_empty() {
1291        return Err(AgentLoopError::store(format!(
1292            "harness not found while seeding files: {}",
1293            session.harness_id
1294        )));
1295    }
1296    let agent = match session.agent_id {
1297        Some(agent_id) => Some(
1298            agent_store
1299                .get_agent(agent_id)
1300                .await?
1301                .ok_or_else(|| AgentLoopError::store(format!("agent not found: {agent_id}")))?,
1302        ),
1303        None => None,
1304    };
1305    let overlay = effective_overlay(&harness_chain, agent.as_ref(), session);
1306    // Seed into the session's workspace (a shared workspace differs from the
1307    // session id; the default 1:1 case is equal).
1308    let seed_key = SessionId::from_uuid(session.workspace_id.uuid());
1309    for file in &overlay.initial_files {
1310        file_store.seed_initial_file(seed_key, file).await?;
1311    }
1312    Ok(())
1313}
1314
1315fn message_from_event(data: &EventData) -> Option<Message> {
1316    match data {
1317        EventData::InputMessage(data) => Some(data.message.clone()),
1318        EventData::OutputMessageCompleted(OutputMessageCompletedData { message, .. }) => {
1319            Some(message.clone())
1320        }
1321        EventData::ToolCompleted(data) => Some(tool_completed_to_message(data.clone())),
1322        _ => None,
1323    }
1324}
1325
1326fn tool_completed_to_message(data: ToolCompletedData) -> Message {
1327    let mut images: Vec<ToolResultImage> = Vec::new();
1328    let metadata = tool_result_metadata(&data);
1329    let result = data.result.map(|parts| {
1330        for part in &parts {
1331            if let ContentPart::Image(img) = part
1332                && let (Some(base64), Some(media_type)) = (&img.base64, &img.media_type)
1333            {
1334                images.push(ToolResultImage {
1335                    base64: base64.clone(),
1336                    media_type: media_type.clone(),
1337                });
1338            }
1339        }
1340
1341        let text_parts: Vec<&ContentPart> = parts
1342            .iter()
1343            .filter(|part| matches!(part, ContentPart::Text(_)))
1344            .collect();
1345        if text_parts.len() == 1
1346            && let ContentPart::Text(text) = text_parts[0]
1347        {
1348            return parse_structured_tool_result_text(&text.text);
1349        }
1350        if !text_parts.is_empty() {
1351            serde_json::to_value(&text_parts).unwrap_or_default()
1352        } else {
1353            serde_json::Value::Null
1354        }
1355    });
1356
1357    let mut message = if images.is_empty() {
1358        Message::tool_result(&data.tool_call_id, result, data.error)
1359    } else {
1360        Message::tool_result_with_images(&data.tool_call_id, result, images)
1361    };
1362    message.metadata = metadata;
1363    message
1364}
1365
1366fn tool_result_metadata(
1367    data: &ToolCompletedData,
1368) -> Option<std::collections::HashMap<String, serde_json::Value>> {
1369    let mut metadata = std::collections::HashMap::new();
1370    metadata.insert("tool_name".to_string(), serde_json::json!(data.tool_name));
1371    if let Some(fingerprint) = &data.tool_call_fingerprint {
1372        metadata.insert(
1373            "tool_call_fingerprint".to_string(),
1374            serde_json::json!(fingerprint),
1375        );
1376    }
1377    if let Some(fingerprint) = &data.tool_result_fingerprint {
1378        metadata.insert(
1379            "tool_result_fingerprint".to_string(),
1380            serde_json::json!(fingerprint),
1381        );
1382    }
1383    (!metadata.is_empty()).then_some(metadata)
1384}
1385
1386fn parse_structured_tool_result_text(text: &str) -> serde_json::Value {
1387    let trimmed = text.trim_start();
1388    if !trimmed.starts_with('{') && !trimmed.starts_with('[') {
1389        return serde_json::Value::String(text.to_string());
1390    }
1391
1392    match serde_json::from_str(text) {
1393        Ok(value @ (serde_json::Value::Object(_) | serde_json::Value::Array(_))) => value,
1394        _ => serde_json::Value::String(text.to_string()),
1395    }
1396}
1397
1398#[cfg(test)]
1399mod tool_completed_replay_tests {
1400    use super::*;
1401
1402    #[test]
1403    fn tool_completed_replay_preserves_json_object_shape() {
1404        let data = ToolCompletedData::success(
1405            "call_read".to_string(),
1406            "read_file".to_string(),
1407            vec![ContentPart::text(
1408                serde_json::json!({
1409                    "path": "/workspace/src/lib.rs",
1410                    "content": "1|fn main() {}"
1411                })
1412                .to_string(),
1413            )],
1414            Some(1),
1415        );
1416
1417        let message = tool_completed_to_message(data);
1418        let result = message
1419            .tool_result_content()
1420            .and_then(|content| content.result.as_ref())
1421            .expect("tool result should be present");
1422
1423        assert_eq!(result["path"], "/workspace/src/lib.rs");
1424        assert_eq!(result["content"], "1|fn main() {}");
1425    }
1426
1427    #[test]
1428    fn tool_completed_replay_keeps_scalar_json_as_text() {
1429        let data = ToolCompletedData::success(
1430            "call_scalar".to_string(),
1431            "custom_tool".to_string(),
1432            vec![ContentPart::text("123")],
1433            Some(1),
1434        );
1435
1436        let message = tool_completed_to_message(data);
1437        let result = message
1438            .tool_result_content()
1439            .and_then(|content| content.result.as_ref())
1440            .expect("tool result should be present");
1441
1442        assert_eq!(result, &serde_json::Value::String("123".to_string()));
1443    }
1444
1445    #[test]
1446    fn tool_completed_replay_preserves_fingerprints_as_metadata() {
1447        let data = ToolCompletedData::success(
1448            "call_read".to_string(),
1449            "read_file".to_string(),
1450            vec![ContentPart::text("{}")],
1451            Some(1),
1452        )
1453        .with_fingerprints("sha256:call".to_string(), "sha256:result".to_string());
1454
1455        let message = tool_completed_to_message(data);
1456        let metadata = message.metadata.expect("metadata should be present");
1457
1458        assert_eq!(metadata["tool_name"], "read_file");
1459        assert_eq!(metadata["tool_call_fingerprint"], "sha256:call");
1460        assert_eq!(metadata["tool_result_fingerprint"], "sha256:result");
1461    }
1462}
1463
1464#[cfg(test)]
1465mod org_id_mapping_tests {
1466    use super::*;
1467    use everruns_core::{DEFAULT_ORG_ID, DEFAULT_ORG_PUBLIC_ID, org_public_id_from_internal};
1468
1469    #[test]
1470    fn default_public_id_maps_to_default_org() {
1471        assert_eq!(
1472            in_process_internal_org_id(DEFAULT_ORG_PUBLIC_ID),
1473            DEFAULT_ORG_ID
1474        );
1475    }
1476
1477    #[test]
1478    fn invalid_public_id_does_not_fall_back_to_default() {
1479        for invalid in [
1480            "",
1481            "not-an-org",
1482            "org_short",
1483            "org_ZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZ",
1484            "ORG_00000000000000000000000000000001",
1485        ] {
1486            let mapped = in_process_internal_org_id(invalid);
1487            assert_ne!(mapped, everruns_core::DEFAULT_ORG_ID);
1488            assert!(
1489                mapped >= 2,
1490                "invalid input {invalid:?} should not map to default"
1491            );
1492        }
1493    }
1494
1495    #[test]
1496    fn zero_public_id_does_not_fall_back_to_default() {
1497        // org_public_id_from_internal never produces this; a hand-crafted
1498        // all-zeros id is treated as invalid (raw == 0).
1499        let mapped = in_process_internal_org_id("org_00000000000000000000000000000000");
1500        assert_ne!(mapped, everruns_core::DEFAULT_ORG_ID);
1501        assert!(mapped >= 2, "all-zero id should not map to default");
1502    }
1503
1504    #[test]
1505    fn synthetic_public_id_round_trips_with_internal_helper() {
1506        for internal in [1_i64, 2, 42, 1_000_000, i64::MAX - 1, i64::MAX] {
1507            let public = org_public_id_from_internal(internal);
1508            assert_eq!(
1509                in_process_internal_org_id(&public),
1510                internal,
1511                "round-trip failed for internal={internal}"
1512            );
1513        }
1514    }
1515
1516    #[test]
1517    fn distinct_synthetic_ids_map_to_distinct_internal_ids() {
1518        let a = org_public_id_from_internal(7);
1519        let b = org_public_id_from_internal(8);
1520        assert_ne!(a, b);
1521        assert_ne!(
1522            in_process_internal_org_id(&a),
1523            in_process_internal_org_id(&b)
1524        );
1525    }
1526
1527    #[test]
1528    fn high_entropy_uuid_style_id_hashes_into_reserved_range() {
1529        // First valid UUID-style id whose raw u128 exceeds i64::MAX
1530        // (top bit of the u128 set). It must hash to a positive i64 that
1531        // is neither 0 nor DEFAULT_ORG_ID.
1532        let high = "org_80000000000000000000000000000000";
1533        let mapped = in_process_internal_org_id(high);
1534        assert!(mapped >= 2, "mapped id {mapped} must be >= 2");
1535        assert_ne!(mapped, DEFAULT_ORG_ID);
1536
1537        // Mapping is deterministic.
1538        assert_eq!(mapped, in_process_internal_org_id(high));
1539    }
1540
1541    #[test]
1542    fn high_entropy_ids_are_isolated_from_each_other() {
1543        let a = in_process_internal_org_id("org_80000000000000000000000000000001");
1544        let b = in_process_internal_org_id("org_80000000000000000000000000000002");
1545        assert_ne!(a, b);
1546        assert_ne!(a, DEFAULT_ORG_ID);
1547        assert_ne!(b, DEFAULT_ORG_ID);
1548    }
1549
1550    #[test]
1551    fn hash_uses_stable_sha256_truncation() {
1552        // SHA-256 with fixed big-endian first-8-byte truncation gives a value
1553        // we can pin. If this assertion ever breaks, callers depending on
1554        // build-stable mapping must be re-audited.
1555        let mapped = in_process_internal_org_id("org_80000000000000000000000000000000");
1556        let expected = {
1557            let digest = sha2::Sha256::digest(b"org_80000000000000000000000000000000");
1558            let mut buf = [0u8; 8];
1559            buf.copy_from_slice(&digest[..8]);
1560            let raw = u64::from_be_bytes(buf);
1561            ((raw % ((i64::MAX - 1) as u64)) as i64) + 2
1562        };
1563        assert_eq!(mapped, expected);
1564    }
1565
1566    #[test]
1567    fn oversize_input_is_bounded_and_does_not_collide_silently() {
1568        // Inputs past HASH_INPUT_CAP_BYTES are truncated before hashing, so
1569        // two oversize strings that agree on the first cap bytes map to the
1570        // same internal id. We only assert the result stays in the safe
1571        // [2, i64::MAX] range and is not DEFAULT_ORG_ID — the cap exists to
1572        // bound work, not to widen the input space.
1573        let oversize = "x".repeat(super::HASH_INPUT_CAP_BYTES * 4);
1574        let mapped = in_process_internal_org_id(&oversize);
1575        assert!(mapped >= 2);
1576        assert_ne!(mapped, DEFAULT_ORG_ID);
1577    }
1578}