1use crate::agent::clock::{Clock, SystemClock};
2use crate::agent::consumer::AgentMessage;
3use crate::agent::laser::ContractTerminal;
4use crate::agent::router::{CapabilitySelector, InboxRoute, Router};
5use crate::error::LaserError;
6use crate::laser::Laser;
7use crate::provenance::AgentTopic;
8use crate::types::{AgentId, MintUlid};
9use laser_wire::agent::{ConversationId, CorrelationId};
10use std::time::Duration;
11use tokio::time::{Instant, sleep};
12
13impl Laser {
14 pub async fn scatter(
22 &self,
23 source: AgentId,
24 selector: &CapabilitySelector,
25 payload: &[u8],
26 inbox_route: &InboxRoute,
27 deadline: Duration,
28 ) -> Result<Vec<Vec<u8>>, LaserError> {
29 let report = self
30 .scatter_report(source, selector, payload, inbox_route, deadline)
31 .await?;
32 Ok(report
33 .completed()
34 .map(|(_, reply)| reply.body().to_vec())
35 .collect())
36 }
37
38 pub async fn scatter_report(
43 &self,
44 source: AgentId,
45 selector: &CapabilitySelector,
46 payload: &[u8],
47 inbox_route: &InboxRoute,
48 deadline: Duration,
49 ) -> Result<ScatterReport, LaserError> {
50 let now = SystemClock.now_micros();
51 let agents: Vec<AgentId> = {
52 let mut registry = self.agent_registry()?;
53 registry.refresh(now).await?;
54 #[cfg(feature = "query")]
55 if matches!(inbox_route, InboxRoute::Advertised) || selector.principal.is_some() {
56 registry.refresh_presence().await?;
57 }
58 Router::AllCapable(selector.clone()).resolve_targets(®istry, now)?
59 };
60 if agents.is_empty() {
61 return Err(LaserError::NoCapableAgent {
62 skill: selector.skill.clone(),
63 });
64 }
65
66 let mut branches = tokio::task::JoinSet::new();
67 for agent in agents {
68 let laser = self.clone();
69 let source = source.clone();
70 let route = inbox_route.clone();
71 let body = payload.to_vec();
72 branches.spawn(async move {
73 let result = laser
74 .contract(Router::to(agent.clone()))
75 .from(source)
76 .payload(body)
77 .inbox_route(route)
78 .deadline(deadline)
79 .send()
80 .await;
81 (agent, result)
82 });
83 }
84
85 let mut outcomes = Vec::new();
86 while let Some(joined) = branches.join_next().await {
87 match joined {
88 Ok((agent, result)) => outcomes.push(ScatterOutcome { agent, result }),
89 Err(join_error) => {
90 tracing::warn!(%join_error, "scatter branch panicked");
91 }
92 }
93 }
94 Ok(ScatterReport { outcomes })
95 }
96}
97
98#[derive(Debug)]
100pub struct ScatterOutcome {
101 pub agent: AgentId,
103 pub result: Result<Contract, LaserError>,
105}
106
107#[derive(Debug)]
111pub struct ScatterReport {
112 pub outcomes: Vec<ScatterOutcome>,
114}
115
116impl ScatterReport {
117 pub fn completed(&self) -> impl Iterator<Item = (&AgentId, &AgentMessage)> {
119 self.outcomes
120 .iter()
121 .filter_map(|outcome| match &outcome.result {
122 Ok(Contract::Completed(reply)) => Some((&outcome.agent, reply)),
123 _ => None,
124 })
125 }
126
127 pub fn failures(&self) -> impl Iterator<Item = (&AgentId, &LaserError)> {
131 self.outcomes
132 .iter()
133 .filter_map(|outcome| match &outcome.result {
134 Err(error) => Some((&outcome.agent, error)),
135 _ => None,
136 })
137 }
138}
139
140#[derive(Debug)]
144pub enum Contract {
145 Completed(AgentMessage),
147 Failed(AgentMessage),
149 NotConsumed,
155 TimedOut,
158}
159
160pub struct ContractBuilder<'a> {
173 laser: &'a Laser,
174 router: Router,
175 from: Option<AgentId>,
176 payload: Vec<u8>,
177 inbox_route: InboxRoute,
178 reply_topic: AgentTopic<'static>,
179 expiry: Option<Duration>,
180 deadline: Duration,
181 fence: Option<u64>,
182 conversation: Option<ConversationId>,
183 parent: Option<(ConversationId, ConversationId)>,
184}
185
186impl Laser {
187 pub fn contract(&self, router: Router) -> ContractBuilder<'_> {
193 ContractBuilder {
194 laser: self,
195 router,
196 from: None,
197 payload: Vec::new(),
198 inbox_route: InboxRoute::default(),
199 reply_topic: AgentTopic::Sessions,
200 expiry: None,
201 deadline: Duration::from_secs(30),
202 fence: None,
203 conversation: None,
204 parent: None,
205 }
206 }
207}
208
209impl ContractBuilder<'_> {
210 pub fn from(mut self, source: AgentId) -> Self {
213 self.from = Some(source);
214 self
215 }
216
217 pub fn payload(mut self, payload: impl Into<Vec<u8>>) -> Self {
219 self.payload = payload.into();
220 self
221 }
222
223 pub fn inbox_route(mut self, inbox_route: InboxRoute) -> Self {
226 self.inbox_route = inbox_route;
227 self
228 }
229
230 pub fn reply_on(mut self, reply_topic: AgentTopic<'static>) -> Self {
233 self.reply_topic = reply_topic;
234 self
235 }
236
237 pub fn expire_if_not_consumed(mut self, expiry: Duration) -> Self {
243 self.expiry = Some(expiry);
244 self
245 }
246
247 pub fn deadline(mut self, deadline: Duration) -> Self {
250 self.deadline = deadline;
251 self
252 }
253
254 pub fn conversation(mut self, conversation: crate::types::ConversationId) -> Self {
263 self.conversation = Some(conversation.into());
264 self
265 }
266
267 pub fn fence(mut self, fence: u64) -> Self {
285 self.fence = Some(fence);
286 self
287 }
288
289 pub fn parent(
297 mut self,
298 parent: crate::types::ConversationId,
299 root: crate::types::ConversationId,
300 ) -> Self {
301 self.parent = Some((parent.into(), root.into()));
302 self
303 }
304
305 #[tracing::instrument(target = "laser", level = "info", skip_all, fields(conversation = self.conversation.as_ref().map(tracing::field::display), operation = "contract"))]
308 pub async fn send(self) -> Result<Contract, LaserError> {
309 let source = self.from.clone().ok_or_else(|| {
310 LaserError::Invalid("a contract requires `.from(source agent id)`".to_owned())
311 })?;
312 #[cfg(feature = "sign")]
313 let expected_principal = self.router.required_principal();
314 let (target, inbox) = self.resolve().await?;
315
316 let laser = self.laser;
317 let expiry = self.expiry;
318 let deadline = self.deadline;
319 let conversation = self.conversation.unwrap_or_else(ConversationId::mint);
320 let correlation = CorrelationId::mint();
321
322 let child = match self.parent {
325 Some((parent, root)) => Some(
326 start_child(
327 laser,
328 &source.wire_id(),
329 &target.wire_id(),
330 conversation,
331 parent,
332 root,
333 )
334 .await?,
335 ),
336 None => None,
337 };
338
339 let mut reader = laser
344 .agdx_reply_reader(self.reply_topic.clone(), Some(&source.wire_id()))
345 .await?;
346 #[cfg(feature = "sign")]
347 {
348 reader.expected_signer = Some(
349 expected_principal
350 .map(|principal| principal.to_string())
351 .unwrap_or_else(|| target.to_string()),
352 );
353 }
354
355 let producer = laser.agdx(AgentTopic::Custom(&inbox), source.wire_id(), conversation);
362 let mut command = producer
363 .command(correlation, self.payload)
364 .with_target(target.wire_id());
365 if let Some(fence) = self.fence {
366 command = command.with_metadata(
367 laser_wire::headers::FENCE,
368 laser_wire::query::Value::Uint(fence),
369 );
370 }
371 if let Some((parent, root)) = self.parent {
372 command = command
373 .with_ancestry(Some(parent), Some(root))
374 .with_metadata(
375 laser_wire::agent::METADATA_SUBMITTED,
376 laser_wire::query::Value::Bool(true),
377 );
378 }
379 if let Some(expiry) = expiry {
380 let at = SystemClock
381 .now_micros()
382 .saturating_add(expiry.as_micros().min(u128::from(u64::MAX)) as u64);
383 command = command.with_deadline_micros(at);
384 }
385 command.send().await?;
386
387 let outcome = watch_terminal(laser, &mut reader, correlation, expiry, deadline).await;
388
389 if let Some(child) = child {
393 let ended = match &outcome {
394 Ok(Contract::Completed(_)) => child.end().await,
395 Ok(other) => child.fail(contract_failure(failure_reason(other))).await,
396 Err(error) => child.fail(contract_failure(error.to_string())).await,
397 };
398 if let (Ok(_), Err(error)) = (&outcome, ended) {
399 return Err(error);
400 }
401 }
402 outcome
403 }
404
405 async fn resolve(&self) -> Result<(AgentId, iggy::prelude::Identifier), LaserError> {
408 if matches!(self.router, Router::Broadcast | Router::AllCapable(_)) {
409 return Err(LaserError::Invalid(
410 "a contract is directed to one agent, not a broadcast or all-capable route"
411 .to_owned(),
412 ));
413 }
414 let mut registry = self.laser.agent_registry()?;
415 let now = SystemClock.now_micros();
416 registry.refresh(now).await?;
417 #[cfg(feature = "query")]
418 if matches!(self.inbox_route, InboxRoute::Advertised) || self.router.requires_presence() {
419 registry.refresh_presence().await?;
420 }
421 let target = self
422 .router
423 .resolve_targets(®istry, now)?
424 .into_iter()
425 .next()
426 .ok_or_else(|| {
427 LaserError::Invalid("the contract route resolved no target".to_owned())
428 })?;
429 let inbox = self
430 .inbox_route
431 .resolve(&target, registry.inbox_for(&target))?;
432 Ok((target, inbox))
433 }
434}
435
436pub(crate) async fn start_child(
438 laser: &Laser,
439 source: &laser_wire::agent::AgentId,
440 target: &laser_wire::agent::AgentId,
441 conversation: ConversationId,
442 parent: ConversationId,
443 root: ConversationId,
444) -> Result<crate::agent::Session, LaserError> {
445 let sessions = laser.sessions();
446 let start = laser_wire::agent::SessionStart {
447 label: None,
448 namespace: None,
449 agent: target.clone(),
450 sdk: sessions.config().sdk_info().clone(),
451 parent: Some(parent),
452 root: Some(root),
453 idle_timeout_micros: Some(
454 u64::try_from(sessions.config().idle_timeout_value().as_micros()).unwrap_or(u64::MAX),
455 ),
456 budget: None,
457 tags: Vec::new(),
458 };
459 let session = sessions
460 .open(conversation.into())
461 .as_agent(source.clone())
462 .with_ancestry(Some(parent.into()), Some(root.into()));
463 session
464 .lane()?
465 .status(laser_wire::agent::OPERATION_SESSION)
466 .with_task_state(laser_wire::agent::TaskState::Submitted)
467 .body(laser_wire::framing::encode_named(&start)?)
468 .content_type(laser_wire::content::ContentType::Cbor)
469 .with_ancestry(Some(parent), Some(root))
470 .send()
471 .await?;
472 Ok(session)
473}
474
475fn failure_reason(outcome: &Contract) -> String {
476 match outcome {
477 Contract::Completed(_) => "completed".to_owned(),
478 Contract::Failed(_) => "the target replied with a terminal error".to_owned(),
479 Contract::NotConsumed => "the command was not consumed within the expiry".to_owned(),
480 Contract::TimedOut => "no terminal reply landed within the deadline".to_owned(),
481 }
482}
483
484fn contract_failure(message: String) -> laser_wire::agent::AgentErrorBody {
485 laser_wire::agent::AgentErrorBody {
486 code: laser_wire::agent::AgentErrorCode::Internal,
487 message: Some(message),
488 retryable: false,
489 detail: None,
490 }
491}
492
493async fn watch_terminal(
496 laser: &Laser,
497 reader: &mut crate::agent::laser::AgentReplyReader,
498 correlation: CorrelationId,
499 expiry: Option<Duration>,
500 deadline: Duration,
501) -> Result<Contract, LaserError> {
502 let start = Instant::now();
503 let expiry_at = expiry.map(|expiry| start + expiry);
504 let deadline_at = start + deadline;
505 let mut consumed = false;
506 loop {
507 let pass = reader.poll_contract(&laser.client(), correlation).await?;
508 match pass.terminal {
509 Some(ContractTerminal::Completed(reply)) => return Ok(Contract::Completed(reply)),
510 Some(ContractTerminal::Failed(reply)) => return Ok(Contract::Failed(reply)),
511 None => {}
512 }
513 consumed |= pass.consumed;
514
515 let now = Instant::now();
516 if !consumed
517 && let Some(expiry_at) = expiry_at
518 && now >= expiry_at
519 {
520 return Ok(Contract::NotConsumed);
521 }
522 if now >= deadline_at {
523 return Ok(Contract::TimedOut);
524 }
525 if !pass.read_any {
526 let remaining = deadline_at.saturating_duration_since(Instant::now());
528 sleep(Duration::from_millis(100).min(remaining)).await;
529 }
530 }
531}