1use crate::error::LaserError;
2use crate::govern::{ActionCounters, ActionKind, GovernedAction};
3use crate::laser::Laser;
4use crate::provenance::AgentTopic;
5use crate::types::MintUlid;
6use iggy::prelude::{HeaderKey, HeaderValue};
7use laser_wire::agent::{
8 AgentEnvelope, AgentErrorBody, AgentId, AgentKind, ChannelId, ConversationId, CorrelationId,
9 IdempotencyKey, LogPosition, RecordId, TaskState, TokenUsage, validate,
10};
11#[cfg(feature = "sign")]
12use laser_wire::codes::AGENT_OP_VERSION;
13use laser_wire::content::ContentType;
14use laser_wire::framing::{decode_named, encode_named};
15use laser_wire::query::Value;
16use std::collections::BTreeMap;
17use std::time::Duration;
18
19pub const DEFAULT_CHUNK_FLUSH_BYTES: usize = 512;
24pub const DEFAULT_CHUNK_LINGER_MS: u64 = 20;
26pub const MAX_CHUNK_BODY_BYTES: usize = 64 * 1024;
28
29#[derive(Clone)]
36pub struct Agdx {
37 laser: Laser,
38 topic: String,
39 source: AgentId,
40 conversation: ConversationId,
41 lane_guard: Option<std::sync::Arc<crate::agent::session::LaneGuard>>,
42 stream_generation: Option<u64>,
43}
44
45impl Laser {
46 pub fn agdx(
50 &self,
51 topic: AgentTopic<'_>,
52 source: impl Into<AgentId>,
53 conversation: ConversationId,
54 ) -> Agdx {
55 let source = source.into();
56 Agdx {
57 laser: self.clone(),
58 topic: topic.topic_string(),
59 source,
60 conversation,
61 lane_guard: None,
62 stream_generation: None,
63 }
64 }
65}
66
67impl Agdx {
68 pub(crate) fn with_lane_guard(
69 mut self,
70 guard: std::sync::Arc<crate::agent::session::LaneGuard>,
71 ) -> Self {
72 self.lane_guard = Some(guard);
73 self
74 }
75
76 pub(crate) fn with_stream_generation(mut self, generation: u64) -> Self {
77 self.stream_generation = Some(generation);
78 self
79 }
80
81 pub fn command(&self, correlation: CorrelationId, body: Vec<u8>) -> AgdxSend<'_> {
83 self.send_of(AgentEnvelope::command(
84 RecordId::mint(),
85 self.conversation,
86 self.source.clone(),
87 correlation,
88 body,
89 ))
90 }
91
92 pub fn respond(&self, correlation: CorrelationId, body: Vec<u8>) -> AgdxSend<'_> {
94 self.send_of(AgentEnvelope::response(
95 RecordId::mint(),
96 self.conversation,
97 self.source.clone(),
98 correlation,
99 body,
100 ))
101 }
102
103 pub fn emit(&self, body: Vec<u8>) -> AgdxSend<'_> {
105 self.send_of(AgentEnvelope::event(
106 RecordId::mint(),
107 self.conversation,
108 self.source.clone(),
109 body,
110 ))
111 }
112
113 pub fn status(&self, operation: impl Into<String>) -> AgdxSend<'_> {
118 self.send_of(AgentEnvelope::status(
119 RecordId::mint(),
120 self.conversation,
121 self.source.clone(),
122 operation,
123 ))
124 }
125
126 pub fn fail(
129 &self,
130 correlation: CorrelationId,
131 error: &AgentErrorBody,
132 ) -> Result<AgdxSend<'_>, LaserError> {
133 let body = encode_named(error)?;
134 Ok(self
135 .send_of(AgentEnvelope::error(
136 RecordId::mint(),
137 self.conversation,
138 self.source.clone(),
139 correlation,
140 body,
141 ))
142 .content_type(ContentType::Cbor))
143 }
144
145 pub fn stream(&self, correlation: CorrelationId, purpose: impl Into<String>) -> AgdxStream {
149 AgdxStream {
150 agdx: self.clone(),
151 correlation,
152 channel: ChannelId::mint(),
153 purpose: purpose.into(),
154 sequence: 0,
155 deadline_micros: None,
156 target: None,
157 content_type: ContentType::Raw,
158 buffer: None,
159 }
160 }
161
162 pub async fn request_input(
175 &self,
176 reply_topic: AgentTopic<'_>,
177 prompt: impl Into<Vec<u8>>,
178 timeout: Duration,
179 ) -> Result<Vec<u8>, LaserError> {
180 self.request_input_addressed(None, reply_topic, prompt.into(), timeout)
181 .await
182 }
183
184 pub async fn request_input_from(
187 &self,
188 target: impl Into<AgentId>,
189 reply_topic: AgentTopic<'_>,
190 prompt: impl Into<Vec<u8>>,
191 timeout: Duration,
192 ) -> Result<Vec<u8>, LaserError> {
193 let target = target.into();
194 self.request_input_addressed(Some(target), reply_topic, prompt.into(), timeout)
195 .await
196 }
197
198 async fn request_input_addressed(
199 &self,
200 target: Option<AgentId>,
201 reply_topic: AgentTopic<'_>,
202 prompt: Vec<u8>,
203 timeout: Duration,
204 ) -> Result<Vec<u8>, LaserError> {
205 let interrupt = CorrelationId::mint();
206 let mut reader = self
209 .laser
210 .agdx_reply_reader(reply_topic, Some(&self.source))
211 .await?;
212 let command = self.command(interrupt, prompt);
213 match target {
214 Some(target) => command.with_target(target).send().await?,
215 None => command.send().await?,
216 };
217 let reply = self
218 .laser
219 .await_agdx_reply(&mut reader, interrupt, timeout)
220 .await?;
221 if reply.kind == AgentKind::Error {
222 let message = decode_named::<AgentErrorBody>(&reply.body)
223 .ok()
224 .and_then(|body| body.message)
225 .unwrap_or_else(|| "the input request was rejected".to_owned());
226 return Err(LaserError::Rejected(message));
227 }
228 Ok(reply.body)
229 }
230
231 pub(crate) async fn publish_envelope(
233 &self,
234 envelope: AgentEnvelope,
235 ) -> Result<AgdxReceipt, LaserError> {
236 self.publish(envelope, ContentType::Raw).await
237 }
238
239 fn send_of(&self, envelope: AgentEnvelope) -> AgdxSend<'_> {
240 AgdxSend {
241 agdx: self,
242 envelope,
243 content_type: ContentType::Raw,
244 #[cfg(feature = "sign")]
245 sign_key: None,
246 claim_check: None,
247 }
248 }
249
250 #[tracing::instrument(target = "laser", level = "debug", skip_all, fields(conversation = %self.conversation, correlation = envelope.correlation.as_ref().map(tracing::field::display), topic = %self.topic, operation = "agdx"))]
251 async fn publish(
252 &self,
253 envelope: AgentEnvelope,
254 content_type: ContentType,
255 ) -> Result<AgdxReceipt, LaserError> {
256 let record = envelope.record;
257 let destination = self.laser.agent_destination(
260 &self.topic,
261 Some(envelope.kind),
262 envelope.target.as_ref(),
263 );
264 let (message, partitioning) = self.assemble(envelope, content_type)?;
265 let sent = self
266 .laser
267 .send_batch_partitioned_on_checked(
268 self.laser.stream_required()?,
269 destination.as_deref().unwrap_or(&self.topic),
270 vec![message],
271 partitioning.clone(),
272 || async {
273 if destination.is_some() {
274 return Ok(None);
275 }
276 let (stream_id, topic_id, partitions) = if let Some(guard) = &self.lane_guard {
277 let identity = guard.check(&self.laser, self.conversation.into()).await?;
278 (identity.stream_id, identity.topic_id, identity.partitions)
279 } else if let Some(generation) = self.stream_generation {
280 use iggy::prelude::StreamClient;
281 let details = self
282 .laser
283 .client()
284 .get_stream(&iggy::prelude::Identifier::named(
285 self.laser.stream_required()?,
286 )?)
287 .await?
288 .ok_or_else(|| {
289 LaserError::Session(laser_wire::session::SessionError::Stale(
290 "the heartbeat stream does not exist".to_owned(),
291 ))
292 })?;
293 if details.created_at.as_micros() != generation {
294 return Err(LaserError::Session(
295 laser_wire::session::SessionError::Stale(
296 "the heartbeat stream generation changed".to_owned(),
297 ),
298 ));
299 }
300 let topic = details
301 .topics
302 .iter()
303 .find(|topic| topic.name == self.topic)
304 .filter(|topic| topic.partitions_count > 0)
305 .ok_or_else(|| {
306 LaserError::Session(laser_wire::session::SessionError::Stale(
307 "the heartbeat topic does not exist".to_owned(),
308 ))
309 })?;
310 (details.id, topic.id, topic.partitions_count)
311 } else {
312 return Ok(None);
313 };
314 let route = if partitioning.kind == iggy_common::PartitioningKind::MessagesKey {
315 iggy::prelude::Partitioning::partition_id(
316 iggy_common::calculate_32(&partitioning.value) % partitions,
317 )
318 } else {
319 partitioning.clone()
320 };
321 Ok(Some((stream_id, topic_id, route)))
322 },
323 )
324 .await?;
325 let confirmation = sent.confirmations.first();
326 Ok(AgdxReceipt {
327 record,
328 partition_id: confirmation.map(|confirmation| confirmation.partition_id),
329 offset: confirmation.map(|confirmation| confirmation.base_offset),
330 })
331 }
332
333 fn assemble(
338 &self,
339 envelope: AgentEnvelope,
340 content_type: ContentType,
341 ) -> Result<(iggy::prelude::IggyMessage, iggy::prelude::Partitioning), LaserError> {
342 validate(&envelope)?;
343 let payload = encode_named(&envelope)?;
344 let headers = agdx_headers(
347 &envelope,
348 content_type,
349 self.topic == laser_wire::topics::AGENT_SESSIONS
350 || self.topic == laser_wire::topics::AGENT_CONTROL,
351 )?;
352 let partitioning = self.partitioning(envelope.kind, envelope.target.as_ref())?;
353 let message = iggy::prelude::IggyMessage::builder()
354 .payload(bytes::Bytes::from(payload))
355 .user_headers(headers)
356 .build()?;
357 Ok((message, partitioning))
358 }
359
360 fn partitioning(
363 &self,
364 kind: laser_wire::agent::AgentKind,
365 target: Option<&AgentId>,
366 ) -> Result<iggy::prelude::Partitioning, LaserError> {
367 let layout = self
368 .laser
369 .default_stream()
370 .and_then(|stream| self.laser.layout(stream));
371 crate::agent::partitioning::AgentPartitioning::resolve(
372 layout.as_ref(),
373 &self.topic,
374 kind,
375 target,
376 )
377 .into_partitioning(self.conversation)
378 }
379}
380
381#[derive(Debug, Clone, Copy, PartialEq, Eq)]
383pub struct AgdxReceipt {
384 pub record: Option<RecordId>,
386 pub partition_id: Option<u32>,
388 pub offset: Option<u64>,
390}
391
392#[must_use = "an unsent envelope does nothing until you call .send().await"]
395pub struct AgdxSend<'a> {
396 agdx: &'a Agdx,
397 envelope: AgentEnvelope,
398 content_type: ContentType,
399 #[cfg(feature = "sign")]
405 sign_key: Option<&'a crate::sign::SigningKey>,
406 claim_check: Option<(&'a dyn crate::blob::BlobStore, usize)>,
409}
410
411impl<'a> AgdxSend<'a> {
412 pub(crate) fn with_record(mut self, record: RecordId) -> Self {
415 self.envelope.record = Some(record);
416 self
417 }
418
419 pub fn with_ancestry(
422 mut self,
423 parent: Option<laser_wire::agent::ConversationId>,
424 root: Option<laser_wire::agent::ConversationId>,
425 ) -> Self {
426 self.envelope.parent = parent;
427 self.envelope.root = root;
428 self
429 }
430
431 pub fn with_target(mut self, target: impl Into<AgentId>) -> Self {
433 self.envelope = self.envelope.with_target(target.into());
434 self
435 }
436
437 pub fn with_cause(mut self, cause: RecordId, cause_at: Option<LogPosition>) -> Self {
439 self.envelope = self.envelope.with_cause(cause, cause_at);
440 self
441 }
442
443 pub fn with_correlation(mut self, correlation: CorrelationId) -> Self {
445 self.envelope = self.envelope.with_correlation(correlation);
446 self
447 }
448
449 pub fn with_idempotency_key(mut self, key: IdempotencyKey) -> Self {
451 self.envelope = self.envelope.with_idempotency_key(key);
452 self
453 }
454
455 pub fn with_deadline_micros(mut self, deadline_micros: u64) -> Self {
457 self.envelope = self.envelope.with_deadline_micros(deadline_micros);
458 self
459 }
460
461 pub fn with_task_state(mut self, state: TaskState) -> Self {
463 self.envelope = self.envelope.with_task_state(state);
464 self
465 }
466
467 pub fn with_operation(mut self, operation: impl Into<String>) -> Self {
469 self.envelope = self.envelope.with_operation(operation);
470 self
471 }
472
473 pub fn with_finish_reason(mut self, reason: impl Into<String>) -> Self {
475 self.envelope.finish_reason = Some(reason.into());
476 self
477 }
478
479 pub fn with_tool(mut self, tool: impl Into<String>) -> Self {
481 self.envelope = self.envelope.with_tool(tool);
482 self
483 }
484
485 pub fn with_usage(mut self, usage: TokenUsage) -> Self {
487 self.envelope = self.envelope.with_usage(usage);
488 self
489 }
490
491 pub fn with_metadata(mut self, key: impl Into<String>, value: impl Into<Value>) -> Self {
493 self.envelope = self.envelope.with_metadata(key, value);
494 self
495 }
496
497 pub fn last(mut self) -> Self {
499 self.envelope.last = true;
500 self
501 }
502
503 pub fn content_type(mut self, content_type: ContentType) -> Self {
507 self.content_type = content_type;
508 self
509 }
510
511 pub fn body(mut self, body: impl Into<Vec<u8>>) -> Self {
516 self.envelope.body = body.into();
517 self
518 }
519
520 #[cfg(feature = "sign")]
525 pub fn signed_by(mut self, key: &'a crate::sign::SigningKey) -> Self {
526 self.sign_key = Some(key);
527 self
528 }
529
530 pub fn claim_check(
537 mut self,
538 store: &'a dyn crate::blob::BlobStore,
539 threshold_bytes: usize,
540 ) -> Self {
541 self.claim_check = Some((store, threshold_bytes));
542 self
543 }
544
545 pub async fn send(self) -> Result<Option<RecordId>, LaserError> {
550 Ok(self.send_receipt().await?.record)
551 }
552
553 pub async fn send_receipt(self) -> Result<AgdxReceipt, LaserError> {
556 let mut envelope = self.envelope;
557 let mut content_type = self.content_type;
558 if envelope.kind == AgentKind::Error && content_type != ContentType::Cbor {
559 return Err(LaserError::Invalid(
560 "an error envelope carries a CBOR AgentErrorBody, so its content type cannot change"
561 .to_owned(),
562 ));
563 }
564 #[cfg(feature = "sign")]
567 let will_sign = self.sign_key.is_some();
568 #[cfg(not(feature = "sign"))]
569 let will_sign = false;
570 let correlation = envelope.correlation.map(|value| value.to_string());
571 let action = GovernedAction {
572 kind: action_kind(envelope.kind),
573 stream: self.agdx.laser.stream_required()?,
574 topic: &self.agdx.topic,
575 source: Some(envelope.source.as_str()),
576 target: envelope.target.as_ref().map(|target| target.as_str()),
577 conversation: Some(envelope.conversation.into()),
578 correlation: correlation.as_deref(),
579 operation: envelope.operation.as_deref(),
580 tool: envelope.tool.as_deref(),
581 on_behalf_of: metadata_str(&envelope, laser_wire::agent::METADATA_DELEGATED_BY),
582 purpose: metadata_str(&envelope, laser_wire::agent::METADATA_PURPOSE),
583 data_classification: metadata_str(
584 &envelope,
585 laser_wire::agent::METADATA_DATA_CLASSIFICATION,
586 ),
587 payload: &envelope.body,
588 signed: will_sign,
589 counters: ActionCounters::default(),
590 };
591 if let Some(modified) = self.agdx.laser.govern(action).await? {
592 envelope.body = modified;
593 }
594 if let Some((store, threshold)) = self.claim_check {
595 let body = std::mem::take(&mut envelope.body);
596 let (body, replaced) = crate::blob::check_in(store, threshold, body).await?;
597 envelope.body = body;
598 if let Some(replaced) = replaced {
599 content_type = replaced;
600 }
601 }
602 #[cfg(feature = "sign")]
603 let envelope = if let Some(key) = self.sign_key {
604 let context = laser_wire::agent::SignatureContext {
608 content_type: Some(content_type.code()),
609 agent_version: Some(AGENT_OP_VERSION),
610 };
611 let signature = key.sign_with_context(&envelope, context)?;
612 envelope.with_signature(signature)
613 } else {
614 envelope
615 };
616 self.agdx.publish(envelope, content_type).await
617 }
618}
619
620pub struct AgdxStream {
625 agdx: Agdx,
626 correlation: CorrelationId,
627 channel: ChannelId,
628 purpose: String,
629 sequence: u64,
630 deadline_micros: Option<u64>,
631 target: Option<AgentId>,
632 content_type: ContentType,
633 buffer: Option<ChunkBuffer>,
634}
635
636struct ChunkBuffer {
640 max_chunks: usize,
641 linger: std::time::Duration,
642 first_at: Option<std::time::Instant>,
643 messages: Vec<iggy::prelude::IggyMessage>,
644}
645
646impl AgdxStream {
647 pub fn channel(&self) -> ChannelId {
649 self.channel
650 }
651
652 pub fn with_deadline_micros(mut self, deadline_micros: u64) -> Self {
655 self.deadline_micros = deadline_micros.into();
656 self
657 }
658
659 pub fn with_target(mut self, target: AgentId) -> Self {
661 self.target = Some(target);
662 self
663 }
664
665 pub fn content_type(mut self, content_type: ContentType) -> Self {
667 self.content_type = content_type;
668 self
669 }
670
671 #[must_use]
679 pub fn buffered(mut self, max_chunks: usize, linger: std::time::Duration) -> Self {
680 self.buffer = Some(ChunkBuffer {
681 max_chunks: max_chunks.max(1),
682 linger,
683 first_at: None,
684 messages: Vec::new(),
685 });
686 self
687 }
688
689 pub async fn write(&mut self, body: Vec<u8>) -> Result<(), LaserError> {
692 ensure_chunk_body_within_cap(&body)?;
693 let envelope = self.chunk(body, false, None, None);
694 self.sequence += 1;
695 match &mut self.buffer {
696 None => {
697 self.agdx.publish(envelope, self.content_type).await?;
698 }
699 Some(buffer) => {
700 let (message, _) = self.agdx.assemble(envelope, self.content_type)?;
701 buffer.first_at.get_or_insert_with(std::time::Instant::now);
702 buffer.messages.push(message);
703 let linger_tripped = buffer
704 .first_at
705 .is_some_and(|first| first.elapsed() >= buffer.linger);
706 if buffer.messages.len() >= buffer.max_chunks || linger_tripped {
707 self.flush().await?;
708 }
709 }
710 }
711 Ok(())
712 }
713
714 pub async fn flush(&mut self) -> Result<(), LaserError> {
716 let Some(buffer) = &mut self.buffer else {
717 return Ok(());
718 };
719 if buffer.messages.is_empty() {
720 return Ok(());
721 }
722 let batch = std::mem::take(&mut buffer.messages);
723 buffer.first_at = None;
724 let partitioning = self
725 .agdx
726 .partitioning(laser_wire::agent::AgentKind::Chunk, self.target.as_ref())?;
727 let destination = self.destination();
728 self.agdx
729 .laser
730 .send_batch_partitioned_on(
731 self.agdx.laser.stream_required()?,
732 &destination,
733 batch,
734 partitioning,
735 )
736 .await
737 .map(|_| ())
738 }
739
740 pub async fn finish(
744 mut self,
745 finish_reason: impl Into<String>,
746 usage: Option<TokenUsage>,
747 ) -> Result<(), LaserError> {
748 let envelope = self.chunk(Vec::new(), true, Some(finish_reason.into()), usage);
749 self.send_terminal(envelope, self.content_type).await
750 }
751
752 pub async fn fail(mut self, error: &AgentErrorBody) -> Result<(), LaserError> {
755 let body = encode_named(error)?;
756 let mut envelope = AgentEnvelope::error(
757 RecordId::mint(),
758 self.agdx.conversation,
759 self.agdx.source.clone(),
760 self.correlation,
761 body,
762 );
763 envelope.channel = Some(self.channel);
764 envelope.sequence = Some(self.sequence);
765 if let Some(target) = self.target.clone() {
766 envelope = envelope.with_target(target);
767 }
768 self.send_terminal(envelope, ContentType::Cbor).await
769 }
770
771 async fn send_terminal(
775 &mut self,
776 envelope: AgentEnvelope,
777 content_type: ContentType,
778 ) -> Result<(), LaserError> {
779 match &mut self.buffer {
780 None => {
781 self.agdx.publish(envelope, content_type).await?;
782 }
783 Some(buffer) => {
784 let (message, partitioning) = self.agdx.assemble(envelope, content_type)?;
785 buffer.messages.push(message);
786 let batch = std::mem::take(&mut buffer.messages);
787 buffer.first_at = None;
788 let destination = self.destination();
789 self.agdx
790 .laser
791 .send_batch_partitioned_on(
792 self.agdx.laser.stream_required()?,
793 &destination,
794 batch,
795 partitioning,
796 )
797 .await?;
798 }
799 }
800 Ok(())
801 }
802
803 fn destination(&self) -> String {
806 self.agdx
807 .laser
808 .agent_destination(
809 &self.agdx.topic,
810 Some(laser_wire::agent::AgentKind::Chunk),
811 self.target.as_ref(),
812 )
813 .unwrap_or_else(|| self.agdx.topic.clone())
814 }
815
816 fn chunk(
817 &self,
818 body: Vec<u8>,
819 last: bool,
820 finish_reason: Option<String>,
821 usage: Option<TokenUsage>,
822 ) -> AgentEnvelope {
823 let mut envelope = AgentEnvelope::chunk(
824 self.agdx.conversation,
825 self.agdx.source.clone(),
826 self.correlation,
827 self.channel,
828 self.sequence,
829 body,
830 );
831 if self.sequence == 0 {
832 envelope = envelope.with_operation(self.purpose.clone());
833 if let Some(deadline) = self.deadline_micros {
834 envelope = envelope.with_deadline_micros(deadline);
835 }
836 }
837 if let Some(target) = self.target.clone() {
838 envelope = envelope.with_target(target);
839 }
840 if last {
841 envelope.last = true;
842 envelope.finish_reason = finish_reason;
843 if let Some(usage) = usage {
844 envelope = envelope.with_usage(usage);
845 }
846 }
847 envelope
848 }
849}
850
851fn action_kind(kind: AgentKind) -> ActionKind {
855 match kind {
856 AgentKind::Command => ActionKind::Command,
857 AgentKind::Response => ActionKind::Response,
858 AgentKind::Event => ActionKind::Event,
859 AgentKind::Status => ActionKind::Status,
860 AgentKind::Error => ActionKind::Error,
861 _ => ActionKind::Send,
862 }
863}
864
865fn metadata_str<'e>(envelope: &'e AgentEnvelope, key: &str) -> Option<&'e str> {
867 match envelope.metadata.as_ref()?.get(key)? {
868 Value::Str(value) => Some(value.as_str()),
869 _ => None,
870 }
871}
872
873fn ensure_chunk_body_within_cap(body: &[u8]) -> Result<(), LaserError> {
874 if body.len() > MAX_CHUNK_BODY_BYTES {
875 return Err(LaserError::Invalid(format!(
876 "chunk body is {}B, exceeds cap {}B",
877 body.len(),
878 MAX_CHUNK_BODY_BYTES
879 )));
880 }
881 Ok(())
882}
883
884pub(crate) fn agdx_headers(
890 envelope: &AgentEnvelope,
891 content_type: ContentType,
892 broadcast: bool,
893) -> Result<BTreeMap<HeaderKey, HeaderValue>, LaserError> {
894 let headers =
895 laser_wire::headers::RecordHeaders::for_envelope(envelope, content_type, broadcast);
896 Ok(crate::provenance::header_map(&headers)?)
897}
898
899#[cfg(test)]
900mod tests {
901 use super::*;
902 use laser_wire::agent::OPERATION_CHAT;
903 use laser_wire::fixtures::assert_matches;
904 use laser_wire::headers::{
905 AGENT_ID, AGENT_VERSION, CONTENT_TYPE, CONVERSATION_ID, PARENT_CONVERSATION_ID,
906 ROOT_CONVERSATION_ID, TARGET_AGENT_ID,
907 };
908 use serde::Serialize;
909 use serde::Serializer;
910 use std::env;
911 use std::fs;
912 use std::path::PathBuf;
913 use std::str::FromStr;
914
915 #[tokio::test]
916 async fn given_a_fail_with_another_content_type_when_sent_then_should_refuse_it() {
917 let laser = Laser::from_client(iggy::prelude::IggyClient::default())
918 .with_default_stream("agdx-fail");
919 let agdx = laser.agdx(
920 AgentTopic::Sessions,
921 AgentId::from_str("worker").expect("a valid agent id"),
922 ConversationId::mint(),
923 );
924 let error = AgentErrorBody {
925 code: laser_wire::agent::AgentErrorCode::Internal,
926 message: Some("boom".to_owned()),
927 retryable: false,
928 detail: None,
929 };
930 let result = agdx
931 .fail(CorrelationId::mint(), &error)
932 .expect("the error body encodes")
933 .content_type(ContentType::Json)
934 .send_receipt()
935 .await;
936 assert!(matches!(result, Err(LaserError::Invalid(_))));
937 }
938
939 #[test]
943 fn given_agdx_headers_when_stamped_then_should_pin_the_typed_encodings() {
944 let (record, conversation, source, correlation) = ids();
945 let envelope =
946 AgentEnvelope::command(record, conversation, source, correlation, b"x".to_vec())
947 .with_target("target-agent".parse().expect("valid agent id"));
948 let headers = agdx_headers(&envelope, ContentType::Json, false).expect("headers stamp");
949
950 let value = |key: &str| {
951 headers
952 .get(&HeaderKey::from_str(key).expect("key parses"))
953 .expect("header present")
954 };
955 assert_eq!(value(AGENT_VERSION).as_bytes(), [1, 0, 0, 0]);
957 assert_eq!(value(CONTENT_TYPE).as_bytes(), [1]);
959 assert_eq!(
960 value(CONVERSATION_ID).as_str().expect("string"),
961 conversation.to_string()
962 );
963 assert_eq!(
965 value(TARGET_AGENT_ID).as_str().expect("string"),
966 "target-agent"
967 );
968 assert_eq!(
970 value(AGENT_ID).as_str().expect("string"),
971 envelope.source.as_str()
972 );
973 assert_eq!(envelope.conversation.to_string().len(), 26);
975 }
976
977 #[test]
978 fn given_child_session_envelope_when_stamped_then_should_carry_canonical_ancestry_headers() {
979 let (record, conversation, source, correlation) = ids();
980 let mut envelope =
981 AgentEnvelope::command(record, conversation, source, correlation, b"x".to_vec());
982 envelope.parent = Some(laser_wire::agent::ConversationId::from_u128(2));
983 envelope.root = Some(laser_wire::agent::ConversationId::from_u128(1));
984 let headers = agdx_headers(&envelope, ContentType::Cbor, false).expect("headers stamp");
985 for (name, expected) in [
986 (PARENT_CONVERSATION_ID, envelope.parent.expect("parent")),
987 (ROOT_CONVERSATION_ID, envelope.root.expect("root")),
988 ] {
989 let value = headers
990 .get(&HeaderKey::from_str(name).expect("key"))
991 .expect("header");
992 assert_eq!(value.as_str().expect("string"), expected.to_string());
993 }
994 }
995
996 #[test]
999 fn given_the_canonical_command_when_encoded_then_should_match_the_corpus() {
1000 let (record, conversation, source, correlation) = ids();
1001 let command = AgentEnvelope::command(
1002 record,
1003 conversation,
1004 source,
1005 correlation,
1006 br#"{"ask":"plan the rollout"}"#.to_vec(),
1007 )
1008 .with_target("target-agent".parse().expect("valid agent id"))
1009 .with_idempotency_key("job-123-attempt-2".parse().expect("valid key"))
1010 .with_deadline_micros(1_717_171_777_000_000)
1011 .with_operation(OPERATION_CHAT)
1012 .with_metadata("priority", "high");
1013 let encoded = encode_named(&command).expect("encodes");
1014 assert_matches("agent_command.bin", &encoded);
1015 }
1016
1017 #[test]
1018 fn given_an_oversized_chunk_body_when_checked_then_should_reject_before_publish() {
1019 let body = vec![0u8; MAX_CHUNK_BODY_BYTES + 1];
1020 let error = ensure_chunk_body_within_cap(&body).expect_err("oversized body rejects");
1021 assert!(matches!(error, LaserError::Invalid(_)));
1022 }
1023
1024 #[test]
1025 fn given_the_canonical_record_when_encoded_then_should_match_record_fixture() {
1026 let (record, conversation, source, correlation) = ids();
1027 let envelope = AgentEnvelope::command(
1028 record,
1029 conversation,
1030 source,
1031 correlation,
1032 br#"{"ask":"plan the rollout"}"#.to_vec(),
1033 )
1034 .with_target("target-agent".parse().expect("valid agent id"))
1035 .with_idempotency_key("job-123-attempt-2".parse().expect("valid key"))
1036 .with_deadline_micros(1_717_171_777_000_000)
1037 .with_operation(OPERATION_CHAT)
1038 .with_metadata("priority", "high");
1039 let headers = agdx_headers(&envelope, ContentType::Json, false).expect("headers stamp");
1040 let payload = encode_named(&envelope).expect("payload encodes");
1041 let record =
1042 CanonicalAlpRecord::from_headers(envelope.conversation.to_string(), headers, payload);
1043 let encoded = encode_named(&record).expect("record fixture encodes");
1044 assert_record_fixture("agent_record.bin", &encoded);
1045 }
1046
1047 #[derive(Serialize)]
1048 struct CanonicalAlpRecord {
1049 partition_key: String,
1050 headers: BTreeMap<String, CanonicalHeader>,
1051 payload: Binary,
1052 }
1053
1054 impl CanonicalAlpRecord {
1055 fn from_headers(
1056 partition_key: String,
1057 headers: BTreeMap<HeaderKey, HeaderValue>,
1058 payload: Vec<u8>,
1059 ) -> Self {
1060 let mut canonical_headers = BTreeMap::new();
1061 for (key, value) in headers {
1062 let key = key.as_str().expect("header key is utf8").to_owned();
1063 let kind = match key.as_str() {
1064 AGENT_VERSION => "u32",
1065 CONTENT_TYPE => "u8",
1066 CONVERSATION_ID | TARGET_AGENT_ID | AGENT_ID => "string",
1067 _ => "bytes",
1068 };
1069 canonical_headers.insert(
1070 key,
1071 CanonicalHeader {
1072 kind,
1073 bytes: Binary(value.as_bytes().to_vec()),
1074 },
1075 );
1076 }
1077 Self {
1078 partition_key,
1079 headers: canonical_headers,
1080 payload: Binary(payload),
1081 }
1082 }
1083 }
1084
1085 #[derive(Serialize)]
1086 struct CanonicalHeader {
1087 kind: &'static str,
1088 bytes: Binary,
1089 }
1090
1091 struct Binary(Vec<u8>);
1092
1093 impl Serialize for Binary {
1094 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
1095 where
1096 S: Serializer,
1097 {
1098 serializer.serialize_bytes(&self.0)
1099 }
1100 }
1101
1102 fn assert_record_fixture(name: &str, encoded: &[u8]) {
1103 let path = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
1104 .parent()
1105 .expect("sdk crate has workspace parent")
1106 .join("wire")
1107 .join("fixtures")
1108 .join(name);
1109 if env::var("AGDX_WIRE_FIXTURES_REGEN").is_ok() {
1110 fs::write(&path, encoded).expect("write record fixture");
1111 }
1112 let golden = fs::read(&path).unwrap_or_else(|error| {
1113 panic!("read fixture {name}: {error} (regen with AGDX_WIRE_FIXTURES_REGEN=1)")
1114 });
1115 assert_eq!(
1116 encoded, golden,
1117 "fixture `{name}` drifted from the canonical AGDX record"
1118 );
1119 }
1120
1121 fn ids() -> (RecordId, ConversationId, AgentId, CorrelationId) {
1122 (
1123 RecordId::from_u128(0x0190_3c1f_aa00_0000_0000_0000_0000_0001),
1124 ConversationId::from_u128(0x0190_3c1f_aa00_0000_0000_0000_0000_0002),
1125 "source-agent".parse().expect("valid agent id"),
1126 CorrelationId::from_u128(0x0190_3c1f_aa00_0000_0000_0000_0000_0005),
1127 )
1128 }
1129}