use crate::agent::clock::{Clock, SystemClock};
use crate::agent::consumer::AgentMessage;
use crate::agent::laser::ContractTerminal;
use crate::agent::router::{CapabilitySelector, InboxRoute, Router};
use crate::error::LaserError;
use crate::laser::Laser;
use crate::provenance::AgentTopic;
use crate::types::{AgentId, MintUlid};
use laser_wire::agent::{ConversationId, CorrelationId};
use std::time::Duration;
use tokio::time::{Instant, sleep};
impl Laser {
pub async fn scatter(
&self,
source: AgentId,
selector: &CapabilitySelector,
payload: &[u8],
inbox_route: &InboxRoute,
deadline: Duration,
) -> Result<Vec<Vec<u8>>, LaserError> {
let report = self
.scatter_report(source, selector, payload, inbox_route, deadline)
.await?;
Ok(report
.completed()
.map(|(_, reply)| reply.body().to_vec())
.collect())
}
pub async fn scatter_report(
&self,
source: AgentId,
selector: &CapabilitySelector,
payload: &[u8],
inbox_route: &InboxRoute,
deadline: Duration,
) -> Result<ScatterReport, LaserError> {
let now = SystemClock.now_micros();
let agents: Vec<AgentId> = {
let mut registry = self.agent_registry()?;
registry.refresh(now).await?;
#[cfg(feature = "query")]
if matches!(inbox_route, InboxRoute::Advertised) || selector.principal.is_some() {
registry.refresh_presence().await?;
}
Router::AllCapable(selector.clone()).resolve_targets(®istry, now)?
};
if agents.is_empty() {
return Err(LaserError::NoCapableAgent {
skill: selector.skill.clone(),
});
}
let mut branches = tokio::task::JoinSet::new();
for agent in agents {
let laser = self.clone();
let source = source.clone();
let route = inbox_route.clone();
let body = payload.to_vec();
branches.spawn(async move {
let result = laser
.contract(Router::to(agent.clone()))
.from(source)
.payload(body)
.inbox_route(route)
.deadline(deadline)
.send()
.await;
(agent, result)
});
}
let mut outcomes = Vec::new();
while let Some(joined) = branches.join_next().await {
match joined {
Ok((agent, result)) => outcomes.push(ScatterOutcome { agent, result }),
Err(join_error) => {
tracing::warn!(%join_error, "scatter branch panicked");
}
}
}
Ok(ScatterReport { outcomes })
}
}
#[derive(Debug)]
pub struct ScatterOutcome {
pub agent: AgentId,
pub result: Result<Contract, LaserError>,
}
#[derive(Debug)]
pub struct ScatterReport {
pub outcomes: Vec<ScatterOutcome>,
}
impl ScatterReport {
pub fn completed(&self) -> impl Iterator<Item = (&AgentId, &AgentMessage)> {
self.outcomes
.iter()
.filter_map(|outcome| match &outcome.result {
Ok(Contract::Completed(reply)) => Some((&outcome.agent, reply)),
_ => None,
})
}
pub fn failures(&self) -> impl Iterator<Item = (&AgentId, &LaserError)> {
self.outcomes
.iter()
.filter_map(|outcome| match &outcome.result {
Err(error) => Some((&outcome.agent, error)),
_ => None,
})
}
}
#[derive(Debug)]
pub enum Contract {
Completed(AgentMessage),
Failed(AgentMessage),
NotConsumed,
TimedOut,
}
pub struct ContractBuilder<'a> {
laser: &'a Laser,
router: Router,
from: Option<AgentId>,
payload: Vec<u8>,
inbox_route: InboxRoute,
reply_topic: AgentTopic<'static>,
expiry: Option<Duration>,
deadline: Duration,
fence: Option<u64>,
conversation: Option<ConversationId>,
#[cfg(feature = "runs")]
registered: bool,
}
impl Laser {
pub fn contract(&self, router: Router) -> ContractBuilder<'_> {
ContractBuilder {
laser: self,
router,
from: None,
payload: Vec::new(),
inbox_route: InboxRoute::default(),
reply_topic: AgentTopic::Responses,
expiry: None,
deadline: Duration::from_secs(30),
fence: None,
conversation: None,
#[cfg(feature = "runs")]
registered: false,
}
}
}
impl ContractBuilder<'_> {
pub fn from(mut self, source: AgentId) -> Self {
self.from = Some(source);
self
}
pub fn payload(mut self, payload: impl Into<Vec<u8>>) -> Self {
self.payload = payload.into();
self
}
pub fn inbox_route(mut self, inbox_route: InboxRoute) -> Self {
self.inbox_route = inbox_route;
self
}
pub fn reply_on(mut self, reply_topic: AgentTopic<'static>) -> Self {
self.reply_topic = reply_topic;
self
}
pub fn expire_if_not_consumed(mut self, expiry: Duration) -> Self {
self.expiry = Some(expiry);
self
}
pub fn deadline(mut self, deadline: Duration) -> Self {
self.deadline = deadline;
self
}
pub fn conversation(mut self, conversation: crate::types::ConversationId) -> Self {
self.conversation = Some(conversation.into());
self
}
pub fn fence(mut self, fence: u64) -> Self {
self.fence = Some(fence);
self
}
#[cfg(feature = "runs")]
pub fn registered(mut self) -> Self {
self.registered = true;
self
}
#[tracing::instrument(target = "laser", level = "info", skip_all, fields(conversation = self.conversation.as_ref().map(tracing::field::display), operation = "contract"))]
pub async fn send(self) -> Result<Contract, LaserError> {
let source = self.from.clone().ok_or_else(|| {
LaserError::Invalid("a contract requires `.from(source agent id)`".to_owned())
})?;
#[cfg(feature = "runs")]
if self.registered && !self.laser.capabilities().await.agent_workflow {
return Err(LaserError::unsupported_feature(
"contract",
"agent_workflow",
"a registered contract requires a plane that serves the run registry",
));
}
#[cfg(feature = "sign")]
let expected_principal = self.router.required_principal();
let (target, inbox) = self.resolve().await?;
let laser = self.laser;
let expiry = self.expiry;
let deadline = self.deadline;
let conversation = self.conversation.unwrap_or_else(ConversationId::mint);
let correlation = CorrelationId::mint();
#[cfg(feature = "runs")]
let registered_run = if self.registered {
let info = laser
.runs()
.submit_with(
target.to_string(),
None,
Some(self.payload.clone()),
std::collections::BTreeMap::new(),
)
.await?;
Some(info.run_id)
} else {
None
};
let mut reader = laser.agdx_reply_reader(self.reply_topic.clone()).await?;
#[cfg(feature = "sign")]
{
reader.expected_signer = Some(
expected_principal
.map(|principal| principal.to_string())
.unwrap_or_else(|| target.to_string()),
);
}
let producer = laser.agdx(AgentTopic::Custom(&inbox), source.wire_id(), conversation);
let mut command = producer
.command(correlation, self.payload)
.with_target(target.wire_id());
if let Some(fence) = self.fence {
command = command.with_metadata(
laser_wire::headers::FENCE,
laser_wire::query::Value::Uint(fence),
);
}
#[cfg(feature = "runs")]
if let Some(run) = registered_run.as_deref() {
command = command.with_metadata(laser_wire::agent::METADATA_RUN, run);
}
if let Some(expiry) = expiry {
let at = SystemClock
.now_micros()
.saturating_add(expiry.as_micros().min(u128::from(u64::MAX)) as u64);
command = command.with_deadline_micros(at);
}
command.send().await?;
#[cfg(feature = "runs")]
if let Some(run) = registered_run.as_deref() {
crate::agent::workflow::mark_run(
laser,
&source,
conversation,
run,
laser_wire::agent::TaskState::Working,
None,
)
.await?;
}
let outcome = watch_terminal(laser, &mut reader, correlation, expiry, deadline).await;
#[cfg(feature = "runs")]
if let Some(run) = registered_run.as_deref() {
use laser_wire::agent::TaskState;
let (state, detail) = match &outcome {
Ok(Contract::Completed(_)) => (TaskState::Completed, None),
Ok(Contract::Failed(_)) => (
TaskState::Failed,
Some("the target replied with a terminal error".to_owned()),
),
Ok(Contract::NotConsumed) => (
TaskState::Failed,
Some("the command was not consumed within the expiry".to_owned()),
),
Ok(Contract::TimedOut) => (
TaskState::Failed,
Some("no terminal reply landed within the deadline".to_owned()),
),
Err(error) => (TaskState::Failed, Some(error.to_string())),
};
let marked =
crate::agent::workflow::mark_run(laser, &source, conversation, run, state, detail)
.await;
if let (Ok(_), Err(error)) = (&outcome, marked) {
return Err(error);
}
}
outcome
}
async fn resolve(&self) -> Result<(AgentId, iggy::prelude::Identifier), LaserError> {
if matches!(self.router, Router::Broadcast | Router::AllCapable(_)) {
return Err(LaserError::Invalid(
"a contract is directed to one agent, not a broadcast or all-capable route"
.to_owned(),
));
}
let mut registry = self.laser.agent_registry()?;
let now = SystemClock.now_micros();
registry.refresh(now).await?;
#[cfg(feature = "query")]
if matches!(self.inbox_route, InboxRoute::Advertised) || self.router.requires_presence() {
registry.refresh_presence().await?;
}
let target = self
.router
.resolve_targets(®istry, now)?
.into_iter()
.next()
.ok_or_else(|| {
LaserError::Invalid("the contract route resolved no target".to_owned())
})?;
let inbox = self
.inbox_route
.resolve(&target, registry.inbox_for(&target))?;
Ok((target, inbox))
}
}
async fn watch_terminal(
laser: &Laser,
reader: &mut crate::agent::laser::AgentReplyReader,
correlation: CorrelationId,
expiry: Option<Duration>,
deadline: Duration,
) -> Result<Contract, LaserError> {
let start = Instant::now();
let expiry_at = expiry.map(|expiry| start + expiry);
let deadline_at = start + deadline;
let mut consumed = false;
loop {
let pass = reader.poll_contract(&laser.client(), correlation).await?;
match pass.terminal {
Some(ContractTerminal::Completed(reply)) => return Ok(Contract::Completed(reply)),
Some(ContractTerminal::Failed(reply)) => return Ok(Contract::Failed(reply)),
None => {}
}
consumed |= pass.consumed;
let now = Instant::now();
if !consumed
&& let Some(expiry_at) = expiry_at
&& now >= expiry_at
{
return Ok(Contract::NotConsumed);
}
if now >= deadline_at {
return Ok(Contract::TimedOut);
}
if !pass.read_any {
let remaining = deadline_at.saturating_duration_since(Instant::now());
sleep(Duration::from_millis(100).min(remaining)).await;
}
}
}