use std::{
collections::HashMap,
future::Future,
pin::Pin,
sync::{Arc, Mutex},
};
use async_trait::async_trait;
use mentra::agent::{
Deferred, DeferredItem, RoundBoundary, RoundContext, RoundDecision, RoundStrategy,
};
use tokio::task::JoinHandle;
use crate::runtime::agents::AgentRegistry;
pub const DEFAULT_DELEGATION_WIDTH: usize = 4;
pub type TaskFuture = Pin<Box<dyn Future<Output = ()> + Send + 'static>>;
pub type TaskScope = Arc<dyn Fn(TaskFuture) -> TaskFuture + Send + Sync>;
pub(super) struct Settled {
pub(super) outcome: Result<String, String>,
pub(super) items: Vec<DeferredItem>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(super) enum Kind {
Subagent,
Teammate,
Remote,
}
impl Kind {
pub(super) fn noun(self) -> &'static str {
match self {
Self::Subagent => "subagent",
Self::Teammate => "teammate",
Self::Remote => "remote agent",
}
}
}
struct Slot {
name: Option<String>,
kind: Kind,
task: Option<JoinHandle<Settled>>,
claimed: bool,
}
#[derive(Clone, Default)]
pub(crate) struct Children {
slots: Arc<Mutex<HashMap<String, Slot>>>,
}
impl std::fmt::Debug for Children {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Children")
.field("running", &self.running())
.finish()
}
}
pub(super) struct Claimed {
pub(super) id: String,
pub(super) name: Option<String>,
pub(super) kind: Kind,
pub(super) task: JoinHandle<Settled>,
}
impl Children {
fn lock(&self) -> std::sync::MutexGuard<'_, HashMap<String, Slot>> {
self.slots
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
}
pub(crate) fn running(&self) -> usize {
self.lock().len()
}
pub(super) fn listed(&self) -> Vec<(String, Option<String>, Kind)> {
self.lock()
.iter()
.map(|(id, slot)| (id.clone(), slot.name.clone(), slot.kind))
.collect()
}
pub(super) fn find(&self, key: &str) -> Option<String> {
let slots = self.lock();
if slots.contains_key(key) {
return Some(key.to_string());
}
slots
.iter()
.find(|(_, slot)| slot.name.as_deref() == Some(key))
.map(|(id, _)| id.clone())
}
pub(super) fn start(
&self,
scope: Option<&TaskScope>,
id: String,
name: Option<String>,
kind: Kind,
deferred: Deferred,
work: impl Future<Output = Settled> + Send + 'static,
) {
self.lock().insert(
id.clone(),
Slot {
name,
kind,
task: None,
claimed: false,
},
);
let children = self.clone();
let settled_id = id.clone();
let (tx, rx) = tokio::sync::oneshot::channel::<Settled>();
let body: TaskFuture = Box::pin(async move {
let settled = work.await;
let _ = tx.send(settled);
});
let body = match scope {
Some(scope) => scope(body),
None => body,
};
let task = tokio::spawn(async move {
body.await;
let settled =
rx.await.unwrap_or_else(|_| Settled {
outcome: Err(
"the host's task scope ended the delegation before it ran".to_string()
),
items: Vec::new(),
});
children.settle(&settled_id, &deferred, settled)
});
if let Some(slot) = self.lock().get_mut(&id) {
slot.task = Some(task);
}
}
fn settle(&self, id: &str, deferred: &Deferred, settled: Settled) -> Settled {
let mut slots = self.lock();
let claimed = slots.get(id).is_some_and(|slot| slot.claimed);
if claimed {
return settled;
}
for item in &settled.items {
deferred.push(item.clone());
}
slots.remove(id);
settled
}
pub(super) fn claim(&self, id: &str) -> Result<Claimed, String> {
let mut slots = self.lock();
let Some(slot) = slots.get_mut(id) else {
return Err(format!("nothing is running under `{id}`"));
};
if slot.claimed {
return Err(format!("`{id}` is already being waited on"));
}
let Some(task) = slot.task.take() else {
return Err(format!("`{id}` has not started yet; try again"));
};
slot.claimed = true;
Ok(Claimed {
id: id.to_string(),
name: slot.name.clone(),
kind: slot.kind,
task,
})
}
pub(super) fn release(&self, claimed: Claimed) -> Option<Claimed> {
if claimed.task.is_finished() {
return Some(claimed);
}
let mut slots = self.lock();
match slots.get_mut(&claimed.id) {
Some(slot) => {
slot.task = Some(claimed.task);
slot.claimed = false;
None
}
None => Some(claimed),
}
}
pub(super) fn finish(&self, id: &str) {
self.lock().remove(id);
}
pub(super) async fn join_all(&self) {
let handles: Vec<JoinHandle<Settled>> = {
let mut slots = self.lock();
slots
.values_mut()
.filter(|slot| !slot.claimed)
.filter_map(|slot| slot.task.take())
.collect()
};
for handle in handles {
let _ = handle.await;
}
self.lock()
.retain(|_, slot| slot.claimed || slot.task.is_some());
}
}
impl Claimed {
pub(super) async fn settled(self) -> (String, Option<String>, Kind, Settled) {
let settled = match self.task.await {
Ok(settled) => settled,
Err(error) => Settled {
outcome: Err(format!("the delegated run stopped unexpectedly: {error}")),
items: Vec::new(),
},
};
(self.id, self.name, self.kind, settled)
}
}
pub(crate) struct JoinAtEnd {
agents: Arc<AgentRegistry>,
agent_id: String,
host: Option<Arc<dyn RoundStrategy>>,
}
impl JoinAtEnd {
pub(crate) fn new(
agents: Arc<AgentRegistry>,
agent_id: impl Into<String>,
host: Option<Arc<dyn RoundStrategy>>,
) -> Self {
Self {
agents,
agent_id: agent_id.into(),
host,
}
}
fn children(&self) -> Option<Children> {
self.agents
.of(&self.agent_id)
.map(|tools| tools.background.clone())
}
}
#[async_trait]
impl RoundStrategy for JoinAtEnd {
async fn on_round(&self, ctx: RoundContext<'_>) -> RoundDecision {
let boundary = ctx.boundary();
let decision = match &self.host {
Some(host) => host.on_round(ctx).await,
None => RoundDecision::proceed(),
};
if boundary == RoundBoundary::AssistantMessageCommitted
&& matches!(decision, RoundDecision::Continue(_))
&& let Some(children) = self.children()
{
children.join_all().await;
}
decision
}
}
pub(super) fn too_wide(running: usize, width: usize) -> String {
format!(
"{running} delegations are already running in the background, and spawn runs no more \
than {width} at once; wait for one with `wait` before starting another, or do this \
one here"
)
}
#[cfg(test)]
mod tests {
use super::*;
fn settled(text: &str) -> Settled {
Settled {
outcome: Ok(text.to_string()),
items: vec![DeferredItem::Context(text.to_string())],
}
}
struct Stub;
#[async_trait]
impl mentra::Provider for Stub {
fn descriptor(&self) -> mentra::provider::ProviderDescriptor {
mentra::provider::ProviderDescriptor::new("stub")
}
async fn list_models(
&self,
) -> Result<Vec<mentra::ModelInfo>, mentra::provider::ProviderError> {
Ok(vec![mentra::ModelInfo::new("stub-model", "stub")])
}
async fn stream(
&self,
_request: mentra::provider::Request<'_>,
) -> Result<mentra::provider::ProviderEventStream, mentra::provider::ProviderError>
{
std::future::pending().await
}
}
fn deferred() -> Deferred {
mentra::Runtime::empty_builder()
.with_provider_instance(Stub)
.build()
.expect("a runtime with a stub provider builds")
.deferred("owner")
}
#[tokio::test]
async fn a_claimed_task_is_left_to_its_waiter() {
let children = Children::default();
let (tx, rx) = tokio::sync::oneshot::channel::<()>();
children.start(
None,
"child".to_string(),
None,
Kind::Subagent,
deferred(),
async move {
let _ = rx.await;
settled("the answer")
},
);
assert_eq!(children.running(), 1);
assert_eq!(children.find("child").as_deref(), Some("child"));
let claimed = children.claim("child").expect("claims");
assert!(
children.claim("child").is_err(),
"a second wait on one handle is refused"
);
tx.send(()).expect("the task is waiting");
let (id, _, _, settled) = claimed.settled().await;
assert_eq!(id, "child");
assert_eq!(settled.outcome.as_deref(), Ok("the answer"));
assert_eq!(
children.running(),
1,
"the waiter removes the slot once it has recorded the result"
);
children.finish("child");
assert_eq!(children.running(), 0);
}
#[tokio::test]
async fn a_released_handle_that_finished_meanwhile_is_handed_back() {
let children = Children::default();
children.start(
None,
"quick".to_string(),
Some("q".to_string()),
Kind::Teammate,
deferred(),
async { settled("done") },
);
let claimed = children.claim("quick").expect("claims");
tokio::task::yield_now().await;
tokio::time::sleep(std::time::Duration::from_millis(20)).await;
let back = children
.release(claimed)
.expect("finished meanwhile, so it comes back to be collected");
let (_, name, kind, settled) = back.settled().await;
assert_eq!(name.as_deref(), Some("q"));
assert_eq!(kind, Kind::Teammate);
assert_eq!(settled.outcome.as_deref(), Ok("done"));
}
#[tokio::test]
async fn join_all_waits_for_what_nobody_claimed() {
let children = Children::default();
children.start(
None,
"a".to_string(),
None,
Kind::Subagent,
deferred(),
async {
tokio::time::sleep(std::time::Duration::from_millis(30)).await;
settled("a done")
},
);
children.join_all().await;
assert_eq!(
children.running(),
0,
"the task delivered its own result and left"
);
assert_eq!(
too_wide(4, 4),
"4 delegations are already running in the background, and spawn runs no more than 4 \
at once; wait for one with `wait` before starting another, or do this one here"
);
}
tokio::task_local! {
static WHERE: String;
}
#[tokio::test]
async fn the_host_scope_is_taken_on_the_spawning_task_and_wraps_the_child() {
let children = Children::default();
let scope: TaskScope = Arc::new(|future| {
let here = WHERE
.try_with(Clone::clone)
.unwrap_or_else(|_| "unset".to_string());
Box::pin(WHERE.scope(here, future))
});
let seen = Arc::new(Mutex::new(None::<String>));
let report = Arc::clone(&seen);
WHERE
.scope("the parent's turn".to_string(), async {
children.start(
Some(&scope),
"child".to_string(),
None,
Kind::Subagent,
deferred(),
async move {
*report.lock().expect("slot") = Some(
WHERE
.try_with(Clone::clone)
.unwrap_or_else(|_| "unset".to_string()),
);
settled("done")
},
);
})
.await;
children.join_all().await;
assert_eq!(
seen.lock().expect("slot").as_deref(),
Some("the parent's turn"),
"the child saw the local the parent's task had set"
);
}
#[tokio::test]
async fn a_scope_that_drops_the_work_is_reported_not_awaited() {
let children = Children::default();
let scope: TaskScope = Arc::new(|_future| Box::pin(async {}));
children.start(
Some(&scope),
"dropped".to_string(),
None,
Kind::Subagent,
deferred(),
async { settled("never") },
);
let claimed = children.claim("dropped").expect("claims");
let (_, _, _, settled) = claimed.settled().await;
assert_eq!(
settled.outcome.as_deref().map_err(String::as_str),
Err("the host's task scope ended the delegation before it ran")
);
}
}