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