Skip to main content

everruns_host/
host.rs

1// Shared host orchestration for embedded and durable execution hosts.
2// Decision: everruns-host owns worker-facing turn phase execution so
3// durable/server-backed hosts reuse the same input/reason/act wiring without
4// depending on the application facade.
5
6use crate::{SessionMutator, SessionMutatorExt};
7use async_trait::async_trait;
8use everruns_core::capabilities::{
9    Capability, SystemPromptContext, collect_capabilities_with_configs,
10};
11use everruns_core::events::{
12    EventContext, EventRequest, OutputMessageCompletedData, SessionActivatedData, SessionIdledData,
13    SessionModelChangedData, TurnCompletedData, TurnFailedData, TurnStartedData,
14};
15use everruns_core::message::{ContentPart, Message, MessageRole};
16use everruns_core::message_retriever::MessageRetriever;
17use everruns_core::runtime_context::AssembledTurnContext;
18use everruns_core::session::SessionExecutionState;
19use everruns_core::{
20    CapabilityRegistry, CapabilityStatus, ClassifierService, DependencyBlocker, EgressService,
21    ResolvedExecutionSnapshot, TokenUsage, ToolRegistry, UtilityLlmService,
22    org_public_id_from_internal, resolve_runtime_capabilities,
23};
24use everruns_core::{
25    connection_services::ProviderCredentialStore, connection_services::UserConnectionResolver,
26    delegation_services::SessionCreationAuthority, event_emitter::EventEmitter,
27    execution_loading::AgentStore, execution_loading::HarnessStore,
28    execution_loading::SessionStore, file_services::FileResolver,
29    image_services::ImageArtifactStore, image_services::ImageResolver,
30    provider_resolution::ProviderStore, session_files::SessionFileSystem,
31    session_services::LeasedResourceStore, session_services::SessionResourceRegistry,
32    session_services::SessionScheduleStore, session_services::SessionStorageStore,
33    tool_context::ToolContextServices, tool_execution::BudgetChecker,
34    tool_execution::PaymentAuthority,
35};
36use everruns_engine::{
37    ActAtom, ActInput, ActResult, InputAtom, InputAtomInput, InputAtomResult, ReasonAtom,
38    ReasonInput, ReasonResult,
39};
40use everruns_provider::driver_registry::DriverRegistry;
41use everruns_provider::tool_types::ToolDefinition;
42use everruns_provider::typed_id::{AgentId, HarnessId, MessageId, ModelId, SessionId, TurnId};
43use everruns_provider::user_facing_error::{ErrorDisclosure, UserFacingError};
44use std::sync::Arc;
45use tracing::warn;
46
47/// Turn-local view that preserves a capability's message filtering while
48/// suppressing every model-visible contribution.
49struct MessageFilterOnlyCapability(Arc<dyn Capability>);
50
51impl Capability for MessageFilterOnlyCapability {
52    fn id(&self) -> &str {
53        self.0.id()
54    }
55
56    fn aliases(&self) -> Vec<&'static str> {
57        self.0.aliases()
58    }
59
60    fn name(&self) -> &str {
61        self.0.name()
62    }
63
64    fn description(&self) -> &str {
65        self.0.description()
66    }
67
68    fn status(&self) -> CapabilityStatus {
69        self.0.status()
70    }
71
72    fn message_filter_provider(
73        &self,
74    ) -> Option<Arc<dyn everruns_core::message_filter::MessageFilterProvider>> {
75        self.0.message_filter_provider()
76    }
77
78    fn message_filter_config(
79        &self,
80        config: &serde_json::Value,
81        compaction_enabled: bool,
82    ) -> serde_json::Value {
83        self.0.message_filter_config(config, compaction_enabled)
84    }
85}
86
87#[cfg(feature = "bashkit")]
88fn bash_hook_dispatcher(
89    file_store: Arc<dyn SessionFileSystem>,
90) -> Arc<dyn everruns_core::hook_executor::BashHookDispatcher> {
91    Arc::new(everruns_integrations_bashkit::BashkitShellHookDispatcher::new(file_store))
92}
93
94#[cfg(not(feature = "bashkit"))]
95fn bash_hook_dispatcher(
96    _file_store: Arc<dyn SessionFileSystem>,
97) -> Arc<dyn everruns_core::hook_executor::BashHookDispatcher> {
98    struct DisabledDispatcher;
99
100    #[async_trait]
101    impl everruns_core::hook_executor::BashHookDispatcher for DisabledDispatcher {
102        async fn dispatch(
103            &self,
104            _payload: &everruns_core::hook_executor::HookPayload,
105            _command: &str,
106            _extra_env: &std::collections::BTreeMap<String, String>,
107            _opts: &everruns_core::hook_executor::ExecutorOpts,
108        ) -> std::result::Result<everruns_core::hook_executor::BashExecOutput, String> {
109            Err("bash hooks require the everruns-host `bashkit` feature".to_string())
110        }
111    }
112
113    Arc::new(DisabledDispatcher)
114}
115
116/// Resolved inputs loaded in one batched call for runtime host execution.
117///
118/// This is the narrow load/resolve contract (EVE-872): hosts return the
119/// canonical [`ResolvedExecutionSnapshot`] plus the turn's message and
120/// MCP tool inputs. Stored Agent/Harness/Session aggregates never cross this
121/// boundary — platform projection happens inside the adapter.
122#[derive(Debug, Clone)]
123pub struct ResolvedTurnInputs {
124    /// Canonical resolved execution value for the session.
125    pub snapshot: ResolvedExecutionSnapshot,
126    /// Conversation messages available to the turn.
127    pub messages: Vec<Message>,
128    /// MCP tool definitions discovered for the session's scoped servers.
129    pub mcp_tool_definitions: Vec<ToolDefinition>,
130}
131
132/// Public adapter contract for server-backed or durable runtime hosts.
133///
134/// `everruns-host` owns shared orchestration for both embedded and durable
135/// execution. That includes phase execution (`input -> reason -> act`),
136/// lifecycle emission, and the generic turn-strategy decisions used by durable
137/// or custom hosts.
138///
139/// Host crates implement this trait to provide persistence, session-lifecycle
140/// plumbing, event delivery, and their own orchestration backend. The durable
141/// engine itself remains outside this crate.
142#[async_trait]
143pub trait RuntimeHostAdapter: Send + Sync + Clone + 'static {
144    /// Durable task cancellation/ownership loss, scoped to this execution.
145    fn turn_cancellation(&self) -> Option<tokio::sync::watch::Receiver<bool>> {
146        None
147    }
148    /// Session status mutation is a host effect, separate from execution
149    /// inputs: it exposes no stored Session record to the engine.
150    async fn set_session_status(
151        &self,
152        org_id: i64,
153        session_id: SessionId,
154        status: SessionExecutionState,
155    ) -> everruns_provider::error::Result<()>;
156
157    /// Load and resolve the turn's execution inputs.
158    ///
159    /// Implementations project their stored records into the canonical
160    /// [`ResolvedExecutionSnapshot`] (via
161    /// [`ResolvedExecutionSnapshot::project`]) so missing, mismatched, or
162    /// inactive records fail here — during platform projection — before host
163    /// execution.
164    async fn load_resolved_turn(
165        &self,
166        org_id: i64,
167        session_id: SessionId,
168    ) -> everruns_provider::error::Result<ResolvedTurnInputs>;
169
170    fn capability_registry(&self) -> CapabilityRegistry;
171
172    fn driver_registry(&self) -> DriverRegistry;
173
174    fn harness_store(&self, org_id: i64) -> Arc<dyn HarnessStore>;
175
176    fn agent_store(&self, org_id: i64) -> Arc<dyn AgentStore>;
177
178    fn session_store(&self, org_id: i64) -> Arc<dyn SessionStore>;
179
180    fn session_mutator(&self, org_id: i64) -> Arc<dyn SessionMutator>;
181
182    fn provider_store(&self, org_id: i64) -> Arc<dyn ProviderStore>;
183
184    fn message_store(&self) -> Arc<dyn MessageRetriever>;
185
186    fn native_async_store(
187        &self,
188    ) -> Option<Arc<dyn everruns_core::native_async_store::NativeAsyncStore>> {
189        None
190    }
191
192    fn compaction_checkpoint_store(
193        &self,
194    ) -> Option<Arc<dyn everruns_core::CompactionCheckpointStore>> {
195        None
196    }
197
198    fn event_emitter(&self) -> Arc<dyn EventEmitter>;
199
200    fn file_store(&self) -> Arc<dyn SessionFileSystem>;
201
202    fn image_resolver(&self, _org_id: i64) -> Option<Arc<dyn ImageResolver>> {
203        None
204    }
205
206    fn file_resolver(&self, _org_id: i64) -> Option<Arc<dyn FileResolver>> {
207        None
208    }
209
210    fn image_artifact_store(&self, _org_id: i64) -> Option<Arc<dyn ImageArtifactStore>> {
211        None
212    }
213
214    fn provider_credential_store(&self, _org_id: i64) -> Option<Arc<dyn ProviderCredentialStore>> {
215        None
216    }
217
218    fn utility_llm_service(&self) -> Option<Arc<dyn UtilityLlmService>> {
219        None
220    }
221
222    /// Classification service for capability internals that ask typed questions.
223    fn classifier(&self) -> Option<Arc<dyn ClassifierService>> {
224        None
225    }
226
227    fn egress_service(&self) -> Option<Arc<dyn EgressService>> {
228        None
229    }
230
231    fn storage_store(&self) -> Option<Arc<dyn SessionStorageStore>> {
232        None
233    }
234
235    fn connection_resolver(&self) -> Option<Arc<dyn UserConnectionResolver>> {
236        None
237    }
238
239    /// Type-erased tool services supplied by layers above the host.
240    fn tool_context_extensions(
241        &self,
242        _org_id: i64,
243        _session_id: SessionId,
244    ) -> everruns_core::tool_context::ToolContextExtensions {
245        Default::default()
246    }
247
248    /// Neutral subagent delegation supplied by layers above the host.
249    fn subagent_delegate(
250        &self,
251        _org_id: i64,
252        _session_id: SessionId,
253    ) -> Option<Arc<dyn everruns_core::subagent_delegation::SubagentSessionDelegate>> {
254        None
255    }
256
257    /// Turn-dependent tools supplied by layers above the host.
258    fn tool_augmentor(&self) -> Option<Arc<dyn crate::HostToolAugmentor>> {
259        None
260    }
261
262    fn leased_resource_store(&self) -> Option<Arc<dyn LeasedResourceStore>> {
263        None
264    }
265
266    fn session_resource_registry(&self) -> Option<Arc<dyn SessionResourceRegistry>> {
267        None
268    }
269
270    fn session_task_registry(
271        &self,
272    ) -> Option<Arc<dyn everruns_core::session_task::SessionTaskRegistry>> {
273        None
274    }
275
276    fn schedule_store(&self, _org_id: i64) -> Option<Arc<dyn SessionScheduleStore>> {
277        None
278    }
279
280    fn budget_checker(
281        &self,
282        _org_id: i64,
283        _agent_id: Option<AgentId>,
284    ) -> Option<Arc<dyn BudgetChecker>> {
285        None
286    }
287
288    fn payment_authority(
289        &self,
290        _org_id: i64,
291        _agent_id: Option<AgentId>,
292    ) -> Option<Arc<dyn PaymentAuthority>> {
293        None
294    }
295
296    fn session_creation_authority(
297        &self,
298        _org_id: i64,
299        _session_id: SessionId,
300    ) -> Option<Arc<dyn SessionCreationAuthority>> {
301        None
302    }
303
304    /// Per-org outbound tool-call rate limiter (TM-TOOL-009).
305    /// Default: `None` (no rate limiting — suitable for in-process / test environments).
306    fn outbound_tool_rate_limiter(
307        &self,
308        _org_id: i64,
309    ) -> Option<Arc<dyn everruns_core::tool_execution::OutboundToolRateLimiter>> {
310        None
311    }
312
313    /// Per-turn durable tool result store for act-activity idempotency (EVE-530).
314    /// Default: `None` (no durable claim/settle — every execution runs tools fresh).
315    fn durable_tool_result_store(
316        &self,
317    ) -> Option<Arc<dyn everruns_core::durability::DurableToolResultStore>> {
318        None
319    }
320
321    /// Durable subagent spawn handle store for reattach on reclaim (EVE-535).
322    /// Default: `None` (no spawn dedup — dev/test mode or hosts without durable execution).
323    fn subagent_spawn_store(
324        &self,
325    ) -> Option<Arc<dyn everruns_core::delegation_services::SubagentSpawnStore>> {
326        None
327    }
328
329    /// Stream-liveness heartbeater for the Reason activity (EVE-531).
330    /// Default: `None` (no heartbeats sent — durable workers supply one).
331    fn stream_heartbeater(&self) -> Option<Arc<dyn everruns_core::durability::StreamHeartbeater>> {
332        None
333    }
334
335    /// Partial-stream store for ContinuePartial recovery (EVE-532).
336    /// Default: `None` (no recovery; in-memory and dev hosts use this default).
337    fn partial_stream_store(
338        &self,
339    ) -> Option<Arc<dyn everruns_core::durability::PartialStreamStore>> {
340        None
341    }
342
343    /// Live, turn-scoped reasoning-effort handle for the given session (EVE-595).
344    ///
345    /// When a host returns a handle, the Reason activity re-reads it on every
346    /// LLM step and the Act activity hands the same instance to each tool's
347    /// `ToolContext`. A tool can then change effort mid-turn and have subsequent
348    /// LLM steps in the same turn observe it. Hosts MUST return the *same*
349    /// handle instance for a session across reason/act activities of one turn.
350    /// Default: `None` (effort is resolved solely from message controls).
351    fn reasoning_effort_handle(
352        &self,
353        _session_id: SessionId,
354    ) -> Option<everruns_core::tool_context::ReasoningEffortHandle> {
355        None
356    }
357
358    /// Provider stall timeout for the Reason activity (EVE-531).
359    /// Default: `None` (use built-in 120s default).
360    fn provider_stall_timeout(&self) -> Option<std::time::Duration> {
361        None
362    }
363
364    /// Bounded automatic-recovery policy for provider failures.
365    /// Default: `None` (use the provider policy defaults).
366    fn provider_retry_config(&self) -> Option<everruns_provider::llm_retry::LlmRetryConfig> {
367        None
368    }
369
370    /// MCP executor routing `mcp_*` tool calls for this session, if the host
371    /// configures MCP (knowledge/integrations/runtime-mcp.md D4). Default: `None`, so hosts
372    /// without scoped MCP servers keep the plain tool registry unchanged.
373    async fn mcp_executor(
374        &self,
375        _org_id: i64,
376        _session_id: SessionId,
377    ) -> Option<Arc<dyn everruns_core::McpToolInvoker>> {
378        None
379    }
380}
381
382struct RuntimeExecutionCapabilities {
383    tool_registry: ToolRegistry,
384    post_tool_hooks: Vec<Arc<dyn everruns_core::tool_hooks::PostToolExecHook>>,
385    pre_tool_hooks: Vec<Arc<dyn everruns_core::tool_hooks::PreToolUseHook>>,
386    tool_call_hooks: Vec<Arc<dyn everruns_core::ToolCallHook>>,
387    subagent_nesting_policy: everruns_core::delegation_services::SubagentNestingPolicy,
388}
389
390fn subagent_nesting_policy_from_configs(
391    resolved_capability_configs: &[everruns_capability::CapabilityRef],
392) -> everruns_core::delegation_services::SubagentNestingPolicy {
393    let subagents_config = resolved_capability_configs
394        .iter()
395        .find(|config| config.capability_id() == "subagents");
396
397    let configured_depth = subagents_config
398        .and_then(|config| {
399            config
400                .config_value()
401                .get("max_subagent_depth")
402                .or_else(|| config.config_value().get("max_depth"))
403        })
404        .and_then(|value| value.as_u64())
405        .and_then(|value| u32::try_from(value).ok());
406    let configured_max_active = subagents_config
407        .and_then(|config| {
408            config
409                .config_value()
410                .get("max_active_descendant_tasks")
411                .or_else(|| config.config_value().get("max_concurrent_descendant_tasks"))
412        })
413        .and_then(|value| value.as_u64())
414        .and_then(|value| u32::try_from(value).ok());
415    let configured_max_total = subagents_config
416        .and_then(|config| config.config_value().get("max_total_descendant_tasks"))
417        .and_then(|value| value.as_u64())
418        .and_then(|value| u32::try_from(value).ok());
419    let configured_max_active_detached = subagents_config
420        .and_then(|config| config.config_value().get("max_active_detached_tasks"))
421        .and_then(|value| value.as_u64())
422        .and_then(|value| u32::try_from(value).ok());
423    let configured_max_total_detached = subagents_config
424        .and_then(|config| config.config_value().get("max_total_detached_tasks"))
425        .and_then(|value| value.as_u64())
426        .and_then(|value| u32::try_from(value).ok());
427
428    everruns_core::delegation_services::SubagentNestingPolicy::default()
429        .with_agent_override(configured_depth)
430        .with_agent_task_caps_override(configured_max_active, configured_max_total)
431        .with_agent_detached_task_caps_override(
432            configured_max_active_detached,
433            configured_max_total_detached,
434        )
435}
436
437/// Collect and finalize user-hook specs for a session from its resolved
438/// capability configs, plus the shared bash dispatcher used to run them.
439///
440/// This is the single place hook specs are gathered so every firing point —
441/// the act path (`load_execution_capabilities`) and the lifecycle firing
442/// points (`execute_reason_activity` for `user_prompt_submit`, turn completion
443/// for `turn_end`, and the server session paths) — applies identical
444/// `finalize_hook_specs` semantics: `{capability_id}:` namespace stamping,
445/// stable default ids, and `disabled_contributions` muting (TM-HOOK-004).
446fn finalize_specs_from_configs(
447    resolved_capability_configs: &[everruns_capability::CapabilityRef],
448    capability_registry: &CapabilityRegistry,
449    tool_augmentor: Option<&dyn crate::HostToolAugmentor>,
450) -> Vec<everruns_core::user_hook_types::UserHookSpec> {
451    let mut hook_contributions: Vec<(String, Vec<everruns_core::user_hook_types::UserHookSpec>)> =
452        Vec::new();
453    let mut disabled_contributions: Vec<String> = Vec::new();
454    for config in resolved_capability_configs {
455        let Some(capability) = capability_registry.get(config.capability_id()) else {
456            continue;
457        };
458        let specs = capability.user_hooks_with_config(config.config_value());
459        if !specs.is_empty() {
460            hook_contributions.push((config.capability_id().to_string(), specs));
461        }
462        if let Some(augmentor) = tool_augmentor {
463            disabled_contributions.extend(
464                augmentor
465                    .disabled_hook_contributions(config.capability_id(), config.config_value()),
466            );
467        }
468    }
469    everruns_core::hook_adapter::finalize_hook_specs(hook_contributions, &disabled_contributions)
470}
471
472/// Resolve a session's capability configs and collect finalized hook specs.
473/// Used by the lifecycle firing points, which need specs outside the act path.
474/// Returns `(specs, dispatcher)`; `specs` is empty when the session has no
475/// hook-contributing capabilities.
476async fn collect_lifecycle_hook_specs<A: RuntimeHostAdapter>(
477    adapter: &A,
478    org_id: i64,
479    session_id: SessionId,
480    harness_id: HarnessId,
481    agent_id: Option<AgentId>,
482) -> everruns_provider::error::Result<(
483    Vec<everruns_core::user_hook_types::UserHookSpec>,
484    Arc<dyn everruns_core::hook_executor::BashHookDispatcher>,
485)> {
486    let capability_registry = adapter.capability_registry();
487    let harness = adapter
488        .harness_store(org_id)
489        .get_harness(harness_id)
490        .await?
491        .ok_or_else(|| everruns_provider::error::AgentLoopError::harness_not_found(harness_id))?;
492    let session = adapter
493        .session_store(org_id)
494        .get_session(session_id)
495        .await?
496        .ok_or_else(|| everruns_provider::error::AgentLoopError::session_not_found(session_id))?;
497    let agent = match agent_id {
498        Some(agent_id) => adapter.agent_store(org_id).get_agent(agent_id).await?,
499        None => None,
500    };
501    let resolved =
502        resolve_runtime_capabilities(&harness, agent.as_ref(), &session, &capability_registry);
503    let tool_augmentor = adapter.tool_augmentor();
504    let specs = finalize_specs_from_configs(
505        &resolved.resolved_capability_configs,
506        &capability_registry,
507        tool_augmentor.as_deref(),
508    );
509    let dispatcher = bash_hook_dispatcher(adapter.file_store());
510    Ok((specs, dispatcher))
511}
512
513async fn load_execution_capabilities<A: RuntimeHostAdapter>(
514    adapter: &A,
515    org_id: i64,
516    session_id: SessionId,
517    harness_id: HarnessId,
518    agent_id: Option<AgentId>,
519    locale: Option<String>,
520    blueprint_id: Option<&str>,
521) -> everruns_provider::error::Result<RuntimeExecutionCapabilities> {
522    let capability_registry = adapter.capability_registry();
523    if let Some(blueprint_id) = blueprint_id {
524        let mut registry = ToolRegistry::with_defaults();
525        #[cfg(feature = "builtins")]
526        everruns_builtins::register_default_tools(&mut registry);
527        let blueprint = capability_registry.blueprint(blueprint_id).ok_or_else(|| {
528            everruns_provider::error::AgentLoopError::config(format!(
529                "Blueprint \"{blueprint_id}\" not found in registry"
530            ))
531        })?;
532        for tool in blueprint.tools {
533            registry.register_boxed(tool);
534        }
535        return Ok(RuntimeExecutionCapabilities {
536            tool_registry: registry,
537            post_tool_hooks: Vec::new(),
538            pre_tool_hooks: Vec::new(),
539            tool_call_hooks: Vec::new(),
540            subagent_nesting_policy:
541                everruns_core::delegation_services::SubagentNestingPolicy::default(),
542        });
543    }
544
545    let harness = adapter
546        .harness_store(org_id)
547        .get_harness(harness_id)
548        .await?
549        .ok_or_else(|| everruns_provider::error::AgentLoopError::harness_not_found(harness_id))?;
550
551    let session = adapter
552        .session_store(org_id)
553        .get_session(session_id)
554        .await?
555        .ok_or_else(|| everruns_provider::error::AgentLoopError::session_not_found(session_id))?;
556
557    let agent_store = adapter.agent_store(org_id);
558    let agent =
559        match agent_id {
560            Some(agent_id) => Some(agent_store.get_agent(agent_id).await?.ok_or_else(|| {
561                everruns_provider::error::AgentLoopError::agent_not_found(agent_id)
562            })?),
563            None => None,
564        };
565
566    let resolved =
567        resolve_runtime_capabilities(&harness, agent.as_ref(), &session, &capability_registry);
568    // Executor (act) path: this builds the worker-side tool registry, not the
569    // model-visible tool list. The model is left unset, so a model-adaptive
570    // capability like `auto_tool_search` resolves to its provider-agnostic
571    // client-side mechanism here. That registers the `tool_search` tool in the
572    // executor, which is a harmless superset: on native models the reason path
573    // never shows that tool to the model, so it is simply never called.
574    let prompt_ctx = SystemPromptContext {
575        session_id,
576        locale: locale.or(session.locale.clone()),
577        // Pin system-prompt file reads to the session's workspace (the default
578        // 1:1 case is a transparent pass-through), then resolve through the
579        // mount resolver (EVE-660): `/workspace` is a mount + cwd.
580        // `scoped_prompt_file_store` wraps with `wrap_if_needed` so a local
581        // embedder's backend-native display policy survives here too (it must
582        // match the reason path — see its doc); server stores stay on `/workspace`.
583        file_store: Some(everruns_core::scoped_prompt_file_store(
584            adapter.file_store(),
585            session.workspace_id,
586        )),
587        model: None,
588        session_storage: None,
589    };
590    let collected = collect_capabilities_with_configs(
591        &resolved.resolved_capability_configs,
592        &capability_registry,
593        &prompt_ctx,
594    )
595    .await;
596
597    let mut registry = ToolRegistry::with_defaults();
598    #[cfg(feature = "builtins")]
599    everruns_builtins::register_default_tools(&mut registry);
600    for tool in collected.tools {
601        registry.register_boxed(tool);
602    }
603
604    // Only `Available` capabilities contribute hooks, matching
605    // `collect_capabilities_with_configs` (which skips non-available
606    // capabilities). This keeps a `ComingSoon`/unavailable capability from
607    // affecting execution via any of its hook seams.
608    let mut post_tool_hooks: Vec<Arc<dyn everruns_core::tool_hooks::PostToolExecHook>> = resolved
609        .resolved_capability_configs
610        .iter()
611        .flat_map(|config| {
612            capability_registry
613                .get(config.capability_id())
614                .filter(|capability| capability.status().is_active())
615                .map(|capability| {
616                    capability.post_tool_exec_hooks_with_config(config.config_value())
617                })
618                .unwrap_or_default()
619        })
620        .collect();
621    // Tool-output guardrails must inspect the original result before other
622    // capability hooks can persist or compact it into secondary surfaces.
623    post_tool_hooks.sort_by_key(|hook| hook.priority());
624
625    // User-hook contributions (see `knowledge/runtime-resources/user-hooks.md`). `finalize_specs_from_configs`
626    // gathers specs across every resolved capability — both the user-facing
627    // `user_hooks` capability and any capability that bundles hooks — and applies
628    // `finalize_hook_specs` (namespace stamping, stable ids, `disabled_contributions`
629    // muting; TM-HOOK-004). The same helper backs the lifecycle firing points so
630    // every event finalizes specs identically.
631    let tool_augmentor = adapter.tool_augmentor();
632    let user_hook_specs = finalize_specs_from_configs(
633        &resolved.resolved_capability_configs,
634        &capability_registry,
635        tool_augmentor.as_deref(),
636    );
637    // Persisted messages remain the immutable audit record, so they can contain
638    // text removed by a provider-bound user_prompt_submit hook. Until there is a
639    // durable provider-visible history view, fail closed rather than let
640    // query_history bypass that enforcement boundary.
641    if user_hook_specs
642        .iter()
643        .any(|spec| spec.event == everruns_core::user_hook_types::HookEvent::UserPromptSubmit)
644    {
645        registry.unregister("query_history");
646    }
647    // Capability-contributed pre-tool hooks run first (e.g. approval gating),
648    // then user-hook (`PreToolUse`) specs. The first hook to block wins.
649    let mut pre_tool_hooks: Vec<Arc<dyn everruns_core::tool_hooks::PreToolUseHook>> = resolved
650        .resolved_capability_configs
651        .iter()
652        .flat_map(|config| {
653            capability_registry
654                .get(config.capability_id())
655                .filter(|capability| capability.status().is_active())
656                .map(|capability| capability.pre_tool_use_hooks_with_config(config.config_value()))
657                .unwrap_or_default()
658        })
659        .collect();
660    if !user_hook_specs.is_empty() {
661        let dispatcher = bash_hook_dispatcher(adapter.file_store());
662        post_tool_hooks.extend(everruns_core::hook_adapter::build_post_tool_use_hooks(
663            &user_hook_specs,
664            dispatcher.clone(),
665        ));
666        pre_tool_hooks.extend(everruns_core::hook_adapter::build_pre_tool_use_hooks(
667            &user_hook_specs,
668            dispatcher,
669        ));
670    }
671
672    // Use the hook list assembled by `collect_capabilities_with_configs` as the
673    // single source of truth. It already contains every explicit capability
674    // `tool_call_hooks()` followed by the generated `CapabilityNarrationHook`
675    // adapters — one per collected capability plus any auto-activated
676    // cross-cutting capability such as `background_execution`. Re-deriving only
677    // the explicit subset here dropped capability-owned narration, so tools fell
678    // back to generic `Ran {display_name}` lines (EVE-601). Explicit hooks stay
679    // first in this list, so model-authored narration (`human_intent`) keeps its
680    // precedence over default `Tool::narrate()`, and only available capabilities
681    // contributed because collection skips non-available ones.
682    let tool_call_hooks = collected.tool_call_hooks;
683
684    Ok(RuntimeExecutionCapabilities {
685        tool_registry: registry,
686        post_tool_hooks,
687        pre_tool_hooks,
688        tool_call_hooks,
689        subagent_nesting_policy: subagent_nesting_policy_from_configs(
690            &resolved.resolved_capability_configs,
691        ),
692    })
693}
694
695fn runtime_tool_context_services<A: RuntimeHostAdapter>(
696    adapter: &A,
697    org_id: i64,
698    session_id: SessionId,
699    agent_id: Option<AgentId>,
700    tool_registry: Option<Arc<ToolRegistry>>,
701    mcp_invoker: Option<Arc<dyn everruns_core::McpToolInvoker>>,
702    subagent_nesting_policy: everruns_core::delegation_services::SubagentNestingPolicy,
703) -> ToolContextServices {
704    let extensions = {
705        let mut extensions = adapter.tool_context_extensions(org_id, session_id);
706        extensions.insert(Arc::new(SessionMutatorExt(adapter.session_mutator(org_id))));
707        extensions
708    };
709    ToolContextServices {
710        file_store: Some(adapter.file_store()),
711        storage_store: adapter.storage_store(),
712        image_store: adapter.image_artifact_store(org_id),
713        provider_credential_store: adapter.provider_credential_store(org_id),
714        utility_llm_service: adapter.utility_llm_service(),
715        classifier: adapter.classifier(),
716        mcp_invoker,
717        egress_service: adapter.egress_service(),
718        message_retriever: Some(adapter.message_store()),
719        session_store: Some(adapter.session_store(org_id)),
720        agent_store: Some(adapter.agent_store(org_id)),
721        connection_resolver: adapter.connection_resolver(),
722        schedule_store: adapter.schedule_store(org_id),
723        subagent_delegate: adapter.subagent_delegate(org_id, session_id),
724        extensions,
725        leased_resource_store: adapter.leased_resource_store(),
726        session_resource_registry: adapter.session_resource_registry(),
727        session_task_registry: adapter.session_task_registry(),
728        event_emitter: Some(adapter.event_emitter()),
729        capability_registry: Some(adapter.capability_registry()),
730        tool_registry,
731        org_id: Some(
732            org_public_id_from_internal(org_id)
733                .parse()
734                .expect("internal org id converts to valid public org id"),
735        ),
736        network_access: None,
737        budget_checker: adapter.budget_checker(org_id, agent_id),
738        payment_authority: adapter.payment_authority(org_id, agent_id),
739        session_creation_authority: adapter.session_creation_authority(org_id, session_id),
740        subagent_spawn_store: adapter.subagent_spawn_store(),
741        subagent_nesting_policy,
742        reasoning_effort_handle: adapter.reasoning_effort_handle(session_id),
743    }
744}
745
746/// Shared lifecycle helper for runtime-backed hosts.
747/// Agent identity snapshot carried on `turn.started`.
748#[derive(Debug, Default)]
749struct TurnAgentIdentity {
750    id: Option<AgentId>,
751    name: Option<String>,
752    description: Option<String>,
753}
754
755pub struct RuntimeSessionLifecycle<A: RuntimeHostAdapter> {
756    adapter: A,
757    org_id: i64,
758    session_id: SessionId,
759}
760
761impl<A: RuntimeHostAdapter> RuntimeSessionLifecycle<A> {
762    pub fn new(adapter: A, org_id: i64, session_id: SessionId) -> Self {
763        Self {
764            adapter,
765            org_id,
766            session_id,
767        }
768    }
769
770    async fn set_session_status(
771        &self,
772        status: SessionExecutionState,
773        _action: &'static str,
774    ) -> everruns_provider::error::Result<()> {
775        self.adapter
776            .set_session_status(self.org_id, self.session_id, status)
777            .await
778    }
779
780    async fn emit_event(&self, request: EventRequest) -> everruns_provider::error::Result<()> {
781        self.adapter.event_emitter().emit(request).await.map(|_| ())
782    }
783
784    pub async fn turn_started(
785        &self,
786        turn_id: TurnId,
787        input_message_id: MessageId,
788    ) -> everruns_provider::error::Result<()> {
789        let input_content = self
790            .adapter
791            .message_store()
792            .get(self.session_id, input_message_id)
793            .await
794            .ok()
795            .flatten()
796            .map(|message| message.content_to_llm_string());
797
798        self.set_session_status(SessionExecutionState::Active, "turn_started")
799            .await?;
800
801        self.emit_event(EventRequest::new(
802            self.session_id,
803            EventContext::turn(turn_id, input_message_id),
804            SessionActivatedData {
805                turn_id,
806                input_message_id,
807            },
808        ))
809        .await?;
810
811        let agent = self.agent_identity().await;
812        self.emit_event(EventRequest::new(
813            self.session_id,
814            EventContext::turn(turn_id, input_message_id),
815            TurnStartedData {
816                turn_id,
817                input_message_id,
818                input_content,
819                agent_id: agent.id,
820                agent_name: agent.name,
821                agent_description: agent.description,
822            },
823        ))
824        .await?;
825        Ok(())
826    }
827
828    /// Agent identity for the turn root event, resolved best-effort: a store
829    /// miss or failure yields `None` fields rather than failing the turn, since
830    /// the identity only labels traces.
831    async fn agent_identity(&self) -> TurnAgentIdentity {
832        let agent_id = self
833            .adapter
834            .session_store(self.org_id)
835            .get_session(self.session_id)
836            .await
837            .ok()
838            .flatten()
839            .and_then(|session| session.agent_id);
840        let Some(agent_id) = agent_id else {
841            return TurnAgentIdentity::default();
842        };
843        let agent = self
844            .adapter
845            .agent_store(self.org_id)
846            .get_agent(agent_id)
847            .await
848            .ok()
849            .flatten();
850        TurnAgentIdentity {
851            id: Some(agent_id),
852            // The conventions want the human-readable name; the slug is the
853            // fallback when no display name was set.
854            name: agent
855                .as_ref()
856                .map(|a| a.display_name.clone().unwrap_or_else(|| a.name.clone())),
857            description: agent.and_then(|a| a.description),
858        }
859    }
860
861    pub async fn emit_turn_completed(
862        &self,
863        input_message_id: MessageId,
864        data: TurnCompletedData,
865    ) -> everruns_provider::error::Result<()> {
866        let turn_id = data.turn_id;
867        self.emit_event(EventRequest::new(
868            self.session_id,
869            EventContext::turn(turn_id, input_message_id),
870            data,
871        ))
872        .await
873    }
874
875    pub async fn emit_session_idled(
876        &self,
877        turn_id: TurnId,
878        input_message_id: MessageId,
879        iterations: Option<u32>,
880        usage: Option<TokenUsage>,
881    ) -> everruns_provider::error::Result<()> {
882        self.set_session_status(SessionExecutionState::Idle, "emit_session_idled")
883            .await?;
884
885        self.emit_event(EventRequest::new(
886            self.session_id,
887            EventContext::turn(turn_id, input_message_id),
888            SessionIdledData {
889                turn_id,
890                iterations,
891                usage,
892            },
893        ))
894        .await
895    }
896
897    pub async fn turn_completed(
898        &self,
899        turn_id: TurnId,
900        input_message_id: MessageId,
901        iterations: u32,
902        usage: Option<TokenUsage>,
903        input_content: Option<String>,
904    ) -> everruns_provider::error::Result<()> {
905        self.emit_turn_completed(
906            input_message_id,
907            TurnCompletedData {
908                turn_id,
909                iterations,
910                duration_ms: None,
911                usage: usage.clone(),
912                input_content,
913                final_message_id: None,
914                final_answer_preview: None,
915                time_to_first_token_ms: None,
916                tool_call_count: None,
917                llm_call_count: None,
918                status: Some("completed".to_string()),
919            },
920        )
921        .await?;
922        self.emit_session_idled(turn_id, input_message_id, Some(iterations), usage)
923            .await
924    }
925
926    /// Turn was deliberately sealed (EVE-534): emit `turn.sealed` + a
927    /// user-facing message + `session.idled`, and idle the session.
928    ///
929    /// Distinct from `turn_completed` (success) and `turn_failed` (error). The
930    /// session returns to `idle` so the UI unblocks; the Sealed state is
931    /// observable via the `turn.sealed` event and its `reason`.
932    pub async fn turn_sealed(
933        &self,
934        turn_id: TurnId,
935        input_message_id: MessageId,
936        reason: &str,
937        iterations: u32,
938        usage: Option<TokenUsage>,
939    ) -> everruns_provider::error::Result<()> {
940        let context = EventContext::turn(turn_id, input_message_id);
941
942        self.emit_event(EventRequest::new(
943            self.session_id,
944            context.clone(),
945            everruns_core::events::TurnSealedData {
946                turn_id,
947                reason: reason.to_string(),
948                detail: None,
949                iterations: Some(iterations),
950                usage: usage.clone(),
951            },
952        ))
953        .await?;
954
955        self.emit_session_idled(turn_id, input_message_id, Some(iterations), usage)
956            .await
957    }
958
959    /// Fire `turn_end` lifecycle hooks (advisory). Collects the session's hook
960    /// specs and runs every `turn_end` hook; failures are logged, never fatal.
961    /// `harness_id`/`agent_id` are required to resolve the capability chain.
962    pub async fn fire_turn_end_hooks(
963        &self,
964        harness_id: HarnessId,
965        agent_id: Option<AgentId>,
966        turn_id: TurnId,
967        success: bool,
968    ) {
969        let (specs, dispatcher) = match collect_lifecycle_hook_specs(
970            &self.adapter,
971            self.org_id,
972            self.session_id,
973            harness_id,
974            agent_id,
975        )
976        .await
977        {
978            Ok(pair) => pair,
979            Err(error) => {
980                warn!(
981                    session_id = %self.session_id,
982                    %error,
983                    "failed to collect turn_end hook specs; skipping"
984                );
985                return;
986            }
987        };
988        let hooks = everruns_core::lifecycle_hooks::build_turn_lifecycle_hooks(
989            &specs,
990            everruns_core::user_hook_types::HookEvent::TurnEnd,
991            dispatcher,
992        );
993        if hooks.is_empty() {
994            return;
995        }
996        let ctx = everruns_core::lifecycle_hooks::TurnHookContext {
997            session_id: self.session_id,
998            turn_id: Some(turn_id),
999            org_id: org_public_id_from_internal(self.org_id).parse().ok(),
1000            agent_id: agent_id.map(|a| a.to_string()),
1001        };
1002        everruns_core::lifecycle_hooks::run_turn_end_hooks(
1003            &hooks,
1004            &ctx,
1005            serde_json::json!({ "success": success }),
1006        )
1007        .await;
1008    }
1009
1010    /// Abort a turn because a `user_prompt_submit` hook returned `Block`.
1011    /// Reuses the dependency-blocked failure shape: emit a user-facing message
1012    /// carrying the hook's `user_message` (or `reason`), then mark the turn
1013    /// failed and idle the session.
1014    pub async fn user_prompt_blocked(
1015        &self,
1016        turn_id: TurnId,
1017        input_message_id: MessageId,
1018        reason: &str,
1019        user_message: Option<&str>,
1020    ) -> everruns_provider::error::Result<()> {
1021        let user_error =
1022            UserFacingError::new(everruns_provider::user_facing_error::codes::BLOCKED_BY_HOOK);
1023        let shown = user_message.unwrap_or(reason);
1024        let mut error_message = Message::assistant(shown);
1025        let mut metadata = std::collections::HashMap::new();
1026        user_error.apply_to_message_metadata(&mut metadata);
1027        error_message.metadata = Some(metadata);
1028
1029        self.emit_event(EventRequest::new(
1030            self.session_id,
1031            EventContext::turn(turn_id, input_message_id),
1032            OutputMessageCompletedData::new(error_message).with_user_facing_error(&user_error),
1033        ))
1034        .await?;
1035
1036        self.turn_failed(turn_id, input_message_id, reason, Some(&user_error))
1037            .await
1038    }
1039
1040    pub async fn turn_failed(
1041        &self,
1042        turn_id: TurnId,
1043        input_message_id: MessageId,
1044        error: &str,
1045        user_error: Option<&UserFacingError>,
1046    ) -> everruns_provider::error::Result<()> {
1047        self.turn_failed_with_disclosure(turn_id, input_message_id, error, user_error, None)
1048            .await
1049    }
1050
1051    /// `turn_failed` with the applied error-disclosure mode recorded on the
1052    /// event. `user_error` (and the `error` text shown alongside it) must
1053    /// already be disclosure-filtered by the caller.
1054    pub async fn turn_failed_with_disclosure(
1055        &self,
1056        turn_id: TurnId,
1057        input_message_id: MessageId,
1058        error: &str,
1059        user_error: Option<&UserFacingError>,
1060        disclosure: Option<ErrorDisclosure>,
1061    ) -> everruns_provider::error::Result<()> {
1062        self.set_session_status(SessionExecutionState::Idle, "turn_failed")
1063            .await?;
1064
1065        self.emit_event(EventRequest::new(
1066            self.session_id,
1067            EventContext::turn(turn_id, input_message_id),
1068            {
1069                let mut data = TurnFailedData {
1070                    turn_id,
1071                    error: error.to_string(),
1072                    error_code: None,
1073                    error_fields: None,
1074                    error_disclosure: disclosure.map(|mode| mode.as_str().to_string()),
1075                };
1076                if let Some(user_error) = user_error {
1077                    user_error.apply_to_event_fields(&mut data.error_code, &mut data.error_fields);
1078                }
1079                data
1080            },
1081        ))
1082        .await?;
1083
1084        self.emit_event(EventRequest::new(
1085            self.session_id,
1086            EventContext::turn(turn_id, input_message_id),
1087            SessionIdledData {
1088                turn_id,
1089                iterations: None,
1090                usage: None,
1091            },
1092        ))
1093        .await
1094    }
1095
1096    pub async fn waiting_for_tool_results(&self) -> everruns_provider::error::Result<()> {
1097        self.set_session_status(
1098            SessionExecutionState::WaitingForToolResults,
1099            "waiting_for_tool_results",
1100        )
1101        .await
1102    }
1103
1104    pub async fn dependency_blocked(
1105        &self,
1106        turn_id: TurnId,
1107        input_message_id: MessageId,
1108        blocker: DependencyBlocker,
1109    ) -> everruns_provider::error::Result<()> {
1110        let user_error = UserFacingError::new(blocker.error_code())
1111            .with_field(
1112                "dependency",
1113                match blocker {
1114                    DependencyBlocker::HarnessArchived | DependencyBlocker::HarnessDeleted => {
1115                        "harness"
1116                    }
1117                    DependencyBlocker::AgentArchived | DependencyBlocker::AgentDeleted => "agent",
1118                },
1119            )
1120            .with_field(
1121                "state",
1122                match blocker {
1123                    DependencyBlocker::HarnessArchived | DependencyBlocker::AgentArchived => {
1124                        "archived"
1125                    }
1126                    DependencyBlocker::HarnessDeleted | DependencyBlocker::AgentDeleted => {
1127                        "deleted"
1128                    }
1129                },
1130            );
1131        let mut error_message = Message::assistant(blocker.message());
1132        let mut metadata = std::collections::HashMap::new();
1133        user_error.apply_to_message_metadata(&mut metadata);
1134        error_message.metadata = Some(metadata);
1135
1136        self.emit_event(EventRequest::new(
1137            self.session_id,
1138            EventContext::turn(turn_id, input_message_id),
1139            OutputMessageCompletedData::new(error_message).with_user_facing_error(&user_error),
1140        ))
1141        .await?;
1142
1143        self.turn_failed(
1144            turn_id,
1145            input_message_id,
1146            blocker.message(),
1147            Some(&user_error),
1148        )
1149        .await
1150    }
1151}
1152
1153pub async fn detect_dependency_blocker<A: RuntimeHostAdapter>(
1154    adapter: &A,
1155    org_id: i64,
1156    harness_id: HarnessId,
1157    agent_id: Option<AgentId>,
1158) -> everruns_provider::error::Result<Option<DependencyBlocker>> {
1159    let harness_store = adapter.harness_store(org_id);
1160    let agent_store = adapter.agent_store(org_id);
1161    if let Some(blocker) = harness_store.get_harness_blocker(harness_id).await? {
1162        return Ok(Some(blocker));
1163    }
1164    if let Some(agent_id) = agent_id
1165        && let Some(blocker) = agent_store.get_agent_blocker(agent_id).await?
1166    {
1167        return Ok(Some(blocker));
1168    }
1169    Ok(None)
1170}
1171
1172pub async fn execute_input_activity<A: RuntimeHostAdapter>(
1173    adapter: &A,
1174    org_id: i64,
1175    input: InputAtomInput,
1176) -> everruns_provider::error::Result<InputAtomResult> {
1177    // The live effort override is turn-scoped. Clear any value left by the
1178    // previous turn before ReasonAtom can prefer it over this turn's message
1179    // controls.
1180    if let Some(handle) = adapter.reasoning_effort_handle(input.context.session_id) {
1181        handle.set(None);
1182    }
1183
1184    RuntimeSessionLifecycle::new(adapter.clone(), org_id, input.context.session_id)
1185        .turn_started(input.context.turn_id, input.context.input_message_id)
1186        .await?;
1187
1188    let atom = InputAtom::new(adapter.message_store());
1189    atom.execute(input).await
1190}
1191
1192/// Collect `user_prompt_submit` hooks for this turn and run them against the
1193/// inbound user message text. Returns `None` when the session has no such
1194/// hooks (the common case — no overhead beyond the spec collection, which is
1195/// skipped early). Errors loading specs are logged and treated as "no hooks"
1196/// so a hook-collection failure never blocks a turn that wasn't asking to be
1197/// hooked.
1198pub(crate) struct UserPromptHookResult {
1199    pub(crate) decision: everruns_core::lifecycle_hooks::UserPromptDecision,
1200    pub(crate) original_message: String,
1201}
1202
1203pub(crate) async fn run_user_prompt_submit_for_message<A: RuntimeHostAdapter>(
1204    adapter: &A,
1205    org_id: i64,
1206    input: &ReasonInput,
1207    message_text: String,
1208) -> everruns_provider::error::Result<Option<UserPromptHookResult>> {
1209    let (specs, dispatcher) = match collect_lifecycle_hook_specs(
1210        adapter,
1211        org_id,
1212        input.context.session_id,
1213        input.harness_id,
1214        input.agent_id,
1215    )
1216    .await
1217    {
1218        Ok(pair) => pair,
1219        Err(error) => {
1220            warn!(
1221                session_id = %input.context.session_id,
1222                %error,
1223                "failed to collect user_prompt_submit hook specs; continuing without them"
1224            );
1225            return Ok(None);
1226        }
1227    };
1228    let hooks = everruns_core::lifecycle_hooks::build_turn_lifecycle_hooks(
1229        &specs,
1230        everruns_core::user_hook_types::HookEvent::UserPromptSubmit,
1231        dispatcher,
1232    );
1233    if hooks.is_empty() {
1234        return Ok(None);
1235    }
1236
1237    let ctx = everruns_core::lifecycle_hooks::TurnHookContext {
1238        session_id: input.context.session_id,
1239        turn_id: Some(input.context.turn_id),
1240        org_id: org_public_id_from_internal(org_id).parse().ok(),
1241        agent_id: input.agent_id.map(|a| a.to_string()),
1242    };
1243    let original_message = message_text.clone();
1244    let decision =
1245        everruns_core::lifecycle_hooks::run_user_prompt_submit_hooks(&hooks, &ctx, message_text)
1246            .await;
1247    Ok(Some(UserPromptHookResult {
1248        decision,
1249        original_message,
1250    }))
1251}
1252
1253async fn run_user_prompt_submit_for_turn<A: RuntimeHostAdapter>(
1254    adapter: &A,
1255    org_id: i64,
1256    input: &ReasonInput,
1257) -> everruns_provider::error::Result<Option<UserPromptHookResult>> {
1258    let message_text = adapter
1259        .message_store()
1260        .get(input.context.session_id, input.context.input_message_id)
1261        .await
1262        .ok()
1263        .flatten()
1264        .map(|m| m.content_to_llm_string())
1265        .unwrap_or_default();
1266    run_user_prompt_submit_for_message(adapter, org_id, input, message_text).await
1267}
1268
1269pub async fn execute_reason_activity<A: RuntimeHostAdapter>(
1270    adapter: &A,
1271    org_id: i64,
1272    input: ReasonInput,
1273) -> everruns_provider::error::Result<ReasonResult> {
1274    let prompt_message_ids = (input.iteration <= 1)
1275        .then_some(input.context.input_message_id)
1276        .into_iter()
1277        .collect();
1278    execute_reason_activity_with_prompt_messages(adapter, org_id, input, prompt_message_ids).await
1279}
1280
1281/// Execute a reason activity while applying `user_prompt_submit` hooks to the
1282/// supplied messages. Hosts that inject synthetic user messages between reason
1283/// iterations must include their ids here so they cross the same policy
1284/// boundary as the turn's original input.
1285pub async fn execute_reason_activity_with_prompt_messages<A: RuntimeHostAdapter>(
1286    adapter: &A,
1287    org_id: i64,
1288    input: ReasonInput,
1289    prompt_message_ids: Vec<MessageId>,
1290) -> everruns_provider::error::Result<ReasonResult> {
1291    if let Some(blocker) =
1292        detect_dependency_blocker(adapter, org_id, input.harness_id, input.agent_id).await?
1293    {
1294        RuntimeSessionLifecycle::new(adapter.clone(), org_id, input.context.session_id)
1295            .dependency_blocked(
1296                input.context.turn_id,
1297                input.context.input_message_id,
1298                blocker,
1299            )
1300            .await?;
1301        return Ok(ReasonResult {
1302            native_counts: None,
1303            success: false,
1304            text: blocker.message().to_string(),
1305            tool_calls: vec![],
1306            has_tool_calls: false,
1307            tool_definitions: vec![],
1308            max_iterations: everruns_core::runtime_agent::default_max_iterations(),
1309            error: Some("dependency_unavailable".to_string()),
1310            user_facing_error: None,
1311            error_disclosure: None,
1312            usage: None,
1313            output_message_id: None,
1314            time_to_first_token_ms: None,
1315            response_id: None,
1316            finish_reason: None,
1317            locale: None,
1318            network_access: None,
1319            parallel_tool_calls: None,
1320        });
1321    }
1322
1323    // A `Block` aborts the turn by reusing the same failure path as
1324    // `dependency_blocked`. Hosts pass the original input on iteration one and
1325    // any synthetic user messages injected later, ensuring every provider-bound
1326    // user message crosses this policy boundary.
1327    let mut user_prompt_message_overrides = Vec::new();
1328    for message_id in prompt_message_ids {
1329        let mut hook_input = input.clone();
1330        hook_input.context.input_message_id = message_id;
1331        let Some(hook_result) =
1332            run_user_prompt_submit_for_turn(adapter, org_id, &hook_input).await?
1333        else {
1334            continue;
1335        };
1336        match hook_result.decision {
1337            everruns_core::lifecycle_hooks::UserPromptDecision::Block {
1338                reason,
1339                user_message,
1340            } => {
1341                RuntimeSessionLifecycle::new(adapter.clone(), org_id, input.context.session_id)
1342                    .user_prompt_blocked(
1343                        input.context.turn_id,
1344                        input.context.input_message_id,
1345                        &reason,
1346                        user_message.as_deref(),
1347                    )
1348                    .await?;
1349                return Ok(ReasonResult {
1350                    native_counts: None,
1351                    success: false,
1352                    text: user_message.unwrap_or_else(|| reason.clone()),
1353                    tool_calls: vec![],
1354                    has_tool_calls: false,
1355                    tool_definitions: vec![],
1356                    max_iterations: everruns_core::runtime_agent::default_max_iterations(),
1357                    error: Some("blocked_by_user_prompt_hook".to_string()),
1358                    user_facing_error: None,
1359                    error_disclosure: None,
1360                    usage: None,
1361                    output_message_id: None,
1362                    time_to_first_token_ms: None,
1363                    response_id: None,
1364                    finish_reason: None,
1365                    locale: None,
1366                    network_access: None,
1367                    parallel_tool_calls: None,
1368                });
1369            }
1370            everruns_core::lifecycle_hooks::UserPromptDecision::Continue { message } => {
1371                if message != hook_result.original_message {
1372                    user_prompt_message_overrides.push((message_id, message));
1373                }
1374            }
1375        }
1376    }
1377
1378    // Validate the executor-side registry before ReasonAtom exposes its tool
1379    // definitions to the model. This catches host wiring errors as
1380    // configuration failures instead of late tool-call failures.
1381    let validation_session = adapter
1382        .session_store(org_id)
1383        .get_session(input.context.session_id)
1384        .await?
1385        .ok_or_else(|| {
1386            everruns_provider::error::AgentLoopError::session_not_found(input.context.session_id)
1387        })?;
1388    let validation_capabilities = load_execution_capabilities(
1389        adapter,
1390        org_id,
1391        input.context.session_id,
1392        input.harness_id,
1393        input.agent_id,
1394        validation_session.locale.clone(),
1395        validation_session.blueprint_id.as_deref(),
1396    )
1397    .await?;
1398    let query_history_allowed = validation_capabilities
1399        .tool_registry
1400        .get("query_history")
1401        .is_some();
1402    let validation_services = runtime_tool_context_services(
1403        adapter,
1404        org_id,
1405        input.context.session_id,
1406        input.agent_id,
1407        Some(Arc::new(validation_capabilities.tool_registry.clone())),
1408        None,
1409        validation_capabilities.subagent_nesting_policy,
1410    );
1411    validation_capabilities
1412        .tool_registry
1413        .validate_context_services(&validation_services)?;
1414
1415    let mut turn_inputs = adapter
1416        .load_resolved_turn(org_id, input.context.session_id)
1417        .await?;
1418    if let Some(augmentor) = adapter.tool_augmentor() {
1419        augmentor
1420            .augment_reason_tools(
1421                input.context.session_id,
1422                adapter.session_store(org_id),
1423                adapter.session_task_registry(),
1424                &mut turn_inputs.mcp_tool_definitions,
1425            )
1426            .await?;
1427    }
1428
1429    let reason_capability_registry = {
1430        let mut registry = adapter.capability_registry();
1431        if !query_history_allowed {
1432            // Persisted history may contain raw text removed by earlier prompt
1433            // hooks. Preserve the owning capability's message filter while
1434            // suppressing its prompt/tool contributions for this turn. Tool
1435            // ownership is discovered through the neutral capability contract;
1436            // host does not depend on a concrete implementation or capability ID.
1437            let query_history_owner = registry
1438                .list()
1439                .into_iter()
1440                .find(|capability| {
1441                    capability
1442                        .tool_definitions()
1443                        .iter()
1444                        .any(|tool| tool.name() == "query_history")
1445                })
1446                .map(Arc::clone);
1447            if let Some(capability) = query_history_owner {
1448                registry.register(MessageFilterOnlyCapability(capability));
1449            }
1450        }
1451        registry
1452    };
1453    let context_resolver = crate::runtime_context::StoreTurnContextResolver::new(
1454        adapter.harness_store(org_id),
1455        adapter.agent_store(org_id),
1456        adapter.session_store(org_id),
1457        adapter.message_store(),
1458        adapter.provider_store(org_id),
1459        reason_capability_registry.clone(),
1460        adapter.driver_registry(),
1461    )
1462    .with_file_store(adapter.file_store());
1463    let context_resolver = match adapter.storage_store() {
1464        Some(store) => context_resolver.with_session_storage(store),
1465        None => context_resolver,
1466    };
1467    let mut atom = ReasonAtom::new(
1468        context_resolver,
1469        adapter.message_store(),
1470        reason_capability_registry.clone(),
1471        adapter.event_emitter(),
1472    );
1473    if let Some(image_resolver) = adapter.image_resolver(org_id) {
1474        atom = atom.with_image_resolver(image_resolver);
1475    }
1476    if let Some(file_resolver) = adapter.file_resolver(org_id) {
1477        atom = atom.with_file_resolver(file_resolver);
1478    }
1479    if let Some(hb) = adapter.stream_heartbeater() {
1480        atom = atom.with_stream_heartbeater(hb);
1481    }
1482    if let Some(timeout) = adapter.provider_stall_timeout() {
1483        atom = atom.with_provider_stall_timeout(timeout);
1484    }
1485    if let Some(config) = adapter.provider_retry_config() {
1486        atom = atom.with_provider_retry_config(config);
1487    }
1488    if let Some(store) = adapter.partial_stream_store() {
1489        atom = atom.with_partial_stream_store(store);
1490    }
1491    if let Some(store) = adapter.durable_tool_result_store() {
1492        atom = atom.with_durable_tool_result_store(store);
1493    }
1494    if let Some(store) = adapter.compaction_checkpoint_store() {
1495        atom = atom.with_compaction_checkpoint_store(store);
1496    }
1497    if let Some(handle) = adapter.reasoning_effort_handle(input.context.session_id) {
1498        atom = atom.with_reasoning_effort_handle(handle);
1499    }
1500    if let Some(utility_llm_service) = adapter.utility_llm_service() {
1501        atom = atom.with_utility_llm_service(utility_llm_service);
1502    }
1503    if let Some(classifier) = adapter.classifier() {
1504        atom = atom.with_classifier(classifier);
1505    }
1506    // Schedule store powers the `usage_limit_auto_continue` capability, which
1507    // schedules a continuation after a provider usage limit resets.
1508    if let Some(schedule_store) = adapter.schedule_store(org_id) {
1509        atom = atom.with_schedule_store(schedule_store);
1510    }
1511
1512    let mut assembled = crate::runtime_context::assemble_turn_context_from_snapshot(
1513        turn_inputs.snapshot,
1514        adapter.message_store().as_ref(),
1515        adapter.provider_store(org_id).as_ref(),
1516        &reason_capability_registry,
1517        &adapter.driver_registry(),
1518        &turn_inputs.mcp_tool_definitions,
1519        Some(adapter.file_store()),
1520        // Lets `channel_context` read the session's persisted ThreadContext at
1521        // prompt-assembly time (EVE-977). `None` when the adapter has no store;
1522        // the capability then contributes nothing.
1523        adapter.storage_store(),
1524    )
1525    .await?;
1526    let input = ReasonInput {
1527        mcp_tool_definitions: turn_inputs.mcp_tool_definitions,
1528        ..input
1529    };
1530
1531    if !user_prompt_message_overrides.is_empty() {
1532        for (message_id, message_override) in user_prompt_message_overrides {
1533            let message = assembled
1534                .messages
1535                .iter_mut()
1536                .find(|message| message.id == message_id)
1537                .ok_or_else(|| {
1538                    everruns_provider::error::AgentLoopError::config(
1539                        "user_prompt_submit mutation: input message not found in assembled context",
1540                    )
1541                })?;
1542
1543            // Apply enforcement mutations to provider context only, retaining
1544            // persisted history as an audit record of the original content.
1545            message
1546                .content
1547                .retain(|part| !matches!(part, ContentPart::Text(_)));
1548            message
1549                .content
1550                .insert(0, ContentPart::text(message_override));
1551        }
1552    }
1553    // A model switch is otherwise visible only in `llm.generation`, which is
1554    // diagnostic and off the reading path. Mark it here, where every host —
1555    // the durable worker and the in-process framework runtime alike — resolves
1556    // the turn's model, rather than at one API's message-create path.
1557    if input.iteration <= 1 {
1558        emit_model_change_if_switched(adapter, org_id, &input, &assembled).await;
1559    }
1560
1561    crate::native_async::execute_reason(adapter, org_id, input, assembled, atom).await
1562}
1563
1564/// Emit `session.model.changed` when this turn's input selects a model
1565/// different from the previous turn's.
1566///
1567/// Best effort: a missing marker must not fail the turn.
1568async fn emit_model_change_if_switched<A: RuntimeHostAdapter>(
1569    adapter: &A,
1570    org_id: i64,
1571    input: &ReasonInput,
1572    assembled: &AssembledTurnContext,
1573) {
1574    let Some((previous_model_id, model_id)) = model_switch(&assembled.messages) else {
1575        return;
1576    };
1577    if assembled.resolved_model_id != Some(model_id) {
1578        // The requested model did not survive resolution (unknown or removed),
1579        // so the turn runs on a fallback this marker would misname.
1580        return;
1581    }
1582
1583    // The previous model is named by looking it up; the current one is already
1584    // resolved for this turn. Names are captured now so the transcript stays
1585    // readable after a model is removed from the org.
1586    let previous_model_name = adapter
1587        .provider_store(org_id)
1588        .get_model_spec(previous_model_id)
1589        .await
1590        .ok()
1591        .flatten()
1592        .map(|spec| spec.model);
1593
1594    let request = EventRequest::new(
1595        input.context.session_id,
1596        EventContext::turn(input.context.turn_id, input.context.input_message_id),
1597        SessionModelChangedData {
1598            previous_model_id: Some(previous_model_id),
1599            previous_model_name,
1600            model_id,
1601            model_name: assembled.model.model.clone(),
1602        },
1603    );
1604    if let Err(e) = adapter.event_emitter().emit(request).await {
1605        warn!(error = %e, "Failed to emit session.model.changed event");
1606    }
1607}
1608
1609/// `(previous, current)` model overrides when the turn's input switched models.
1610///
1611/// Only an explicit override replacing a different explicit override counts. A
1612/// turn without an override runs on an inherited default, and history visible
1613/// here is capability-filtered: treating a missing override as "the default"
1614/// would report a switch whenever an older message was filtered out.
1615fn model_switch(messages: &[Message]) -> Option<(ModelId, ModelId)> {
1616    let mut user_model_ids = messages
1617        .iter()
1618        .rev()
1619        .filter(|message| message.role == MessageRole::User)
1620        .map(|message| {
1621            message
1622                .controls
1623                .as_ref()
1624                .and_then(|controls| controls.model_id)
1625        });
1626
1627    // `latest_model_override` in `runtime_context` resolves the turn's model
1628    // from the last user message alone, so the comparison is between the last
1629    // two user messages — not the last two overrides anywhere in history.
1630    let model_id = user_model_ids.next().flatten()?;
1631    let previous_model_id = user_model_ids.next().flatten()?;
1632    (previous_model_id != model_id).then_some((previous_model_id, model_id))
1633}
1634
1635pub async fn execute_act_activity<A: RuntimeHostAdapter>(
1636    adapter: &A,
1637    input: ActInput,
1638) -> everruns_provider::error::Result<ActResult> {
1639    let org_id = input.org_id.ok_or_else(|| {
1640        everruns_provider::error::AgentLoopError::config(
1641            "ActInput.org_id must be set for runtime host execution",
1642        )
1643    })?;
1644
1645    if let Some(blocker) =
1646        detect_dependency_blocker(adapter, org_id, input.harness_id, input.agent_id).await?
1647    {
1648        RuntimeSessionLifecycle::new(adapter.clone(), org_id, input.context.session_id)
1649            .dependency_blocked(
1650                input.context.turn_id,
1651                input.context.input_message_id,
1652                blocker,
1653            )
1654            .await?;
1655        return Ok(ActResult {
1656            results: vec![],
1657            completed: true,
1658            success_count: 0,
1659            error_count: 1,
1660            waiting_for_tool_results: false,
1661            waiting_for_url_elicitation: false,
1662            blocked: true,
1663            client_tool_calls: vec![],
1664            client_tool_definitions: vec![],
1665        });
1666    }
1667
1668    let execution_capabilities = load_execution_capabilities(
1669        adapter,
1670        org_id,
1671        input.context.session_id,
1672        input.harness_id,
1673        input.agent_id,
1674        input.locale.clone(),
1675        input.blueprint_id.as_deref(),
1676    )
1677    .await?;
1678    let mut tool_registry = execution_capabilities.tool_registry;
1679
1680    if let Some(augmentor) = adapter.tool_augmentor() {
1681        augmentor
1682            .augment_act_tools(
1683                input.context.session_id,
1684                adapter.session_store(org_id),
1685                adapter.session_task_registry(),
1686                adapter.file_store(),
1687                &input.tool_definitions,
1688                &mut tool_registry,
1689            )
1690            .await?;
1691    }
1692
1693    // Register the session's MCP tools as first-class registry tools, so they
1694    // execute through the regular `ToolExecutor` path and are visible to
1695    // everything that introspects the registry (spawn_background, tool_search,
1696    // openai_tool_search namespaces, ...). The turn's tool definitions already
1697    // include the discovered MCP tools, so no re-discovery is needed; the host's
1698    // MCP executor supplies execution (knowledge/integrations/runtime-mcp.md D5).
1699    // The MCP invoker is reused below for the guardrails `mcp` check, which
1700    // delegates a guardrail decision to an external endpoint over the same
1701    // scoped-MCP client/auth (knowledge/execution/guardrails.md).
1702    let mut mcp_invoker: Option<Arc<dyn everruns_core::McpToolInvoker>> = None;
1703    if let Some(mcp) = adapter.mcp_executor(org_id, input.context.session_id).await {
1704        let invoker: Arc<dyn everruns_core::McpToolInvoker> = mcp;
1705        for tool in everruns_core::build_mcp_proxy_tools(&input.tool_definitions, invoker.clone()) {
1706            tool_registry.register_boxed(tool);
1707        }
1708        mcp_invoker = Some(Arc::new(everruns_core::ScopedMcpToolInvoker::new(
1709            &input.tool_definitions,
1710            invoker,
1711        )));
1712    }
1713
1714    let builtin_tool_registry = Arc::new(tool_registry.clone());
1715    let context_services = runtime_tool_context_services(
1716        adapter,
1717        org_id,
1718        input.context.session_id,
1719        input.agent_id,
1720        Some(builtin_tool_registry),
1721        mcp_invoker,
1722        execution_capabilities.subagent_nesting_policy,
1723    );
1724    tool_registry.validate_context_services(&context_services)?;
1725    let executor: Arc<dyn everruns_core::tool_execution::ToolExecutor> = Arc::new(tool_registry);
1726
1727    let mut atom = ActAtom::new(executor, adapter.event_emitter())
1728        .with_context_services(context_services)
1729        .with_post_tool_hooks(execution_capabilities.post_tool_hooks)
1730        .with_pre_tool_hooks(execution_capabilities.pre_tool_hooks)
1731        .with_tool_call_hooks(execution_capabilities.tool_call_hooks);
1732
1733    #[cfg(feature = "builtins")]
1734    {
1735        atom = atom.with_final_post_tool_hook(Arc::new(everruns_builtins::PersistOutputHook));
1736    }
1737
1738    if let Some(limiter) = adapter.outbound_tool_rate_limiter(org_id) {
1739        atom = atom.with_outbound_tool_rate_limiter(limiter);
1740    }
1741    if let Some(store) = adapter.durable_tool_result_store() {
1742        atom = atom.with_durable_tool_result_store(store);
1743    }
1744
1745    atom.execute(input).await
1746}