1use crate::agent::consumer::AgentMessage;
2use crate::agent::{ChunkAssembler, StreamEvent};
3use crate::context::ContextAssembler;
4use crate::error::LaserError;
5use crate::govern::{ActionCounters, ActionKind, GovernedAction};
6use crate::laser::{Laser, ensure_stream, ensure_topic, ensure_topic_retained};
7use crate::provenance::{AgentTopic, Provenance};
8use crate::types::{AgentId, ConsumerGroupName, ConversationId, MessageId};
9use iggy::prelude::*;
10use laser_wire::agent::{
11 AgentDeadLetter, AgentEnvelope, AgentKind, ChannelId, CorrelationId, LogPosition,
12 OPERATION_TASK, TaskState,
13};
14use laser_wire::dispatch::{Dispatch, HandledOperations, classify};
15use std::collections::BTreeMap;
16use std::str::FromStr;
17use std::time::Duration;
18use tokio::time::{Instant, sleep};
19use tracing::info;
20
21const SATELLITE_TOPICS: [AgentTopic<'static>; 5] = [
25 AgentTopic::Streams,
26 AgentTopic::Memory,
27 AgentTopic::Dlq,
28 AgentTopic::Audit,
29 AgentTopic::WorkflowJournal,
30];
31
32const HEARTBEAT_EXPIRY_MICROS: u64 = 3_600_000_000;
34
35const REPLY_BATCH: u32 = 1000;
36
37impl Laser {
38 #[tracing::instrument(
52 target = "laser",
53 level = "info",
54 skip_all,
55 fields(operation = "bootstrap")
56 )]
57 pub async fn bootstrap(
58 &self,
59 partitions: u32,
60 retention: crate::agent::TopicRetention,
61 ) -> Result<(), LaserError> {
62 let stream = self.stream_required()?;
63 let client = self.client();
64 ensure_stream(&client, stream).await?;
65 ensure_topic_retained(
66 &client,
67 stream,
68 laser_wire::topics::AGENT_SESSIONS,
69 partitions,
70 retention.expiry(),
71 retention.max_size(),
72 )
73 .await?;
74 ensure_topic_retained(
75 &client,
76 stream,
77 laser_wire::topics::AGENT_HEARTBEATS,
78 partitions,
79 IggyExpiry::ExpireDuration(IggyDuration::from(HEARTBEAT_EXPIRY_MICROS)),
80 MaxTopicSize::ServerDefault,
81 )
82 .await?;
83 for topic in SATELLITE_TOPICS {
84 ensure_topic(&client, stream, &topic.topic_string(), partitions).await?;
85 }
86 let mut warming = tokio::task::JoinSet::new();
87 for topic in [AgentTopic::Sessions, AgentTopic::Heartbeats]
88 .into_iter()
89 .chain(SATELLITE_TOPICS)
90 {
91 let laser = self.clone();
92 let stream = stream.to_owned();
93 warming.spawn(async move { laser.producer_on(&stream, &topic.topic_string()).await });
94 }
95 while let Some(joined) = warming.join_next().await {
96 joined.map_err(|_| LaserError::Config("producer warm-up task panicked"))??;
97 }
98 info!(stream = %stream, partitions, "bootstrapped agent topology");
99 Ok(())
100 }
101
102 #[tracing::instrument(target = "laser", level = "debug", skip_all, fields(conversation = %provenance.conversation_id, topic = %topic.topic_string(), operation = "send"))]
109 pub async fn send_agent(
110 &self,
111 topic: AgentTopic<'_>,
112 payload: impl Into<Vec<u8>>,
113 provenance: &Provenance,
114 ) -> Result<(), LaserError> {
115 self.send_agent_as(ActionKind::Send, topic, payload, provenance)
116 .await
117 .map(|_| ())
118 }
119
120 pub(crate) async fn send_agent_as(
124 &self,
125 kind: ActionKind,
126 topic: AgentTopic<'_>,
127 payload: impl Into<Vec<u8>>,
128 provenance: &Provenance,
129 ) -> Result<SendMessagesResponse, LaserError> {
130 let mut headers: BTreeMap<HeaderKey, HeaderValue> = provenance.try_into()?;
131 let key = provenance.partition_key();
132 let topic_name = topic.topic_string();
133 stamp_broadcast_addressee(&mut headers, &topic_name)?;
134 let mut payload: Vec<u8> = payload.into();
135 let action = GovernedAction {
136 kind,
137 stream: self.stream_required()?,
138 topic: &topic_name,
139 source: provenance.agent.as_ref().map(|agent| agent.as_str()),
140 target: provenance
141 .target_agent_id
142 .as_ref()
143 .map(|agent| agent.as_str()),
144 conversation: Some(provenance.conversation_id),
145 correlation: provenance.correlation_id.as_deref(),
146 operation: None,
147 tool: None,
148 on_behalf_of: None,
149 purpose: None,
150 data_classification: None,
151 payload: &payload,
152 signed: false,
153 counters: ActionCounters::default(),
154 };
155 if let Some(modified) = self.govern(action).await? {
156 payload = modified;
157 }
158 let destination = provenance
161 .target_agent_id
162 .as_ref()
163 .and_then(|target| self.agent_destination(&topic_name, None, Some(&target.wire_id())));
164 self.send_with_headers(
165 destination.as_deref().unwrap_or(&topic_name),
166 payload,
167 headers,
168 Some(&key),
169 )
170 .await
171 }
172
173 pub async fn redrive_dead_letter(&self, capsule: &AgentDeadLetter) -> Result<(), LaserError> {
187 let source = capsule.source;
188 let stream = Identifier::numeric(source.stream_id)?;
189 let topic = Identifier::numeric(source.topic_id)?;
190 let stream_name = self
191 .client()
192 .get_stream(&stream)
193 .await?
194 .ok_or_else(|| {
195 LaserError::Invalid(format!(
196 "dead-letter source stream {} no longer exists",
197 source.stream_id
198 ))
199 })?
200 .name;
201 let expected = self.stream_required()?;
202 if stream_name != expected {
203 return Err(LaserError::Invalid(format!(
204 "dead-letter capsule names stream `{stream_name}`, outside this connection's stream `{expected}`"
205 )));
206 }
207 let topic_name = self
208 .client()
209 .get_topic(&stream, &topic)
210 .await?
211 .ok_or_else(|| {
212 LaserError::Invalid(format!(
213 "dead-letter source topic {} no longer exists",
214 source.topic_id
215 ))
216 })?
217 .name;
218 let reader = Consumer::new(Identifier::named("laser-redrive")?);
219 let polled = self
220 .client()
221 .poll_messages(
222 &stream,
223 &topic,
224 Some(source.partition_id),
225 &reader,
226 &PollingStrategy::offset(source.offset),
227 1,
228 false,
229 )
230 .await?;
231 let original = polled
232 .messages
233 .into_iter()
234 .find(|message| message.header.offset == source.offset)
235 .ok_or_else(|| {
236 LaserError::Invalid(format!(
237 "dead-letter source record at offset {} is no longer on the log",
238 source.offset
239 ))
240 })?;
241 let mut headers = original.user_headers_map()?.unwrap_or_default();
242 use std::str::FromStr;
249 let idempotency = HeaderKey::from_str(crate::provenance::keys::IDEMPOTENCY_KEY)
250 .map_err(|_| LaserError::Invalid("idempotency header key".to_owned()))?;
251 if let Some(existing) = headers.get(&idempotency) {
252 let redrive_key = format!(
253 "{}/redrive/{}-{}",
254 existing.as_str()?,
255 source.partition_id,
256 source.offset
257 );
258 headers.insert(
259 idempotency,
260 HeaderValue::from_str(&redrive_key)
261 .map_err(|_| LaserError::Invalid("redrive idempotency value".to_owned()))?,
262 );
263 }
264 let key = Provenance::try_from(&original)
267 .ok()
268 .map(|provenance| provenance.partition_key());
269 let message = IggyMessage::builder()
270 .payload(original.payload)
271 .user_headers(headers)
272 .build()?;
273 self.send_batch_on(&stream_name, &topic_name, vec![message], key.as_deref())
274 .await
275 .map(|_| ())
276 }
277
278 pub async fn reassemble_channel(
285 &self,
286 conversation: ConversationId,
287 topic: AgentTopic<'static>,
288 channel: ChannelId,
289 ) -> Result<Vec<StreamEvent>, LaserError> {
290 let messages = ContextAssembler::builder()
291 .conversation_id(conversation)
292 .topics(vec![topic])
293 .build()
294 .assemble(self)
295 .await?;
296 let mut chunks: Vec<_> = messages
297 .iter()
298 .filter_map(|message| message.envelope.as_ref())
299 .filter(|envelope| {
300 envelope.kind == AgentKind::Chunk && envelope.channel == Some(channel)
301 })
302 .collect();
303 chunks.sort_by_key(|envelope| envelope.sequence);
307 let mut assembler = ChunkAssembler::new();
308 let mut events = Vec::new();
309 for chunk in chunks {
310 events.extend(assembler.feed(chunk));
311 }
312 Ok(events)
313 }
314
315 pub(crate) fn reply_topic_for(
319 &self,
320 reply_topic: &AgentTopic<'_>,
321 requester: Option<&laser_wire::agent::AgentId>,
322 ) -> Result<iggy::prelude::Identifier, LaserError> {
323 let declared = (reply_topic.topic_string() == laser_wire::topics::AGENT_SESSIONS)
324 .then(|| requester.and_then(|agent| self.declared_topic(agent)))
325 .flatten();
326 Ok(match declared {
327 Some(topic) => iggy::prelude::Identifier::named(&topic)?,
328 None => reply_topic.as_identifier(),
329 })
330 }
331
332 pub(crate) async fn agdx_reply_reader(
338 &self,
339 reply_topic: AgentTopic<'_>,
340 requester: Option<&laser_wire::agent::AgentId>,
341 ) -> Result<AgentReplyReader, LaserError> {
342 let topic = self.reply_topic_for(&reply_topic, requester)?;
343 #[allow(unused_mut)]
344 let mut reader =
345 AgentReplyReader::new_at_tail(&self.client(), self.stream_required()?, topic).await?;
346 #[cfg(feature = "sign")]
347 {
348 reader.verifier = self.registry_verifier();
349 }
350 Ok(reader)
351 }
352
353 pub(crate) async fn await_agdx_reply(
359 &self,
360 reader: &mut AgentReplyReader,
361 correlation: CorrelationId,
362 timeout: Duration,
363 ) -> Result<AgentEnvelope, LaserError> {
364 let deadline = Instant::now() + timeout;
365 loop {
366 if Instant::now() >= deadline {
370 return Err(LaserError::Timeout("the AGDX reply"));
371 }
372 match reader.next_agdx_match(&self.client(), correlation).await? {
373 ReplyScan::Found(envelope) => return Ok(*envelope),
374 ReplyScan::More => continue,
375 ReplyScan::CaughtUp => sleep(Duration::from_millis(50)).await,
376 }
377 }
378 }
379
380 #[cfg(feature = "a2a-bridge")]
387 pub(crate) async fn find_agdx_reply(
388 &self,
389 reply_topic: AgentTopic<'_>,
390 correlation: CorrelationId,
391 ) -> Result<ReplyLookup, LaserError> {
392 const MAX_LOOKUP_PASSES: usize = 32;
393 let mut reader =
394 AgentReplyReader::new(self.stream_required()?, reply_topic.as_identifier())?;
395 #[cfg(feature = "sign")]
396 {
397 reader.verifier = self.registry_verifier();
398 }
399 for _ in 0..MAX_LOOKUP_PASSES {
400 match reader.next_agdx_match(&self.client(), correlation).await? {
401 ReplyScan::Found(envelope) => return Ok(ReplyLookup::Found(envelope)),
402 ReplyScan::More => continue,
403 ReplyScan::CaughtUp => return Ok(ReplyLookup::NotFound),
404 }
405 }
406 Ok(ReplyLookup::Incomplete)
409 }
410
411 pub fn spawn_subconversation(&self, parent: &Provenance, author: &AgentId) -> Provenance {
413 let root = parent
414 .root_conversation_id
415 .unwrap_or(parent.conversation_id);
416 Provenance::builder()
417 .conversation_id(ConversationId::new())
418 .parent_conversation_id(parent.conversation_id)
419 .root_conversation_id(root)
420 .agent(author.clone())
421 .build()
422 }
423
424 #[tracing::instrument(target = "laser", level = "debug", skip_all, fields(conversation = %provenance.conversation_id, topic = %request_topic.topic_string(), operation = "ask"))]
438 pub async fn request(
439 &self,
440 request_topic: AgentTopic<'_>,
441 reply_topic: AgentTopic<'_>,
442 payload: impl Into<Vec<u8>>,
443 provenance: &Provenance,
444 timeout: Duration,
445 ) -> Result<AgentMessage, LaserError> {
446 let correlation = provenance
447 .correlation_id
448 .clone()
449 .unwrap_or_else(|| ulid::Ulid::generate().to_string());
450 let mut provenance = provenance.clone();
451 provenance.correlation_id = Some(correlation.clone());
452 let same_topic = request_topic.topic_string() == reply_topic.topic_string();
453 let requester = provenance
458 .agent
459 .as_ref()
460 .map(crate::types::AgentId::wire_id);
461 let topic = self.reply_topic_for(&reply_topic, requester.as_ref())?;
462 let hub = self.reply_hub(&AgentTopic::Custom(&topic)).await?;
463 let ticket = hub.subscribe(
464 correlation,
465 crate::agent::replies::ExpectedReply {
466 session: provenance.conversation_id,
467 requester: provenance
468 .agent
469 .as_ref()
470 .map(|agent| agent.as_str().to_owned()),
471 },
472 provenance
473 .target_agent_id
474 .as_ref()
475 .map(|target| target.as_str().to_owned()),
476 );
477 let sent = self
478 .send_agent_as(ActionKind::Request, request_topic, payload, &provenance)
479 .await?;
480 let confirmation = sent.confirmations.first().ok_or_else(|| {
481 LaserError::Protocol("request send had no committed message address".to_owned())
482 })?;
483 ticket.arm(
484 same_topic.then(|| MessageId::new(confirmation.partition_id, confirmation.base_offset)),
485 );
486 ticket.wait(timeout).await
487 }
488
489 pub async fn consumed(
503 &self,
504 target: ConsumerRef,
505 at: LogPosition,
506 ) -> Result<ConsumptionStatus, LaserError> {
507 let consumer = match &target {
508 ConsumerRef::Group(name) => Consumer::group(Identifier::named(name.as_str())?),
509 ConsumerRef::Consumer(id) => Consumer::new(Identifier::named(id)?),
510 };
511 let info = self
512 .client()
513 .get_consumer_offset(
514 &consumer,
515 &Identifier::numeric(at.stream_id)?,
516 &Identifier::numeric(at.topic_id)?,
517 Some(at.partition_id),
518 )
519 .await?;
520 Ok(match info {
521 Some(offset) if offset.stored_offset >= at.offset => {
522 let dispatch = match &target {
523 ConsumerRef::Group(name) => self.dispatch_at(name.as_str(), at).await?,
524 ConsumerRef::Consumer(_) => None,
525 };
526 match dispatch {
527 Some(dispatch) if dispatch != Dispatch::Work => ConsumptionStatus::Skipped {
528 committed: offset.stored_offset,
529 head: offset.current_offset,
530 dispatch,
531 },
532 _ => ConsumptionStatus::Consumed {
533 committed: offset.stored_offset,
534 head: offset.current_offset,
535 },
536 }
537 }
538 Some(offset) => ConsumptionStatus::NotYetConsumed {
539 behind_by: at.offset.saturating_sub(offset.stored_offset),
540 },
541 None => ConsumptionStatus::NotYetConsumed {
542 behind_by: at.offset.saturating_add(1),
543 },
544 })
545 }
546}
547
548#[derive(Debug, Clone, Copy, PartialEq, Eq)]
551pub enum ConsumptionStatus {
552 NotYetConsumed { behind_by: u64 },
554 Consumed { committed: u64, head: u64 },
557 Skipped {
560 committed: u64,
561 head: u64,
562 dispatch: Dispatch,
563 },
564}
565
566impl Laser {
567 async fn dispatch_at(
570 &self,
571 group: &str,
572 at: LogPosition,
573 ) -> Result<Option<Dispatch>, LaserError> {
574 let Ok(me) = group.parse::<laser_wire::agent::AgentId>() else {
575 return Ok(None);
576 };
577 let stream = Identifier::numeric(at.stream_id)?;
578 let topic = Identifier::numeric(at.topic_id)?;
579 let Some(details) = self.client().get_topic(&stream, &topic).await? else {
580 return Ok(None);
581 };
582 let reader = Consumer::new(Identifier::named("laser-consumed")?);
583 let polled = self
584 .client()
585 .poll_messages(
586 &stream,
587 &topic,
588 Some(at.partition_id),
589 &reader,
590 &PollingStrategy::offset(at.offset),
591 1,
592 false,
593 )
594 .await?;
595 let Some(message) = polled
596 .messages
597 .iter()
598 .find(|message| message.header.offset == at.offset)
599 else {
600 return Ok(None);
601 };
602 let Ok(record) = crate::agent::consumer::decode_agent_record(message, u64::MAX) else {
603 return Ok(None);
604 };
605 Ok(Some(match record.envelope {
606 Some(envelope) => classify(&envelope, &details.name, &me, HandledOperations::Any),
607 None if record
608 .provenance
609 .target_agent_id
610 .as_ref()
611 .is_some_and(|target| target.as_str() != me.as_str()) =>
612 {
613 Dispatch::Foreign
614 }
615 None => Dispatch::Work,
616 }))
617 }
618}
619
620#[derive(Debug, Clone)]
623pub enum ConsumerRef {
624 Group(ConsumerGroupName),
625 Consumer(String),
626}
627
628pub(crate) struct AgentReplyReader {
634 stream: Identifier,
635 topic: Identifier,
636 consumer: Consumer,
637 offsets: Vec<u64>,
638 partitions: Option<u32>,
639 #[cfg(feature = "sign")]
646 verifier: Option<std::sync::Arc<crate::sign::KeyRegistry>>,
647 #[cfg(feature = "sign")]
651 pub(crate) expected_signer: Option<String>,
652}
653
654impl AgentReplyReader {
655 fn new(stream: &str, topic: Identifier) -> Result<Self, LaserError> {
656 Ok(Self {
657 stream: Identifier::named(stream)?,
658 topic,
659 consumer: Consumer::new(Identifier::named("laser-agent-reply-reader")?),
660 offsets: Vec::new(),
661 partitions: None,
662 #[cfg(feature = "sign")]
663 verifier: None,
664 #[cfg(feature = "sign")]
665 expected_signer: None,
666 })
667 }
668
669 #[cfg(feature = "sign")]
674 fn verified_principal(
675 &self,
676 envelope: &AgentEnvelope,
677 context: Option<&laser_wire::agent::SignatureContext>,
678 observed_at_micros: u64,
679 ) -> Result<Option<String>, ()> {
680 match &self.verifier {
681 Some(registry) => match context.and_then(|context| {
682 registry
683 .verify_observed_at(envelope, context, observed_at_micros)
684 .ok()
685 }) {
686 Some(verified)
687 if self
688 .expected_signer
689 .as_deref()
690 .is_none_or(|expected| expected == verified.principal) =>
691 {
692 Ok(Some(verified.principal))
693 }
694 Some(_) | None => Err(()),
695 },
696 None => Ok(None),
697 }
698 }
699
700 #[cfg(not(feature = "sign"))]
701 fn verified_principal(
702 &self,
703 _envelope: &AgentEnvelope,
704 _context: Option<&laser_wire::agent::SignatureContext>,
705 _observed_at_micros: u64,
706 ) -> Result<Option<String>, ()> {
707 Ok(None)
708 }
709
710 #[cfg(feature = "sign")]
715 fn plain_reply_ok(&self) -> bool {
716 self.verifier.is_none()
717 }
718
719 #[cfg(not(feature = "sign"))]
720 fn plain_reply_ok(&self) -> bool {
721 true
722 }
723
724 async fn new_at_tail(
730 client: &IggyClient,
731 stream: &str,
732 topic: Identifier,
733 ) -> Result<Self, LaserError> {
734 let mut reader = Self::new(stream, topic)?;
735 let partitions = match client.get_topic(&reader.stream, &reader.topic).await? {
736 Some(details) => crate::poll::bounded_partitions(details.partitions_count),
737 None => return Ok(reader),
740 };
741 reader.partitions = Some(partitions);
742 reader.offsets = vec![0; partitions as usize];
743 for partition in 0..partitions {
744 let polled = client
745 .poll_messages(
746 &reader.stream,
747 &reader.topic,
748 Some(partition),
749 &reader.consumer,
750 &PollingStrategy::last(),
751 1,
752 false,
753 )
754 .await?;
755 if let Some(last) = polled.messages.last() {
757 reader.offsets[partition as usize] = last.header.offset.saturating_add(1);
758 }
759 }
760 Ok(reader)
761 }
762
763 async fn next_agdx_match(
769 &mut self,
770 client: &IggyClient,
771 correlation: CorrelationId,
772 ) -> Result<ReplyScan, LaserError> {
773 let partitions = match self.partitions {
774 Some(value) => value,
775 None => {
776 let Some(details) = client.get_topic(&self.stream, &self.topic).await? else {
777 return Ok(ReplyScan::CaughtUp);
778 };
779 self.partitions = Some(details.partitions_count);
780 details.partitions_count
781 }
782 };
783 if (self.offsets.len() as u32) < partitions {
784 self.offsets.resize(partitions as usize, 0);
785 }
786 let mut read_any = false;
787 for partition in 0..partitions {
788 let from = self.offsets[partition as usize];
789 let batch = crate::poll::drain_partition(
790 client,
791 &self.stream,
792 &self.topic,
793 &self.consumer,
794 crate::poll::DrainRange::open(partition, from),
795 REPLY_BATCH,
796 )
797 .await?;
798 self.offsets[partition as usize] = batch.next_offset;
799 for message in batch.messages {
800 read_any = true;
801 let Ok(decoded) = crate::agent::consumer::decode_agent_record(
802 &message,
803 laser_wire::agent::features::NONE,
804 ) else {
805 continue;
806 };
807 if let Some(envelope) = decoded.envelope
808 && envelope.correlation == Some(correlation)
809 && matches!(envelope.kind, AgentKind::Response | AgentKind::Error)
810 && self
811 .verified_principal(
812 &envelope,
813 decoded.signature_context.as_ref(),
814 decoded.observed_at_micros,
815 )
816 .is_ok()
817 {
818 return Ok(ReplyScan::Found(Box::new(envelope)));
819 }
820 }
821 }
822 Ok(if read_any {
823 ReplyScan::More
824 } else {
825 ReplyScan::CaughtUp
826 })
827 }
828}
829
830enum ReplyScan {
833 Found(Box<AgentEnvelope>),
834 More,
835 CaughtUp,
836}
837
838impl AgentReplyReader {
839 pub(crate) async fn poll_contract(
846 &mut self,
847 client: &IggyClient,
848 correlation: CorrelationId,
849 ) -> Result<ContractPass, LaserError> {
850 let correlation_key = correlation.to_string();
851 let partitions = match self.partitions {
852 Some(value) => value,
853 None => {
854 let Some(details) = client.get_topic(&self.stream, &self.topic).await? else {
855 return Ok(ContractPass::default());
856 };
857 self.partitions = Some(details.partitions_count);
858 details.partitions_count
859 }
860 };
861 if (self.offsets.len() as u32) < partitions {
862 self.offsets.resize(partitions as usize, 0);
863 }
864 let mut pass = ContractPass::default();
865 for partition in 0..partitions {
866 let from = self.offsets[partition as usize];
867 let batch = crate::poll::drain_partition(
868 client,
869 &self.stream,
870 &self.topic,
871 &self.consumer,
872 crate::poll::DrainRange::open(partition, from),
873 REPLY_BATCH,
874 )
875 .await?;
876 self.offsets[partition as usize] = batch.next_offset;
877 for message in batch.messages {
878 pass.read_any = true;
879 let Ok(decoded) = crate::agent::consumer::decode_agent_record(
880 &message,
881 laser_wire::agent::features::NONE,
882 ) else {
883 continue;
884 };
885 let provenance = decoded.provenance;
886 let envelope = decoded.envelope;
887 let signal = match &envelope {
890 Some(agdx) if agdx.correlation == Some(correlation) => {
894 let Ok(verified_principal) = self.verified_principal(
895 agdx,
896 decoded.signature_context.as_ref(),
897 decoded.observed_at_micros,
898 ) else {
899 continue;
900 };
901 if agdx.kind == AgentKind::Status
902 && agdx.operation.as_deref() == Some(OPERATION_TASK)
903 && agdx.task_state == Some(TaskState::Working)
904 {
905 ContractSignal::Working
906 } else {
907 match agdx.kind {
908 AgentKind::Response => {
909 ContractSignal::Completed(verified_principal)
910 }
911 AgentKind::Error => ContractSignal::Failed(verified_principal),
912 _ => ContractSignal::Ignore,
913 }
914 }
915 }
916 None if provenance.correlation_id.as_deref()
924 == Some(correlation_key.as_str())
925 && self.plain_reply_ok() =>
926 {
927 ContractSignal::Completed(None)
928 }
929 _ => ContractSignal::Ignore,
930 };
931 let reply = |verified_principal| AgentMessage {
932 provenance,
933 id: MessageId::new(partition, message.header.offset),
934 payload: message.payload.to_vec(),
935 envelope,
936 content_type: decoded.content_type,
937 verified_principal,
938 };
939 match signal {
940 ContractSignal::Working => pass.consumed = true,
941 ContractSignal::Completed(principal) => {
942 pass.terminal = Some(ContractTerminal::Completed(reply(principal)));
943 return Ok(pass);
944 }
945 ContractSignal::Failed(principal) => {
946 pass.terminal = Some(ContractTerminal::Failed(reply(principal)));
947 return Ok(pass);
948 }
949 ContractSignal::Ignore => {}
950 }
951 }
952 }
953 Ok(pass)
954 }
955}
956
957#[derive(Default)]
960pub(crate) struct ContractPass {
961 pub consumed: bool,
962 pub terminal: Option<ContractTerminal>,
963 pub read_any: bool,
964}
965
966pub(crate) enum ContractTerminal {
968 Completed(AgentMessage),
969 Failed(AgentMessage),
970}
971
972#[derive(Clone)]
975enum ContractSignal {
976 Working,
977 Completed(Option<String>),
978 Failed(Option<String>),
979 Ignore,
980}
981
982pub(crate) fn stamp_broadcast_addressee(
988 headers: &mut BTreeMap<HeaderKey, HeaderValue>,
989 topic: &str,
990) -> Result<(), LaserError> {
991 if topic != laser_wire::topics::AGENT_SESSIONS && topic != laser_wire::topics::AGENT_CONTROL {
992 return Ok(());
993 }
994 let key = HeaderKey::from_str(laser_wire::headers::TARGET_AGENT_ID)?;
995 let broadcast = HeaderValue::from_str(laser_wire::headers::Addressee::Broadcast.as_str())?;
996 headers.entry(key).or_insert(broadcast);
997 Ok(())
998}
999
1000#[cfg(feature = "a2a-bridge")]
1002pub(crate) enum ReplyLookup {
1003 Found(Box<AgentEnvelope>),
1004 NotFound,
1006 Incomplete,
1008}
1009
1010#[cfg(test)]
1011mod addressee_tests {
1012 use super::*;
1013
1014 fn headers_with_target(target: Option<&str>) -> BTreeMap<HeaderKey, HeaderValue> {
1015 let mut headers = BTreeMap::new();
1016 if let Some(target) = target {
1017 headers.insert(
1018 HeaderKey::from_str(laser_wire::headers::TARGET_AGENT_ID).expect("key"),
1019 HeaderValue::from_str(target).expect("value"),
1020 );
1021 }
1022 headers
1023 }
1024
1025 fn addressee(headers: &BTreeMap<HeaderKey, HeaderValue>) -> Option<String> {
1026 headers
1027 .get(&HeaderKey::from_str(laser_wire::headers::TARGET_AGENT_ID).expect("key"))
1028 .map(|value| value.as_str().expect("text addressee").to_owned())
1029 }
1030
1031 #[test]
1032 fn given_an_untargeted_send_on_a_session_topic_when_stamped_then_should_broadcast() {
1033 for topic in [
1034 laser_wire::topics::AGENT_SESSIONS,
1035 laser_wire::topics::AGENT_CONTROL,
1036 ] {
1037 let mut headers = headers_with_target(None);
1038 stamp_broadcast_addressee(&mut headers, topic).expect("stamp");
1039 assert_eq!(addressee(&headers).as_deref(), Some("*"), "{topic}");
1040 }
1041 }
1042
1043 #[test]
1044 fn given_a_targeted_send_when_stamped_then_should_keep_the_target() {
1045 let mut headers = headers_with_target(Some("planner"));
1046 stamp_broadcast_addressee(&mut headers, laser_wire::topics::AGENT_SESSIONS).expect("stamp");
1047 assert_eq!(addressee(&headers).as_deref(), Some("planner"));
1048 }
1049
1050 #[test]
1051 fn given_an_untargeted_send_on_another_topic_when_stamped_then_should_leave_headers_alone() {
1052 let mut headers = headers_with_target(None);
1053 stamp_broadcast_addressee(&mut headers, "commands").expect("stamp");
1054 assert_eq!(addressee(&headers), None);
1055 assert!(headers.is_empty());
1056 }
1057}