1use serde::{Deserialize, Serialize};
4
5use crate::error::ErrorCode;
6use crate::ids::JobId;
7
8#[derive(Debug, Clone, PartialEq, Eq, Hash)]
23pub struct AgentRef {
24 pub name: String,
26 pub version: Option<String>,
28}
29
30#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
33pub enum AgentRefParseError {
34 #[error("invalid agent name {0:?}")]
37 InvalidName(String),
38 #[error("invalid agent version {0:?}")]
40 InvalidVersion(String),
41}
42
43const fn is_name_head(c: char) -> bool {
44 matches!(c, 'a'..='z' | '0'..='9')
45}
46
47const fn is_name_tail(c: char) -> bool {
48 matches!(c, 'a'..='z' | '0'..='9' | '.' | '_' | '-')
49}
50
51const fn is_version_char(c: char) -> bool {
52 matches!(c, 'a'..='z' | 'A'..='Z' | '0'..='9' | '.' | '+' | '_' | '-')
53}
54
55fn validate_name(name: &str) -> Result<(), AgentRefParseError> {
56 let mut chars = name.chars();
57 let Some(head) = chars.next() else {
58 return Err(AgentRefParseError::InvalidName(name.to_owned()));
59 };
60 if !is_name_head(head) {
61 return Err(AgentRefParseError::InvalidName(name.to_owned()));
62 }
63 for c in chars {
64 if !is_name_tail(c) {
65 return Err(AgentRefParseError::InvalidName(name.to_owned()));
66 }
67 }
68 Ok(())
69}
70
71fn validate_version(version: &str) -> Result<(), AgentRefParseError> {
72 if version.is_empty() {
73 return Err(AgentRefParseError::InvalidVersion(version.to_owned()));
74 }
75 for c in version.chars() {
76 if !is_version_char(c) {
77 return Err(AgentRefParseError::InvalidVersion(version.to_owned()));
78 }
79 }
80 Ok(())
81}
82
83impl AgentRef {
84 pub fn parse(input: &str) -> Result<Self, AgentRefParseError> {
91 if let Some(at) = input.find('@') {
92 let (name, rest) = input.split_at(at);
93 let version = &rest[1..];
95 validate_name(name)?;
96 validate_version(version)?;
97 Ok(Self {
98 name: name.to_owned(),
99 version: Some(version.to_owned()),
100 })
101 } else {
102 validate_name(input)?;
103 Ok(Self {
104 name: input.to_owned(),
105 version: None,
106 })
107 }
108 }
109
110 #[must_use]
112 pub fn format(&self) -> String {
113 self.version.as_ref().map_or_else(
114 || self.name.clone(),
115 |v| format!("{name}@{v}", name = self.name),
116 )
117 }
118}
119
120impl std::fmt::Display for AgentRef {
121 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
122 f.write_str(&self.format())
123 }
124}
125
126impl std::str::FromStr for AgentRef {
127 type Err = AgentRefParseError;
128 fn from_str(s: &str) -> Result<Self, Self::Err> {
129 Self::parse(s)
130 }
131}
132
133impl Serialize for AgentRef {
134 fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
135 serializer.serialize_str(&self.format())
136 }
137}
138
139impl<'de> Deserialize<'de> for AgentRef {
140 fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
141 use serde::de::Error;
142 let raw = String::deserialize(deserializer)?;
143 Self::parse(&raw).map_err(D::Error::custom)
144 }
145}
146
147#[cfg(test)]
148#[allow(clippy::unwrap_used)]
149mod agent_ref_tests {
150 use super::*;
151
152 #[test]
153 fn parse_bare_name() {
154 let r = AgentRef::parse("code-refactor").unwrap();
155 assert_eq!(r.name, "code-refactor");
156 assert!(r.version.is_none());
157 }
158
159 #[test]
160 fn parse_name_at_version() {
161 let r = AgentRef::parse("code-refactor@2.0.0").unwrap();
162 assert_eq!(r.name, "code-refactor");
163 assert_eq!(r.version.as_deref(), Some("2.0.0"));
164 }
165
166 #[test]
167 fn format_round_trips() {
168 for s in ["a", "a-b", "a@1.0.0", "agent_x@v1.2.3+build.4"] {
169 let r = AgentRef::parse(s).unwrap();
170 assert_eq!(r.format(), s);
171 }
172 }
173
174 #[test]
175 fn rejects_uppercase_in_name() {
176 assert!(AgentRef::parse("CodeRefactor").is_err());
177 assert!(AgentRef::parse("Foo@1").is_err());
178 }
179
180 #[test]
181 fn rejects_empty_version() {
182 assert!(AgentRef::parse("ok@").is_err());
183 }
184
185 #[test]
186 fn serde_round_trip() {
187 let r = AgentRef::parse("web-research@1.0.0").unwrap();
188 let json = serde_json::to_string(&r).unwrap();
189 assert_eq!(json, "\"web-research@1.0.0\"");
190 let back: AgentRef = serde_json::from_str(&json).unwrap();
191 assert_eq!(back, r);
192 }
193}
194
195#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
197pub struct ToolInvokePayload {
198 pub tool: String,
200 pub arguments: serde_json::Value,
202 #[serde(default, skip_serializing_if = "Option::is_none")]
207 pub cost_budget: Option<crate::messages::permissions::CostBudget>,
208 #[serde(default, skip_serializing_if = "Option::is_none")]
211 pub lease_request: Option<crate::messages::permissions::LeaseRequest>,
212}
213
214impl ToolInvokePayload {
215 #[must_use]
217 pub fn new(tool: impl Into<String>, arguments: serde_json::Value) -> Self {
218 Self {
219 tool: tool.into(),
220 arguments,
221 cost_budget: None,
222 lease_request: None,
223 }
224 }
225}
226
227#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
229pub struct ToolResultPayload {
230 #[serde(default, skip_serializing_if = "Option::is_none")]
232 pub value: Option<serde_json::Value>,
233 #[serde(default, skip_serializing_if = "Option::is_none")]
235 pub result_ref: Option<crate::messages::artifacts::ArtifactRef>,
236}
237
238#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
240pub struct ToolErrorPayload {
241 pub code: ErrorCode,
243 #[serde(default, skip_serializing_if = "Option::is_none")]
245 pub retryable: Option<bool>,
246 pub message: String,
248 #[serde(default, skip_serializing_if = "Option::is_none")]
250 pub details: Option<serde_json::Value>,
251}
252
253#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
255#[serde(rename_all = "lowercase")]
256pub enum JobState {
257 Accepted,
259 Queued,
261 Running,
263 Blocked,
265 Paused,
267 Completed,
269 Failed,
271 Cancelled,
273}
274
275impl JobState {
276 #[must_use]
278 pub const fn is_terminal(self) -> bool {
279 matches!(self, Self::Completed | Self::Failed | Self::Cancelled)
280 }
281
282 #[must_use]
284 pub const fn wire_str(self) -> &'static str {
285 match self {
286 Self::Accepted => "accepted",
287 Self::Queued => "queued",
288 Self::Running => "running",
289 Self::Blocked => "blocked",
290 Self::Paused => "paused",
291 Self::Completed => "completed",
292 Self::Failed => "failed",
293 Self::Cancelled => "cancelled",
294 }
295 }
296}
297
298#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
300pub struct JobAcceptedPayload {
301 pub job_id: JobId,
303 #[serde(default, skip_serializing_if = "Vec::is_empty")]
305 pub credentials: Vec<crate::messages::credentials::ProvisionedCredential>,
306 #[serde(default, skip_serializing_if = "Option::is_none")]
308 pub lease: Option<crate::messages::permissions::LeaseRequest>,
309}
310
311#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
313pub struct JobStartedPayload {
314 #[serde(default, skip_serializing_if = "Option::is_none")]
316 pub description: Option<String>,
317}
318
319#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
321pub struct JobProgressPayload {
322 #[serde(default, skip_serializing_if = "Option::is_none")]
324 pub percent: Option<f64>,
325 #[serde(default, skip_serializing_if = "Option::is_none")]
327 pub message: Option<String>,
328}
329
330#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
332pub struct JobHeartbeatPayload {
333 pub sequence: u64,
335 #[serde(default, skip_serializing_if = "Option::is_none")]
337 pub deadline_ms: Option<u64>,
338 pub state: JobState,
340}
341
342#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
344pub struct JobCheckpointPayload {
345 pub checkpoint_id: String,
347 pub data: serde_json::Value,
349}
350
351#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
353pub struct JobCompletedPayload {
354 #[serde(default, skip_serializing_if = "Option::is_none")]
356 pub value: Option<serde_json::Value>,
357 #[serde(default, skip_serializing_if = "Option::is_none")]
359 pub result_ref: Option<crate::messages::artifacts::ArtifactRef>,
360 #[serde(default, skip_serializing_if = "Option::is_none")]
364 pub result_id: Option<String>,
365 #[serde(default, skip_serializing_if = "Option::is_none")]
368 pub result_size: Option<u64>,
369 #[serde(default, skip_serializing_if = "Option::is_none")]
372 pub summary: Option<String>,
373}
374
375#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
377#[serde(rename_all = "lowercase")]
378pub enum ResultChunkEncoding {
379 Utf8,
381 Base64,
383}
384
385#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
392pub struct JobResultChunkPayload {
393 pub result_id: String,
396 pub chunk_seq: u64,
398 pub data: String,
400 pub encoding: ResultChunkEncoding,
402 pub more: bool,
404}
405
406#[derive(Debug, Default)]
414pub struct ResultChunkAssembler {
415 result_id: Option<String>,
416 encoding: Option<ResultChunkEncoding>,
417 next_seq: u64,
418 buffer: Vec<u8>,
419 finished: bool,
420}
421
422#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
425pub enum ResultChunkError {
426 #[error("result_chunk out of order: expected seq {expected}, got {got}")]
429 OutOfOrder {
430 expected: u64,
432 got: u64,
434 },
435 #[error("result_chunk result_id mismatch: expected {expected:?}, got {got:?}")]
437 ResultIdMismatch {
438 expected: String,
440 got: String,
442 },
443 #[error("result_chunk encoding mismatch: expected {expected:?}, got {got:?}")]
445 EncodingMismatch {
446 expected: ResultChunkEncoding,
448 got: ResultChunkEncoding,
450 },
451 #[error("result_chunk base64 decode failed at seq {seq}")]
453 Base64Decode {
454 seq: u64,
456 },
457 #[error("result_chunk: chunk pushed after final chunk")]
459 AfterFinal,
460 #[error("result_chunk: not yet final")]
462 NotFinal,
463}
464
465impl ResultChunkAssembler {
466 #[must_use]
468 pub const fn new() -> Self {
469 Self {
470 result_id: None,
471 encoding: None,
472 next_seq: 0,
473 buffer: Vec::new(),
474 finished: false,
475 }
476 }
477
478 pub fn push(&mut self, chunk: &JobResultChunkPayload) -> Result<bool, ResultChunkError> {
487 if self.finished {
488 return Err(ResultChunkError::AfterFinal);
489 }
490 if chunk.chunk_seq != self.next_seq {
491 return Err(ResultChunkError::OutOfOrder {
492 expected: self.next_seq,
493 got: chunk.chunk_seq,
494 });
495 }
496 if let Some(rid) = self.result_id.as_deref() {
497 if rid != chunk.result_id {
498 return Err(ResultChunkError::ResultIdMismatch {
499 expected: rid.to_owned(),
500 got: chunk.result_id.clone(),
501 });
502 }
503 } else {
504 self.result_id = Some(chunk.result_id.clone());
505 }
506 if let Some(enc) = self.encoding {
507 if enc != chunk.encoding {
508 return Err(ResultChunkError::EncodingMismatch {
509 expected: enc,
510 got: chunk.encoding,
511 });
512 }
513 } else {
514 self.encoding = Some(chunk.encoding);
515 }
516 match chunk.encoding {
517 ResultChunkEncoding::Utf8 => {
518 self.buffer.extend_from_slice(chunk.data.as_bytes());
519 }
520 ResultChunkEncoding::Base64 => {
521 let decoded =
522 decode_base64(&chunk.data).map_err(|()| ResultChunkError::Base64Decode {
523 seq: chunk.chunk_seq,
524 })?;
525 self.buffer.extend_from_slice(&decoded);
526 }
527 }
528 self.next_seq += 1;
529 if !chunk.more {
530 self.finished = true;
531 }
532 Ok(!chunk.more)
533 }
534
535 #[must_use]
537 pub const fn is_finished(&self) -> bool {
538 self.finished
539 }
540
541 #[must_use]
543 pub const fn encoding(&self) -> Option<ResultChunkEncoding> {
544 self.encoding
545 }
546
547 #[must_use]
550 pub fn result_id(&self) -> Option<&str> {
551 self.result_id.as_deref()
552 }
553
554 pub fn finish(self) -> Result<Vec<u8>, ResultChunkError> {
561 if !self.finished {
562 return Err(ResultChunkError::NotFinal);
563 }
564 Ok(self.buffer)
565 }
566
567 pub fn finish_utf8(self) -> Result<String, ResultChunkError> {
575 let bytes = self.finish()?;
576 String::from_utf8(bytes).map_err(|_| ResultChunkError::Base64Decode { seq: u64::MAX })
577 }
578}
579
580fn decode_base64(input: &str) -> Result<Vec<u8>, ()> {
583 const fn val(c: u8) -> Option<u8> {
584 match c {
585 b'A'..=b'Z' => Some(c - b'A'),
586 b'a'..=b'z' => Some(c - b'a' + 26),
587 b'0'..=b'9' => Some(c - b'0' + 52),
588 b'+' => Some(62),
589 b'/' => Some(63),
590 _ => None,
591 }
592 }
593 let bytes: Vec<u8> = input.bytes().filter(|b| !b.is_ascii_whitespace()).collect();
594 let (data, pad) = bytes
595 .iter()
596 .position(|&b| b == b'=')
597 .map_or((bytes.as_slice(), 0), |p| (&bytes[..p], bytes.len() - p));
598 if (data.len() + pad) % 4 != 0 {
599 return Err(());
600 }
601 let mut out = Vec::with_capacity(data.len() * 3 / 4);
602 let mut chunk = [0u8; 4];
603 let mut filled = 0;
604 for &b in data {
605 let v = val(b).ok_or(())?;
606 chunk[filled] = v;
607 filled += 1;
608 if filled == 4 {
609 out.push((chunk[0] << 2) | (chunk[1] >> 4));
610 out.push((chunk[1] << 4) | (chunk[2] >> 2));
611 out.push((chunk[2] << 6) | chunk[3]);
612 filled = 0;
613 }
614 }
615 match filled {
616 0 => {}
617 2 => out.push((chunk[0] << 2) | (chunk[1] >> 4)),
618 3 => {
619 out.push((chunk[0] << 2) | (chunk[1] >> 4));
620 out.push((chunk[1] << 4) | (chunk[2] >> 2));
621 }
622 _ => return Err(()),
623 }
624 Ok(out)
625}
626
627#[cfg(test)]
628#[allow(clippy::unwrap_used)]
629mod result_chunk_tests {
630 use super::*;
631
632 #[test]
633 fn utf8_chunks_assemble_in_order() {
634 let mut a = ResultChunkAssembler::new();
635 for (seq, fragment, more) in [(0u64, "hello ", true), (1, "world", false)] {
636 let done = a
637 .push(&JobResultChunkPayload {
638 result_id: "res_x".into(),
639 chunk_seq: seq,
640 data: fragment.into(),
641 encoding: ResultChunkEncoding::Utf8,
642 more,
643 })
644 .unwrap();
645 assert_eq!(done, !more);
646 }
647 assert!(a.is_finished());
648 assert_eq!(a.finish_utf8().unwrap(), "hello world");
649 }
650
651 #[test]
652 fn out_of_order_chunks_rejected() {
653 let mut a = ResultChunkAssembler::new();
654 let _ = a
655 .push(&JobResultChunkPayload {
656 result_id: "r".into(),
657 chunk_seq: 0,
658 data: "a".into(),
659 encoding: ResultChunkEncoding::Utf8,
660 more: true,
661 })
662 .unwrap();
663 let err = a
664 .push(&JobResultChunkPayload {
665 result_id: "r".into(),
666 chunk_seq: 2,
667 data: "c".into(),
668 encoding: ResultChunkEncoding::Utf8,
669 more: false,
670 })
671 .unwrap_err();
672 assert!(matches!(
673 err,
674 ResultChunkError::OutOfOrder {
675 expected: 1,
676 got: 2
677 }
678 ));
679 }
680
681 #[test]
682 fn encoding_mismatch_rejected() {
683 let mut a = ResultChunkAssembler::new();
684 let _ = a
685 .push(&JobResultChunkPayload {
686 result_id: "r".into(),
687 chunk_seq: 0,
688 data: "a".into(),
689 encoding: ResultChunkEncoding::Utf8,
690 more: true,
691 })
692 .unwrap();
693 let err = a
694 .push(&JobResultChunkPayload {
695 result_id: "r".into(),
696 chunk_seq: 1,
697 data: "AA==".into(),
698 encoding: ResultChunkEncoding::Base64,
699 more: false,
700 })
701 .unwrap_err();
702 assert!(matches!(err, ResultChunkError::EncodingMismatch { .. }));
703 }
704
705 #[test]
706 fn base64_chunks_assemble() {
707 let mut a = ResultChunkAssembler::new();
708 a.push(&JobResultChunkPayload {
710 result_id: "r".into(),
711 chunk_seq: 0,
712 data: "aGk=".into(),
713 encoding: ResultChunkEncoding::Base64,
714 more: false,
715 })
716 .unwrap();
717 assert_eq!(a.finish().unwrap(), b"hi");
718 }
719
720 #[test]
721 fn finish_before_terminal_is_error() {
722 let mut a = ResultChunkAssembler::new();
723 a.push(&JobResultChunkPayload {
724 result_id: "r".into(),
725 chunk_seq: 0,
726 data: "x".into(),
727 encoding: ResultChunkEncoding::Utf8,
728 more: true,
729 })
730 .unwrap();
731 assert!(matches!(a.finish(), Err(ResultChunkError::NotFinal)));
732 }
733
734 #[test]
735 fn payload_round_trips_through_serde() {
736 let p = JobResultChunkPayload {
737 result_id: "res_01J".into(),
738 chunk_seq: 7,
739 data: "fragment".into(),
740 encoding: ResultChunkEncoding::Utf8,
741 more: true,
742 };
743 let j = serde_json::to_string(&p).unwrap();
744 let back: JobResultChunkPayload = serde_json::from_str(&j).unwrap();
745 assert_eq!(p, back);
746 }
747}
748
749#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
751pub struct JobFailedPayload {
752 pub code: ErrorCode,
754 #[serde(default, skip_serializing_if = "Option::is_none")]
756 pub retryable: Option<bool>,
757 pub message: String,
759 #[serde(default, skip_serializing_if = "Option::is_none")]
761 pub details: Option<serde_json::Value>,
762}
763
764#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
766pub struct JobCancelledPayload {
767 #[serde(default, skip_serializing_if = "Option::is_none")]
769 pub reason: Option<String>,
770}
771
772#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
774pub struct JobSchedulePayload {
775 pub job: serde_json::Value,
777 pub when: serde_json::Value,
779}
780
781#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
783pub struct AgentDelegatePayload {
784 pub target: String,
786 pub task: String,
788 #[serde(default, skip_serializing_if = "Option::is_none")]
790 pub context: Option<serde_json::Value>,
791}
792
793#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
795pub struct AgentHandoffPayload {
796 pub runtime: serde_json::Value,
798 #[serde(default, skip_serializing_if = "Option::is_none")]
800 pub reason: Option<String>,
801}
802
803#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
805pub struct WorkflowStartPayload {
806 pub workflow: String,
808 #[serde(default, skip_serializing_if = "Option::is_none")]
810 pub arguments: Option<serde_json::Value>,
811}
812
813#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
815pub struct WorkflowCompletePayload {
816 #[serde(default, skip_serializing_if = "Option::is_none")]
818 pub value: Option<serde_json::Value>,
819}