1use crate::error::InvalidError;
19use crate::query::Value;
20use serde::de::{self, Visitor};
21use serde::{Deserialize, Deserializer, Serialize, Serializer};
22use std::collections::BTreeMap;
23use std::fmt;
24use std::str::FromStr;
25
26#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
28#[non_exhaustive]
29pub enum IdParseError {
30 #[error("id must be 26 characters, got {got}")]
31 Length { got: usize },
32 #[error("id contains invalid character `{0}`")]
33 Char(char),
34 #[error("id overflows 128 bits")]
35 Overflow,
36}
37
38macro_rules! wire_id {
43 ($(#[$doc:meta])* $name:ident) => {
44 $(#[$doc])*
45 #[derive(Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
46 pub struct $name(u128);
47
48 impl $name {
49 pub const fn from_u128(value: u128) -> Self {
51 Self(value)
52 }
53
54 pub const fn as_u128(self) -> u128 {
56 self.0
57 }
58
59 pub const fn to_bytes(self) -> [u8; 16] {
61 self.0.to_be_bytes()
62 }
63
64 pub const fn from_bytes(payload: [u8; 16]) -> Self {
66 Self(u128::from_be_bytes(payload))
67 }
68 }
69
70 impl std::fmt::Display for $name {
71 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
72 let encoded = $crate::agent::crockford_encode(self.0);
73 f.write_str(std::str::from_utf8(&encoded).expect("crockford output is ASCII"))
75 }
76 }
77
78 impl std::fmt::Debug for $name {
79 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
80 write!(f, "{}({})", stringify!($name), self)
81 }
82 }
83
84 impl std::str::FromStr for $name {
85 type Err = $crate::agent::IdParseError;
86
87 fn from_str(s: &str) -> Result<Self, Self::Err> {
88 $crate::agent::crockford_decode(s).map(Self)
89 }
90 }
91
92 impl From<u128> for $name {
93 fn from(value: u128) -> Self {
94 Self(value)
95 }
96 }
97
98 impl From<$name> for u128 {
99 fn from(value: $name) -> u128 {
100 value.0
101 }
102 }
103
104 impl serde::Serialize for $name {
105 fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
106 if serializer.is_human_readable() {
107 serializer.serialize_str(&self.to_string())
108 } else {
109 serializer.serialize_bytes(&self.to_bytes())
110 }
111 }
112 }
113
114 impl<'de> serde::Deserialize<'de> for $name {
115 fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
116 if deserializer.is_human_readable() {
117 struct TextVisitor;
118
119 impl<'de> serde::de::Visitor<'de> for TextVisitor {
120 type Value = $name;
121
122 fn expecting(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
123 f.write_str("a 26-character Crockford base32 id or 16 id bytes")
124 }
125
126 fn visit_str<E: serde::de::Error>(self, value: &str) -> Result<Self::Value, E> {
127 value.parse().map_err(E::custom)
128 }
129
130 fn visit_bytes<E: serde::de::Error>(self, value: &[u8]) -> Result<Self::Value, E> {
131 let payload: [u8; 16] = value
132 .try_into()
133 .map_err(|_| E::invalid_length(value.len(), &self))?;
134 Ok($name::from_bytes(payload))
135 }
136
137 fn visit_byte_buf<E: serde::de::Error>(self, value: Vec<u8>) -> Result<Self::Value, E> {
138 self.visit_bytes(&value)
139 }
140 }
141
142 return deserializer.deserialize_any(TextVisitor);
146 }
147
148 struct BytesVisitor;
149
150 impl<'de> serde::de::Visitor<'de> for BytesVisitor {
151 type Value = $name;
152
153 fn expecting(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
154 f.write_str("16 big-endian id bytes")
155 }
156
157 fn visit_bytes<E: serde::de::Error>(self, v: &[u8]) -> Result<Self::Value, E> {
158 let payload: [u8; 16] = v
159 .try_into()
160 .map_err(|_| E::invalid_length(v.len(), &self))?;
161 Ok($name::from_bytes(payload))
162 }
163
164 fn visit_byte_buf<E: serde::de::Error>(self, v: Vec<u8>) -> Result<Self::Value, E> {
165 self.visit_bytes(&v)
166 }
167
168 fn visit_seq<A: serde::de::SeqAccess<'de>>(
169 self,
170 mut sequence: A,
171 ) -> Result<Self::Value, A::Error> {
172 let mut payload = [0_u8; 16];
173 for (index, byte) in payload.iter_mut().enumerate() {
174 *byte = sequence
175 .next_element()?
176 .ok_or_else(|| serde::de::Error::invalid_length(index, &self))?;
177 }
178 if sequence.next_element::<u8>()?.is_some() {
179 return Err(serde::de::Error::invalid_length(17, &self));
180 }
181 Ok($name::from_bytes(payload))
182 }
183 }
184
185 deserializer.deserialize_bytes(BytesVisitor)
186 }
187 }
188 };
189}
190
191pub(crate) use wire_id;
194
195wire_id!(
196 RecordId
203);
204wire_id!(
205 ConversationId
208);
209wire_id!(
210 CorrelationId
213);
214wire_id!(
215 ChannelId
218);
219
220#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
232pub struct LogPosition {
233 pub stream_id: u32,
234 pub topic_id: u32,
235 pub partition_id: u32,
236 pub offset: u64,
237}
238
239const LOG_POSITION_BYTES: usize = 20;
240
241impl LogPosition {
242 pub const fn new(stream_id: u32, topic_id: u32, partition_id: u32, offset: u64) -> Self {
244 Self {
245 stream_id,
246 topic_id,
247 partition_id,
248 offset,
249 }
250 }
251
252 pub fn to_bytes(self) -> [u8; LOG_POSITION_BYTES] {
254 let mut out = [0u8; LOG_POSITION_BYTES];
255 out[0..4].copy_from_slice(&self.stream_id.to_be_bytes());
256 out[4..8].copy_from_slice(&self.topic_id.to_be_bytes());
257 out[8..12].copy_from_slice(&self.partition_id.to_be_bytes());
258 out[12..20].copy_from_slice(&self.offset.to_be_bytes());
259 out
260 }
261
262 pub fn from_bytes(payload: [u8; LOG_POSITION_BYTES]) -> Self {
264 let u32_at = |start: usize| {
265 u32::from_be_bytes(payload[start..start + 4].try_into().expect("4-byte slice"))
266 };
267 Self {
268 stream_id: u32_at(0),
269 topic_id: u32_at(4),
270 partition_id: u32_at(8),
271 offset: u64::from_be_bytes(payload[12..20].try_into().expect("8-byte slice")),
272 }
273 }
274}
275
276impl Serialize for LogPosition {
280 fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
281 serializer.serialize_bytes(&self.to_bytes())
282 }
283}
284
285impl<'de> Deserialize<'de> for LogPosition {
286 fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
287 struct LocatorVisitor;
288
289 impl<'de> Visitor<'de> for LocatorVisitor {
290 type Value = LogPosition;
291
292 fn expecting(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
293 f.write_str("20 packed locator bytes")
294 }
295
296 fn visit_bytes<E: de::Error>(self, v: &[u8]) -> Result<Self::Value, E> {
297 let payload: [u8; LOG_POSITION_BYTES] = v
298 .try_into()
299 .map_err(|_| E::invalid_length(v.len(), &self))?;
300 Ok(LogPosition::from_bytes(payload))
301 }
302
303 fn visit_byte_buf<E: de::Error>(self, v: Vec<u8>) -> Result<Self::Value, E> {
304 self.visit_bytes(&v)
305 }
306
307 fn visit_seq<A: de::SeqAccess<'de>>(
308 self,
309 mut sequence: A,
310 ) -> Result<Self::Value, A::Error> {
311 let mut payload = [0_u8; LOG_POSITION_BYTES];
312 for (index, byte) in payload.iter_mut().enumerate() {
313 *byte = sequence
314 .next_element()?
315 .ok_or_else(|| de::Error::invalid_length(index, &self))?;
316 }
317 if sequence.next_element::<u8>()?.is_some() {
318 return Err(de::Error::invalid_length(LOG_POSITION_BYTES + 1, &self));
319 }
320 Ok(LogPosition::from_bytes(payload))
321 }
322 }
323
324 deserializer.deserialize_bytes(LocatorVisitor)
325 }
326}
327
328#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
334#[serde(try_from = "String", into = "String")]
335pub struct IdempotencyKey(String);
336
337impl IdempotencyKey {
338 pub fn as_str(&self) -> &str {
340 &self.0
341 }
342}
343
344impl FromStr for IdempotencyKey {
345 type Err = InvalidError;
346
347 fn from_str(s: &str) -> Result<Self, Self::Err> {
348 s.to_owned().try_into()
349 }
350}
351
352impl TryFrom<String> for IdempotencyKey {
353 type Error = InvalidError;
354
355 fn try_from(value: String) -> Result<Self, Self::Error> {
356 if value.is_empty() {
357 return Err(InvalidError::new("idempotency key must not be empty"));
358 }
359 if value.len() > crate::limits::MAX_IDEMPOTENCY_KEY_BYTES {
360 return Err(InvalidError::new(format!(
361 "idempotency key is {}B, exceeds cap {}B",
362 value.len(),
363 crate::limits::MAX_IDEMPOTENCY_KEY_BYTES
364 )));
365 }
366 if value.chars().any(char::is_control) {
367 return Err(InvalidError::new(
368 "idempotency key contains a control character",
369 ));
370 }
371 Ok(Self(value))
372 }
373}
374
375impl From<IdempotencyKey> for String {
376 fn from(value: IdempotencyKey) -> Self {
377 value.0
378 }
379}
380
381impl fmt::Display for IdempotencyKey {
382 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
383 f.write_str(&self.0)
384 }
385}
386
387#[derive(Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
397#[serde(try_from = "String", into = "String")]
398pub struct AgentId(String);
399
400impl AgentId {
401 pub fn as_str(&self) -> &str {
403 &self.0
404 }
405}
406
407impl FromStr for AgentId {
408 type Err = InvalidError;
409
410 fn from_str(s: &str) -> Result<Self, Self::Err> {
411 s.to_owned().try_into()
412 }
413}
414
415impl TryFrom<String> for AgentId {
416 type Error = InvalidError;
417
418 fn try_from(value: String) -> Result<Self, Self::Error> {
419 if value.is_empty() {
420 return Err(InvalidError::new("agent id must not be empty"));
421 }
422 if value.len() > crate::limits::MAX_AGENT_STRING_BYTES {
423 return Err(InvalidError::new(format!(
424 "agent id is {}B, exceeds cap {}B",
425 value.len(),
426 crate::limits::MAX_AGENT_STRING_BYTES
427 )));
428 }
429 if let Some(c) = value.chars().find(|c| c.is_control()) {
430 return Err(InvalidError::new(format!(
431 "agent id must not contain control characters (found {c:?})"
432 )));
433 }
434 Ok(Self(value))
435 }
436}
437
438impl TryFrom<&str> for AgentId {
439 type Error = InvalidError;
440
441 fn try_from(value: &str) -> Result<Self, Self::Error> {
442 value.to_owned().try_into()
443 }
444}
445
446impl From<AgentId> for String {
447 fn from(value: AgentId) -> Self {
448 value.0
449 }
450}
451
452impl fmt::Display for AgentId {
453 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
454 f.write_str(&self.0)
455 }
456}
457
458#[derive(
462 Clone,
463 Copy,
464 Debug,
465 PartialEq,
466 Eq,
467 Hash,
468 Serialize,
469 Deserialize,
470 strum::Display,
471 strum::EnumString,
472)]
473#[serde(rename_all = "snake_case")]
474#[strum(serialize_all = "snake_case")]
475pub enum AgentKind {
476 Command,
479 Response,
481 Event,
483 Chunk,
485 Status,
488 Error,
490}
491
492#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
504#[serde(from = "u8", into = "u8")]
505pub enum TaskState {
506 Submitted,
507 Working,
508 InputRequired,
509 Completed,
510 Canceled,
511 Failed,
512 Rejected,
513 AuthRequired,
514 Unknown,
515 Paused,
517 Unrecognized(u8),
519}
520
521impl TaskState {
522 pub const fn code(self) -> u8 {
524 match self {
525 TaskState::Submitted => 1,
526 TaskState::Working => 2,
527 TaskState::InputRequired => 3,
528 TaskState::Completed => 4,
529 TaskState::Canceled => 5,
530 TaskState::Failed => 6,
531 TaskState::Rejected => 7,
532 TaskState::AuthRequired => 8,
533 TaskState::Unknown => 9,
534 TaskState::Paused => 10,
535 TaskState::Unrecognized(code) => code,
536 }
537 }
538
539 pub const fn from_code(code: u8) -> Self {
542 match code {
543 1 => TaskState::Submitted,
544 2 => TaskState::Working,
545 3 => TaskState::InputRequired,
546 4 => TaskState::Completed,
547 5 => TaskState::Canceled,
548 6 => TaskState::Failed,
549 7 => TaskState::Rejected,
550 8 => TaskState::AuthRequired,
551 9 => TaskState::Unknown,
552 10 => TaskState::Paused,
553 other => TaskState::Unrecognized(other),
554 }
555 }
556
557 pub const fn is_terminal(self) -> bool {
560 matches!(
561 self,
562 TaskState::Completed | TaskState::Canceled | TaskState::Failed | TaskState::Rejected
563 )
564 }
565}
566
567impl From<u8> for TaskState {
568 fn from(code: u8) -> Self {
569 Self::from_code(code)
570 }
571}
572
573impl From<TaskState> for u8 {
574 fn from(state: TaskState) -> u8 {
575 state.code()
576 }
577}
578
579impl fmt::Display for TaskState {
580 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
582 match self {
583 TaskState::Submitted => f.write_str("submitted"),
584 TaskState::Working => f.write_str("working"),
585 TaskState::InputRequired => f.write_str("input-required"),
586 TaskState::Completed => f.write_str("completed"),
587 TaskState::Canceled => f.write_str("canceled"),
588 TaskState::Failed => f.write_str("failed"),
589 TaskState::Rejected => f.write_str("rejected"),
590 TaskState::AuthRequired => f.write_str("auth-required"),
591 TaskState::Unknown => f.write_str("unknown"),
592 TaskState::Paused => f.write_str("paused"),
593 TaskState::Unrecognized(code) => write!(f, "unrecognized-{code}"),
594 }
595 }
596}
597
598impl FromStr for TaskState {
599 type Err = InvalidError;
600
601 fn from_str(s: &str) -> Result<Self, Self::Err> {
602 Ok(match s {
603 "submitted" => TaskState::Submitted,
604 "working" => TaskState::Working,
605 "input-required" => TaskState::InputRequired,
606 "completed" => TaskState::Completed,
607 "canceled" => TaskState::Canceled,
608 "failed" => TaskState::Failed,
609 "rejected" => TaskState::Rejected,
610 "auth-required" => TaskState::AuthRequired,
611 "unknown" => TaskState::Unknown,
612 "paused" => TaskState::Paused,
613 other => return Err(InvalidError::new(format!("unknown task state `{other}`"))),
614 })
615 }
616}
617
618#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
620#[serde(rename_all = "snake_case")]
621pub enum SessionStatus {
622 Submitted,
623 Active,
624 Paused,
625 Completed,
626 Failed,
627 Canceled,
628 #[serde(other)]
629 Unrecognized,
630}
631
632#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
634pub struct SessionRef {
635 pub stream: String,
636 pub session: ConversationId,
637}
638
639#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
641pub struct SdkInfo {
642 pub language: String,
643 pub version: String,
644}
645
646#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
648pub struct Budget {
649 #[serde(default, skip_serializing_if = "Option::is_none")]
650 pub tokens: Option<u64>,
651 #[serde(default, skip_serializing_if = "Option::is_none")]
652 pub cost_micros: Option<u64>,
653}
654
655#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
657pub struct SessionStart {
658 #[serde(default, skip_serializing_if = "Option::is_none")]
659 pub label: Option<String>,
660 #[serde(default, skip_serializing_if = "Option::is_none")]
661 pub namespace: Option<String>,
662 pub agent: AgentId,
663 pub sdk: SdkInfo,
664 #[serde(default, skip_serializing_if = "Option::is_none")]
665 pub parent: Option<ConversationId>,
666 #[serde(default, skip_serializing_if = "Option::is_none")]
667 pub root: Option<ConversationId>,
668 #[serde(default, skip_serializing_if = "Option::is_none")]
669 pub idle_timeout_micros: Option<u64>,
670 #[serde(default, skip_serializing_if = "Option::is_none")]
671 pub budget: Option<Budget>,
672 #[serde(default, skip_serializing_if = "Vec::is_empty")]
673 pub tags: Vec<String>,
674}
675
676#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
678pub struct SessionTransition {
679 #[serde(default, skip_serializing_if = "Option::is_none")]
680 pub actor: Option<AgentId>,
681 #[serde(default, skip_serializing_if = "Option::is_none")]
682 pub acknowledges: Option<LogPosition>,
683}
684
685#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
690pub struct SessionPauseRequest {
691 #[serde(default, skip_serializing_if = "Vec::is_empty")]
692 pub participants: Vec<AgentId>,
693}
694
695#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
701pub struct SessionParking {
702 pub source: crate::graph::SourceRef,
703 pub role: AgentId,
704 pub request: LogPosition,
705}
706
707impl crate::validate::Validate for SessionParking {
708 fn validate(&self) -> Result<(), InvalidError> {
709 match &self.source {
710 crate::graph::SourceRef::Message {
711 generation: Some(_),
712 ..
713 } => Ok(()),
714 crate::graph::SourceRef::Message { .. } => Err(InvalidError::new(
715 "a parked record must name its source topic generation",
716 )),
717 _ => Err(InvalidError::new(
718 "a parked record must point at a log record",
719 )),
720 }
721 }
722}
723
724#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
726pub struct SessionEnd {
727 #[serde(default, skip_serializing_if = "Option::is_none")]
728 pub reason: Option<String>,
729 #[serde(default, skip_serializing_if = "Option::is_none")]
730 pub error: Option<AgentErrorBody>,
731}
732
733pub const fn estimate_tokens(bytes: usize) -> u64 {
736 bytes.div_ceil(4) as u64
737}
738
739pub const METADATA_REQUEST_MODEL: &str = "gen_ai.request.model";
741pub const METADATA_RESPONSE_MODEL: &str = "gen_ai.response.model";
743pub const METADATA_PROVIDER_NAME: &str = "gen_ai.provider.name";
745pub const METADATA_SUBMITTED: &str = "submitted";
749pub const METADATA_RECORDED: &str = "recorded";
753pub const METADATA_DURATION_MICROS: &str = "duration_micros";
756
757#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
759pub struct ContextManifest {
760 pub policy: String,
761 pub policy_version: String,
762 pub fragments: Vec<Fragment>,
763 pub tokens: u64,
764 pub bytes: u64,
765 pub frontier: Vec<(u32, u32, u64)>,
766 #[serde(default, skip_serializing_if = "Option::is_none")]
767 pub correlation: Option<CorrelationId>,
768}
769
770#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
772pub enum Fragment {
773 Message {
774 at: crate::graph::SourceRef,
775 tokens: u32,
776 bytes: u32,
777 },
778 Memory {
779 id: String,
780 #[serde(default, skip_serializing_if = "Option::is_none")]
781 version: Option<u64>,
782 #[serde(default, skip_serializing_if = "Option::is_none")]
783 digest: Option<crate::schema::Digest32>,
784 tokens: u32,
785 bytes: u32,
786 },
787 Kv {
788 namespace: String,
789 key: String,
790 #[serde(default, skip_serializing_if = "Option::is_none")]
791 version: Option<u64>,
792 #[serde(default, skip_serializing_if = "Option::is_none")]
793 digest: Option<crate::schema::Digest32>,
794 tokens: u32,
795 bytes: u32,
796 },
797 State {
798 key: String,
799 revision: u64,
800 tokens: u32,
801 bytes: u32,
802 },
803 Summary {
804 at: crate::graph::SourceRef,
805 tokens: u32,
806 bytes: u32,
807 },
808}
809
810#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
812pub struct ContextCompaction {
813 pub summary_at: crate::graph::SourceRef,
814 pub covered: Vec<(u32, u32, u64, u64)>,
815 pub summarizer: crate::graph::ProducerInfo,
816}
817
818#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
820pub struct ContextRetrieval {
821 #[serde(default, skip_serializing_if = "Option::is_none")]
822 pub query: Option<String>,
823 pub items: Vec<(String, f32)>,
824}
825
826pub use json_patch::PatchOperation;
828
829#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
831pub struct StateDelta {
832 pub base_revision: u64,
833 pub patch: Vec<json_patch::PatchOperation>,
834 pub op_id: String,
835}
836
837#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
839pub struct StateSnapshot {
840 pub base_revision: u64,
841 pub document: serde_json::Value,
842}
843
844impl crate::validate::Validate for ContextManifest {
845 fn validate(&self) -> Result<(), InvalidError> {
846 if self.fragments.len() > crate::limits::MAX_MANIFEST_FRAGMENTS {
847 return Err(InvalidError::new("context manifest has too many fragments"));
848 }
849 for fragment in &self.fragments {
850 let digest = match fragment {
851 Fragment::Memory { digest, .. } | Fragment::Kv { digest, .. } => digest.as_ref(),
852 _ => None,
853 };
854 if let Some(digest) = digest {
855 crate::validate::Validate::validate(digest)?;
856 }
857 }
858 Ok(())
859 }
860}
861
862impl crate::validate::Validate for StateDelta {
863 fn validate(&self) -> Result<(), InvalidError> {
864 if self.patch.len() > crate::limits::MAX_STATE_PATCH_OPS {
865 return Err(InvalidError::new("state patch has too many operations"));
866 }
867 if self.op_id.is_empty() {
868 return Err(InvalidError::new(
869 "state patch operation id must not be empty",
870 ));
871 }
872 let bytes = serde_json::to_vec(&self.patch)
873 .map_err(|error| InvalidError::new(error.to_string()))?;
874 if bytes.len() > crate::limits::MAX_STATE_DOCUMENT_BYTES {
875 return Err(InvalidError::new(
876 "state patch exceeds the document byte cap",
877 ));
878 }
879 let patch = serde_json::to_value(&self.patch)
880 .map_err(|error| InvalidError::new(error.to_string()))?;
881 validate_portable_json(&patch)?;
882 Ok(())
883 }
884}
885
886impl crate::validate::Validate for StateSnapshot {
887 fn validate(&self) -> Result<(), InvalidError> {
888 let bytes = serde_json::to_vec(&self.document)
889 .map_err(|error| InvalidError::new(error.to_string()))?;
890 if bytes.len() > crate::limits::MAX_STATE_DOCUMENT_BYTES {
891 return Err(InvalidError::new("state document exceeds its byte cap"));
892 }
893 validate_portable_json(&self.document)?;
894 Ok(())
895 }
896}
897
898const MAX_EXACT_JSON_INTEGER: i128 = 9_007_199_254_740_991;
899
900fn validate_portable_json(value: &serde_json::Value) -> Result<(), InvalidError> {
901 match value {
902 serde_json::Value::Number(number) => {
903 let exact = if let Some(integer) = number.as_i64() {
904 i128::from(integer).abs() <= MAX_EXACT_JSON_INTEGER
905 } else if let Some(integer) = number.as_u64() {
906 i128::from(integer) <= MAX_EXACT_JSON_INTEGER
907 } else {
908 number.as_f64().is_some_and(|float| {
909 float.is_finite()
910 && (float.fract() != 0.0 || float.abs() <= MAX_EXACT_JSON_INTEGER as f64)
911 })
912 };
913 if !exact {
914 return Err(InvalidError::new(
915 "state JSON integer exceeds the exact number range",
916 ));
917 }
918 }
919 serde_json::Value::Array(items) => {
920 for item in items {
921 validate_portable_json(item)?;
922 }
923 }
924 serde_json::Value::Object(fields) => {
925 for item in fields.values() {
926 validate_portable_json(item)?;
927 }
928 }
929 _ => {}
930 }
931 Ok(())
932}
933
934pub fn apply_json_patch(
936 document: &serde_json::Value,
937 patch: &[json_patch::PatchOperation],
938) -> Result<serde_json::Value, InvalidError> {
939 if patch.len() > crate::limits::MAX_STATE_PATCH_OPS {
940 return Err(InvalidError::new("state patch has too many operations"));
941 }
942 let patch_bytes =
943 serde_json::to_vec(patch).map_err(|error| InvalidError::new(error.to_string()))?;
944 if patch_bytes.len() > crate::limits::MAX_STATE_DOCUMENT_BYTES {
945 return Err(InvalidError::new(
946 "state patch exceeds the document byte cap",
947 ));
948 }
949 validate_portable_json(document)?;
950 let patch_value =
951 serde_json::to_value(patch).map_err(|error| InvalidError::new(error.to_string()))?;
952 validate_portable_json(&patch_value)?;
953 let mut result = document.clone();
954 json_patch::patch(&mut result, patch).map_err(|error| InvalidError::new(error.to_string()))?;
955 crate::validate::Validate::validate(&StateSnapshot {
956 base_revision: 0,
957 document: result.clone(),
958 })?;
959 Ok(result)
960}
961
962impl crate::validate::Validate for SessionStart {
963 fn validate(&self) -> Result<(), InvalidError> {
964 if let Some(label) = &self.label
965 && (label.len() > crate::limits::MAX_SESSION_LABEL_BYTES
966 || label.chars().any(char::is_control))
967 {
968 return Err(InvalidError::new(
969 "session label exceeds its cap or contains a control character",
970 ));
971 }
972 if let Some(namespace) = &self.namespace {
973 crate::kv::validate_namespace(namespace)?;
974 }
975 if self.root.is_some() && self.parent.is_none() {
976 return Err(InvalidError::new("session root requires a parent"));
977 }
978 if self.tags.len() > crate::limits::MAX_MEMORY_TAGS {
979 return Err(InvalidError::new("session has too many tags"));
980 }
981 if self
982 .tags
983 .iter()
984 .any(|tag| tag.len() > crate::limits::MAX_MEMORY_TAG_BYTES)
985 {
986 return Err(InvalidError::new("session tag exceeds its byte cap"));
987 }
988 Ok(())
989 }
990}
991
992#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
999pub struct TokenUsage {
1000 pub input_tokens: u64,
1001 pub output_tokens: u64,
1002 #[serde(default, skip_serializing_if = "Option::is_none")]
1003 pub reasoning_output_tokens: Option<u64>,
1004 #[serde(default, skip_serializing_if = "Option::is_none")]
1005 pub cache_read_input_tokens: Option<u64>,
1006 #[serde(default, skip_serializing_if = "Option::is_none")]
1007 pub cache_creation_input_tokens: Option<u64>,
1008 #[serde(default, skip_serializing_if = "Option::is_none")]
1010 pub cost_micros: Option<u64>,
1011}
1012
1013#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
1016#[serde(from = "u8", into = "u8")]
1017pub enum AgentErrorCode {
1018 InvalidRequest,
1019 Unauthorized,
1020 Unsupported,
1021 DeadlineExceeded,
1022 Cancelled,
1023 ToolFailure,
1024 Internal,
1025 Unrecognized(u8),
1027}
1028
1029impl AgentErrorCode {
1030 pub const fn code(self) -> u8 {
1032 match self {
1033 AgentErrorCode::InvalidRequest => 1,
1034 AgentErrorCode::Unauthorized => 2,
1035 AgentErrorCode::Unsupported => 3,
1036 AgentErrorCode::DeadlineExceeded => 4,
1037 AgentErrorCode::Cancelled => 5,
1038 AgentErrorCode::ToolFailure => 6,
1039 AgentErrorCode::Internal => 7,
1040 AgentErrorCode::Unrecognized(code) => code,
1041 }
1042 }
1043
1044 pub const fn from_code(code: u8) -> Self {
1046 match code {
1047 1 => AgentErrorCode::InvalidRequest,
1048 2 => AgentErrorCode::Unauthorized,
1049 3 => AgentErrorCode::Unsupported,
1050 4 => AgentErrorCode::DeadlineExceeded,
1051 5 => AgentErrorCode::Cancelled,
1052 6 => AgentErrorCode::ToolFailure,
1053 7 => AgentErrorCode::Internal,
1054 other => AgentErrorCode::Unrecognized(other),
1055 }
1056 }
1057}
1058
1059impl From<u8> for AgentErrorCode {
1060 fn from(code: u8) -> Self {
1061 Self::from_code(code)
1062 }
1063}
1064
1065impl From<AgentErrorCode> for u8 {
1066 fn from(code: AgentErrorCode) -> u8 {
1067 code.code()
1068 }
1069}
1070
1071#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
1075pub struct AgentErrorBody {
1076 pub code: AgentErrorCode,
1077 #[serde(default, skip_serializing_if = "Option::is_none")]
1078 pub message: Option<String>,
1079 #[serde(default)]
1080 pub retryable: bool,
1081 #[serde(default, skip_serializing_if = "Option::is_none")]
1082 pub detail: Option<BTreeMap<String, Value>>,
1083}
1084
1085#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
1087#[serde(from = "u8", into = "u8")]
1088pub enum DeadLetterReason {
1089 RetryExhausted,
1090 Rejected,
1091 DecodeFailed,
1092 DeadlineExceeded,
1093 Unrecognized(u8),
1095}
1096
1097impl DeadLetterReason {
1098 pub const fn code(self) -> u8 {
1100 match self {
1101 DeadLetterReason::RetryExhausted => 1,
1102 DeadLetterReason::Rejected => 2,
1103 DeadLetterReason::DecodeFailed => 3,
1104 DeadLetterReason::DeadlineExceeded => 4,
1105 DeadLetterReason::Unrecognized(code) => code,
1106 }
1107 }
1108
1109 pub const fn from_code(code: u8) -> Self {
1111 match code {
1112 1 => DeadLetterReason::RetryExhausted,
1113 2 => DeadLetterReason::Rejected,
1114 3 => DeadLetterReason::DecodeFailed,
1115 4 => DeadLetterReason::DeadlineExceeded,
1116 other => DeadLetterReason::Unrecognized(other),
1117 }
1118 }
1119}
1120
1121impl From<u8> for DeadLetterReason {
1122 fn from(code: u8) -> Self {
1123 Self::from_code(code)
1124 }
1125}
1126
1127impl From<DeadLetterReason> for u8 {
1128 fn from(reason: DeadLetterReason) -> u8 {
1129 reason.code()
1130 }
1131}
1132
1133#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
1143pub struct AgentDeadLetter {
1144 pub source: LogPosition,
1145 pub reason: DeadLetterReason,
1146 pub attempts: u32,
1147 #[serde(default, skip_serializing_if = "Option::is_none")]
1148 pub detail: Option<String>,
1149 #[serde(with = "crate::encoding::bin_bytes")]
1150 pub payload: Vec<u8>,
1151}
1152
1153#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
1156#[serde(from = "u8", into = "u8")]
1157pub enum Health {
1158 Healthy,
1159 Degraded,
1160 Unavailable,
1161 Unrecognized(u8),
1163}
1164
1165impl Health {
1166 pub const fn code(self) -> u8 {
1168 match self {
1169 Health::Healthy => 1,
1170 Health::Degraded => 2,
1171 Health::Unavailable => 3,
1172 Health::Unrecognized(code) => code,
1173 }
1174 }
1175
1176 pub const fn from_code(code: u8) -> Self {
1179 match code {
1180 1 => Health::Healthy,
1181 2 => Health::Degraded,
1182 3 => Health::Unavailable,
1183 other => Health::Unrecognized(other),
1184 }
1185 }
1186}
1187
1188impl From<u8> for Health {
1189 fn from(code: u8) -> Self {
1190 Self::from_code(code)
1191 }
1192}
1193
1194impl From<Health> for u8 {
1195 fn from(health: Health) -> u8 {
1196 health.code()
1197 }
1198}
1199
1200#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
1208#[serde(rename_all = "snake_case")]
1209pub enum ContentRef {
1210 ContentType(crate::content::ContentType),
1211 SchemaId(String),
1212}
1213
1214#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
1218pub struct CapabilityDescriptor {
1219 pub skill_id: String,
1221 #[serde(default, skip_serializing_if = "Option::is_none")]
1222 pub input: Option<ContentRef>,
1223 #[serde(default, skip_serializing_if = "Option::is_none")]
1224 pub output: Option<ContentRef>,
1225 #[serde(default, skip_serializing_if = "Option::is_none")]
1227 pub cost_class: Option<u8>,
1228 #[serde(default, skip_serializing_if = "Option::is_none")]
1230 pub latency_class: Option<u8>,
1231 #[serde(default, skip_serializing_if = "Option::is_none")]
1232 pub max_concurrency: Option<u32>,
1233 #[serde(default, skip_serializing_if = "Option::is_none")]
1234 pub health: Option<Health>,
1235 #[serde(default, skip_serializing_if = "Option::is_none")]
1237 pub load: Option<u16>,
1238}
1239
1240#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
1248pub struct AgentCard {
1249 #[serde(default, skip_serializing_if = "Option::is_none")]
1252 pub name: Option<String>,
1253 #[serde(default, skip_serializing_if = "Option::is_none")]
1255 pub version: Option<String>,
1256 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1259 pub capabilities: Vec<CapabilityDescriptor>,
1260 #[serde(default, skip_serializing_if = "Option::is_none")]
1263 pub ttl_micros: Option<u64>,
1264 #[serde(default, skip_serializing_if = "Option::is_none")]
1267 pub inbox: Option<String>,
1268}
1269
1270impl AgentCard {
1271 pub fn validate(&self) -> Result<(), ValidateError> {
1273 cap_str(self.name.as_deref(), "name")?;
1274 cap_str(self.version.as_deref(), "version")?;
1275 cap_str(self.inbox.as_deref(), "inbox")?;
1276 if self.capabilities.len() > crate::limits::MAX_CARD_CAPABILITIES {
1277 return Err(ValidateError::TooLarge {
1278 field: "capabilities",
1279 size: self.capabilities.len(),
1280 cap: crate::limits::MAX_CARD_CAPABILITIES,
1281 });
1282 }
1283 for capability in &self.capabilities {
1284 cap_str(Some(&capability.skill_id), "capability skill_id")?;
1285 }
1286 Ok(())
1287 }
1288}
1289
1290#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
1308pub struct AgentPresence {
1309 pub v: u32,
1312 pub agent: AgentId,
1317 #[serde(default, skip_serializing_if = "Option::is_none")]
1321 pub inbox: Option<String>,
1322}
1323
1324impl AgentPresence {
1325 pub fn new(agent: AgentId) -> Self {
1328 Self {
1329 v: crate::codes::PRESENCE_OP_VERSION,
1330 agent,
1331 inbox: None,
1332 }
1333 }
1334
1335 pub fn with_inbox(mut self, inbox: impl Into<String>) -> Self {
1337 self.inbox = Some(inbox.into());
1338 self
1339 }
1340
1341 pub fn validate(&self) -> Result<(), ValidateError> {
1344 cap_str(self.inbox.as_deref(), "inbox")?;
1345 Ok(())
1346 }
1347}
1348
1349#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
1358pub struct BodyRef {
1359 pub reference: String,
1362 pub size_bytes: u64,
1364 #[serde(with = "crate::encoding::bin_bytes")]
1366 pub sha256: Vec<u8>,
1367 #[serde(default, skip_serializing_if = "Option::is_none")]
1371 pub encryption: Option<u8>,
1372}
1373
1374const SHA256_BYTES: usize = 32;
1375
1376impl BodyRef {
1377 pub fn new(reference: impl Into<String>, size_bytes: u64, sha256: [u8; 32]) -> Self {
1379 Self {
1380 reference: reference.into(),
1381 size_bytes,
1382 sha256: sha256.to_vec(),
1383 encryption: None,
1384 }
1385 }
1386
1387 pub fn validate(&self) -> Result<(), ValidateError> {
1391 if self.reference.is_empty() {
1392 return Err(ValidateError::Invalid {
1393 field: "reference",
1394 reason: "reference must not be empty".to_owned(),
1395 });
1396 }
1397 if self.reference.len() > crate::limits::MAX_BODY_REFERENCE_BYTES {
1398 return Err(ValidateError::TooLarge {
1399 field: "reference",
1400 size: self.reference.len(),
1401 cap: crate::limits::MAX_BODY_REFERENCE_BYTES,
1402 });
1403 }
1404 if self.sha256.len() != SHA256_BYTES {
1405 return Err(ValidateError::Invalid {
1406 field: "sha256",
1407 reason: format!(
1408 "digest must be {SHA256_BYTES} bytes, got {}",
1409 self.sha256.len()
1410 ),
1411 });
1412 }
1413 Ok(())
1414 }
1415}
1416
1417#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
1429pub struct Signature {
1430 pub scheme: u8,
1433 #[serde(with = "crate::encoding::bin_bytes")]
1435 pub key_id: Vec<u8>,
1436 #[serde(with = "crate::encoding::bin_bytes")]
1439 pub bytes: Vec<u8>,
1440 #[serde(default, skip_serializing_if = "Option::is_none")]
1446 pub context: Option<SignatureContext>,
1447}
1448
1449#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
1455pub struct SignatureContext {
1456 #[serde(default, skip_serializing_if = "Option::is_none")]
1458 pub content_type: Option<u8>,
1459 #[serde(default, skip_serializing_if = "Option::is_none")]
1461 pub agent_version: Option<u32>,
1462}
1463
1464pub const SIGNATURE_SCHEME_ED25519: u8 = 1;
1466
1467pub const SIGNATURE_DOMAIN: &[u8] = b"agdx.signature.v1";
1472
1473const ED25519_KEY_ID_BYTES: usize = 8;
1474const ED25519_SIGNATURE_BYTES: usize = 64;
1475
1476impl Signature {
1477 pub fn validate(&self) -> Result<(), ValidateError> {
1482 if self.scheme != SIGNATURE_SCHEME_ED25519 {
1483 return Ok(());
1484 }
1485 if self.key_id.len() != ED25519_KEY_ID_BYTES {
1486 return Err(ValidateError::Invalid {
1487 field: "key_id",
1488 reason: format!(
1489 "Ed25519 key id must be {ED25519_KEY_ID_BYTES} bytes, got {}",
1490 self.key_id.len()
1491 ),
1492 });
1493 }
1494 if self.bytes.len() != ED25519_SIGNATURE_BYTES {
1495 return Err(ValidateError::Invalid {
1496 field: "bytes",
1497 reason: format!(
1498 "Ed25519 signature must be {ED25519_SIGNATURE_BYTES} bytes, got {}",
1499 self.bytes.len()
1500 ),
1501 });
1502 }
1503 Ok(())
1504 }
1505}
1506
1507#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
1520pub struct AgentEnvelope {
1521 pub kind: AgentKind,
1522 #[serde(default, skip_serializing_if = "Option::is_none")]
1526 pub record: Option<RecordId>,
1527 pub conversation: ConversationId,
1529 #[serde(default, skip_serializing_if = "Option::is_none")]
1531 pub parent: Option<ConversationId>,
1532 #[serde(default, skip_serializing_if = "Option::is_none")]
1534 pub root: Option<ConversationId>,
1535 pub source: AgentId,
1537 #[serde(default, skip_serializing_if = "Option::is_none")]
1541 pub target: Option<AgentId>,
1542 #[serde(default, skip_serializing_if = "Option::is_none")]
1545 pub cause: Option<RecordId>,
1546 #[serde(default, skip_serializing_if = "Option::is_none")]
1549 pub cause_at: Option<LogPosition>,
1550 #[serde(default, skip_serializing_if = "Option::is_none")]
1552 pub correlation: Option<CorrelationId>,
1553 #[serde(default, skip_serializing_if = "Option::is_none")]
1555 pub channel: Option<ChannelId>,
1556 #[serde(default, skip_serializing_if = "Option::is_none")]
1557 pub idempotency_key: Option<IdempotencyKey>,
1558 #[serde(default, skip_serializing_if = "Option::is_none")]
1562 pub deadline_micros: Option<u64>,
1563 #[serde(default, skip_serializing_if = "Option::is_none")]
1565 pub sequence: Option<u64>,
1566 #[serde(default, skip_serializing_if = "is_false")]
1570 pub last: bool,
1571 #[serde(default, skip_serializing_if = "Option::is_none")]
1575 pub finish_reason: Option<String>,
1576 #[serde(default, skip_serializing_if = "Option::is_none")]
1577 pub task_state: Option<TaskState>,
1578 #[serde(default, skip_serializing_if = "Option::is_none")]
1581 pub operation: Option<String>,
1582 #[serde(default, skip_serializing_if = "Option::is_none")]
1584 pub tool: Option<String>,
1585 #[serde(default, skip_serializing_if = "Option::is_none")]
1586 pub usage: Option<TokenUsage>,
1587 #[serde(default, skip_serializing_if = "Option::is_none")]
1589 pub metadata: Option<BTreeMap<String, Value>>,
1590 #[serde(default, skip_serializing_if = "is_zero_u64")]
1602 pub must_understand: u64,
1603 #[serde(
1606 default,
1607 skip_serializing_if = "Vec::is_empty",
1608 with = "crate::encoding::bin_bytes"
1609 )]
1610 pub body: Vec<u8>,
1611 #[serde(default, skip_serializing_if = "Option::is_none")]
1618 pub signature: Option<Signature>,
1619}
1620
1621fn is_false(value: &bool) -> bool {
1622 !*value
1623}
1624
1625fn is_zero_u64(value: &u64) -> bool {
1626 *value == 0
1627}
1628
1629pub mod features {
1636 pub const NONE: u64 = 0;
1640}
1641
1642impl AgentEnvelope {
1643 fn base(kind: AgentKind, conversation: ConversationId, source: AgentId) -> Self {
1644 Self {
1645 kind,
1646 record: None,
1647 conversation,
1648 parent: None,
1649 root: None,
1650 source,
1651 target: None,
1652 cause: None,
1653 cause_at: None,
1654 correlation: None,
1655 channel: None,
1656 idempotency_key: None,
1657 deadline_micros: None,
1658 sequence: None,
1659 last: false,
1660 finish_reason: None,
1661 task_state: None,
1662 operation: None,
1663 tool: None,
1664 usage: None,
1665 metadata: None,
1666 must_understand: 0,
1667 body: Vec::new(),
1668 signature: None,
1669 }
1670 }
1671
1672 #[must_use]
1677 pub fn requiring(mut self, bits: u64) -> Self {
1678 self.must_understand = bits;
1679 self
1680 }
1681
1682 pub fn unmet_requirements(&self, understood: u64) -> u64 {
1687 self.must_understand & !understood
1688 }
1689
1690 pub fn command(
1692 record: RecordId,
1693 conversation: ConversationId,
1694 source: AgentId,
1695 correlation: CorrelationId,
1696 body: Vec<u8>,
1697 ) -> Self {
1698 let mut envelope = Self::base(AgentKind::Command, conversation, source);
1699 envelope.record = Some(record);
1700 envelope.correlation = Some(correlation);
1701 envelope.body = body;
1702 envelope
1703 }
1704
1705 pub fn response(
1707 record: RecordId,
1708 conversation: ConversationId,
1709 source: AgentId,
1710 correlation: CorrelationId,
1711 body: Vec<u8>,
1712 ) -> Self {
1713 let mut envelope = Self::base(AgentKind::Response, conversation, source);
1714 envelope.record = Some(record);
1715 envelope.correlation = Some(correlation);
1716 envelope.body = body;
1717 envelope
1718 }
1719
1720 pub fn event(
1722 record: RecordId,
1723 conversation: ConversationId,
1724 source: AgentId,
1725 body: Vec<u8>,
1726 ) -> Self {
1727 let mut envelope = Self::base(AgentKind::Event, conversation, source);
1728 envelope.record = Some(record);
1729 envelope.body = body;
1730 envelope
1731 }
1732
1733 pub fn chunk(
1736 conversation: ConversationId,
1737 source: AgentId,
1738 correlation: CorrelationId,
1739 channel: ChannelId,
1740 sequence: u64,
1741 body: Vec<u8>,
1742 ) -> Self {
1743 let mut envelope = Self::base(AgentKind::Chunk, conversation, source);
1744 envelope.correlation = Some(correlation);
1745 envelope.channel = Some(channel);
1746 envelope.sequence = Some(sequence);
1747 envelope.body = body;
1748 envelope
1749 }
1750
1751 pub fn status(
1755 record: RecordId,
1756 conversation: ConversationId,
1757 source: AgentId,
1758 operation: impl Into<String>,
1759 ) -> Self {
1760 let mut envelope = Self::base(AgentKind::Status, conversation, source);
1761 envelope.record = Some(record);
1762 envelope.operation = Some(operation.into());
1763 envelope
1764 }
1765
1766 pub fn error(
1769 record: RecordId,
1770 conversation: ConversationId,
1771 source: AgentId,
1772 correlation: CorrelationId,
1773 body: Vec<u8>,
1774 ) -> Self {
1775 let mut envelope = Self::base(AgentKind::Error, conversation, source);
1776 envelope.record = Some(record);
1777 envelope.correlation = Some(correlation);
1778 envelope.body = body;
1779 envelope
1780 }
1781
1782 pub fn with_target(mut self, target: AgentId) -> Self {
1784 self.target = Some(target);
1785 self
1786 }
1787
1788 pub fn with_cause(mut self, cause: RecordId, cause_at: Option<LogPosition>) -> Self {
1792 self.cause = Some(cause);
1793 self.cause_at = cause_at;
1794 self
1795 }
1796
1797 pub fn with_correlation(mut self, correlation: CorrelationId) -> Self {
1801 self.correlation = Some(correlation);
1802 self
1803 }
1804
1805 pub fn with_idempotency_key(mut self, key: IdempotencyKey) -> Self {
1807 self.idempotency_key = Some(key);
1808 self
1809 }
1810
1811 pub fn with_deadline_micros(mut self, deadline_micros: u64) -> Self {
1814 self.deadline_micros = Some(deadline_micros);
1815 self
1816 }
1817
1818 pub fn terminal(mut self, finish_reason: impl Into<String>) -> Self {
1821 self.last = true;
1822 self.finish_reason = Some(finish_reason.into());
1823 self
1824 }
1825
1826 pub fn with_task_state(mut self, state: TaskState) -> Self {
1829 self.task_state = Some(state);
1830 self
1831 }
1832
1833 pub fn with_operation(mut self, operation: impl Into<String>) -> Self {
1835 self.operation = Some(operation.into());
1836 self
1837 }
1838
1839 pub fn with_tool(mut self, tool: impl Into<String>) -> Self {
1841 self.tool = Some(tool.into());
1842 self
1843 }
1844
1845 pub fn with_usage(mut self, usage: TokenUsage) -> Self {
1847 self.usage = Some(usage);
1848 self
1849 }
1850
1851 pub fn with_metadata(mut self, key: impl Into<String>, value: impl Into<Value>) -> Self {
1853 self.metadata
1854 .get_or_insert_with(BTreeMap::new)
1855 .insert(key.into(), value.into());
1856 self
1857 }
1858
1859 pub fn with_signature(mut self, signature: Signature) -> Self {
1864 self.signature = Some(signature);
1865 self
1866 }
1867}
1868
1869#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
1872#[non_exhaustive]
1873pub enum ValidateError {
1874 #[error("{kind} requires `{field}`")]
1875 Missing {
1876 kind: AgentKind,
1877 field: &'static str,
1878 },
1879 #[error("`{field}` is invalid on {kind}")]
1880 Forbidden {
1881 kind: AgentKind,
1882 field: &'static str,
1883 },
1884 #[error("`{field}` is {size}B, exceeds cap {cap}B")]
1885 TooLarge {
1886 field: &'static str,
1887 size: usize,
1888 cap: usize,
1889 },
1890 #[error("`{field}`: {reason}")]
1891 Invalid { field: &'static str, reason: String },
1892}
1893
1894pub fn validate(envelope: &AgentEnvelope) -> Result<(), ValidateError> {
1919 use AgentKind::*;
1920 let kind = envelope.kind;
1921
1922 let require = |present: bool, field: &'static str| {
1923 if present {
1924 Ok(())
1925 } else {
1926 Err(ValidateError::Missing { kind, field })
1927 }
1928 };
1929 let forbid = |absent: bool, field: &'static str| {
1930 if absent {
1931 Ok(())
1932 } else {
1933 Err(ValidateError::Forbidden { kind, field })
1934 }
1935 };
1936
1937 if kind != Chunk {
1939 require(envelope.record.is_some(), "record")?;
1940 }
1941
1942 if envelope.root.is_some() && envelope.parent.is_none() {
1943 return Err(ValidateError::Invalid {
1944 field: "root",
1945 reason: "root requires parent".to_owned(),
1946 });
1947 }
1948 if envelope.parent == Some(envelope.conversation)
1949 || envelope.root == Some(envelope.conversation)
1950 {
1951 return Err(ValidateError::Invalid {
1952 field: "parent",
1953 reason: "parent and root must differ from conversation".to_owned(),
1954 });
1955 }
1956
1957 match kind {
1959 Command | Response | Chunk | Error => {
1960 require(envelope.correlation.is_some(), "correlation")?
1961 }
1962 Status => {
1963 if envelope.operation.as_deref() == Some(OPERATION_TASK) {
1964 require(envelope.correlation.is_some(), "correlation")?;
1965 }
1966 }
1967 Event => {}
1968 }
1969
1970 match kind {
1973 Chunk => {
1974 require(envelope.channel.is_some(), "channel")?;
1975 require(envelope.sequence.is_some(), "sequence")?;
1976 }
1977 Error => {
1978 if envelope.sequence.is_some() && envelope.channel.is_none() {
1979 return Err(ValidateError::Invalid {
1980 field: "sequence",
1981 reason: "sequence requires channel".to_owned(),
1982 });
1983 }
1984 }
1985 _ => {
1986 forbid(envelope.channel.is_none(), "channel")?;
1987 forbid(envelope.sequence.is_none(), "sequence")?;
1988 }
1989 }
1990
1991 if envelope.last && !matches!(kind, Chunk | Status) {
1994 return Err(ValidateError::Forbidden {
1995 kind,
1996 field: "last",
1997 });
1998 }
1999
2000 match kind {
2002 Response => {}
2003 Chunk => {
2004 if envelope.finish_reason.is_some() && !envelope.last {
2005 return Err(ValidateError::Invalid {
2006 field: "finish_reason",
2007 reason: "finish_reason rides only the terminal chunk".to_owned(),
2008 });
2009 }
2010 }
2011 _ => forbid(envelope.finish_reason.is_none(), "finish_reason")?,
2012 }
2013
2014 if matches!(kind, Chunk | Status | Error) {
2017 forbid(envelope.idempotency_key.is_none(), "idempotency_key")?;
2018 }
2019
2020 let registry_fact = kind == Status
2024 && matches!(
2025 envelope.operation.as_deref(),
2026 Some(OPERATION_QUARANTINE | OPERATION_UNQUARANTINE)
2027 );
2028 if registry_fact {
2029 require(envelope.deadline_micros.is_some(), "deadline_micros")?;
2030 } else if matches!(kind, Response | Event | Status | Error) {
2031 forbid(envelope.deadline_micros.is_none(), "deadline_micros")?;
2032 }
2033 if kind == Chunk && envelope.deadline_micros.is_some() && envelope.sequence != Some(0) {
2034 return Err(ValidateError::Invalid {
2035 field: "deadline_micros",
2036 reason: "the stream bound rides the opening chunk (sequence 0)".to_owned(),
2037 });
2038 }
2039
2040 match kind {
2042 Status => {
2043 if matches!(
2044 envelope.operation.as_deref(),
2045 Some(OPERATION_TASK | OPERATION_SESSION)
2046 ) {
2047 require(envelope.task_state.is_some(), "task_state")?;
2048 }
2049 }
2050 Response | Error => {}
2051 _ => forbid(envelope.task_state.is_none(), "task_state")?,
2052 }
2053
2054 match kind {
2060 Status => {
2061 require(envelope.operation.is_some(), "operation")?;
2062 if let Some(operation) = envelope.operation.as_deref()
2063 && !matches!(
2064 operation,
2065 OPERATION_TASK
2066 | OPERATION_SESSION
2067 | OPERATION_CARD
2068 | OPERATION_PROGRESS
2069 | OPERATION_QUARANTINE
2070 | OPERATION_UNQUARANTINE
2071 )
2072 {
2073 return Err(ValidateError::Invalid {
2074 field: "operation",
2075 reason: format!(
2076 "status operation must be `{OPERATION_TASK}`, `{OPERATION_SESSION}`, `{OPERATION_CARD}`, \
2077 `{OPERATION_PROGRESS}`, `{OPERATION_QUARANTINE}`, or \
2078 `{OPERATION_UNQUARANTINE}`, got `{operation}`"
2079 ),
2080 });
2081 }
2082 }
2083 Chunk => {
2084 if envelope.sequence == Some(0) {
2085 require(envelope.operation.is_some(), "operation")?;
2086 }
2087 if let Some(operation) = envelope.operation.as_deref() {
2088 if envelope.sequence != Some(0) {
2089 return Err(ValidateError::Invalid {
2090 field: "operation",
2091 reason: "the stream purpose rides the opening chunk (sequence 0)"
2092 .to_owned(),
2093 });
2094 }
2095 if !matches!(
2096 operation,
2097 OPERATION_CHAT | OPERATION_REASONING | OPERATION_TOOL_ARGS
2098 ) {
2099 return Err(ValidateError::Invalid {
2100 field: "operation",
2101 reason: format!(
2102 "chunk-stream purpose must be `{OPERATION_CHAT}`, \
2103 `{OPERATION_REASONING}`, or `{OPERATION_TOOL_ARGS}`, \
2104 got `{operation}`"
2105 ),
2106 });
2107 }
2108 }
2109 }
2110 Command | Response | Event | Error => {}
2111 }
2112
2113 if kind == Status {
2115 forbid(envelope.tool.is_none(), "tool")?;
2116 }
2117
2118 match kind {
2120 Command => forbid(envelope.usage.is_none(), "usage")?,
2121 Chunk if envelope.usage.is_some() && !envelope.last => {
2122 return Err(ValidateError::Invalid {
2123 field: "usage",
2124 reason: "whole-stream accounting rides the terminal chunk".to_owned(),
2125 });
2126 }
2127 _ => {}
2128 }
2129
2130 match kind {
2132 Status if envelope.operation.as_deref() == Some(OPERATION_SESSION) => {
2133 require(!envelope.body.is_empty(), "body")?;
2134 let state = envelope
2135 .task_state
2136 .expect("session state was required above");
2137 if envelope.last != state.is_terminal() {
2138 return Err(ValidateError::Invalid {
2139 field: "last",
2140 reason: "session last must match terminal task state".to_owned(),
2141 });
2142 }
2143 #[cfg(feature = "cbor")]
2145 {
2146 let invalid_body = |reason: String| ValidateError::Invalid {
2147 field: "body",
2148 reason,
2149 };
2150 let validate_start = |start: &SessionStart| {
2151 crate::validate::Validate::validate(start)
2152 .map_err(|error| invalid_body(error.to_string()))?;
2153 if start.parent != envelope.parent || start.root != envelope.root {
2154 return Err(invalid_body(
2155 "session ancestry must match envelope parent and root".to_owned(),
2156 ));
2157 }
2158 Ok(())
2159 };
2160 match state {
2161 TaskState::Submitted => {
2162 let start: SessionStart = crate::framing::decode_named(&envelope.body)
2163 .map_err(|error| invalid_body(error.to_string()))?;
2164 validate_start(&start)?;
2165 }
2166 TaskState::Working => {
2167 let body: ciborium::value::Value =
2168 crate::framing::decode_named(&envelope.body)
2169 .map_err(|error| invalid_body(error.to_string()))?;
2170 let is_start = matches!(
2171 body,
2172 ciborium::value::Value::Map(ref entries)
2173 if entries.iter().any(|(key, _)| {
2174 matches!(key, ciborium::value::Value::Text(name) if name == "agent" || name == "sdk")
2175 })
2176 );
2177 if is_start {
2178 let start: SessionStart = crate::framing::decode_named(&envelope.body)
2179 .map_err(|error| invalid_body(error.to_string()))?;
2180 validate_start(&start)?;
2181 } else {
2182 crate::framing::decode_named::<SessionTransition>(&envelope.body)
2183 .map_err(|error| invalid_body(error.to_string()))?;
2184 }
2185 }
2186 state if state.is_terminal() => {
2187 crate::framing::decode_named::<SessionEnd>(&envelope.body)
2188 .map_err(|error| invalid_body(error.to_string()))?;
2189 }
2190 _ => {
2191 crate::framing::decode_named::<SessionTransition>(&envelope.body)
2192 .map_err(|error| invalid_body(error.to_string()))?;
2193 }
2194 }
2195 }
2196 }
2197 Status => {}
2198 Chunk => {
2199 if envelope.body.is_empty() && !envelope.last {
2200 return Err(ValidateError::Missing {
2201 kind,
2202 field: "body",
2203 });
2204 }
2205 }
2206 _ => require(!envelope.body.is_empty(), "body")?,
2207 }
2208
2209 cap_str(envelope.operation.as_deref(), "operation")?;
2213 cap_str(envelope.tool.as_deref(), "tool")?;
2214 cap_str(envelope.finish_reason.as_deref(), "finish_reason")?;
2215 if let Some(metadata) = &envelope.metadata {
2216 if metadata.len() > crate::limits::MAX_METADATA_ENTRIES {
2217 return Err(ValidateError::TooLarge {
2218 field: "metadata",
2219 size: metadata.len(),
2220 cap: crate::limits::MAX_METADATA_ENTRIES,
2221 });
2222 }
2223 let mut total = 0usize;
2224 for (key, value) in metadata {
2225 if key.len() > crate::limits::MAX_METADATA_KEY_BYTES {
2226 return Err(ValidateError::TooLarge {
2227 field: "metadata key",
2228 size: key.len(),
2229 cap: crate::limits::MAX_METADATA_KEY_BYTES,
2230 });
2231 }
2232 let value_size = value_size(value);
2233 if value_size > crate::limits::MAX_METADATA_VALUE_BYTES {
2234 return Err(ValidateError::TooLarge {
2235 field: "metadata value",
2236 size: value_size,
2237 cap: crate::limits::MAX_METADATA_VALUE_BYTES,
2238 });
2239 }
2240 total += key.len() + value_size;
2241 }
2242 if total > crate::limits::MAX_METADATA_TOTAL_BYTES {
2243 return Err(ValidateError::TooLarge {
2244 field: "metadata",
2245 size: total,
2246 cap: crate::limits::MAX_METADATA_TOTAL_BYTES,
2247 });
2248 }
2249 }
2250 if let Some(signature) = &envelope.signature {
2253 signature.validate()?;
2254 }
2255 Ok(())
2256}
2257
2258pub const OPERATION_TASK: &str = "task";
2260pub const OPERATION_SESSION: &str = "session";
2262pub const OPERATION_CARD: &str = "card";
2264pub const OPERATION_PROGRESS: &str = "progress";
2266pub const OPERATION_QUARANTINE: &str = "quarantine";
2271pub const OPERATION_UNQUARANTINE: &str = "unquarantine";
2276
2277pub const OPERATION_CHAT: &str = "chat";
2284pub const OPERATION_REASONING: &str = "reasoning";
2286pub const OPERATION_TOOL_ARGS: &str = "tool_args";
2288
2289pub const OPERATION_STATE_SNAPSHOT: &str = "state_snapshot";
2295pub const OPERATION_STATE_DELTA: &str = "state_delta";
2298pub const OPERATION_SESSION_PARKED: &str = "session_parked";
2301pub const OPERATION_SESSION_UNPARKED: &str = "session_unparked";
2304
2305pub const METADATA_ROLE: &str = "role";
2312pub const METADATA_BRIDGE_HOPS: &str = "bridge_hops";
2318pub const METADATA_DELEGATED_BY: &str = "on_behalf_of";
2322pub const METADATA_PURPOSE: &str = "purpose";
2325pub const METADATA_DATA_CLASSIFICATION: &str = "data_classification";
2328pub const METADATA_TASK_CONTEXT: &str = "task_context";
2331pub const METADATA_SESSION_INTENT: &str = "session_intent";
2334
2335const CROCKFORD: &[u8; 32] = b"0123456789ABCDEFGHJKMNPQRSTVWXYZ";
2339
2340pub(crate) fn crockford_encode(value: u128) -> [u8; 26] {
2341 let mut out = [0u8; 26];
2342 let mut v = value;
2343 for slot in out.iter_mut().rev() {
2344 *slot = CROCKFORD[(v & 0x1f) as usize];
2345 v >>= 5;
2346 }
2347 out
2348}
2349
2350pub(crate) fn crockford_decode(s: &str) -> Result<u128, IdParseError> {
2351 let bytes = s.as_bytes();
2352 if bytes.len() != 26 {
2353 return Err(IdParseError::Length { got: bytes.len() });
2354 }
2355 let mut value: u128 = 0;
2356 for (i, byte) in bytes.iter().enumerate() {
2357 let digit = CROCKFORD
2358 .iter()
2359 .position(|c| *c == byte.to_ascii_uppercase())
2360 .ok_or(IdParseError::Char(*byte as char))?;
2361 if i == 0 && digit > 7 {
2364 return Err(IdParseError::Overflow);
2365 }
2366 value = (value << 5) | digit as u128;
2367 }
2368 Ok(value)
2369}
2370
2371fn cap_str(value: Option<&str>, field: &'static str) -> Result<(), ValidateError> {
2372 if let Some(value) = value
2373 && value.len() > crate::limits::MAX_AGENT_STRING_BYTES
2374 {
2375 return Err(ValidateError::TooLarge {
2376 field,
2377 size: value.len(),
2378 cap: crate::limits::MAX_AGENT_STRING_BYTES,
2379 });
2380 }
2381 Ok(())
2382}
2383
2384fn value_size(value: &Value) -> usize {
2387 match value {
2388 Value::Str(s) => s.len(),
2389 Value::List(items) => items.iter().map(|item| 1 + value_size(item)).sum(),
2390 _ => 9,
2391 }
2392}
2393
2394#[cfg(test)]
2395mod tests {
2396 use super::*;
2397 use crate::validate::Validate;
2398
2399 fn session_start() -> SessionStart {
2400 SessionStart {
2401 label: Some("Research".to_owned()),
2402 namespace: Some("agents".to_owned()),
2403 agent: "planner".parse().expect("valid agent id"),
2404 sdk: SdkInfo {
2405 language: "rust".to_owned(),
2406 version: "0.8.0".to_owned(),
2407 },
2408 parent: None,
2409 root: None,
2410 idle_timeout_micros: Some(300_000_000),
2411 budget: Some(Budget {
2412 tokens: Some(1000),
2413 cost_micros: None,
2414 }),
2415 tags: vec!["demo".to_owned()],
2416 }
2417 }
2418
2419 #[test]
2420 fn given_session_status_when_decoded_then_should_keep_known_and_unknown_values() {
2421 assert_eq!(
2422 serde_json::from_str::<SessionStatus>("\"paused\"").expect("paused"),
2423 SessionStatus::Paused
2424 );
2425 assert_eq!(
2426 serde_json::from_str::<SessionStatus>("\"future\"").expect("unknown"),
2427 SessionStatus::Unrecognized
2428 );
2429 }
2430
2431 #[test]
2432 fn given_session_start_when_validated_then_should_enforce_label_and_tag_caps() {
2433 let mut start = session_start();
2434 start.validate().expect("valid start");
2435 start.label = Some("bad\nlabel".to_owned());
2436 assert!(start.validate().is_err());
2437 start.label = Some("x".repeat(crate::limits::MAX_SESSION_LABEL_BYTES + 1));
2438 assert!(start.validate().is_err());
2439 start.label = None;
2440 start.tags = vec!["tag".to_owned(); crate::limits::MAX_MEMORY_TAGS + 1];
2441 assert!(start.validate().is_err());
2442 }
2443
2444 #[cfg(feature = "cbor")]
2445 #[test]
2446 fn given_session_status_bodies_when_validated_then_should_match_the_lifecycle() {
2447 let mut status = AgentEnvelope::status(
2448 RecordId::from_u128(1),
2449 ConversationId::from_u128(2),
2450 "planner".parse().expect("valid agent id"),
2451 OPERATION_SESSION,
2452 );
2453 status.task_state = Some(TaskState::Submitted);
2454 status.body = crate::framing::encode_named(&session_start()).expect("start encodes");
2455 validate(&status).expect("submitted start");
2456 status.parent = Some(ConversationId::from_u128(8));
2457 assert!(
2458 validate(&status).is_err(),
2459 "start and envelope ancestry must match"
2460 );
2461 status.parent = None;
2462
2463 status.task_state = Some(TaskState::Paused);
2464 status.body = crate::framing::encode_named(&SessionTransition::default())
2465 .expect("transition encodes");
2466 validate(&status).expect("paused transition");
2467
2468 status.task_state = Some(TaskState::Completed);
2469 status.body = crate::framing::encode_named(&SessionEnd::default()).expect("end encodes");
2470 assert!(validate(&status).is_err(), "terminal status needs last");
2471 status.last = true;
2472 validate(&status).expect("completed end");
2473 }
2474
2475 #[cfg(feature = "cbor")]
2476 #[test]
2477 fn given_a_parked_record_when_encoded_then_should_round_trip_and_require_a_generation() {
2478 use crate::validate::Validate;
2479 let source = |generation| crate::graph::SourceRef::Message {
2480 stream: 1,
2481 topic: 2,
2482 partition: 3,
2483 offset: 4,
2484 generation,
2485 conversation: Some(ConversationId::from_u128(5).to_string()),
2486 };
2487 let parking = SessionParking {
2488 source: source(Some(6)),
2489 role: "worker".parse().expect("valid agent id"),
2490 request: LogPosition::new(1, 7, 0, 8),
2491 };
2492 parking.validate().expect("a complete parking is valid");
2493 let bytes = crate::framing::encode_named(&parking).expect("parking encodes");
2494 let decoded: SessionParking = crate::framing::decode_named(&bytes).expect("decodes");
2495 assert_eq!(decoded, parking);
2496 let unproven = SessionParking {
2497 source: source(None),
2498 ..parking.clone()
2499 };
2500 assert!(unproven.validate().is_err());
2501 let not_a_record = SessionParking {
2502 source: crate::graph::SourceRef::Memory { id: "m".to_owned() },
2503 ..parking
2504 };
2505 assert!(not_a_record.validate().is_err());
2506 }
2507
2508 #[test]
2509 fn given_a_pause_request_body_when_decoded_then_should_read_an_empty_body_as_no_participants() {
2510 let empty: SessionPauseRequest = serde_json::from_slice(b"{}").expect("empty decodes");
2511 assert!(empty.participants.is_empty());
2512 let named = SessionPauseRequest {
2513 participants: vec!["worker".parse().expect("valid agent id")],
2514 };
2515 let json = serde_json::to_string(&named).expect("request encodes");
2516 assert_eq!(json, r#"{"participants":["worker"]}"#);
2517 assert_eq!(
2518 serde_json::from_str::<SessionPauseRequest>(&json).expect("decodes"),
2519 named
2520 );
2521 }
2522
2523 #[test]
2524 fn given_an_id_when_displayed_then_should_round_trip_through_crockford_base32() {
2525 let id = RecordId::from_u128(0x0123_4567_89ab_cdef_0123_4567_89ab_cdef);
2526 let text = id.to_string();
2527 assert_eq!(text.len(), 26);
2528 assert_eq!(text.parse::<RecordId>().expect("parses"), id);
2529 assert_eq!(text.to_lowercase().parse::<RecordId>().expect("parses"), id);
2531 assert_eq!(
2532 RecordId::from_u128(0).to_string(),
2533 "00000000000000000000000000"
2534 );
2535 assert_eq!(
2536 RecordId::from_u128(u128::MAX).to_string(),
2537 "7ZZZZZZZZZZZZZZZZZZZZZZZZZ"
2538 );
2539 }
2540
2541 #[test]
2542 fn given_an_id_in_json_when_encoded_then_should_use_readable_text() {
2543 let id = RecordId::from_u128(0x0123_4567_89ab_cdef_0123_4567_89ab_cdef);
2544 let json = serde_json::to_string(&id).expect("serializes");
2545 assert_eq!(json, format!("\"{id}\""));
2546 assert_eq!(
2547 serde_json::from_str::<RecordId>(&json).expect("deserializes"),
2548 id
2549 );
2550 let byte_array = serde_json::to_string(&[0_u8; 16]).expect("serializes byte array");
2551 assert!(serde_json::from_str::<RecordId>(&byte_array).is_err());
2552 }
2553
2554 #[cfg(feature = "cbor")]
2555 #[test]
2556 fn given_an_id_in_named_cbor_when_encoded_then_should_use_fixed_width_bytes() {
2557 let id = RecordId::from_u128(0x0123_4567_89ab_cdef_0123_4567_89ab_cdef);
2558 let encoded = crate::framing::encode_named(&id).expect("serializes");
2559 let decoded = crate::framing::decode_named::<RecordId>(&encoded).expect("deserializes");
2560 assert_eq!(decoded, id);
2561 }
2562
2563 #[test]
2564 fn given_invalid_id_strings_when_parsed_then_should_reject_with_the_right_error() {
2565 assert_eq!(
2566 "short".parse::<RecordId>(),
2567 Err(IdParseError::Length { got: 5 })
2568 );
2569 assert_eq!(
2570 "8ZZZZZZZZZZZZZZZZZZZZZZZZZ".parse::<RecordId>(),
2571 Err(IdParseError::Overflow)
2572 );
2573 assert!(matches!(
2574 "UUUUUUUUUUUUUUUUUUUUUUUUUU".parse::<RecordId>(),
2575 Err(IdParseError::Char('U'))
2576 ));
2577 }
2578
2579 #[test]
2580 fn given_agent_id_strings_when_parsed_then_should_accept_printable_and_reject_control() {
2581 for s in ["planner", "planner@acme.example", "team/planner", "a:b"] {
2582 assert_eq!(
2583 s.parse::<AgentId>()
2584 .expect("a printable agent id is valid")
2585 .as_str(),
2586 s
2587 );
2588 }
2589 assert!("".parse::<AgentId>().is_err());
2590 assert!("bad\nid".parse::<AgentId>().is_err());
2591 }
2592
2593 #[test]
2594 fn given_task_state_codes_when_mapped_then_should_match_the_pinned_dictionary() {
2595 let expected = [
2596 (TaskState::Submitted, 1u8),
2597 (TaskState::Working, 2),
2598 (TaskState::InputRequired, 3),
2599 (TaskState::Completed, 4),
2600 (TaskState::Canceled, 5),
2601 (TaskState::Failed, 6),
2602 (TaskState::Rejected, 7),
2603 (TaskState::AuthRequired, 8),
2604 (TaskState::Unknown, 9),
2605 (TaskState::Paused, 10),
2606 ];
2607 for (state, code) in expected {
2608 assert_eq!(state.code(), code);
2609 assert_eq!(TaskState::from_code(code), state);
2610 }
2611 let future = TaskState::from_code(42);
2613 assert_eq!(future, TaskState::Unrecognized(42));
2614 assert_eq!(future.code(), 42);
2615 assert!(!future.is_terminal());
2616 assert!(TaskState::Completed.is_terminal());
2617 assert!(!TaskState::Working.is_terminal());
2618 assert!(!TaskState::Paused.is_terminal());
2619 assert_eq!(TaskState::Paused.to_string(), "paused");
2620 assert_eq!("paused".parse::<TaskState>(), Ok(TaskState::Paused));
2621 }
2622
2623 #[test]
2624 fn given_task_state_names_when_round_tripped_then_should_match_the_a2a_vocabulary() {
2625 assert_eq!(TaskState::InputRequired.to_string(), "input-required");
2626 assert_eq!(
2627 "auth-required".parse::<TaskState>().expect("parses"),
2628 TaskState::AuthRequired
2629 );
2630 assert!("nope".parse::<TaskState>().is_err());
2631 }
2632
2633 #[test]
2634 fn given_error_and_dead_letter_codes_when_mapped_then_should_match_the_dictionaries() {
2635 assert_eq!(AgentErrorCode::InvalidRequest.code(), 1);
2636 assert_eq!(AgentErrorCode::Internal.code(), 7);
2637 assert_eq!(
2638 AgentErrorCode::from_code(99),
2639 AgentErrorCode::Unrecognized(99)
2640 );
2641 assert_eq!(DeadLetterReason::RetryExhausted.code(), 1);
2642 assert_eq!(DeadLetterReason::DeadlineExceeded.code(), 4);
2643 assert_eq!(
2644 DeadLetterReason::from_code(77),
2645 DeadLetterReason::Unrecognized(77)
2646 );
2647 }
2648
2649 #[test]
2650 fn given_every_u8_when_mapped_through_the_dictionaries_then_the_code_should_round_trip() {
2651 for code in 0u8..=u8::MAX {
2655 assert_eq!(TaskState::from_code(code).code(), code);
2656 assert_eq!(AgentErrorCode::from_code(code).code(), code);
2657 assert_eq!(DeadLetterReason::from_code(code).code(), code);
2658 }
2659 }
2660
2661 #[test]
2662 fn given_an_idempotency_key_when_validated_then_should_enforce_the_cap() {
2663 assert!("job-123-attempt-2".parse::<IdempotencyKey>().is_ok());
2664 assert!("".parse::<IdempotencyKey>().is_err());
2665 assert!("x".repeat(65).parse::<IdempotencyKey>().is_err());
2666 }
2667
2668 #[test]
2669 fn given_an_idempotency_key_with_a_control_character_when_parsed_then_should_refuse_it() {
2670 assert!("job-123\nattempt-2".parse::<IdempotencyKey>().is_err());
2671 assert!("job-123\u{7f}".parse::<IdempotencyKey>().is_err());
2672 assert!(
2673 serde_json::from_str::<IdempotencyKey>("\"job\\u0000\"").is_err(),
2674 "a decoded key is checked too"
2675 );
2676 }
2677
2678 #[test]
2679 fn given_a_command_when_validated_then_should_pass_and_enforce_the_matrix() {
2680 let (record, conversation, source, correlation) = ids();
2681 let command =
2682 AgentEnvelope::command(record, conversation, source, correlation, b"do".to_vec());
2683 validate(&command).expect("a well-formed command validates");
2684
2685 let mut missing = command.clone();
2687 missing.correlation = None;
2688 assert_eq!(
2689 validate(&missing),
2690 Err(ValidateError::Missing {
2691 kind: AgentKind::Command,
2692 field: "correlation"
2693 })
2694 );
2695
2696 let with_usage = command.clone().with_usage(TokenUsage::default());
2698 assert!(matches!(
2699 validate(&with_usage),
2700 Err(ValidateError::Forbidden { field: "usage", .. })
2701 ));
2702
2703 let mut with_channel = command;
2705 with_channel.channel = Some(ChannelId::from_u128(1));
2706 assert!(matches!(
2707 validate(&with_channel),
2708 Err(ValidateError::Forbidden {
2709 field: "channel",
2710 ..
2711 })
2712 ));
2713 }
2714
2715 #[test]
2716 fn given_chunks_when_validated_then_should_enforce_stream_semantics() {
2717 let (_, conversation, source, correlation) = ids();
2718 let channel = ChannelId::from_u128(23);
2719 let chunk = AgentEnvelope::chunk(
2720 conversation,
2721 source.clone(),
2722 correlation,
2723 channel,
2724 0,
2725 b"tok".to_vec(),
2726 )
2727 .with_operation(OPERATION_CHAT);
2728 validate(&chunk).expect("a stream chunk validates");
2729
2730 let mut undeclared = chunk.clone();
2732 undeclared.operation = None;
2733 assert!(matches!(
2734 validate(&undeclared),
2735 Err(ValidateError::Missing {
2736 field: "operation",
2737 ..
2738 })
2739 ));
2740 let mut off_vocabulary = chunk.clone();
2741 off_vocabulary.operation = Some("telemetry".to_owned());
2742 assert!(matches!(
2743 validate(&off_vocabulary),
2744 Err(ValidateError::Invalid {
2745 field: "operation",
2746 ..
2747 })
2748 ));
2749
2750 let redeclared = AgentEnvelope::chunk(
2752 conversation,
2753 source.clone(),
2754 correlation,
2755 channel,
2756 3,
2757 b"tok".to_vec(),
2758 )
2759 .with_operation(OPERATION_REASONING);
2760 assert!(matches!(
2761 validate(&redeclared),
2762 Err(ValidateError::Invalid {
2763 field: "operation",
2764 ..
2765 })
2766 ));
2767
2768 let terminal = AgentEnvelope::chunk(
2770 conversation,
2771 source.clone(),
2772 correlation,
2773 channel,
2774 41,
2775 Vec::new(),
2776 )
2777 .terminal("stop")
2778 .with_usage(TokenUsage {
2779 input_tokens: 100,
2780 output_tokens: 42,
2781 ..Default::default()
2782 });
2783 validate(&terminal).expect("a terminal chunk validates");
2784
2785 let mut early_finish = chunk.clone();
2787 early_finish.finish_reason = Some("stop".to_owned());
2788 assert!(matches!(
2789 validate(&early_finish),
2790 Err(ValidateError::Invalid {
2791 field: "finish_reason",
2792 ..
2793 })
2794 ));
2795
2796 let empty = AgentEnvelope::chunk(
2798 conversation,
2799 source.clone(),
2800 correlation,
2801 channel,
2802 1,
2803 Vec::new(),
2804 );
2805 assert!(matches!(
2806 validate(&empty),
2807 Err(ValidateError::Missing { field: "body", .. })
2808 ));
2809
2810 let mut keyed = chunk.clone();
2812 keyed.idempotency_key = Some("k".parse().expect("valid key"));
2813 assert!(matches!(
2814 validate(&keyed),
2815 Err(ValidateError::Forbidden {
2816 field: "idempotency_key",
2817 ..
2818 })
2819 ));
2820
2821 let opening = chunk.with_deadline_micros(1);
2823 validate(&opening).expect("an opening chunk may declare the bound");
2824 let late = AgentEnvelope::chunk(
2825 conversation,
2826 source.clone(),
2827 correlation,
2828 channel,
2829 5,
2830 b"tok".to_vec(),
2831 )
2832 .with_deadline_micros(1);
2833 assert!(matches!(
2834 validate(&late),
2835 Err(ValidateError::Invalid {
2836 field: "deadline_micros",
2837 ..
2838 })
2839 ));
2840 }
2841
2842 #[test]
2843 fn given_status_signals_when_validated_then_task_updates_should_require_state() {
2844 let (record, conversation, source, correlation) = ids();
2845 let card = AgentEnvelope::status(record, conversation, source.clone(), OPERATION_CARD);
2847 validate(&card).expect("a card validates");
2848
2849 let bare_task = AgentEnvelope::status(record, conversation, source.clone(), OPERATION_TASK);
2851 assert!(matches!(
2852 validate(&bare_task),
2853 Err(ValidateError::Missing {
2854 field: "correlation",
2855 ..
2856 })
2857 ));
2858 let task = AgentEnvelope::status(record, conversation, source.clone(), OPERATION_TASK)
2859 .with_correlation(correlation)
2860 .with_task_state(TaskState::Working);
2861 validate(&task).expect("a task update validates");
2862
2863 let off_vocabulary =
2865 AgentEnvelope::status(record, conversation, source.clone(), "telemetry");
2866 assert!(matches!(
2867 validate(&off_vocabulary),
2868 Err(ValidateError::Invalid {
2869 field: "operation",
2870 ..
2871 })
2872 ));
2873 }
2874
2875 #[test]
2876 fn given_registry_facts_when_validated_then_should_require_the_signed_deadline() {
2877 let (record, conversation, source, _) = ids();
2878 for operation in [OPERATION_QUARANTINE, OPERATION_UNQUARANTINE] {
2879 let mut fact = AgentEnvelope::status(record, conversation, source.clone(), operation);
2880 fact.body = b"worker".to_vec();
2881 assert!(matches!(
2882 validate(&fact),
2883 Err(ValidateError::Missing {
2884 field: "deadline_micros",
2885 ..
2886 })
2887 ));
2888 validate(&fact.with_deadline_micros(1)).expect("a fact with its deadline validates");
2889 }
2890 let card = AgentEnvelope::status(record, conversation, source, OPERATION_CARD)
2891 .with_deadline_micros(1);
2892 assert!(matches!(
2893 validate(&card),
2894 Err(ValidateError::Forbidden {
2895 field: "deadline_micros",
2896 ..
2897 })
2898 ));
2899 }
2900
2901 #[test]
2902 fn given_an_error_when_validated_then_last_should_be_forbidden() {
2903 let (record, conversation, source, correlation) = ids();
2904 let error =
2905 AgentEnvelope::error(record, conversation, source, correlation, b"boom".to_vec());
2906 validate(&error).expect("an error validates");
2907
2908 let mut flagged = error.clone();
2910 flagged.last = true;
2911 assert!(matches!(
2912 validate(&flagged),
2913 Err(ValidateError::Forbidden { field: "last", .. })
2914 ));
2915
2916 let mut dangling = error;
2918 dangling.sequence = Some(3);
2919 assert!(matches!(
2920 validate(&dangling),
2921 Err(ValidateError::Invalid {
2922 field: "sequence",
2923 ..
2924 })
2925 ));
2926 }
2927
2928 fn descriptor(skill_id: &str) -> CapabilityDescriptor {
2929 CapabilityDescriptor {
2930 skill_id: skill_id.to_owned(),
2931 input: None,
2932 output: None,
2933 cost_class: None,
2934 latency_class: None,
2935 max_concurrency: None,
2936 health: None,
2937 load: None,
2938 }
2939 }
2940
2941 #[test]
2942 fn given_an_agent_card_when_validated_then_should_enforce_the_caps() {
2943 let card = AgentCard {
2944 name: Some("rollout-planner".to_owned()),
2945 version: Some("1.4.2".to_owned()),
2946 capabilities: vec![
2947 CapabilityDescriptor {
2948 skill_id: "chat".to_owned(),
2949 input: Some(ContentRef::ContentType(crate::content::ContentType::Json)),
2950 output: Some(ContentRef::ContentType(crate::content::ContentType::Json)),
2951 cost_class: Some(2),
2952 latency_class: Some(1),
2953 max_concurrency: Some(8),
2954 health: Some(Health::Healthy),
2955 load: Some(250),
2956 },
2957 descriptor("plan_rollout"),
2958 ],
2959 ttl_micros: Some(30_000_000),
2960 inbox: Some("rollout-planner.work".to_owned()),
2961 };
2962 card.validate().expect("a well-formed card validates");
2963
2964 let mut crowded = card.clone();
2965 crowded.capabilities = vec![descriptor("x"); crate::limits::MAX_CARD_CAPABILITIES + 1];
2966 assert!(matches!(
2967 crowded.validate(),
2968 Err(ValidateError::TooLarge {
2969 field: "capabilities",
2970 ..
2971 })
2972 ));
2973
2974 let mut oversized = card.clone();
2975 oversized.name = Some("n".repeat(crate::limits::MAX_AGENT_STRING_BYTES + 1));
2976 assert!(matches!(
2977 oversized.validate(),
2978 Err(ValidateError::TooLarge { field: "name", .. })
2979 ));
2980
2981 let mut long_inbox = card;
2982 long_inbox.inbox = Some("i".repeat(crate::limits::MAX_AGENT_STRING_BYTES + 1));
2983 assert!(matches!(
2984 long_inbox.validate(),
2985 Err(ValidateError::TooLarge { field: "inbox", .. })
2986 ));
2987 }
2988
2989 #[cfg(feature = "cbor")]
2990 #[test]
2991 fn given_a_schema_id_named_like_a_content_type_when_round_tripped_then_should_stay_a_schema_id()
2992 {
2993 use crate::framing::{decode_named, encode_named};
2994 let reference = ContentRef::SchemaId("json".to_owned());
2997 let bytes = encode_named(&reference).expect("encodes");
2998 let back: ContentRef = decode_named(&bytes).expect("decodes");
2999 assert_eq!(back, ContentRef::SchemaId("json".to_owned()));
3000 let ct = ContentRef::ContentType(crate::content::ContentType::Json);
3002 let back: ContentRef = decode_named(&encode_named(&ct).expect("encodes")).expect("decodes");
3003 assert_eq!(back, ct);
3004 }
3005
3006 #[test]
3007 fn given_a_signature_when_validated_then_should_enforce_per_scheme_lengths() {
3008 let valid = Signature {
3009 scheme: SIGNATURE_SCHEME_ED25519,
3010 key_id: vec![1u8; 8],
3011 bytes: vec![2u8; 64],
3012 context: None,
3013 };
3014 valid
3015 .validate()
3016 .expect("a well-formed Ed25519 signature validates");
3017
3018 let mut short_key = valid.clone();
3019 short_key.key_id = vec![1u8; 4];
3020 assert!(matches!(
3021 short_key.validate(),
3022 Err(ValidateError::Invalid {
3023 field: "key_id",
3024 ..
3025 })
3026 ));
3027
3028 let mut short_signature = valid.clone();
3029 short_signature.bytes = vec![2u8; 32];
3030 assert!(matches!(
3031 short_signature.validate(),
3032 Err(ValidateError::Invalid { field: "bytes", .. })
3033 ));
3034
3035 let future = Signature {
3038 scheme: 42,
3039 key_id: vec![1u8; 3],
3040 bytes: vec![2u8; 99],
3041 context: None,
3042 };
3043 future.validate().expect("an unknown scheme flows through");
3044 }
3045
3046 #[test]
3047 fn given_a_body_ref_when_validated_then_should_enforce_reference_and_digest() {
3048 let valid = BodyRef::new("s3://transcripts/conv-1/msg-9", 4_194_304, [7u8; 32]);
3049 valid.validate().expect("a well-formed reference validates");
3050
3051 let mut empty = valid.clone();
3052 empty.reference = String::new();
3053 assert!(matches!(
3054 empty.validate(),
3055 Err(ValidateError::Invalid {
3056 field: "reference",
3057 ..
3058 })
3059 ));
3060
3061 let mut oversized = valid.clone();
3062 oversized.reference = "x".repeat(crate::limits::MAX_BODY_REFERENCE_BYTES + 1);
3063 assert!(matches!(
3064 oversized.validate(),
3065 Err(ValidateError::TooLarge {
3066 field: "reference",
3067 ..
3068 })
3069 ));
3070
3071 let mut truncated = valid;
3072 truncated.sha256 = vec![7u8; 16];
3073 assert!(matches!(
3074 truncated.validate(),
3075 Err(ValidateError::Invalid {
3076 field: "sha256",
3077 ..
3078 })
3079 ));
3080 }
3081
3082 #[test]
3083 fn given_oversized_metadata_when_validated_then_should_reject() {
3084 let (record, conversation, source, correlation) = ids();
3085 let mut command =
3086 AgentEnvelope::command(record, conversation, source, correlation, b"x".to_vec());
3087 for i in 0..crate::limits::MAX_METADATA_ENTRIES + 1 {
3088 command = command.with_metadata(format!("k{i}"), i as i64);
3089 }
3090 assert!(matches!(
3091 validate(&command),
3092 Err(ValidateError::TooLarge {
3093 field: "metadata",
3094 ..
3095 })
3096 ));
3097 }
3098
3099 fn ids() -> (RecordId, ConversationId, AgentId, CorrelationId) {
3100 (
3101 RecordId::from_u128(7),
3102 ConversationId::from_u128(11),
3103 "test-agent".parse().expect("valid agent id"),
3104 CorrelationId::from_u128(17),
3105 )
3106 }
3107}
3108
3109#[cfg(all(test, feature = "cbor"))]
3110mod wire_tests {
3111 use super::*;
3112 use crate::framing::{decode_named, encode_named};
3113
3114 #[test]
3115 fn given_a_wrong_length_locator_when_decoded_then_should_error_not_panic() {
3116 let mut valid = vec![0x40 | 20];
3119 valid.extend_from_slice(&[0u8; 20]);
3120 decode_named::<LogPosition>(&valid).expect("20 packed bytes decode");
3121
3122 for bad_len in [0u8, 19, 21, 23] {
3123 let mut bytes = vec![0x40 | bad_len];
3124 bytes.extend_from_slice(&vec![0u8; bad_len as usize]);
3125 assert!(
3126 decode_named::<LogPosition>(&bytes).is_err(),
3127 "a {bad_len}-byte locator must error, not panic"
3128 );
3129 }
3130 }
3131
3132 #[test]
3133 fn given_a_locator_when_round_tripped_then_should_preserve_every_field() {
3134 let pos = LogPosition::new(0x0102_0304, 0x0506_0708, 0x090A_0B0C, 0x0D0E_0F10_1112_1314);
3135 let bytes = encode_named(&pos).expect("encodes");
3136 let back: LogPosition = decode_named(&bytes).expect("decodes");
3137 assert_eq!(back, pos);
3138 }
3139
3140 #[test]
3141 fn given_an_envelope_when_round_tripped_then_should_preserve_every_field() {
3142 let envelope = AgentEnvelope::command(
3143 RecordId::from_u128(1),
3144 ConversationId::from_u128(2),
3145 "source-agent".parse().expect("valid agent id"),
3146 CorrelationId::from_u128(4),
3147 b"payload".to_vec(),
3148 )
3149 .with_target("target-agent".parse().expect("valid agent id"))
3150 .with_cause(RecordId::from_u128(6), Some(LogPosition::new(1, 2, 3, 44)))
3151 .with_idempotency_key("job-1".parse().expect("valid key"))
3152 .with_deadline_micros(1_700_000_000_000_000)
3153 .with_operation("chat")
3154 .with_tool("search")
3155 .with_metadata("pool", "eu-west");
3156 let bytes = encode_named(&envelope).expect("encodes");
3157 let back: AgentEnvelope = decode_named(&bytes).expect("decodes");
3158 assert_eq!(back, envelope);
3159 }
3160
3161 #[test]
3162 fn given_a_must_understand_marker_when_round_tripped_then_should_preserve_bits_and_skip_zero() {
3163 let envelope = AgentEnvelope::event(
3167 RecordId::from_u128(1),
3168 ConversationId::from_u128(2),
3169 "source-agent".parse().expect("valid agent id"),
3170 b"e".to_vec(),
3171 )
3172 .requiring(0b101);
3173 let bytes = encode_named(&envelope).expect("encodes");
3174 let back: AgentEnvelope = decode_named(&bytes).expect("decodes");
3175 assert_eq!(back.must_understand, 0b101);
3176 assert_eq!(back.unmet_requirements(0b001), 0b100);
3178 assert_eq!(back.unmet_requirements(0b111), 0);
3180 let plain = AgentEnvelope::event(
3182 RecordId::from_u128(1),
3183 ConversationId::from_u128(2),
3184 "source-agent".parse().expect("valid agent id"),
3185 b"e".to_vec(),
3186 );
3187 assert_eq!(plain.unmet_requirements(features::NONE), 0);
3188 let json = serde_json::to_string(&plain).expect("json");
3189 assert!(
3190 !json.contains("must_understand"),
3191 "zero marker must be omitted: {json}"
3192 );
3193 }
3194
3195 #[test]
3196 fn given_absent_options_when_encoded_then_should_cost_zero_bytes() {
3197 let envelope = AgentEnvelope::event(
3200 RecordId::from_u128(1),
3201 ConversationId::from_u128(2),
3202 "source-agent".parse().expect("valid agent id"),
3203 b"e".to_vec(),
3204 );
3205 let bytes = encode_named(&envelope).expect("encodes");
3206 assert_eq!(bytes[0] & 0x0f, 5, "absent optionals must not be encoded");
3209 let back: AgentEnvelope = decode_named(&bytes).expect("decodes");
3210 assert!(!back.last);
3211 assert!(back.metadata.is_none());
3212 }
3213
3214 #[test]
3215 fn given_an_id_when_encoded_then_should_ride_as_one_fixed_width_byte_string() {
3216 let bytes = encode_named(&RecordId::from_u128(0x0102)).expect("encodes");
3217 assert_eq!(bytes.len(), 17);
3220 assert_eq!(bytes[0], 0x50);
3221 assert_eq!(bytes[16], 0x02);
3222 assert_eq!(bytes[15], 0x01);
3223 }
3224
3225 #[test]
3226 fn given_a_task_state_when_encoded_then_should_ride_as_a_bare_u8() {
3227 let bytes = encode_named(&TaskState::Completed).expect("encodes");
3228 assert_eq!(bytes, vec![4]);
3229 let unknown = encode_named(&42u8).expect("encodes a raw code");
3230 let back: TaskState = decode_named(&unknown).expect("unknown code decodes");
3231 assert_eq!(back, TaskState::Unrecognized(42));
3232 }
3233
3234 #[test]
3235 fn given_an_error_body_when_round_tripped_then_should_preserve_the_dictionary_code() {
3236 let body = AgentErrorBody {
3237 code: AgentErrorCode::ToolFailure,
3238 message: Some("search timed out".to_owned()),
3239 retryable: true,
3240 detail: Some(BTreeMap::from([("attempt".to_owned(), Value::Int(3))])),
3241 };
3242 let bytes = encode_named(&body).expect("encodes");
3243 let back: AgentErrorBody = decode_named(&bytes).expect("decodes");
3244 assert_eq!(back, body);
3245 }
3246
3247 #[test]
3248 fn given_a_body_ref_when_round_tripped_then_should_preserve_the_digest_as_a_byte_string() {
3249 let capsule = BodyRef::new("kv://bodies/abc", 1024, [9u8; 32]);
3250 let bytes = encode_named(&capsule).expect("encodes");
3251 let back: BodyRef = decode_named(&bytes).expect("decodes");
3252 assert_eq!(back, capsule);
3253 assert!(back.encryption.is_none(), "absent encryption costs nothing");
3254 back.validate().expect("decoded capsule validates");
3255 }
3256
3257 #[test]
3258 fn given_a_dead_letter_when_round_tripped_then_payload_should_stay_byte_identical() {
3259 let inner = AgentEnvelope::command(
3260 RecordId::from_u128(9),
3261 ConversationId::from_u128(8),
3262 "source-agent".parse().expect("valid agent id"),
3263 CorrelationId::from_u128(6),
3264 b"poison".to_vec(),
3265 );
3266 let payload = encode_named(&inner).expect("inner encodes");
3267 let capsule = AgentDeadLetter {
3268 source: LogPosition::new(1, 2, 3, 99),
3269 reason: DeadLetterReason::RetryExhausted,
3270 attempts: 5,
3271 detail: Some("handler kept failing".to_owned()),
3272 payload: payload.clone(),
3273 };
3274 let bytes = encode_named(&capsule).expect("encodes");
3275 let back: AgentDeadLetter = decode_named(&bytes).expect("decodes");
3276 assert_eq!(back.payload, payload, "redrive needs the original bytes");
3277 let redrive: AgentEnvelope = decode_named(&back.payload).expect("inner decodes");
3278 assert_eq!(redrive, inner);
3279 }
3280 #[test]
3281 fn given_a_task_status_when_validated_then_should_require_the_correlation() {
3282 let base = AgentEnvelope::status(
3284 RecordId::from_u128(1),
3285 ConversationId::from_u128(2),
3286 "runner".parse().expect("valid agent id"),
3287 OPERATION_TASK,
3288 )
3289 .with_task_state(TaskState::Working);
3290 assert!(
3291 matches!(
3292 validate(&base),
3293 Err(ValidateError::Missing {
3294 kind: AgentKind::Status,
3295 field: "correlation"
3296 })
3297 ),
3298 "a task status without a correlation is rejected"
3299 );
3300 let correlated = base.with_correlation(CorrelationId::from_u128(2));
3301 validate(&correlated)
3302 .expect("the run-lifecycle status validates once it correlates on the run");
3303 }
3304
3305 #[test]
3306 fn given_task_states_when_displayed_and_parsed_then_should_use_the_pinned_kebab_words() {
3307 assert_eq!(TaskState::InputRequired.to_string(), "input-required");
3308 assert_eq!(TaskState::AuthRequired.to_string(), "auth-required");
3309 assert_eq!(TaskState::Unrecognized(42).to_string(), "unrecognized-42");
3310 assert_eq!(
3311 "input-required".parse::<TaskState>().expect("parses"),
3312 TaskState::InputRequired
3313 );
3314 assert!("bogus".parse::<TaskState>().is_err());
3315 }
3316}