mod active_registry;
mod commit;
mod control;
mod entrypoints;
mod runner;
use std::sync::{Arc, RwLock};
use awaken_runtime_contract::contract::commit_coordinator::CommitCoordinator;
use awaken_runtime_contract::contract::live_control::{
LiveRunCommand, LiveRunCommandEntry, LiveRunCommandSource, LiveRunTarget,
};
use crate::error::RuntimeError;
#[cfg(feature = "a2a")]
use crate::registry::composite::CompositeAgentSpecRegistry;
use awaken_runtime_contract::contract::message::Message;
use awaken_runtime_contract::contract::suspension::ToolCallResume;
use futures::StreamExt;
use futures::channel::mpsc;
use crate::cancellation::CancellationToken;
use crate::checkpoint_store::RuntimeCheckpointStore;
use crate::inbox::InboxSender;
use crate::registry::{AgentResolver, RegistryHandle, RegistrySet, RegistrySnapshot};
use crate::resolution::{
BackendRequirements, CapabilityDecision, LocalRegistryResolver, RegistryResolutionScope,
ResolutionPolicy, ResolutionRequest, ResolutionTarget, ResolveError, ResolvedRunPlan, Resolver,
RootScopeKind,
};
use active_registry::ActiveRunRegistry;
pub(crate) type DecisionBatch = Vec<(String, ToolCallResume)>;
#[derive(Clone)]
pub(crate) struct RunHandle {
pub(crate) run_id: String,
pub(crate) dispatch_id: Option<String>,
cancellation_token: CancellationToken,
live_forwarder_token: CancellationToken,
decision_tx: mpsc::UnboundedSender<DecisionBatch>,
inbox_tx: Option<InboxSender>,
}
impl RunHandle {
pub(crate) fn cancel(&self) {
self.cancellation_token.cancel();
}
pub(crate) fn stop_live_forwarder(&self) {
self.live_forwarder_token.cancel();
}
pub(crate) fn send_decisions(
&self,
decisions: DecisionBatch,
) -> Result<(), Box<mpsc::TrySendError<DecisionBatch>>> {
self.decision_tx.unbounded_send(decisions).map_err(Box::new)
}
pub(crate) fn send_decision(
&self,
call_id: String,
resume: ToolCallResume,
) -> Result<(), Box<mpsc::TrySendError<DecisionBatch>>> {
self.send_decisions(vec![(call_id, resume)])
}
pub(crate) fn send_messages(&self, messages: Vec<Message>) -> bool {
let Some(inbox_tx) = self.inbox_tx.as_ref() else {
return false;
};
if messages.is_empty() || inbox_tx.is_closed() {
return false;
}
inbox_tx.try_send(crate::inbox::inbox_messages_payload(messages))
}
pub(crate) fn wake_pending_boundary(&self) -> bool {
let Some(inbox_tx) = self.inbox_tx.as_ref() else {
return false;
};
if inbox_tx.is_closed() {
return false;
}
inbox_tx.try_send(crate::inbox::pending_boundary_wake_payload())
}
}
pub struct AgentRuntime {
pub(crate) resolver: Arc<dyn AgentResolver>,
pub(crate) run_resolver: RwLock<Arc<dyn Resolver>>,
pub(crate) checkpoint_storage: Option<Arc<dyn RuntimeCheckpointStore>>,
pub(crate) commit_coordinator: Option<Arc<dyn CommitCoordinator>>,
pub(crate) profile_store:
Option<Arc<dyn awaken_runtime_contract::contract::profile_store::ProfileStore>>,
pub(crate) live_control_source: Option<Arc<dyn LiveRunCommandSource>>,
pub(crate) active_runs: ActiveRunRegistry,
pub(crate) registry_handle: Option<RegistryHandle>,
missing_live_control_source_warned: std::sync::atomic::AtomicBool,
#[cfg(feature = "a2a")]
composite_registry: Option<Arc<CompositeAgentSpecRegistry>>,
#[cfg(feature = "background")]
durable_message_sink:
RwLock<Option<Arc<dyn crate::extensions::background::DurableMessageSink>>>,
}
impl AgentRuntime {
pub fn new(resolver: Arc<dyn AgentResolver>) -> Self {
Self::new_with_execution_resolver(resolver)
}
pub fn new_with_execution_resolver(resolver: Arc<dyn AgentResolver>) -> Self {
let run_resolver = Arc::new(LocalRegistryResolver::new(resolver.clone()));
Self {
resolver,
run_resolver: RwLock::new(run_resolver),
checkpoint_storage: None,
commit_coordinator: None,
profile_store: None,
live_control_source: None,
active_runs: ActiveRunRegistry::new(),
registry_handle: None,
missing_live_control_source_warned: std::sync::atomic::AtomicBool::new(false),
#[cfg(feature = "a2a")]
composite_registry: None,
#[cfg(feature = "background")]
durable_message_sink: RwLock::new(None),
}
}
#[cfg(feature = "background")]
pub fn set_durable_message_sink(
&self,
sink: Arc<dyn crate::extensions::background::DurableMessageSink>,
) {
*self
.durable_message_sink
.write()
.expect("durable message sink lock poisoned") = Some(sink);
}
#[must_use]
pub fn with_run_resolver(mut self, resolver: Arc<dyn Resolver>) -> Self {
*self
.run_resolver
.get_mut()
.expect("run resolver lock is not poisoned during construction") = resolver;
self
}
pub fn set_run_resolver(&self, resolver: Arc<dyn Resolver>) {
*self
.run_resolver
.write()
.expect("run resolver lock poisoned") = resolver;
}
#[must_use]
pub fn with_registry_handle(mut self, handle: RegistryHandle) -> Self {
self.registry_handle = Some(handle);
self
}
#[must_use]
pub fn with_checkpoint_reader(mut self, reader: Arc<dyn RuntimeCheckpointStore>) -> Self {
self.checkpoint_storage = Some(reader);
self
}
#[must_use]
pub fn with_live_control_source(mut self, source: Arc<dyn LiveRunCommandSource>) -> Self {
self.live_control_source = Some(source);
self
}
#[must_use]
pub(crate) fn with_profile_store(
mut self,
store: Arc<dyn awaken_runtime_contract::contract::profile_store::ProfileStore>,
) -> Self {
self.profile_store = Some(store);
self
}
pub fn resolver(&self) -> &dyn AgentResolver {
self.resolver.as_ref()
}
pub fn resolver_arc(&self) -> Arc<dyn AgentResolver> {
self.resolver.clone()
}
pub fn execution_resolver(&self) -> &dyn AgentResolver {
self.resolver.as_ref()
}
pub fn execution_resolver_arc(&self) -> Arc<dyn AgentResolver> {
self.resolver.clone()
}
pub fn registry_handle(&self) -> Option<RegistryHandle> {
self.registry_handle.clone()
}
pub fn run_resolver(&self) -> Arc<dyn Resolver> {
self.run_resolver_arc()
}
pub fn run_resolver_arc(&self) -> Arc<dyn Resolver> {
self.run_resolver
.read()
.expect("run resolver lock poisoned")
.clone()
}
pub async fn resolve_activation(
&self,
activation: &crate::RunActivation,
policy: ResolutionPolicy,
) -> Result<ResolvedRunPlan, ResolveError> {
self.resolve_activation_in_scope(activation, policy, RegistryResolutionScope::Live)
.await
}
pub async fn resolve_activation_in_scope(
&self,
activation: &crate::RunActivation,
policy: ResolutionPolicy,
resolution_scope: RegistryResolutionScope,
) -> Result<ResolvedRunPlan, ResolveError> {
let request =
ResolutionRequest::from_activation_with_scope(activation, policy, resolution_scope);
let expected = BackendRequirements::from_features(&request.features);
let resolver = activation
.inherited
.run_resolver
.clone()
.unwrap_or_else(|| self.run_resolver_arc());
let plan = resolver.resolve(request).await?;
if let CapabilityDecision::Unsupported(mismatches) = plan.backend_profile().check(&expected)
{
return Err(ResolveError::CapabilityMismatch(mismatches));
}
if matches!(policy, ResolutionPolicy::PersistentServer)
&& matches!(plan, ResolvedRunPlan::LiveOnly(_))
{
return Err(ResolveError::UnsupportedPersistence(
"persistent execution requires ResolvedRun<ReplayableScope>".into(),
));
}
Ok(plan)
}
pub async fn resolve_nested(
&self,
parent_scope: RootScopeKind,
sub_activation: &crate::RunActivation,
sub_target: ResolutionTarget,
) -> Result<ResolvedRunPlan, ResolveError> {
if matches!(sub_target, ResolutionTarget::Root { .. }) {
return Err(ResolveError::UnsupportedTarget(
"resolve_nested requires Delegate or Handoff target".into(),
));
}
let policy = match parent_scope {
RootScopeKind::Replayable => ResolutionPolicy::PersistentServer,
RootScopeKind::LiveOnly => ResolutionPolicy::LiveOnlyEmbedded,
};
let mut request = ResolutionRequest::from_activation(sub_activation, policy);
request.target = sub_target;
let expected = BackendRequirements::from_features(&request.features);
let resolver = sub_activation
.inherited
.run_resolver
.clone()
.unwrap_or_else(|| self.run_resolver_arc());
let plan = resolver.resolve(request).await?;
if let CapabilityDecision::Unsupported(mismatches) = plan.backend_profile().check(&expected)
{
return Err(ResolveError::CapabilityMismatch(mismatches));
}
if parent_scope == RootScopeKind::Replayable && matches!(plan, ResolvedRunPlan::LiveOnly(_))
{
return Err(ResolveError::NestedScopeMismatch(
"replayable parent run cannot spawn a live-only sub-run".into(),
));
}
Ok(plan)
}
pub fn registry_snapshot(&self) -> Option<RegistrySnapshot> {
self.registry_handle.as_ref().map(RegistryHandle::snapshot)
}
pub fn registry_version(&self) -> Option<u64> {
self.registry_handle.as_ref().map(RegistryHandle::version)
}
pub fn registry_set(&self) -> Option<RegistrySet> {
self.registry_snapshot()
.map(RegistrySnapshot::into_registries)
}
pub fn replace_registry_set(&self, registries: RegistrySet) -> Option<u64> {
self.registry_handle
.as_ref()
.map(|handle| handle.replace(registries))
}
#[cfg(feature = "a2a")]
#[must_use]
pub fn with_composite_registry(mut self, registry: Arc<CompositeAgentSpecRegistry>) -> Self {
self.composite_registry = Some(registry);
self
}
#[cfg(feature = "a2a")]
pub fn composite_registry(&self) -> Option<&Arc<CompositeAgentSpecRegistry>> {
self.composite_registry.as_ref()
}
#[cfg(feature = "a2a")]
pub async fn initialize(&self) -> Result<(), RuntimeError> {
if let Some(composite) = &self.composite_registry {
composite
.discover()
.await
.map_err(|e| RuntimeError::ResolveFailed {
message: format!("remote agent discovery failed: {e}"),
})?;
}
Ok(())
}
pub fn checkpoint_reader(&self) -> Option<&dyn RuntimeCheckpointStore> {
self.checkpoint_storage.as_deref()
}
#[cfg(all(test, feature = "background"))]
pub(crate) fn durable_message_sink_for_test(
&self,
) -> Option<Arc<dyn crate::extensions::background::DurableMessageSink>> {
self.durable_message_sink
.read()
.expect("durable message sink lock poisoned")
.clone()
}
#[cfg(test)]
pub(crate) fn create_run_channels(
&self,
run_id: String,
) -> (
RunHandle,
CancellationToken,
mpsc::UnboundedReceiver<DecisionBatch>,
) {
self.create_run_channels_with_inbox(run_id, None, None)
}
pub(crate) fn create_run_channels_with_inbox(
&self,
run_id: String,
dispatch_id: Option<String>,
inbox_tx: Option<InboxSender>,
) -> (
RunHandle,
CancellationToken,
mpsc::UnboundedReceiver<DecisionBatch>,
) {
let token = CancellationToken::new();
let live_forwarder_token = CancellationToken::new();
let (tx, rx) = mpsc::unbounded();
let handle = RunHandle {
run_id,
dispatch_id,
cancellation_token: token.clone(),
live_forwarder_token,
decision_tx: tx,
inbox_tx,
};
(handle, token, rx)
}
pub(crate) fn register_run(
&self,
thread_id: &str,
handle: RunHandle,
) -> Result<(), RuntimeError> {
let run_id = handle.run_id.clone();
let dispatch_id = handle.dispatch_id.clone();
let forwarder_inputs = self.live_control_source.as_ref().map(|source| {
(
Arc::clone(source),
handle.inbox_tx.clone(),
handle.cancellation_token.clone(),
handle.live_forwarder_token.clone(),
handle.decision_tx.clone(),
)
});
if !self.active_runs.register(&run_id, thread_id, handle) {
return Err(RuntimeError::ThreadAlreadyRunning {
thread_id: thread_id.to_string(),
});
}
if let Some((source, inbox_tx, token, forwarder_token, decision_tx)) = forwarder_inputs {
let thread_id = thread_id.to_string();
let mut target = LiveRunTarget::new(thread_id.clone(), run_id.clone());
if let Some(dispatch_id) = dispatch_id {
target = target.with_dispatch_id(dispatch_id);
}
tokio::spawn(async move {
run_live_forwarder(
source,
target,
inbox_tx,
token,
forwarder_token,
decision_tx,
)
.await;
});
} else if !self
.missing_live_control_source_warned
.swap(true, std::sync::atomic::Ordering::Relaxed)
{
tracing::warn!(
"AgentRuntime has no live control source wired: cross-node live steering \
(LiveRunCommand) will always fall through to durable queue. Call \
`AgentRuntime::with_live_control_source(source)` on multi-node deployments."
);
}
Ok(())
}
pub(crate) fn unregister_run(&self, run_id: &str) {
self.active_runs.unregister(run_id);
}
}
async fn run_live_forwarder(
source: Arc<dyn LiveRunCommandSource>,
target: LiveRunTarget,
inbox_tx: Option<InboxSender>,
cancellation_token: CancellationToken,
live_forwarder_token: CancellationToken,
decision_tx: mpsc::UnboundedSender<DecisionBatch>,
) {
let mut stream = match source.open_live_channel_for(&target).await {
Ok(s) => s,
Err(err) => {
tracing::warn!(
thread_id = %target.thread_id,
run_id = %target.run_id,
dispatch_id = ?target.dispatch_id,
error = %err,
"live channel subscribe failed"
);
return;
}
};
loop {
if live_forwarder_token.is_cancelled() {
break;
}
let next = tokio::select! {
biased;
_ = live_forwarder_token.cancelled() => break,
next = stream.next() => next,
};
let Some(LiveRunCommandEntry { command, receipt }) = next else {
break;
};
match command {
LiveRunCommand::Messages(messages) => {
let Some(tx) = inbox_tx.as_ref() else {
drop(receipt);
continue;
};
if tx.is_closed() {
drop(receipt);
break;
}
if tx.try_send(crate::inbox::inbox_messages_payload(messages)) {
receipt.ack();
} else {
drop(receipt);
}
}
LiveRunCommand::PendingBoundaryWake => {
let Some(tx) = inbox_tx.as_ref() else {
drop(receipt);
continue;
};
if tx.is_closed() {
drop(receipt);
break;
}
if tx.try_send(crate::inbox::pending_boundary_wake_payload()) {
receipt.ack();
} else {
drop(receipt);
}
}
LiveRunCommand::Cancel => {
cancellation_token.cancel();
receipt.ack();
break;
}
LiveRunCommand::Decision(decisions) => {
if decision_tx.is_closed() {
drop(receipt);
break;
}
if decision_tx.unbounded_send(decisions).is_ok() {
receipt.ack();
} else {
drop(receipt);
}
}
_ => {
tracing::error!(
thread_id = %target.thread_id,
run_id = %target.run_id,
dispatch_id = ?target.dispatch_id,
"unsupported live run command received; cancelling run to avoid silent divergence"
);
cancellation_token.cancel();
drop(receipt);
break;
}
}
}
}
#[cfg(test)]
mod tests;