Skip to main content

everruns_runtime/
host.rs

1// Shared host orchestration for embedded and durable execution hosts.
2// Decision: everruns-runtime owns worker-facing turn phase execution so
3// durable/server-backed hosts reuse the same input/reason/act wiring.
4
5use async_trait::async_trait;
6use everruns_core::atoms::{
7    ActAtom, ActInput, ActResult, Atom, InputAtom, InputAtomInput, InputAtomResult, ReasonAtom,
8    ReasonInput, ReasonResult,
9};
10use everruns_core::capabilities::{
11    SystemPromptContext, collect_capabilities_with_configs, report_result_tool_for_child_session,
12    report_task_progress_tool_for_child_session,
13};
14use everruns_core::events::{
15    EventContext, EventRequest, OutputMessageCompletedData, SessionActivatedData, SessionIdledData,
16    TurnCompletedData, TurnFailedData, TurnStartedData,
17};
18use everruns_core::message::{ContentPart, Message};
19use everruns_core::message_retriever::MessageRetriever;
20use everruns_core::platform_store::PlatformStore;
21use everruns_core::session::SessionStatus;
22use everruns_core::tools::Tool;
23use everruns_core::traits::{
24    AgentStore, BudgetChecker, EventEmitter, HarnessStore, ImageArtifactStore, ImageResolver,
25    LeasedResourceStore, PaymentAuthority, ProviderCredentialStore, ProviderStore, ResolvedModel,
26    SessionCreationAuthority, SessionFileSystem, SessionMutator, SessionResourceRegistry,
27    SessionScheduleStore, SessionSqlDbStoreRef, SessionStorageStore, SessionStore,
28    UserConnectionResolver,
29};
30use everruns_core::typed_id::{AgentId, HarnessId, MessageId, SessionId, TurnId};
31use everruns_core::vector_store::KnowledgeIndexSearch;
32use everruns_core::{
33    Agent, CapabilityRegistry, CapabilityStatus, DependencyBlocker, DriverRegistry, EgressService,
34    ErrorDisclosure, Harness, Session, TokenUsage, ToolDefinition, ToolRegistry, UserFacingError,
35    UtilityLlmService, assemble_turn_context, org_public_id_from_internal,
36    resolve_runtime_capabilities,
37};
38use std::sync::Arc;
39use tracing::warn;
40
41/// Turn context loaded in one batched call for runtime host execution.
42#[derive(Debug, Clone)]
43pub struct RuntimeHostTurnContext {
44    pub agent: Option<Agent>,
45    pub session: Session,
46    pub messages: Vec<Message>,
47    pub model: Option<ResolvedModel>,
48    pub mcp_tool_definitions: Vec<ToolDefinition>,
49}
50
51/// Public adapter contract for server-backed or durable runtime hosts.
52///
53/// `everruns-runtime` owns shared host orchestration for both embedded and
54/// durable execution. That includes phase execution (`input -> reason -> act`),
55/// lifecycle emission, and the generic turn-strategy decisions used by durable
56/// or custom hosts.
57///
58/// Host crates implement this trait to provide persistence, session-lifecycle
59/// plumbing, event delivery, and their own orchestration backend. The durable
60/// engine itself remains outside this crate.
61#[async_trait]
62pub trait RuntimeHostAdapter: Send + Sync + Clone + 'static {
63    async fn get_agent(
64        &self,
65        org_id: i64,
66        agent_id: AgentId,
67    ) -> everruns_core::error::Result<Option<Agent>>;
68
69    async fn get_harness(
70        &self,
71        org_id: i64,
72        harness_id: HarnessId,
73    ) -> everruns_core::error::Result<Option<Harness>>;
74
75    async fn set_session_status(
76        &self,
77        org_id: i64,
78        session_id: SessionId,
79        status: SessionStatus,
80    ) -> everruns_core::error::Result<Session>;
81
82    async fn load_turn_context(
83        &self,
84        org_id: i64,
85        session_id: SessionId,
86    ) -> everruns_core::error::Result<RuntimeHostTurnContext>;
87
88    fn capability_registry(&self) -> CapabilityRegistry;
89
90    fn driver_registry(&self) -> DriverRegistry;
91
92    fn harness_store(&self, org_id: i64) -> Arc<dyn HarnessStore>;
93
94    fn agent_store(&self, org_id: i64) -> Arc<dyn AgentStore>;
95
96    fn session_store(&self, org_id: i64) -> Arc<dyn SessionStore>;
97
98    fn session_mutator(&self, org_id: i64) -> Arc<dyn SessionMutator>;
99
100    fn provider_store(&self, org_id: i64) -> Arc<dyn ProviderStore>;
101
102    fn message_store(&self) -> Arc<dyn MessageRetriever>;
103
104    fn event_emitter(&self) -> Arc<dyn EventEmitter>;
105
106    fn file_store(&self) -> Arc<dyn SessionFileSystem>;
107
108    fn image_resolver(&self, _org_id: i64) -> Option<Arc<dyn ImageResolver>> {
109        None
110    }
111
112    fn image_artifact_store(&self, _org_id: i64) -> Option<Arc<dyn ImageArtifactStore>> {
113        None
114    }
115
116    fn provider_credential_store(&self, _org_id: i64) -> Option<Arc<dyn ProviderCredentialStore>> {
117        None
118    }
119
120    fn utility_llm_service(&self) -> Option<Arc<dyn UtilityLlmService>> {
121        None
122    }
123
124    fn egress_service(&self) -> Option<Arc<dyn EgressService>> {
125        None
126    }
127
128    fn storage_store(&self) -> Option<Arc<dyn SessionStorageStore>> {
129        None
130    }
131
132    /// Knowledge store backing the `search_knowledge` tool. Default: none.
133    fn knowledge_store(&self) -> Option<Arc<dyn everruns_core::traits::KnowledgeStore>> {
134        None
135    }
136
137    fn connection_resolver(&self) -> Option<Arc<dyn UserConnectionResolver>> {
138        None
139    }
140
141    fn sqldb_store(&self) -> Option<SessionSqlDbStoreRef> {
142        None
143    }
144
145    fn leased_resource_store(&self) -> Option<Arc<dyn LeasedResourceStore>> {
146        None
147    }
148
149    fn session_resource_registry(&self) -> Option<Arc<dyn SessionResourceRegistry>> {
150        None
151    }
152
153    fn session_task_registry(
154        &self,
155    ) -> Option<Arc<dyn everruns_core::session_task::SessionTaskRegistry>> {
156        None
157    }
158
159    fn schedule_store(&self, _org_id: i64) -> Option<Arc<dyn SessionScheduleStore>> {
160        None
161    }
162
163    fn platform_store(
164        &self,
165        _org_id: i64,
166        _session_id: SessionId,
167    ) -> Option<Arc<dyn PlatformStore>> {
168        None
169    }
170
171    /// Get the Knowledge Index search service for the `search_index` tool.
172    /// Org-scoped; returns None when retrieval is not available (e.g. gRPC
173    /// workers without a search RPC, or in-memory test backends).
174    fn knowledge_index_search(&self, _org_id: i64) -> Option<Arc<dyn KnowledgeIndexSearch>> {
175        None
176    }
177
178    fn budget_checker(
179        &self,
180        _org_id: i64,
181        _agent_id: Option<AgentId>,
182    ) -> Option<Arc<dyn BudgetChecker>> {
183        None
184    }
185
186    fn payment_authority(
187        &self,
188        _org_id: i64,
189        _agent_id: Option<AgentId>,
190    ) -> Option<Arc<dyn PaymentAuthority>> {
191        None
192    }
193
194    fn session_creation_authority(
195        &self,
196        _org_id: i64,
197        _session_id: SessionId,
198    ) -> Option<Arc<dyn SessionCreationAuthority>> {
199        None
200    }
201
202    /// Per-org outbound tool-call rate limiter (TM-TOOL-009).
203    /// Default: `None` (no rate limiting — suitable for in-process / test environments).
204    fn outbound_tool_rate_limiter(
205        &self,
206        _org_id: i64,
207    ) -> Option<Arc<dyn everruns_core::OutboundToolRateLimiter>> {
208        None
209    }
210
211    /// Per-turn durable tool result store for act-activity idempotency (EVE-530).
212    /// Default: `None` (no durable claim/settle — every execution runs tools fresh).
213    fn durable_tool_result_store(&self) -> Option<Arc<dyn everruns_core::DurableToolResultStore>> {
214        None
215    }
216
217    /// Durable subagent spawn handle store for reattach on reclaim (EVE-535).
218    /// Default: `None` (no spawn dedup — dev/test mode or hosts without durable execution).
219    fn subagent_spawn_store(&self) -> Option<Arc<dyn everruns_core::SubagentSpawnStore>> {
220        None
221    }
222
223    /// Stream-liveness heartbeater for the Reason activity (EVE-531).
224    /// Default: `None` (no heartbeats sent — durable workers supply one).
225    fn stream_heartbeater(&self) -> Option<Arc<dyn everruns_core::StreamHeartbeater>> {
226        None
227    }
228
229    /// Partial-stream store for ContinuePartial recovery (EVE-532).
230    /// Default: `None` (no recovery; in-memory and dev hosts use this default).
231    fn partial_stream_store(&self) -> Option<Arc<dyn everruns_core::PartialStreamStore>> {
232        None
233    }
234
235    /// Live, turn-scoped reasoning-effort handle for the given session (EVE-595).
236    ///
237    /// When a host returns a handle, the Reason activity re-reads it on every
238    /// LLM step and the Act activity hands the same instance to each tool's
239    /// `ToolContext`. A tool can then change effort mid-turn and have subsequent
240    /// LLM steps in the same turn observe it. Hosts MUST return the *same*
241    /// handle instance for a session across reason/act activities of one turn.
242    /// Default: `None` (effort is resolved solely from message controls).
243    fn reasoning_effort_handle(
244        &self,
245        _session_id: SessionId,
246    ) -> Option<everruns_core::ReasoningEffortHandle> {
247        None
248    }
249
250    /// Provider stall timeout for the Reason activity (EVE-531).
251    /// Default: `None` (use built-in 120s default).
252    fn provider_stall_timeout(&self) -> Option<std::time::Duration> {
253        None
254    }
255
256    /// MCP executor routing `mcp_*` tool calls for this session, if the host
257    /// configures MCP (specs/runtime-mcp.md D4). Default: `None`, so hosts
258    /// without scoped MCP servers keep the plain tool registry unchanged.
259    async fn mcp_executor(
260        &self,
261        _org_id: i64,
262        _session_id: SessionId,
263    ) -> Option<Arc<everruns_mcp::McpExecutor>> {
264        None
265    }
266}
267
268struct RuntimeExecutionCapabilities {
269    tool_registry: ToolRegistry,
270    post_tool_hooks: Vec<Arc<dyn everruns_core::PostToolExecHook>>,
271    pre_tool_hooks: Vec<Arc<dyn everruns_core::atoms::PreToolUseHook>>,
272    tool_call_hooks: Vec<Arc<dyn everruns_core::ToolCallHook>>,
273    subagent_nesting_policy: everruns_core::SubagentNestingPolicy,
274}
275
276fn subagent_nesting_policy_from_configs(
277    resolved_capability_configs: &[everruns_core::capability_types::AgentCapabilityConfig],
278) -> everruns_core::SubagentNestingPolicy {
279    let subagents_config = resolved_capability_configs.iter().find(|config| {
280        config.capability_id() == everruns_core::capabilities::SUBAGENTS_CAPABILITY_ID
281    });
282
283    let configured_depth = subagents_config
284        .and_then(|config| {
285            config
286                .config
287                .get("max_subagent_depth")
288                .or_else(|| config.config.get("max_depth"))
289        })
290        .and_then(|value| value.as_u64())
291        .and_then(|value| u32::try_from(value).ok());
292    let configured_max_active = subagents_config
293        .and_then(|config| {
294            config
295                .config
296                .get("max_active_descendant_tasks")
297                .or_else(|| config.config.get("max_concurrent_descendant_tasks"))
298        })
299        .and_then(|value| value.as_u64())
300        .and_then(|value| u32::try_from(value).ok());
301    let configured_max_total = subagents_config
302        .and_then(|config| config.config.get("max_total_descendant_tasks"))
303        .and_then(|value| value.as_u64())
304        .and_then(|value| u32::try_from(value).ok());
305    let configured_max_active_detached = subagents_config
306        .and_then(|config| config.config.get("max_active_detached_tasks"))
307        .and_then(|value| value.as_u64())
308        .and_then(|value| u32::try_from(value).ok());
309    let configured_max_total_detached = subagents_config
310        .and_then(|config| config.config.get("max_total_detached_tasks"))
311        .and_then(|value| value.as_u64())
312        .and_then(|value| u32::try_from(value).ok());
313
314    everruns_core::SubagentNestingPolicy::default()
315        .with_agent_override(configured_depth)
316        .with_agent_task_caps_override(configured_max_active, configured_max_total)
317        .with_agent_detached_task_caps_override(
318            configured_max_active_detached,
319            configured_max_total_detached,
320        )
321}
322
323/// Collect and finalize user-hook specs for a session from its resolved
324/// capability configs, plus the shared bash dispatcher used to run them.
325///
326/// This is the single place hook specs are gathered so every firing point —
327/// the act path (`load_execution_capabilities`) and the lifecycle firing
328/// points (`execute_reason_activity` for `user_prompt_submit`, turn completion
329/// for `turn_end`, and the server session paths) — applies identical
330/// `finalize_hook_specs` semantics: `{capability_id}:` namespace stamping,
331/// stable default ids, and `disabled_contributions` muting (TM-HOOK-004).
332fn finalize_specs_from_configs(
333    resolved_capability_configs: &[everruns_core::capability_types::AgentCapabilityConfig],
334    capability_registry: &CapabilityRegistry,
335) -> Vec<everruns_core::user_hook_types::UserHookSpec> {
336    let mut hook_contributions: Vec<(String, Vec<everruns_core::user_hook_types::UserHookSpec>)> =
337        Vec::new();
338    let mut disabled_contributions: Vec<String> = Vec::new();
339    for config in resolved_capability_configs {
340        let Some(capability) = capability_registry.get(config.capability_id()) else {
341            continue;
342        };
343        let specs = capability.user_hooks_with_config(&config.config);
344        if !specs.is_empty() {
345            hook_contributions.push((config.capability_id().to_string(), specs));
346        }
347        if config.capability_id() == "user_hooks" {
348            disabled_contributions.extend(
349                everruns_core::capabilities::user_hooks::disabled_contributions(&config.config),
350            );
351        }
352    }
353    everruns_core::hook_adapter::finalize_hook_specs(hook_contributions, &disabled_contributions)
354}
355
356/// Resolve a session's capability configs and collect finalized hook specs.
357/// Used by the lifecycle firing points, which need specs outside the act path.
358/// Returns `(specs, dispatcher)`; `specs` is empty when the session has no
359/// hook-contributing capabilities.
360async fn collect_lifecycle_hook_specs<A: RuntimeHostAdapter>(
361    adapter: &A,
362    org_id: i64,
363    session_id: SessionId,
364    harness_id: HarnessId,
365    agent_id: Option<AgentId>,
366) -> everruns_core::error::Result<(
367    Vec<everruns_core::user_hook_types::UserHookSpec>,
368    Arc<dyn everruns_core::hook_executor::BashHookDispatcher>,
369)> {
370    let capability_registry = adapter.capability_registry();
371    let harness_chain = adapter
372        .harness_store(org_id)
373        .get_harness_chain(harness_id)
374        .await?;
375    if harness_chain.is_empty() {
376        return Err(everruns_core::error::AgentLoopError::harness_not_found(
377            harness_id,
378        ));
379    }
380    let session = adapter
381        .session_store(org_id)
382        .get_session(session_id)
383        .await?
384        .ok_or_else(|| everruns_core::error::AgentLoopError::session_not_found(session_id))?;
385    let agent = match agent_id {
386        Some(agent_id) => adapter.agent_store(org_id).get_agent(agent_id).await?,
387        None => None,
388    };
389    let resolved = resolve_runtime_capabilities(
390        &harness_chain,
391        agent.as_ref(),
392        &session,
393        &capability_registry,
394    );
395    let specs =
396        finalize_specs_from_configs(&resolved.resolved_capability_configs, &capability_registry);
397    let dispatcher: Arc<dyn everruns_core::hook_executor::BashHookDispatcher> = Arc::new(
398        everruns_core::hook_dispatch::BashkitShellHookDispatcher::new(adapter.file_store()),
399    );
400    Ok((specs, dispatcher))
401}
402
403async fn load_execution_capabilities<A: RuntimeHostAdapter>(
404    adapter: &A,
405    org_id: i64,
406    session_id: SessionId,
407    harness_id: HarnessId,
408    agent_id: Option<AgentId>,
409    locale: Option<String>,
410    blueprint_id: Option<&str>,
411) -> everruns_core::error::Result<RuntimeExecutionCapabilities> {
412    let capability_registry = adapter.capability_registry();
413    if let Some(blueprint_id) = blueprint_id {
414        let mut registry = ToolRegistry::with_defaults();
415        let blueprint = capability_registry.blueprint(blueprint_id).ok_or_else(|| {
416            everruns_core::error::AgentLoopError::config(format!(
417                "Blueprint \"{blueprint_id}\" not found in registry"
418            ))
419        })?;
420        for tool in blueprint.tools {
421            registry.register_boxed(tool);
422        }
423        return Ok(RuntimeExecutionCapabilities {
424            tool_registry: registry,
425            post_tool_hooks: Vec::new(),
426            pre_tool_hooks: Vec::new(),
427            tool_call_hooks: Vec::new(),
428            subagent_nesting_policy: everruns_core::SubagentNestingPolicy::default(),
429        });
430    }
431
432    let harness_chain = adapter
433        .harness_store(org_id)
434        .get_harness_chain(harness_id)
435        .await?;
436    if harness_chain.is_empty() {
437        return Err(everruns_core::error::AgentLoopError::harness_not_found(
438            harness_id,
439        ));
440    }
441
442    let session = adapter
443        .session_store(org_id)
444        .get_session(session_id)
445        .await?
446        .ok_or_else(|| everruns_core::error::AgentLoopError::session_not_found(session_id))?;
447
448    let agent_store = adapter.agent_store(org_id);
449    let agent = match agent_id {
450        Some(agent_id) => Some(
451            agent_store
452                .get_agent(agent_id)
453                .await?
454                .ok_or_else(|| everruns_core::error::AgentLoopError::agent_not_found(agent_id))?,
455        ),
456        None => None,
457    };
458
459    let resolved = resolve_runtime_capabilities(
460        &harness_chain,
461        agent.as_ref(),
462        &session,
463        &capability_registry,
464    );
465    // Executor (act) path: this builds the worker-side tool registry, not the
466    // model-visible tool list. The model is left unset, so a model-adaptive
467    // capability like `auto_tool_search` resolves to its provider-agnostic
468    // client-side mechanism here. That registers the `tool_search` tool in the
469    // executor, which is a harmless superset: on native models the reason path
470    // never shows that tool to the model, so it is simply never called.
471    let prompt_ctx = SystemPromptContext {
472        session_id,
473        locale: locale.or(session.locale.clone()),
474        // Pin system-prompt file reads to the session's workspace (the default
475        // 1:1 case is a transparent pass-through), then resolve through the
476        // mount resolver (EVE-660): `/workspace` is a mount + cwd.
477        // `scoped_prompt_file_store` wraps with `wrap_if_needed` so a local
478        // embedder's backend-native display policy survives here too (it must
479        // match the reason path — see its doc); server stores stay on `/workspace`.
480        file_store: Some(everruns_core::scoped_prompt_file_store(
481            adapter.file_store(),
482            session.workspace_id,
483        )),
484        model: None,
485    };
486    let collected = collect_capabilities_with_configs(
487        &resolved.resolved_capability_configs,
488        &capability_registry,
489        &prompt_ctx,
490    )
491    .await;
492
493    let mut registry = ToolRegistry::with_defaults();
494    for tool in collected.tools {
495        registry.register_boxed(tool);
496    }
497
498    // Only `Available` capabilities contribute hooks, matching
499    // `collect_capabilities_with_configs` (which skips non-available
500    // capabilities). This keeps a `ComingSoon`/unavailable capability from
501    // affecting execution via any of its hook seams.
502    let mut post_tool_hooks: Vec<Arc<dyn everruns_core::PostToolExecHook>> = resolved
503        .resolved_capability_configs
504        .iter()
505        .flat_map(|config| {
506            capability_registry
507                .get(config.capability_id())
508                .filter(|capability| capability.status() == CapabilityStatus::Available)
509                .map(|capability| capability.post_tool_exec_hooks_with_config(&config.config))
510                .unwrap_or_default()
511        })
512        .collect();
513    // Tool-output guardrails must inspect the original result before other
514    // capability hooks can persist or compact it into secondary surfaces.
515    post_tool_hooks.sort_by_key(|hook| hook.priority());
516
517    // User-hook contributions (see `specs/user-hooks.md`). `finalize_specs_from_configs`
518    // gathers specs across every resolved capability — both the user-facing
519    // `user_hooks` capability and any capability that bundles hooks — and applies
520    // `finalize_hook_specs` (namespace stamping, stable ids, `disabled_contributions`
521    // muting; TM-HOOK-004). The same helper backs the lifecycle firing points so
522    // every event finalizes specs identically.
523    let user_hook_specs =
524        finalize_specs_from_configs(&resolved.resolved_capability_configs, &capability_registry);
525    // Capability-contributed pre-tool hooks run first (e.g. approval gating),
526    // then user-hook (`PreToolUse`) specs. The first hook to block wins.
527    let mut pre_tool_hooks: Vec<Arc<dyn everruns_core::atoms::PreToolUseHook>> = resolved
528        .resolved_capability_configs
529        .iter()
530        .flat_map(|config| {
531            capability_registry
532                .get(config.capability_id())
533                .filter(|capability| capability.status() == CapabilityStatus::Available)
534                .map(|capability| capability.pre_tool_use_hooks_with_config(&config.config))
535                .unwrap_or_default()
536        })
537        .collect();
538    if !user_hook_specs.is_empty() {
539        let dispatcher: Arc<dyn everruns_core::hook_executor::BashHookDispatcher> = Arc::new(
540            everruns_core::hook_dispatch::BashkitShellHookDispatcher::new(adapter.file_store()),
541        );
542        post_tool_hooks.extend(everruns_core::hook_adapter::build_post_tool_use_hooks(
543            &user_hook_specs,
544            dispatcher.clone(),
545        ));
546        pre_tool_hooks.extend(everruns_core::hook_adapter::build_pre_tool_use_hooks(
547            &user_hook_specs,
548            dispatcher,
549        ));
550    }
551
552    // Use the hook list assembled by `collect_capabilities_with_configs` as the
553    // single source of truth. It already contains every explicit capability
554    // `tool_call_hooks()` followed by the generated `CapabilityNarrationHook`
555    // adapters — one per collected capability plus any auto-activated
556    // cross-cutting capability such as `background_execution`. Re-deriving only
557    // the explicit subset here dropped capability-owned narration, so tools fell
558    // back to generic `Ran {display_name}` lines (EVE-601). Explicit hooks stay
559    // first in this list, so model-authored narration (`human_intent`) keeps its
560    // precedence over default `Tool::narrate()`, and only available capabilities
561    // contributed because collection skips non-available ones.
562    let tool_call_hooks = collected.tool_call_hooks;
563
564    Ok(RuntimeExecutionCapabilities {
565        tool_registry: registry,
566        post_tool_hooks,
567        pre_tool_hooks,
568        tool_call_hooks,
569        subagent_nesting_policy: subagent_nesting_policy_from_configs(
570            &resolved.resolved_capability_configs,
571        ),
572    })
573}
574
575/// Shared lifecycle helper for runtime-backed hosts.
576pub struct RuntimeSessionLifecycle<A: RuntimeHostAdapter> {
577    adapter: A,
578    org_id: i64,
579    session_id: SessionId,
580}
581
582impl<A: RuntimeHostAdapter> RuntimeSessionLifecycle<A> {
583    pub fn new(adapter: A, org_id: i64, session_id: SessionId) -> Self {
584        Self {
585            adapter,
586            org_id,
587            session_id,
588        }
589    }
590
591    async fn set_session_status(&self, status: SessionStatus, action: &'static str) {
592        if let Err(error) = self
593            .adapter
594            .set_session_status(self.org_id, self.session_id, status)
595            .await
596        {
597            warn!(
598                session_id = %self.session_id,
599                org_id = self.org_id,
600                action,
601                %error,
602                "runtime host lifecycle status update failed"
603            );
604        }
605    }
606
607    async fn emit_event(&self, request: EventRequest) {
608        let event_type = request.event_type.clone();
609        if let Err(error) = self.adapter.event_emitter().emit(request).await {
610            warn!(
611                session_id = %self.session_id,
612                org_id = self.org_id,
613                event_type,
614                %error,
615                "runtime host lifecycle event emission failed"
616            );
617        }
618    }
619
620    pub async fn turn_started(&self, turn_id: TurnId, input_message_id: MessageId) {
621        let input_content = self
622            .adapter
623            .message_store()
624            .get(self.session_id, input_message_id)
625            .await
626            .ok()
627            .flatten()
628            .map(|message| message.content_to_llm_string());
629
630        self.set_session_status(SessionStatus::Active, "turn_started")
631            .await;
632
633        self.emit_event(EventRequest::new(
634            self.session_id,
635            EventContext::turn(turn_id, input_message_id),
636            SessionActivatedData {
637                turn_id,
638                input_message_id,
639            },
640        ))
641        .await;
642
643        self.emit_event(EventRequest::new(
644            self.session_id,
645            EventContext::turn(turn_id, input_message_id),
646            TurnStartedData {
647                turn_id,
648                input_message_id,
649                input_content,
650            },
651        ))
652        .await;
653    }
654
655    pub async fn emit_turn_completed(&self, input_message_id: MessageId, data: TurnCompletedData) {
656        let turn_id = data.turn_id;
657        self.emit_event(EventRequest::new(
658            self.session_id,
659            EventContext::turn(turn_id, input_message_id),
660            data,
661        ))
662        .await;
663    }
664
665    pub async fn emit_session_idled(
666        &self,
667        turn_id: TurnId,
668        input_message_id: MessageId,
669        iterations: Option<u32>,
670        usage: Option<TokenUsage>,
671    ) {
672        self.set_session_status(SessionStatus::Idle, "emit_session_idled")
673            .await;
674
675        self.emit_event(EventRequest::new(
676            self.session_id,
677            EventContext::turn(turn_id, input_message_id),
678            SessionIdledData {
679                turn_id,
680                iterations,
681                usage,
682            },
683        ))
684        .await;
685    }
686
687    pub async fn turn_completed(
688        &self,
689        turn_id: TurnId,
690        input_message_id: MessageId,
691        iterations: u32,
692        usage: Option<TokenUsage>,
693        input_content: Option<String>,
694    ) {
695        self.emit_turn_completed(
696            input_message_id,
697            TurnCompletedData {
698                turn_id,
699                iterations,
700                duration_ms: None,
701                usage: usage.clone(),
702                input_content,
703                final_message_id: None,
704                final_answer_preview: None,
705                time_to_first_token_ms: None,
706                tool_call_count: None,
707                llm_call_count: None,
708                status: Some("completed".to_string()),
709            },
710        )
711        .await;
712        self.emit_session_idled(turn_id, input_message_id, Some(iterations), usage)
713            .await;
714    }
715
716    /// Turn was deliberately sealed (EVE-534): emit `turn.sealed` + a
717    /// user-facing message + `session.idled`, and idle the session.
718    ///
719    /// Distinct from `turn_completed` (success) and `turn_failed` (error). The
720    /// session returns to `idle` so the UI unblocks; the Sealed state is
721    /// observable via the `turn.sealed` event and its `reason`.
722    pub async fn turn_sealed(
723        &self,
724        turn_id: TurnId,
725        input_message_id: MessageId,
726        reason: &str,
727        iterations: u32,
728        usage: Option<TokenUsage>,
729    ) {
730        let context = EventContext::turn(turn_id, input_message_id);
731
732        self.emit_event(EventRequest::new(
733            self.session_id,
734            context.clone(),
735            everruns_core::events::TurnSealedData {
736                turn_id,
737                reason: reason.to_string(),
738                detail: None,
739                iterations: Some(iterations),
740                usage: usage.clone(),
741            },
742        ))
743        .await;
744
745        self.emit_session_idled(turn_id, input_message_id, Some(iterations), usage)
746            .await;
747    }
748
749    /// Fire `turn_end` lifecycle hooks (advisory). Collects the session's hook
750    /// specs and runs every `turn_end` hook; failures are logged, never fatal.
751    /// `harness_id`/`agent_id` are required to resolve the capability chain.
752    pub async fn fire_turn_end_hooks(
753        &self,
754        harness_id: HarnessId,
755        agent_id: Option<AgentId>,
756        turn_id: TurnId,
757        success: bool,
758    ) {
759        let (specs, dispatcher) = match collect_lifecycle_hook_specs(
760            &self.adapter,
761            self.org_id,
762            self.session_id,
763            harness_id,
764            agent_id,
765        )
766        .await
767        {
768            Ok(pair) => pair,
769            Err(error) => {
770                warn!(
771                    session_id = %self.session_id,
772                    %error,
773                    "failed to collect turn_end hook specs; skipping"
774                );
775                return;
776            }
777        };
778        let hooks = everruns_core::lifecycle_hooks::build_turn_lifecycle_hooks(
779            &specs,
780            everruns_core::user_hook_types::HookEvent::TurnEnd,
781            dispatcher,
782        );
783        if hooks.is_empty() {
784            return;
785        }
786        let ctx = everruns_core::lifecycle_hooks::TurnHookContext {
787            session_id: self.session_id,
788            turn_id: Some(turn_id),
789            org_id: org_public_id_from_internal(self.org_id).parse().ok(),
790            agent_id: agent_id.map(|a| a.to_string()),
791        };
792        everruns_core::lifecycle_hooks::run_turn_end_hooks(
793            &hooks,
794            &ctx,
795            serde_json::json!({ "success": success }),
796        )
797        .await;
798    }
799
800    /// Abort a turn because a `user_prompt_submit` hook returned `Block`.
801    /// Reuses the dependency-blocked failure shape: emit a user-facing message
802    /// carrying the hook's `user_message` (or `reason`), then mark the turn
803    /// failed and idle the session.
804    pub async fn user_prompt_blocked(
805        &self,
806        turn_id: TurnId,
807        input_message_id: MessageId,
808        reason: &str,
809        user_message: Option<&str>,
810    ) {
811        let user_error =
812            UserFacingError::new(everruns_core::user_facing_error_codes::BLOCKED_BY_HOOK);
813        let shown = user_message.unwrap_or(reason);
814        let mut error_message = Message::assistant(shown);
815        let mut metadata = std::collections::HashMap::new();
816        user_error.apply_to_message_metadata(&mut metadata);
817        error_message.metadata = Some(metadata);
818
819        self.emit_event(EventRequest::new(
820            self.session_id,
821            EventContext::turn(turn_id, input_message_id),
822            OutputMessageCompletedData::new(error_message).with_user_facing_error(&user_error),
823        ))
824        .await;
825
826        self.turn_failed(turn_id, input_message_id, reason, Some(&user_error))
827            .await;
828    }
829
830    pub async fn turn_failed(
831        &self,
832        turn_id: TurnId,
833        input_message_id: MessageId,
834        error: &str,
835        user_error: Option<&UserFacingError>,
836    ) {
837        self.turn_failed_with_disclosure(turn_id, input_message_id, error, user_error, None)
838            .await;
839    }
840
841    /// `turn_failed` with the applied error-disclosure mode recorded on the
842    /// event. `user_error` (and the `error` text shown alongside it) must
843    /// already be disclosure-filtered by the caller.
844    pub async fn turn_failed_with_disclosure(
845        &self,
846        turn_id: TurnId,
847        input_message_id: MessageId,
848        error: &str,
849        user_error: Option<&UserFacingError>,
850        disclosure: Option<ErrorDisclosure>,
851    ) {
852        self.set_session_status(SessionStatus::Idle, "turn_failed")
853            .await;
854
855        self.emit_event(EventRequest::new(
856            self.session_id,
857            EventContext::turn(turn_id, input_message_id),
858            {
859                let mut data = TurnFailedData {
860                    turn_id,
861                    error: error.to_string(),
862                    error_code: None,
863                    error_fields: None,
864                    error_disclosure: disclosure.map(|mode| mode.as_str().to_string()),
865                };
866                if let Some(user_error) = user_error {
867                    user_error.apply_to_event_fields(&mut data.error_code, &mut data.error_fields);
868                }
869                data
870            },
871        ))
872        .await;
873
874        self.emit_event(EventRequest::new(
875            self.session_id,
876            EventContext::turn(turn_id, input_message_id),
877            SessionIdledData {
878                turn_id,
879                iterations: None,
880                usage: None,
881            },
882        ))
883        .await;
884    }
885
886    pub async fn waiting_for_tool_results(&self) {
887        self.set_session_status(
888            SessionStatus::WaitingForToolResults,
889            "waiting_for_tool_results",
890        )
891        .await;
892    }
893
894    pub async fn dependency_blocked(
895        &self,
896        turn_id: TurnId,
897        input_message_id: MessageId,
898        blocker: DependencyBlocker,
899    ) {
900        let user_error = UserFacingError::new(blocker.error_code())
901            .with_field(
902                "dependency",
903                match blocker {
904                    DependencyBlocker::HarnessArchived | DependencyBlocker::HarnessDeleted => {
905                        "harness"
906                    }
907                    DependencyBlocker::AgentArchived | DependencyBlocker::AgentDeleted => "agent",
908                },
909            )
910            .with_field(
911                "state",
912                match blocker {
913                    DependencyBlocker::HarnessArchived | DependencyBlocker::AgentArchived => {
914                        "archived"
915                    }
916                    DependencyBlocker::HarnessDeleted | DependencyBlocker::AgentDeleted => {
917                        "deleted"
918                    }
919                },
920            );
921        let mut error_message = Message::assistant(blocker.message());
922        let mut metadata = std::collections::HashMap::new();
923        user_error.apply_to_message_metadata(&mut metadata);
924        error_message.metadata = Some(metadata);
925
926        self.emit_event(EventRequest::new(
927            self.session_id,
928            EventContext::turn(turn_id, input_message_id),
929            OutputMessageCompletedData::new(error_message).with_user_facing_error(&user_error),
930        ))
931        .await;
932
933        self.turn_failed(
934            turn_id,
935            input_message_id,
936            blocker.message(),
937            Some(&user_error),
938        )
939        .await;
940    }
941}
942
943pub async fn detect_dependency_blocker<A: RuntimeHostAdapter>(
944    adapter: &A,
945    org_id: i64,
946    harness_id: HarnessId,
947    agent_id: Option<AgentId>,
948) -> everruns_core::error::Result<Option<DependencyBlocker>> {
949    let harness_store = adapter.harness_store(org_id);
950    let agent_store = adapter.agent_store(org_id);
951    everruns_core::detect_dependency_blocker(
952        harness_store.as_ref(),
953        agent_store.as_ref(),
954        harness_id,
955        agent_id,
956    )
957    .await
958}
959
960pub async fn execute_input_activity<A: RuntimeHostAdapter>(
961    adapter: &A,
962    org_id: i64,
963    input: InputAtomInput,
964) -> everruns_core::error::Result<InputAtomResult> {
965    // The live effort override is turn-scoped. Clear any value left by the
966    // previous turn before ReasonAtom can prefer it over this turn's message
967    // controls.
968    if let Some(handle) = adapter.reasoning_effort_handle(input.context.session_id) {
969        handle.set(None);
970    }
971
972    RuntimeSessionLifecycle::new(adapter.clone(), org_id, input.context.session_id)
973        .turn_started(input.context.turn_id, input.context.input_message_id)
974        .await;
975
976    let atom = InputAtom::new(adapter.message_store());
977    atom.execute(input).await
978}
979
980/// Collect `user_prompt_submit` hooks for this turn and run them against the
981/// inbound user message text. Returns `None` when the session has no such
982/// hooks (the common case — no overhead beyond the spec collection, which is
983/// skipped early). Errors loading specs are logged and treated as "no hooks"
984/// so a hook-collection failure never blocks a turn that wasn't asking to be
985/// hooked.
986struct UserPromptHookResult {
987    decision: everruns_core::lifecycle_hooks::UserPromptDecision,
988    original_message: String,
989}
990
991async fn run_user_prompt_submit_for_turn<A: RuntimeHostAdapter>(
992    adapter: &A,
993    org_id: i64,
994    input: &ReasonInput,
995) -> everruns_core::error::Result<Option<UserPromptHookResult>> {
996    let (specs, dispatcher) = match collect_lifecycle_hook_specs(
997        adapter,
998        org_id,
999        input.context.session_id,
1000        input.harness_id,
1001        input.agent_id,
1002    )
1003    .await
1004    {
1005        Ok(pair) => pair,
1006        Err(error) => {
1007            warn!(
1008                session_id = %input.context.session_id,
1009                %error,
1010                "failed to collect user_prompt_submit hook specs; continuing without them"
1011            );
1012            return Ok(None);
1013        }
1014    };
1015    let hooks = everruns_core::lifecycle_hooks::build_turn_lifecycle_hooks(
1016        &specs,
1017        everruns_core::user_hook_types::HookEvent::UserPromptSubmit,
1018        dispatcher,
1019    );
1020    if hooks.is_empty() {
1021        return Ok(None);
1022    }
1023
1024    let message_text = adapter
1025        .message_store()
1026        .get(input.context.session_id, input.context.input_message_id)
1027        .await
1028        .ok()
1029        .flatten()
1030        .map(|m| m.content_to_llm_string())
1031        .unwrap_or_default();
1032
1033    let ctx = everruns_core::lifecycle_hooks::TurnHookContext {
1034        session_id: input.context.session_id,
1035        turn_id: Some(input.context.turn_id),
1036        org_id: org_public_id_from_internal(org_id).parse().ok(),
1037        agent_id: input.agent_id.map(|a| a.to_string()),
1038    };
1039    let original_message = message_text.clone();
1040    let decision =
1041        everruns_core::lifecycle_hooks::run_user_prompt_submit_hooks(&hooks, &ctx, message_text)
1042            .await;
1043    Ok(Some(UserPromptHookResult {
1044        decision,
1045        original_message,
1046    }))
1047}
1048
1049pub async fn execute_reason_activity<A: RuntimeHostAdapter>(
1050    adapter: &A,
1051    org_id: i64,
1052    input: ReasonInput,
1053) -> everruns_core::error::Result<ReasonResult> {
1054    if let Some(blocker) =
1055        detect_dependency_blocker(adapter, org_id, input.harness_id, input.agent_id).await?
1056    {
1057        RuntimeSessionLifecycle::new(adapter.clone(), org_id, input.context.session_id)
1058            .dependency_blocked(
1059                input.context.turn_id,
1060                input.context.input_message_id,
1061                blocker,
1062            )
1063            .await;
1064        return Ok(ReasonResult {
1065            success: false,
1066            text: blocker.message().to_string(),
1067            tool_calls: vec![],
1068            has_tool_calls: false,
1069            tool_definitions: vec![],
1070            max_iterations: everruns_core::runtime_agent::default_max_iterations(),
1071            error: Some("dependency_unavailable".to_string()),
1072            user_facing_error: None,
1073            error_disclosure: None,
1074            usage: None,
1075            output_message_id: None,
1076            time_to_first_token_ms: None,
1077            response_id: None,
1078            locale: None,
1079            network_access: None,
1080            parallel_tool_calls: None,
1081        });
1082    }
1083
1084    // user_prompt_submit hook (see `specs/user-hooks.md`). Fires once per turn,
1085    // on the first reason iteration, before the LLM is consulted — the closest
1086    // choke point to "inbound user message accepted, before reason" that both
1087    // the in-process loop and the durable worker share. A `Block` aborts the
1088    // turn by reusing the same failure path as `dependency_blocked`: emit a
1089    // user-facing message + turn.failed, idle the session, and return a
1090    // non-success `ReasonResult` so no LLM/act work runs.
1091    let mut user_prompt_message_override = None;
1092    if input.iteration <= 1
1093        && let Some(hook_result) = run_user_prompt_submit_for_turn(adapter, org_id, &input).await?
1094    {
1095        match hook_result.decision {
1096            everruns_core::lifecycle_hooks::UserPromptDecision::Block {
1097                reason,
1098                user_message,
1099            } => {
1100                RuntimeSessionLifecycle::new(adapter.clone(), org_id, input.context.session_id)
1101                    .user_prompt_blocked(
1102                        input.context.turn_id,
1103                        input.context.input_message_id,
1104                        &reason,
1105                        user_message.as_deref(),
1106                    )
1107                    .await;
1108                return Ok(ReasonResult {
1109                    success: false,
1110                    text: user_message.unwrap_or_else(|| reason.clone()),
1111                    tool_calls: vec![],
1112                    has_tool_calls: false,
1113                    tool_definitions: vec![],
1114                    max_iterations: everruns_core::runtime_agent::default_max_iterations(),
1115                    error: Some("blocked_by_user_prompt_hook".to_string()),
1116                    user_facing_error: None,
1117                    error_disclosure: None,
1118                    usage: None,
1119                    output_message_id: None,
1120                    time_to_first_token_ms: None,
1121                    response_id: None,
1122                    locale: None,
1123                    network_access: None,
1124                    parallel_tool_calls: None,
1125                });
1126            }
1127            everruns_core::lifecycle_hooks::UserPromptDecision::Continue { message } => {
1128                if message != hook_result.original_message {
1129                    user_prompt_message_override = Some(message);
1130                }
1131            }
1132        }
1133    }
1134
1135    let mut turn_context = adapter
1136        .load_turn_context(org_id, input.context.session_id)
1137        .await?;
1138    if let Some(registry) = adapter.session_task_registry() {
1139        let session_store = adapter.session_store(org_id);
1140        if let Some(tool) = report_result_tool_for_child_session(
1141            input.context.session_id,
1142            session_store.as_ref(),
1143            registry.as_ref(),
1144        )
1145        .await?
1146        {
1147            turn_context.mcp_tool_definitions.push(tool.to_definition());
1148        }
1149        if let Some(tool) = report_task_progress_tool_for_child_session(
1150            input.context.session_id,
1151            session_store.as_ref(),
1152            registry.as_ref(),
1153        )
1154        .await?
1155        {
1156            turn_context.mcp_tool_definitions.push(tool.to_definition());
1157        }
1158    }
1159
1160    let mut atom = ReasonAtom::new(
1161        adapter.harness_store(org_id),
1162        adapter.agent_store(org_id),
1163        adapter.session_store(org_id),
1164        adapter.message_store(),
1165        adapter.provider_store(org_id),
1166        adapter.capability_registry(),
1167        adapter.driver_registry(),
1168        adapter.event_emitter(),
1169    )
1170    .with_file_store(adapter.file_store());
1171    if let Some(image_resolver) = adapter.image_resolver(org_id) {
1172        atom = atom.with_image_resolver(image_resolver);
1173    }
1174    if let Some(hb) = adapter.stream_heartbeater() {
1175        atom = atom.with_stream_heartbeater(hb);
1176    }
1177    if let Some(timeout) = adapter.provider_stall_timeout() {
1178        atom = atom.with_provider_stall_timeout(timeout);
1179    }
1180    if let Some(store) = adapter.partial_stream_store() {
1181        atom = atom.with_partial_stream_store(store);
1182    }
1183    if let Some(store) = adapter.durable_tool_result_store() {
1184        atom = atom.with_durable_tool_result_store(store);
1185    }
1186    if let Some(handle) = adapter.reasoning_effort_handle(input.context.session_id) {
1187        atom = atom.with_reasoning_effort_handle(handle);
1188    }
1189    if let Some(utility_llm_service) = adapter.utility_llm_service() {
1190        atom = atom.with_utility_llm_service(utility_llm_service);
1191    }
1192    // Schedule store powers the `usage_limit_auto_continue` capability, which
1193    // schedules a continuation after a provider usage limit resets.
1194    if let Some(schedule_store) = adapter.schedule_store(org_id) {
1195        atom = atom.with_schedule_store(schedule_store);
1196    }
1197
1198    let input = ReasonInput {
1199        mcp_tool_definitions: turn_context.mcp_tool_definitions,
1200        ..input
1201    };
1202
1203    if let Some(message_override) = user_prompt_message_override {
1204        let mut assembled = assemble_turn_context(
1205            adapter.harness_store(org_id).as_ref(),
1206            adapter.agent_store(org_id).as_ref(),
1207            adapter.session_store(org_id).as_ref(),
1208            adapter.message_store().as_ref(),
1209            adapter.provider_store(org_id).as_ref(),
1210            &adapter.capability_registry(),
1211            input.context.session_id,
1212            input.harness_id,
1213            input.agent_id,
1214            &input.mcp_tool_definitions,
1215            Some(adapter.file_store()),
1216        )
1217        .await?;
1218
1219        let message = assembled
1220            .messages
1221            .iter_mut()
1222            .find(|message| message.id == input.context.input_message_id)
1223            .ok_or_else(|| {
1224                everruns_core::error::AgentLoopError::config(
1225                    "user_prompt_submit mutation: input message not found in assembled context",
1226                )
1227            })?;
1228
1229        // user_prompt_submit mutations are enforcement controls for the
1230        // provider-bound prompt. Apply them to the assembled context only
1231        // so persisted user history remains an audit record of the input.
1232        // Preserve non-text parts (images, files); replace only text parts.
1233        message
1234            .content
1235            .retain(|part| !matches!(part, ContentPart::Text(_)));
1236        message
1237            .content
1238            .insert(0, ContentPart::text(message_override));
1239
1240        return atom.execute_with_assembled_context(input, assembled).await;
1241    }
1242
1243    atom.execute(input).await
1244}
1245
1246pub async fn execute_act_activity<A: RuntimeHostAdapter>(
1247    adapter: &A,
1248    input: ActInput,
1249) -> everruns_core::error::Result<ActResult> {
1250    let org_id = input.org_id.ok_or_else(|| {
1251        everruns_core::error::AgentLoopError::config(
1252            "ActInput.org_id must be set for runtime host execution",
1253        )
1254    })?;
1255
1256    if let Some(blocker) =
1257        detect_dependency_blocker(adapter, org_id, input.harness_id, input.agent_id).await?
1258    {
1259        RuntimeSessionLifecycle::new(adapter.clone(), org_id, input.context.session_id)
1260            .dependency_blocked(
1261                input.context.turn_id,
1262                input.context.input_message_id,
1263                blocker,
1264            )
1265            .await;
1266        return Ok(ActResult {
1267            results: vec![],
1268            completed: true,
1269            success_count: 0,
1270            error_count: 1,
1271            waiting_for_tool_results: false,
1272            blocked: true,
1273            client_tool_calls: vec![],
1274            client_tool_definitions: vec![],
1275        });
1276    }
1277
1278    let execution_capabilities = load_execution_capabilities(
1279        adapter,
1280        org_id,
1281        input.context.session_id,
1282        input.harness_id,
1283        input.agent_id,
1284        input.locale.clone(),
1285        input.blueprint_id.as_deref(),
1286    )
1287    .await?;
1288    let mut tool_registry = execution_capabilities.tool_registry;
1289
1290    if input
1291        .tool_definitions
1292        .iter()
1293        .any(|definition| definition.name() == "report_result")
1294        && let Some(registry) = adapter.session_task_registry()
1295        && let Some(tool) = report_result_tool_for_child_session(
1296            input.context.session_id,
1297            adapter.session_store(org_id).as_ref(),
1298            registry.as_ref(),
1299        )
1300        .await?
1301    {
1302        tool_registry.register_boxed(Box::new(tool.with_file_store(adapter.file_store())));
1303    }
1304    if input
1305        .tool_definitions
1306        .iter()
1307        .any(|definition| definition.name() == "report_task_progress")
1308        && let Some(registry) = adapter.session_task_registry()
1309        && let Some(tool) = report_task_progress_tool_for_child_session(
1310            input.context.session_id,
1311            adapter.session_store(org_id).as_ref(),
1312            registry.as_ref(),
1313        )
1314        .await?
1315    {
1316        tool_registry.register_boxed(Box::new(tool));
1317    }
1318
1319    // Register the session's MCP tools as first-class registry tools, so they
1320    // execute through the regular `ToolExecutor` path and are visible to
1321    // everything that introspects the registry (spawn_background, tool_search,
1322    // openai_tool_search namespaces, ...). The turn's tool definitions already
1323    // include the discovered MCP tools, so no re-discovery is needed; the host's
1324    // MCP executor supplies execution (specs/runtime-mcp.md D5).
1325    // The MCP invoker is reused below for the guardrails `mcp` check, which
1326    // delegates a guardrail decision to an external endpoint over the same
1327    // scoped-MCP client/auth (specs/guardrails.md).
1328    let mut mcp_invoker: Option<Arc<dyn everruns_core::McpToolInvoker>> = None;
1329    if let Some(mcp) = adapter.mcp_executor(org_id, input.context.session_id).await {
1330        let invoker: Arc<dyn everruns_core::McpToolInvoker> = mcp;
1331        for tool in everruns_core::build_mcp_proxy_tools(&input.tool_definitions, invoker.clone()) {
1332            tool_registry.register_boxed(tool);
1333        }
1334        mcp_invoker = Some(Arc::new(everruns_core::ScopedMcpToolInvoker::new(
1335            &input.tool_definitions,
1336            invoker,
1337        )));
1338    }
1339
1340    let builtin_tool_registry = Arc::new(tool_registry.clone());
1341    let executor: Arc<dyn everruns_core::traits::ToolExecutor> = Arc::new(tool_registry);
1342
1343    let mut atom =
1344        ActAtom::with_file_store(executor, adapter.event_emitter(), adapter.file_store())
1345            .with_session_store(adapter.session_store(org_id))
1346            .with_session_mutator(adapter.session_mutator(org_id))
1347            .with_agent_store(adapter.agent_store(org_id))
1348            .with_tool_registry(builtin_tool_registry)
1349            .with_org_id(
1350                org_public_id_from_internal(org_id)
1351                    .parse()
1352                    .expect("internal org id converts to valid public org id"),
1353            )
1354            .with_capability_registry(adapter.capability_registry())
1355            .with_post_tool_hooks(execution_capabilities.post_tool_hooks)
1356            .with_pre_tool_hooks(execution_capabilities.pre_tool_hooks)
1357            .with_tool_call_hooks(execution_capabilities.tool_call_hooks)
1358            .with_subagent_nesting_policy(execution_capabilities.subagent_nesting_policy);
1359
1360    if let Some(storage_store) = adapter.storage_store() {
1361        atom = atom.with_storage_store(storage_store);
1362    }
1363    if let Some(knowledge_store) = adapter.knowledge_store() {
1364        atom = atom.with_knowledge_store(knowledge_store);
1365    }
1366    if let Some(image_store) = adapter.image_artifact_store(org_id) {
1367        atom = atom.with_image_store(image_store);
1368    }
1369    if let Some(provider_credential_store) = adapter.provider_credential_store(org_id) {
1370        atom = atom.with_provider_credential_store(provider_credential_store);
1371    }
1372    if let Some(utility_llm_service) = adapter.utility_llm_service() {
1373        atom = atom.with_utility_llm_service(utility_llm_service);
1374    }
1375    if let Some(invoker) = mcp_invoker {
1376        atom = atom.with_mcp_invoker(invoker);
1377    }
1378    if let Some(egress_service) = adapter.egress_service() {
1379        atom = atom.with_egress_service(egress_service);
1380    }
1381    if let Some(connection_resolver) = adapter.connection_resolver() {
1382        atom = atom.with_connection_resolver(connection_resolver);
1383    }
1384    if let Some(sqldb_store) = adapter.sqldb_store() {
1385        atom = atom.with_sqldb_store(sqldb_store);
1386    }
1387    if let Some(leased_resource_store) = adapter.leased_resource_store() {
1388        atom = atom.with_leased_resource_store(leased_resource_store);
1389    }
1390    if let Some(registry) = adapter.session_resource_registry() {
1391        atom = atom.with_session_resource_registry(registry);
1392    }
1393    if let Some(registry) = adapter.session_task_registry() {
1394        atom = atom.with_session_task_registry(registry);
1395    }
1396    if let Some(schedule_store) = adapter.schedule_store(org_id) {
1397        atom = atom.with_schedule_store(schedule_store);
1398    }
1399    if let Some(platform_store) = adapter.platform_store(org_id, input.context.session_id) {
1400        atom = atom.with_platform_store(platform_store);
1401    }
1402    if let Some(knowledge_index_search) = adapter.knowledge_index_search(org_id) {
1403        atom = atom.with_knowledge_index_search(knowledge_index_search);
1404    }
1405    if let Some(budget_checker) = adapter.budget_checker(org_id, input.agent_id) {
1406        atom = atom.with_budget_checker(budget_checker);
1407    }
1408    if let Some(payment_authority) = adapter.payment_authority(org_id, input.agent_id) {
1409        atom = atom.with_payment_authority(payment_authority);
1410    }
1411    if let Some(authority) = adapter.session_creation_authority(org_id, input.context.session_id) {
1412        atom = atom.with_session_creation_authority(authority);
1413    }
1414    if let Some(limiter) = adapter.outbound_tool_rate_limiter(org_id) {
1415        atom = atom.with_outbound_tool_rate_limiter(limiter);
1416    }
1417    if let Some(store) = adapter.durable_tool_result_store() {
1418        atom = atom.with_durable_tool_result_store(store);
1419    }
1420    if let Some(store) = adapter.subagent_spawn_store() {
1421        atom = atom.with_subagent_spawn_store(store);
1422    }
1423    if let Some(handle) = adapter.reasoning_effort_handle(input.context.session_id) {
1424        atom = atom.with_reasoning_effort_handle(handle);
1425    }
1426
1427    atom.execute(input).await
1428}