1use crate::agent::clock::{Clock, SystemClock};
2use crate::agent::consumer::AgentMessage;
3use crate::agent::router::{CapabilitySelector, InboxRoute, Router};
4use crate::error::LaserError;
5use crate::laser::Laser;
6use crate::provenance::{AgentTopic, Provenance};
7use crate::types::AgentId;
8use std::time::Duration;
9
10pub struct AgentCtx<'a> {
14 laser: &'a Laser,
15 message: &'a AgentMessage,
16 agent: Option<AgentId>,
17 respond_on: Option<AgentTopic<'static>>,
18 inbox_route: InboxRoute,
19 request_at: Option<laser_wire::agent::LogPosition>,
20 sessions: Option<crate::agent::Sessions>,
23 control: Option<std::sync::Arc<crate::agent::control::ControlBook>>,
24 #[cfg(feature = "sign")]
25 signing_key: Option<std::sync::Arc<crate::sign::SigningKey>>,
26}
27
28impl<'a> AgentCtx<'a> {
29 pub(crate) fn new(
30 laser: &'a Laser,
31 message: &'a AgentMessage,
32 agent: Option<AgentId>,
33 respond_on: Option<AgentTopic<'static>>,
34 inbox_route: InboxRoute,
35 #[cfg(feature = "sign")] signing_key: Option<std::sync::Arc<crate::sign::SigningKey>>,
36 ) -> Self {
37 Self {
38 laser,
39 message,
40 agent,
41 respond_on,
42 inbox_route,
43 request_at: None,
44 sessions: None,
45 control: None,
46 #[cfg(feature = "sign")]
47 signing_key,
48 }
49 }
50
51 pub(crate) fn with_sessions(
54 mut self,
55 sessions: crate::agent::Sessions,
56 control: std::sync::Arc<crate::agent::control::ControlBook>,
57 ) -> Self {
58 self.sessions = Some(sessions);
59 self.control = Some(control);
60 self
61 }
62
63 pub fn request_at(&self) -> Option<laser_wire::agent::LogPosition> {
65 self.request_at
66 }
67
68 pub(crate) fn with_request_at(mut self, at: laser_wire::agent::LogPosition) -> Self {
71 self.request_at = Some(at);
72 self
73 }
74
75 pub fn laser(&self) -> &Laser {
77 self.laser
78 }
79
80 pub fn message(&self) -> &AgentMessage {
82 self.message
83 }
84
85 pub fn session(&self) -> crate::agent::Session {
91 let conversation = self.message.provenance.conversation_id;
92 let mut session = match &self.sessions {
93 Some(sessions) => sessions.open(conversation),
94 None => self.laser.sessions().open(conversation),
95 };
96 session.control.clone_from(&self.control);
97 if let Some(agent) = &self.agent {
98 session = session.as_agent(agent.wire_id());
99 }
100 if let Some(at) = self.request_at {
101 session = session.acting_on(laser_wire::graph::SourceRef::Message {
102 stream: at.stream_id,
103 topic: at.topic_id,
104 partition: at.partition_id,
105 offset: at.offset,
106 generation: None,
107 conversation: Some(self.message.provenance.conversation_id.to_string()),
108 });
109 }
110 session
111 }
112
113 pub async fn respond(&self, payload: impl Into<Vec<u8>>) -> Result<(), LaserError> {
121 let topic = self.respond_on.clone().ok_or(LaserError::NoRespondTopic)?;
122 let payload = payload.into();
123 if let Some(envelope) = &self.message.envelope
127 && let Some(correlation) = envelope.correlation
128 {
129 let source = self.agent.as_ref().ok_or_else(|| {
130 LaserError::HandlerConfig("a responding agent must have an id".to_owned())
131 })?;
132 let producer = self
133 .laser
134 .agdx(topic, source.wire_id(), envelope.conversation);
135 let mut send = producer
136 .respond(correlation, payload)
137 .with_target(envelope.source.clone())
138 .with_ancestry(envelope.parent, envelope.root);
139 if let Some(record) = envelope.record {
140 send = send.with_cause(record, self.request_at);
141 }
142 #[cfg(feature = "sign")]
143 if let Some(key) = &self.signing_key {
144 send = send.signed_by(key);
145 }
146 return send.send().await.map(|_| ());
147 }
148 let mut provenance = self.reply_provenance();
149 if let Some(source) = &self.message.provenance.agent {
150 Router::to(source.clone()).apply(&mut provenance);
151 }
152 self.laser.send_agent(topic, payload, &provenance).await
153 }
154
155 pub async fn reply_on(
158 &self,
159 topic: AgentTopic<'_>,
160 payload: impl Into<Vec<u8>>,
161 ) -> Result<(), LaserError> {
162 let mut provenance = self.reply_provenance();
163 if let Some(source) = &self.message.provenance.agent {
164 Router::to(source.clone()).apply(&mut provenance);
165 }
166 self.laser.send_agent(topic, payload, &provenance).await
167 }
168
169 pub async fn send(
171 &self,
172 topic: AgentTopic<'_>,
173 payload: impl Into<Vec<u8>>,
174 provenance: &Provenance,
175 ) -> Result<(), LaserError> {
176 self.laser.send_agent(topic, payload, provenance).await
177 }
178
179 pub async fn request(
181 &self,
182 request_topic: AgentTopic<'_>,
183 reply_topic: AgentTopic<'_>,
184 payload: impl Into<Vec<u8>>,
185 provenance: &Provenance,
186 timeout: Duration,
187 ) -> Result<AgentMessage, LaserError> {
188 self.laser
189 .request(request_topic, reply_topic, payload, provenance, timeout)
190 .await
191 }
192
193 pub async fn respond_input(
203 &self,
204 reply_topic: AgentTopic<'static>,
205 response: impl Into<Vec<u8>>,
206 ) -> Result<(), LaserError> {
207 let envelope = self.message.envelope.as_ref().ok_or_else(|| {
208 LaserError::HandlerConfig(
209 "respond_input: the handled message is not an AGDX envelope".to_owned(),
210 )
211 })?;
212 let correlation = envelope.correlation.ok_or_else(|| {
213 LaserError::HandlerConfig(
214 "respond_input: the interrupt carries no correlation".to_owned(),
215 )
216 })?;
217 let source = self
218 .agent
219 .as_ref()
220 .ok_or_else(|| {
221 LaserError::HandlerConfig("respond_input: the agent has no id".to_owned())
222 })?
223 .wire_id();
224 let producer = self.laser.agdx(reply_topic, source, envelope.conversation);
225 let mut send = producer
226 .respond(correlation, response.into())
227 .with_target(envelope.source.clone())
228 .with_ancestry(envelope.parent, envelope.root);
229 if let Some(record) = envelope.record {
230 send = send.with_cause(record, self.request_at);
231 }
232 #[cfg(feature = "sign")]
233 let send = match &self.signing_key {
234 Some(key) => send.signed_by(key),
235 None => send,
236 };
237 send.send().await?;
238 Ok(())
239 }
240
241 pub async fn approval_gate(
249 &self,
250 reply_topic: AgentTopic<'_>,
251 prompt: impl Into<Vec<u8>>,
252 timeout: Duration,
253 ) -> Result<Vec<u8>, LaserError> {
254 let source = self
255 .agent
256 .as_ref()
257 .ok_or_else(|| {
258 LaserError::HandlerConfig("approval_gate: the agent has no id".to_owned())
259 })?
260 .wire_id();
261 self.laser
262 .agdx(
263 AgentTopic::Sessions,
264 source,
265 self.message.provenance.conversation_id.into(),
266 )
267 .request_input(reply_topic, prompt, timeout)
268 .await
269 }
270
271 pub fn spawn_subconversation(&self) -> Result<Provenance, LaserError> {
273 let author = self.agent.as_ref().ok_or_else(|| {
274 LaserError::HandlerConfig("spawn_subconversation: the agent has no id".to_owned())
275 })?;
276 Ok(self
277 .laser
278 .spawn_subconversation(&self.message.provenance, author))
279 }
280
281 pub async fn fan_out(
298 &self,
299 selector: CapabilitySelector,
300 payload: impl Into<Vec<u8>>,
301 policy: GatherPolicy,
302 deadline: Duration,
303 ) -> Result<Gather, LaserError> {
304 let payload = payload.into();
306 let reply_topic = self.respond_on.clone().ok_or(LaserError::NoRespondTopic)?;
309
310 let mut registry = self.laser.agent_registry()?;
311 let now = SystemClock.now_micros();
312 registry.refresh(now).await?;
313 #[cfg(feature = "query")]
315 if matches!(self.inbox_route, InboxRoute::Advertised) || selector.principal.is_some() {
316 registry.refresh_presence().await?;
317 }
318 let targets = Router::AllCapable(selector).resolve_targets(®istry, now)?;
319 let author = self
320 .agent
321 .clone()
322 .ok_or_else(|| LaserError::HandlerConfig("fan_out: the agent has no id".to_owned()))?;
323
324 let mut gather = Gather::default();
325
326 let mut branches = tokio::task::JoinSet::new();
330 for agent in targets {
331 let advertised = registry.inbox_for(&agent);
332 let inbox = match self.inbox_route.resolve(&agent, advertised) {
333 Ok(inbox) => inbox,
334 Err(error) => {
335 gather.failures.push((agent, error));
336 continue;
337 }
338 };
339 let laser = self.laser.clone();
340 let body = payload.clone();
341 let parent = self.message.provenance.clone();
342 let author = author.clone();
343 let reply_topic = reply_topic.clone();
344 branches.spawn(async move {
345 let mut provenance = laser.spawn_subconversation(&parent, &author);
346 provenance.target_agent_id = Some(agent.clone());
347 provenance.correlation_id = Some(ulid::Ulid::generate().to_string());
349 let result = laser
350 .request(
351 AgentTopic::Custom(&inbox),
352 reply_topic,
353 body,
354 &provenance,
355 deadline,
356 )
357 .await;
358 (agent, result)
359 });
360 }
361
362 Ok(gather_branches(branches, gather, policy, deadline).await)
363 }
364
365 fn reply_provenance(&self) -> Provenance {
366 let mut provenance = Provenance::builder()
367 .conversation_id(self.message.provenance.conversation_id)
368 .causal_parent(self.message.id)
369 .build();
370 provenance.agent = self.agent.clone();
371 provenance.parent_conversation_id = self.message.provenance.parent_conversation_id;
372 provenance.root_conversation_id = self.message.provenance.root_conversation_id;
373 provenance.correlation_id = Some(
382 self.message
383 .provenance
384 .correlation_id
385 .clone()
386 .unwrap_or_else(|| self.message.id.to_string()),
387 );
388 provenance
389 }
390}
391
392#[derive(Debug, Clone, Copy, PartialEq, Eq)]
394pub enum GatherPolicy {
395 RequireAll,
397 Quorum(usize),
399 BestEffort,
401}
402
403#[derive(Debug, Default)]
407pub struct Gather {
408 pub ok: Vec<(AgentId, AgentMessage)>,
411 pub failures: Vec<(AgentId, LaserError)>,
414}
415
416impl Gather {
417 pub fn replies(&self) -> impl Iterator<Item = &AgentMessage> {
420 self.ok.iter().map(|(_, message)| message)
421 }
422}
423
424async fn gather_branches(
431 mut branches: tokio::task::JoinSet<(AgentId, Result<AgentMessage, LaserError>)>,
432 mut gather: Gather,
433 policy: GatherPolicy,
434 deadline: Duration,
435) -> Gather {
436 let wall_clock = tokio::time::sleep(deadline);
437 tokio::pin!(wall_clock);
438 loop {
439 tokio::select! {
440 joined = branches.join_next() => {
441 let Some(joined) = joined else { break };
442 match joined {
443 Ok((agent, Ok(reply))) => gather.ok.push((agent, reply)),
444 Ok((agent, Err(error))) => gather.failures.push((agent, error)),
445 Err(join_error) => {
449 tracing::warn!(%join_error, "fan-out branch panicked");
450 }
451 }
452 if quorum_satisfied(policy, gather.ok.len()) {
453 branches.abort_all();
454 break;
455 }
456 }
457 _ = &mut wall_clock, if matches!(policy, GatherPolicy::BestEffort) => {
458 branches.abort_all();
459 break;
460 }
461 }
462 }
463 gather
464}
465
466fn quorum_satisfied(policy: GatherPolicy, successes: usize) -> bool {
471 matches!(policy, GatherPolicy::Quorum(needed) if successes >= needed)
472}
473
474#[cfg(test)]
475mod tests {
476 use super::*;
477
478 #[test]
479 fn given_a_child_conversation_when_replying_then_should_keep_parent_and_root() {
480 let laser = Laser::from_client(iggy::prelude::IggyClient::default());
481 let root = crate::types::ConversationId::new();
482 let parent = crate::types::ConversationId::new();
483 let child = crate::types::ConversationId::new();
484 let provenance = Provenance::builder()
485 .conversation_id(child)
486 .parent_conversation_id(parent)
487 .root_conversation_id(root)
488 .build();
489 let message = crate::testing::agent_message(b"request".to_vec(), provenance);
490 let ctx = AgentCtx::new(
491 &laser,
492 &message,
493 Some("worker".parse().expect("agent id parses")),
494 None,
495 InboxRoute::default(),
496 #[cfg(feature = "sign")]
497 None,
498 );
499
500 let reply = ctx.reply_provenance();
501 assert_eq!(reply.parent_conversation_id, Some(parent));
502 assert_eq!(reply.root_conversation_id, Some(root));
503 }
504
505 #[test]
506 fn given_require_all_when_checking_quorum_then_should_never_short_circuit() {
507 assert!(!quorum_satisfied(GatherPolicy::RequireAll, 0));
509 assert!(!quorum_satisfied(GatherPolicy::RequireAll, 1));
510 assert!(!quorum_satisfied(GatherPolicy::RequireAll, 1000));
511 }
512
513 #[test]
514 fn given_best_effort_when_checking_quorum_then_should_never_short_circuit() {
515 assert!(!quorum_satisfied(GatherPolicy::BestEffort, 0));
517 assert!(!quorum_satisfied(GatherPolicy::BestEffort, 1));
518 assert!(!quorum_satisfied(GatherPolicy::BestEffort, 1000));
519 }
520
521 #[test]
522 fn given_a_quorum_when_checking_then_should_short_circuit_at_or_above_the_threshold() {
523 assert!(!quorum_satisfied(GatherPolicy::Quorum(2), 0));
525 assert!(!quorum_satisfied(GatherPolicy::Quorum(2), 1));
526 assert!(quorum_satisfied(GatherPolicy::Quorum(2), 2));
527 assert!(quorum_satisfied(GatherPolicy::Quorum(2), 3));
528 }
529
530 #[test]
531 fn given_a_zero_quorum_when_checking_then_should_be_satisfied_immediately() {
532 assert!(quorum_satisfied(GatherPolicy::Quorum(0), 0));
536 }
537
538 #[test]
539 fn given_a_quorum_larger_than_the_target_set_when_never_reached_then_should_not_short_circuit()
540 {
541 assert!(!quorum_satisfied(GatherPolicy::Quorum(5), 3));
545 }
546
547 #[test]
548 fn given_branches_when_gathered_under_require_all_then_should_classify_replies_and_failures() {
549 tokio_test_block(async {
552 let mut branches = tokio::task::JoinSet::new();
553 let agent: AgentId = "worker".parse().expect("valid agent id");
554 branches.spawn(async move { (agent, Err(LaserError::Timeout("reply"))) });
555 let gather = gather_branches(
556 branches,
557 Gather::default(),
558 GatherPolicy::RequireAll,
559 Duration::from_secs(1),
560 )
561 .await;
562 assert_eq!(gather.ok.len(), 0);
563 assert_eq!(gather.failures.len(), 1);
564 assert!(matches!(gather.failures[0].1, LaserError::Timeout(_)));
565 });
566 }
567
568 #[test]
569 fn given_a_best_effort_deadline_when_a_branch_outlives_it_then_should_cut_and_return_what_landed()
570 {
571 tokio_test_block(async {
574 let mut branches = tokio::task::JoinSet::new();
575 let agent: AgentId = "worker".parse().expect("valid agent id");
576 branches.spawn(async move {
577 futures_pending().await;
578 (agent, Ok::<_, LaserError>(unreachable_reply()))
579 });
580 let gather = gather_branches(
581 branches,
582 Gather::default(),
583 GatherPolicy::BestEffort,
584 Duration::from_millis(20),
585 )
586 .await;
587 assert_eq!(gather.ok.len(), 0);
588 assert_eq!(
589 gather.failures.len(),
590 0,
591 "an aborted straggler is dropped, not a failure"
592 );
593 });
594 }
595
596 fn tokio_test_block<F: std::future::Future>(f: F) -> F::Output {
599 tokio::runtime::Builder::new_current_thread()
600 .enable_time()
601 .build()
602 .expect("test runtime builds")
603 .block_on(f)
604 }
605
606 async fn futures_pending() {
607 std::future::pending::<()>().await
608 }
609
610 fn unreachable_reply() -> AgentMessage {
611 unreachable!("the pending branch is aborted before it ever yields a reply")
612 }
613}