Skip to main content

awaken_runtime/
run.rs

1//! Owned run activation boundary.
2
3use std::collections::HashMap;
4use std::sync::Arc;
5
6use awaken_runtime_contract::contract::commit_coordinator::CommitCoordinator;
7use awaken_runtime_contract::contract::run::{
8    RunInput, RunInputSnapshot, RunIntent, RunKind, RunOptions, RunTraceContext,
9};
10use awaken_runtime_contract::contract::storage::{RunRecord, RunRequestOrigin, StorageError};
11use awaken_runtime_contract::contract::suspension::ToolCallResume;
12use awaken_runtime_contract::contract::tool_intercept::{AdapterKind, RunMode};
13use awaken_runtime_contract::contract::{
14    inference::InferenceOverride, message::Message, tool::ToolDescriptor,
15};
16use futures::channel::mpsc;
17
18use crate::cancellation::CancellationToken;
19use crate::inbox::{InboxReceiver, InboxSender};
20use crate::loop_runner::PendingBoundaryHandler;
21use crate::registry::RegistrySet;
22use crate::resolution::Resolver;
23
24/// Read-only snapshot of cached thread state, passed from mailbox to runtime.
25#[non_exhaustive]
26pub struct ThreadContextSnapshot {
27    pub messages: Vec<Message>,
28    pub latest_run: Option<RunRecord>,
29    pub run_cache: HashMap<String, RunRecord>,
30}
31
32impl ThreadContextSnapshot {
33    #[must_use]
34    pub fn new(
35        messages: Vec<Message>,
36        latest_run: Option<RunRecord>,
37        run_cache: HashMap<String, RunRecord>,
38    ) -> Self {
39        Self {
40            messages,
41            latest_run,
42            run_cache,
43        }
44    }
45}
46
47/// In-process inbox pair owned by a single run.
48pub struct RunInbox {
49    pub sender: InboxSender,
50    pub receiver: InboxReceiver,
51}
52
53/// Runtime control handles that cannot be persisted.
54#[derive(Default)]
55pub struct RunControl {
56    pub cancellation_token: Option<CancellationToken>,
57    pub decision_rx: Option<mpsc::UnboundedReceiver<Vec<(String, ToolCallResume)>>>,
58    pub inbox: Option<RunInbox>,
59    pub pending_boundary: Option<Arc<dyn PendingBoundaryHandler>>,
60    pub seeded_decisions: Vec<(String, ToolCallResume)>,
61    /// Optional per-run commit coordinator override. The server dispatch path
62    /// supplies a staging coordinator that folds canonical event drafts into
63    /// each checkpoint commit, so the runtime never observes the staging
64    /// buffer. When `None`, the runtime's build-time coordinator is used.
65    pub commit_coordinator_override: Option<Arc<dyn CommitCoordinator>>,
66}
67
68/// Thread-context bundle threaded into runtime execution.
69/// `thread_context_cache` is an optional caller-side fast path; absent means
70/// the runtime loads thread context from the store as usual. Canonical event
71/// staging is owned by the (server-supplied) commit coordinator, not here.
72#[derive(Default)]
73pub struct CaptureWiring {
74    pub thread_context_cache: Option<Arc<ThreadContextSnapshot>>,
75}
76
77/// Submit-side facts the runtime must adopt to keep durable writes
78/// idempotent and identity chains stable.
79///
80/// - `is_continuation`: the activation continues a prior run (resume /
81///   handoff). The runtime uses this to skip re-persisting messages.
82/// - `messages_already_persisted`: submit paths set this when they have
83///   already appended new messages to the thread log.
84/// - `run_id_hint` / `dispatch_id_hint`: mailbox-allocated identifiers
85///   the runtime adopts instead of minting fresh ones, preserving the
86///   dispatch ↔ run ↔ event chain.
87#[derive(Default)]
88pub struct PersistenceHints {
89    pub is_continuation: bool,
90    pub messages_already_persisted: bool,
91    pub run_id_hint: Option<String>,
92    pub dispatch_id_hint: Option<String>,
93    /// Opaque server-owned resolved registry snapshot id to persist for this run.
94    pub resolution_id_hint: Option<String>,
95}
96
97/// Frozen resolver objects inherited from a pinned root run. Sub-runs
98/// spawned from a replayable parent use this to resolve against the same
99/// registry the parent ran under, independent of the live registry
100/// snapshot.
101#[derive(Default)]
102pub struct ResolverInheritance {
103    pub pinned_registry_set: Option<RegistrySet>,
104    pub run_resolver: Option<Arc<dyn Resolver>>,
105}
106
107/// Owned request to execute or resume a run.
108pub struct RunActivation {
109    pub intent: RunIntent,
110    pub input: RunInput,
111    pub options: RunOptions,
112    pub trace: RunTraceContext,
113    pub control: RunControl,
114    /// Event capture and thread-context inputs the runtime threads into
115    /// execution; orthogonal to user intent and trace metadata.
116    pub capture: CaptureWiring,
117    /// Submit-side persistence facts the runtime must honour for
118    /// idempotency / id stability.
119    pub persistence: PersistenceHints,
120    /// Pinned resolver objects inherited from the parent for sub-run
121    /// scope continuity.
122    pub inherited: ResolverInheritance,
123}
124
125impl RunActivation {
126    /// Build an activation with new message bodies.
127    #[must_use]
128    pub fn new(thread_id: impl Into<String>, messages: Vec<Message>) -> Self {
129        let thread_id = thread_id.into();
130        Self {
131            intent: RunIntent::new(thread_id),
132            input: RunInput::NewMessages(messages),
133            options: RunOptions::default(),
134            trace: RunTraceContext::default(),
135            control: RunControl::default(),
136            capture: CaptureWiring::default(),
137            persistence: PersistenceHints::default(),
138            inherited: ResolverInheritance::default(),
139        }
140    }
141
142    #[must_use]
143    pub fn thread_id(&self) -> &str {
144        &self.intent.thread_id
145    }
146
147    #[must_use]
148    pub fn messages(&self) -> &[Message] {
149        match &self.input {
150            RunInput::NewMessages(messages) => messages,
151            RunInput::AlreadyPersisted(_) => &[],
152        }
153    }
154
155    #[must_use]
156    pub fn messages_already_persisted(&self) -> bool {
157        self.persistence.messages_already_persisted
158            || matches!(self.input, RunInput::AlreadyPersisted(_))
159    }
160
161    #[must_use]
162    pub fn agent_id(&self) -> Option<&str> {
163        self.intent.agent_id.as_deref()
164    }
165
166    #[must_use]
167    pub fn run_id_hint(&self) -> Option<&str> {
168        self.persistence.run_id_hint.as_deref()
169    }
170
171    #[must_use]
172    pub fn dispatch_id_hint(&self) -> Option<&str> {
173        self.persistence.dispatch_id_hint.as_deref()
174    }
175
176    #[must_use]
177    pub fn resume_run_id(&self) -> Option<&str> {
178        match &self.intent.kind {
179            RunKind::HitlResume { run_id } | RunKind::ContinuationFromRun { run_id } => {
180                Some(run_id)
181            }
182            RunKind::NewIntent => None,
183        }
184    }
185
186    #[must_use]
187    pub fn with_agent_id(mut self, agent_id: impl Into<String>) -> Self {
188        self.intent.agent_id = Some(agent_id.into());
189        self
190    }
191
192    #[must_use]
193    pub fn with_overrides(mut self, overrides: InferenceOverride) -> Self {
194        self.options.overrides = Some(overrides);
195        self
196    }
197
198    #[must_use]
199    pub fn with_decisions(mut self, decisions: Vec<(String, ToolCallResume)>) -> Self {
200        self.control.seeded_decisions = decisions;
201        self
202    }
203
204    #[must_use]
205    pub fn with_frontend_tools(mut self, tools: Vec<ToolDescriptor>) -> Self {
206        self.options.frontend_tools = tools;
207        self
208    }
209
210    #[must_use]
211    pub fn with_legacy_origin(mut self, origin: RunRequestOrigin) -> Self {
212        self.trace.origin = origin.into();
213        self
214    }
215
216    #[must_use]
217    pub fn with_origin(self, origin: RunRequestOrigin) -> Self {
218        self.with_legacy_origin(origin)
219    }
220
221    #[must_use]
222    pub fn with_run_mode(mut self, run_mode: RunMode) -> Self {
223        self.trace.run_mode = run_mode;
224        self
225    }
226
227    #[must_use]
228    pub fn with_adapter(mut self, adapter: AdapterKind) -> Self {
229        self.trace.adapter = adapter;
230        self
231    }
232
233    #[must_use]
234    pub fn with_parent_run_id(mut self, parent_run_id: impl Into<String>) -> Self {
235        self.trace.parent_run_id = Some(parent_run_id.into());
236        self
237    }
238
239    #[must_use]
240    pub fn with_parent_thread_id(mut self, parent_thread_id: impl Into<String>) -> Self {
241        self.trace.parent_thread_id = Some(parent_thread_id.into());
242        self
243    }
244
245    #[must_use]
246    pub fn with_hitl_resume_run_id(mut self, run_id: impl Into<String>) -> Self {
247        self.intent.kind = RunKind::HitlResume {
248            run_id: run_id.into(),
249        };
250        self.persistence.is_continuation = true;
251        self.trace.run_mode = RunMode::Resume;
252        self
253    }
254
255    #[must_use]
256    pub fn with_continue_run_id(self, run_id: impl Into<String>) -> Self {
257        self.with_hitl_resume_run_id(run_id)
258    }
259
260    #[must_use]
261    pub fn with_continuation_run_id(mut self, run_id: impl Into<String>) -> Self {
262        self.intent.kind = RunKind::ContinuationFromRun {
263            run_id: run_id.into(),
264        };
265        self.persistence.is_continuation = true;
266        self
267    }
268
269    #[must_use]
270    pub fn with_dispatch_id(mut self, dispatch_id: impl Into<String>) -> Self {
271        self.trace.dispatch_id = Some(dispatch_id.into());
272        self
273    }
274
275    #[must_use]
276    pub fn with_trace_dispatch_id(self, dispatch_id: impl Into<String>) -> Self {
277        self.with_dispatch_id(dispatch_id)
278    }
279
280    #[must_use]
281    pub fn with_run_id_hint(mut self, run_id_hint: impl Into<String>) -> Self {
282        self.persistence.run_id_hint = Some(run_id_hint.into());
283        self
284    }
285
286    #[must_use]
287    pub fn with_dispatch_id_hint(mut self, dispatch_id_hint: impl Into<String>) -> Self {
288        self.persistence.dispatch_id_hint = Some(dispatch_id_hint.into());
289        self
290    }
291
292    #[must_use]
293    pub fn with_resolution_id_hint(mut self, resolution_id_hint: impl Into<String>) -> Self {
294        self.persistence.resolution_id_hint = Some(resolution_id_hint.into());
295        self
296    }
297
298    #[must_use]
299    pub fn with_session_id(mut self, session_id: impl Into<String>) -> Self {
300        self.trace.session_id = Some(session_id.into());
301        self
302    }
303
304    #[must_use]
305    pub fn with_transport_request_id(mut self, id: impl Into<String>) -> Self {
306        self.trace.transport_request_id = Some(id.into());
307        self
308    }
309
310    #[must_use]
311    pub fn with_inbox(mut self, sender: InboxSender, receiver: InboxReceiver) -> Self {
312        self.control.inbox = Some(RunInbox { sender, receiver });
313        self
314    }
315
316    #[must_use]
317    pub fn with_pending_boundary_handler(
318        mut self,
319        handler: Arc<dyn PendingBoundaryHandler>,
320    ) -> Self {
321        self.control.pending_boundary = Some(handler);
322        self
323    }
324
325    #[must_use]
326    pub fn with_already_persisted_input(mut self, input: RunInputSnapshot) -> Self {
327        self.input = RunInput::AlreadyPersisted(input);
328        self.persistence.messages_already_persisted = true;
329        self
330    }
331
332    #[must_use]
333    pub fn with_messages_already_persisted(mut self, value: bool) -> Self {
334        self.persistence.messages_already_persisted = value;
335        self
336    }
337
338    #[must_use]
339    pub fn with_pinned_registry_set(mut self, registry_set: RegistrySet) -> Self {
340        self.inherited.pinned_registry_set = Some(registry_set);
341        self
342    }
343
344    #[must_use]
345    pub fn with_run_resolver(mut self, resolver: Arc<dyn Resolver>) -> Self {
346        self.inherited.run_resolver = Some(resolver);
347        self
348    }
349
350    /// Attach a per-run commit coordinator override (server staging
351    /// coordinator). Supersedes the runtime's build-time coordinator for this
352    /// run only, so canonical drafts staged by the dispatch sink commit
353    /// atomically with the checkpoint without the runtime observing them.
354    #[must_use]
355    pub fn with_commit_coordinator_override(
356        mut self,
357        coordinator: Arc<dyn CommitCoordinator>,
358    ) -> Self {
359        self.control.commit_coordinator_override = Some(coordinator);
360        self
361    }
362
363    #[must_use]
364    pub fn with_thread_context_cache(mut self, cache: Arc<ThreadContextSnapshot>) -> Self {
365        self.capture.thread_context_cache = Some(cache);
366        self
367    }
368
369    /// Validate activation invariants before runtime execution starts.
370    ///
371    /// This keeps the owned runtime boundary aligned with the persisted
372    /// `RunActivationSnapshot` contract without forcing user-authored
373    /// `NewMessages` to already carry persistence ids.
374    pub fn validate(&self) -> Result<(), RunActivationError> {
375        self.intent.validate()?;
376        self.trace.validate()?;
377
378        match self.intent.kind {
379            RunKind::NewIntent if self.persistence.is_continuation => {
380                return Err(RunActivationError::Validation(
381                    "persistence.is_continuation requires a resume or continuation run kind"
382                        .to_string(),
383                ));
384            }
385            RunKind::HitlResume { .. } | RunKind::ContinuationFromRun { .. }
386                if !self.persistence.is_continuation =>
387            {
388                return Err(RunActivationError::Validation(
389                    "resume and continuation run kinds require persistence.is_continuation"
390                        .to_string(),
391                ));
392            }
393            _ => {}
394        }
395
396        if let RunInput::AlreadyPersisted(snapshot) = &self.input {
397            snapshot.validate()?;
398            if snapshot.thread_id != self.intent.thread_id {
399                return Err(RunActivationError::Validation(format!(
400                    "run activation intent.thread_id '{}' must match input.thread_id '{}'",
401                    self.intent.thread_id, snapshot.thread_id
402                )));
403            }
404        }
405
406        for (idx, (call_id, _)) in self.control.seeded_decisions.iter().enumerate() {
407            require_non_empty(
408                &format!("run activation seeded_decisions[{idx}].call_id"),
409                call_id,
410            )?;
411        }
412        require_optional_non_empty(
413            "run activation run_id_hint",
414            self.persistence.run_id_hint.as_deref(),
415        )?;
416        require_optional_non_empty(
417            "run activation dispatch_id_hint",
418            self.persistence.dispatch_id_hint.as_deref(),
419        )?;
420        Ok(())
421    }
422}
423
424#[derive(Debug, thiserror::Error)]
425pub enum RunActivationError {
426    #[error("run activation is missing thread_id")]
427    MissingThreadId,
428    #[error("run activation validation failed: {0}")]
429    Validation(String),
430    #[error(transparent)]
431    Contract(#[from] StorageError),
432}
433
434fn require_non_empty(field: &str, value: &str) -> Result<(), RunActivationError> {
435    if value.trim().is_empty() {
436        return Err(RunActivationError::Validation(format!(
437            "{field} must not be empty"
438        )));
439    }
440    Ok(())
441}
442
443fn require_optional_non_empty(field: &str, value: Option<&str>) -> Result<(), RunActivationError> {
444    if let Some(value) = value {
445        require_non_empty(field, value)?;
446    }
447    Ok(())
448}
449
450#[cfg(test)]
451mod tests {
452    use super::*;
453    use crate::resolution::{ResolutionRequest, ResolveError, ResolvedRunPlan};
454
455    fn assert_send_static<T: Send + 'static>(_: T) {}
456
457    struct NoopResolver;
458
459    #[async_trait::async_trait]
460    impl Resolver for NoopResolver {
461        async fn resolve(
462            &self,
463            _request: ResolutionRequest,
464        ) -> Result<ResolvedRunPlan, ResolveError> {
465            Err(ResolveError::Runtime("noop".into()))
466        }
467    }
468
469    struct StubCoordinator;
470
471    #[async_trait::async_trait]
472    impl CommitCoordinator for StubCoordinator {
473        fn scope(
474            &self,
475        ) -> awaken_runtime_contract::contract::commit_coordinator::TransactionScopeId {
476            awaken_runtime_contract::contract::commit_coordinator::TransactionScopeId::new("test")
477                .unwrap()
478        }
479        fn reader(
480            &self,
481        ) -> Arc<dyn awaken_runtime_contract::contract::storage::RuntimeCheckpointStore> {
482            unreachable!("routing test does not commit")
483        }
484        async fn commit_checkpoint(
485            &self,
486            _plan: awaken_runtime_contract::contract::commit_coordinator::ThreadCommit,
487        ) -> Result<
488            awaken_runtime_contract::contract::commit_coordinator::ThreadCommitOutcome,
489            awaken_runtime_contract::contract::commit_coordinator::CommitError,
490        > {
491            unreachable!("routing test does not commit")
492        }
493    }
494
495    #[test]
496    fn activation_is_owned_send_static() {
497        let activation = RunActivation::new("thread", vec![Message::user("hi")]);
498        assert_send_static(activation);
499    }
500
501    #[test]
502    fn activation_validation_rejects_blank_identity_fields() {
503        let activation = RunActivation::new("thread", vec![Message::user("hi")])
504            .with_agent_id(" ")
505            .with_run_id_hint("run-1");
506
507        let err = activation.validate().unwrap_err();
508        assert!(err.to_string().contains("agent_id"));
509    }
510
511    #[test]
512    fn activation_validation_rejects_mismatched_persisted_input_thread() {
513        let activation = RunActivation::new("thread-a", Vec::new()).with_already_persisted_input(
514            RunInputSnapshot {
515                thread_id: "thread-b".into(),
516                ..RunInputSnapshot::default()
517            },
518        );
519
520        let err = activation.validate().unwrap_err();
521        assert!(err.to_string().contains("intent.thread_id"));
522    }
523
524    #[test]
525    fn activation_validation_rejects_orphaned_continuation_hint() {
526        let mut activation = RunActivation::new("thread", Vec::new());
527        activation.persistence.is_continuation = true;
528
529        let err = activation.validate().unwrap_err();
530        assert!(err.to_string().contains("is_continuation"));
531    }
532
533    #[test]
534    fn hitl_resume_builder_sets_continuation_hint() {
535        let activation = RunActivation::new("thread", Vec::new()).with_hitl_resume_run_id("run-1");
536
537        assert!(activation.persistence.is_continuation);
538        activation.validate().unwrap();
539    }
540
541    #[test]
542    fn activation_validation_rejects_resume_without_continuation_hint() {
543        let mut activation = RunActivation::new("thread", Vec::new());
544        activation.intent.kind = RunKind::HitlResume {
545            run_id: "run-1".into(),
546        };
547
548        let err = activation.validate().unwrap_err();
549        assert!(err.to_string().contains("is_continuation"));
550    }
551
552    /// Pins the routing between builder methods and the three split
553    /// `CaptureWiring` / `PersistenceHints` / `ResolverInheritance`
554    /// sub-structs introduced when `RunExecutionWiring` was decomposed.
555    /// Any future renaming that accidentally drops a setter into the wrong
556    /// bucket will trip these field-by-field assertions.
557    #[test]
558    fn builder_methods_route_to_correct_wiring_sub_struct() {
559        use crate::registry::RegistrySet;
560        use crate::registry::memory::{
561            MapAgentSpecRegistry, MapModelRegistry, MapPluginSource, MapProviderRegistry,
562            MapToolRegistry,
563        };
564
565        let coordinator: Arc<dyn CommitCoordinator> = Arc::new(StubCoordinator);
566        let cache = Arc::new(ThreadContextSnapshot::new(
567            Vec::new(),
568            None,
569            Default::default(),
570        ));
571        let registry_set = RegistrySet {
572            agents: Arc::new(MapAgentSpecRegistry::new()),
573            tools: Arc::new(MapToolRegistry::new()),
574            models: Arc::new(MapModelRegistry::new()),
575            providers: Arc::new(MapProviderRegistry::new()),
576            plugins: Arc::new(MapPluginSource::new()),
577            #[cfg(feature = "a2a")]
578            backends: Arc::new(crate::registry::memory::MapBackendRegistry::new()),
579        };
580
581        let activation = RunActivation::new("thread", vec![Message::user("hi")])
582            .with_commit_coordinator_override(Arc::clone(&coordinator))
583            .with_thread_context_cache(Arc::clone(&cache))
584            .with_run_id_hint("hinted-run-id")
585            .with_dispatch_id_hint("hinted-dispatch-id")
586            .with_resolution_id_hint("hinted-resolution-id")
587            .with_continuation_run_id("parent-run")
588            .with_messages_already_persisted(true)
589            .with_pinned_registry_set(registry_set)
590            .with_run_resolver(Arc::new(NoopResolver));
591
592        // Control sub-struct: per-run commit coordinator override.
593        assert!(
594            activation.control.commit_coordinator_override.is_some(),
595            "with_commit_coordinator_override routes to RunControl"
596        );
597        // Capture sub-struct: thread-context fast path.
598        assert!(
599            activation.capture.thread_context_cache.is_some(),
600            "with_thread_context_cache routes to CaptureWiring"
601        );
602        // The other two sub-structs must not contain capture-shaped fields.
603
604        // Persistence sub-struct: submit-side idempotency + id injection.
605        assert_eq!(
606            activation.persistence.run_id_hint.as_deref(),
607            Some("hinted-run-id"),
608            "with_run_id_hint routes to PersistenceHints"
609        );
610        assert_eq!(
611            activation.persistence.dispatch_id_hint.as_deref(),
612            Some("hinted-dispatch-id"),
613            "with_dispatch_id_hint routes to PersistenceHints"
614        );
615        assert_eq!(
616            activation.persistence.resolution_id_hint.as_deref(),
617            Some("hinted-resolution-id"),
618            "with_resolution_id_hint routes to PersistenceHints"
619        );
620        assert!(
621            activation.persistence.is_continuation,
622            "with_continuation_run_id sets PersistenceHints::is_continuation"
623        );
624        assert!(
625            activation.persistence.messages_already_persisted,
626            "with_messages_already_persisted routes to PersistenceHints"
627        );
628
629        // Resolver inheritance sub-struct: sub-run scope pinning.
630        assert!(
631            activation.inherited.pinned_registry_set.is_some(),
632            "with_pinned_registry_set routes to ResolverInheritance"
633        );
634        assert!(
635            activation.inherited.run_resolver.is_some(),
636            "with_run_resolver routes to ResolverInheritance"
637        );
638
639        // Reverse spot-check: capture must not be mutated by submit-side or
640        // inheritance setters, and vice versa.
641        let neutral = RunActivation::new("t", Vec::new()).with_run_id_hint("x");
642        assert!(neutral.control.commit_coordinator_override.is_none());
643        assert!(neutral.inherited.pinned_registry_set.is_none());
644        assert!(neutral.inherited.run_resolver.is_none());
645        assert_eq!(neutral.persistence.run_id_hint.as_deref(), Some("x"));
646    }
647}