1use serde::{Deserialize, Serialize};
4
5pub const A2A_PROTOCOL_NAME: &str = "oris.a2a";
6pub const A2A_PROTOCOL_VERSION: &str = "0.1.0-experimental";
7pub const A2A_PROTOCOL_VERSION_V1: &str = "1.0.0";
8pub const A2A_SUPPORTED_PROTOCOL_VERSIONS: [&str; 2] =
9 [A2A_PROTOCOL_VERSION_V1, A2A_PROTOCOL_VERSION];
10
11#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
12pub struct A2aProtocol {
13 pub name: String,
14 pub version: String,
15}
16
17impl A2aProtocol {
18 pub fn current() -> Self {
19 Self {
20 name: A2A_PROTOCOL_NAME.to_string(),
21 version: A2A_PROTOCOL_VERSION.to_string(),
22 }
23 }
24}
25
26#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
27pub enum A2aCapability {
28 Coordination,
29 MutationProposal,
30 ReplayFeedback,
31 SupervisedDevloop,
32 EvolutionPublish,
33 EvolutionFetch,
34 EvolutionRevoke,
35}
36
37#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
38pub struct A2aHandshakeRequest {
39 pub agent_id: String,
40 pub role: AgentRole,
41 pub capability_level: AgentCapabilityLevel,
42 pub supported_protocols: Vec<A2aProtocol>,
43 pub advertised_capabilities: Vec<A2aCapability>,
44}
45
46impl A2aHandshakeRequest {
47 pub fn supports_protocol_version(&self, version: &str) -> bool {
48 self.supported_protocols
49 .iter()
50 .any(|protocol| protocol.name == A2A_PROTOCOL_NAME && protocol.version == version)
51 }
52
53 pub fn supports_current_protocol(&self) -> bool {
54 self.supports_protocol_version(A2A_PROTOCOL_VERSION)
55 }
56
57 pub fn negotiate_supported_protocol(&self) -> Option<A2aProtocol> {
58 for version in A2A_SUPPORTED_PROTOCOL_VERSIONS {
59 if self.supports_protocol_version(version) {
60 return Some(A2aProtocol {
61 name: A2A_PROTOCOL_NAME.to_string(),
62 version: version.to_string(),
63 });
64 }
65 }
66 None
67 }
68}
69
70#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
71pub struct A2aHandshakeResponse {
72 pub accepted: bool,
73 pub negotiated_protocol: Option<A2aProtocol>,
74 pub enabled_capabilities: Vec<A2aCapability>,
75 pub message: Option<String>,
76 pub error: Option<A2aErrorEnvelope>,
77}
78
79impl A2aHandshakeResponse {
80 pub fn accept(enabled_capabilities: Vec<A2aCapability>) -> Self {
81 Self {
82 accepted: true,
83 negotiated_protocol: Some(A2aProtocol::current()),
84 enabled_capabilities,
85 message: Some("handshake accepted".to_string()),
86 error: None,
87 }
88 }
89
90 pub fn reject(code: A2aErrorCode, message: impl Into<String>, details: Option<String>) -> Self {
91 Self {
92 accepted: false,
93 negotiated_protocol: None,
94 enabled_capabilities: Vec::new(),
95 message: Some("handshake rejected".to_string()),
96 error: Some(A2aErrorEnvelope {
97 code,
98 message: message.into(),
99 retriable: true,
100 details,
101 }),
102 }
103 }
104}
105
106#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
107pub enum A2aTaskLifecycleState {
108 Queued,
109 Running,
110 Succeeded,
111 Failed,
112 Cancelled,
113}
114
115#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
116pub struct A2aTaskLifecycleEvent {
117 pub task_id: String,
118 pub state: A2aTaskLifecycleState,
119 pub summary: String,
120 pub updated_at_ms: u64,
121 pub error: Option<A2aErrorEnvelope>,
122}
123
124pub const A2A_TASK_SESSION_PROTOCOL_VERSION: &str = A2A_PROTOCOL_VERSION;
125
126#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
127pub enum A2aTaskSessionState {
128 Started,
129 Dispatched,
130 InProgress,
131 Completed,
132 Failed,
133 Cancelled,
134}
135
136#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
137pub struct A2aTaskSessionStartRequest {
138 pub sender_id: String,
139 pub protocol_version: String,
140 pub task_id: String,
141 pub task_summary: String,
142}
143
144#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
145pub struct A2aTaskSessionDispatchRequest {
146 pub sender_id: String,
147 pub protocol_version: String,
148 pub dispatch_id: String,
149 pub summary: String,
150}
151
152#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
153pub struct A2aTaskSessionProgressRequest {
154 pub sender_id: String,
155 pub protocol_version: String,
156 pub progress_pct: u8,
157 pub summary: String,
158 pub retryable: bool,
159 pub retry_after_ms: Option<u64>,
160}
161
162#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
163pub struct A2aTaskSessionCompletionRequest {
164 pub sender_id: String,
165 pub protocol_version: String,
166 pub terminal_state: A2aTaskLifecycleState,
167 pub summary: String,
168 pub retryable: bool,
169 pub retry_after_ms: Option<u64>,
170 pub failure_code: Option<A2aErrorCode>,
171 pub failure_details: Option<String>,
172 pub used_capsule: bool,
173 pub capsule_id: Option<String>,
174 pub reasoning_steps_avoided: u64,
175 pub fallback_reason: Option<String>,
176 pub reason_code: Option<ReplayFallbackReasonCode>,
177 pub repair_hint: Option<String>,
178 pub next_action: Option<ReplayFallbackNextAction>,
179 pub confidence: Option<u8>,
180 pub task_class_id: String,
181 pub task_label: String,
182}
183
184#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
185pub struct A2aTaskSessionProgressItem {
186 pub progress_pct: u8,
187 pub summary: String,
188 pub retryable: bool,
189 pub retry_after_ms: Option<u64>,
190 pub updated_at_ms: u64,
191}
192
193#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
194pub struct A2aTaskSessionAck {
195 pub session_id: String,
196 pub task_id: String,
197 pub state: A2aTaskSessionState,
198 pub summary: String,
199 pub retryable: bool,
200 pub retry_after_ms: Option<u64>,
201 pub updated_at_ms: u64,
202}
203
204#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
205pub struct A2aTaskSessionResult {
206 pub terminal_state: A2aTaskLifecycleState,
207 pub summary: String,
208 pub retryable: bool,
209 pub retry_after_ms: Option<u64>,
210 pub failure_code: Option<A2aErrorCode>,
211 pub failure_details: Option<String>,
212 pub replay_feedback: ReplayFeedback,
213}
214
215#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
216pub struct A2aTaskSessionCompletionResponse {
217 pub ack: A2aTaskSessionAck,
218 pub result: A2aTaskSessionResult,
219}
220
221#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
222pub struct A2aTaskSessionSnapshot {
223 pub session_id: String,
224 pub sender_id: String,
225 pub task_id: String,
226 pub protocol_version: String,
227 pub state: A2aTaskSessionState,
228 pub created_at_ms: u64,
229 pub updated_at_ms: u64,
230 pub dispatch_ids: Vec<String>,
231 pub progress: Vec<A2aTaskSessionProgressItem>,
232 pub result: Option<A2aTaskSessionResult>,
233}
234
235#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
236pub enum A2aErrorCode {
237 UnsupportedProtocol,
238 UnsupportedCapability,
239 ValidationFailed,
240 AuthorizationDenied,
241 Timeout,
242 Internal,
243}
244
245#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
246pub struct A2aErrorEnvelope {
247 pub code: A2aErrorCode,
248 pub message: String,
249 pub retriable: bool,
250 pub details: Option<String>,
251}
252
253#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
254pub enum AgentCapabilityLevel {
255 A0,
256 A1,
257 A2,
258 A3,
259 A4,
260}
261
262#[derive(Clone, Debug, Serialize, Deserialize)]
263pub enum ProposalTarget {
264 WorkspaceRoot,
265 Paths(Vec<String>),
266}
267
268#[derive(Clone, Debug, Serialize, Deserialize)]
269pub struct AgentTask {
270 pub id: String,
271 pub description: String,
272}
273
274#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
275pub enum AgentRole {
276 Planner,
277 Coder,
278 Repair,
279 Optimizer,
280}
281
282#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
283pub enum CoordinationPrimitive {
284 Sequential,
285 Parallel,
286 Conditional,
287}
288
289#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
290pub struct CoordinationTask {
291 pub id: String,
292 pub role: AgentRole,
293 pub description: String,
294 pub depends_on: Vec<String>,
295}
296
297#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
298pub struct CoordinationMessage {
299 pub from_role: AgentRole,
300 pub to_role: AgentRole,
301 pub task_id: String,
302 pub content: String,
303}
304
305#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
306pub struct CoordinationPlan {
307 pub root_goal: String,
308 pub primitive: CoordinationPrimitive,
309 pub tasks: Vec<CoordinationTask>,
310 pub timeout_ms: u64,
311 pub max_retries: u32,
312}
313
314#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
315pub struct CoordinationResult {
316 pub completed_tasks: Vec<String>,
317 pub failed_tasks: Vec<String>,
318 pub messages: Vec<CoordinationMessage>,
319 pub summary: String,
320}
321
322#[derive(Clone, Debug, Serialize, Deserialize)]
323pub struct MutationProposal {
324 pub intent: String,
325 pub files: Vec<String>,
326 pub expected_effect: String,
327}
328
329#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
330pub struct ExecutionFeedback {
331 pub accepted: bool,
332 pub asset_state: Option<String>,
333 pub summary: String,
334}
335
336#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
337pub enum ReplayPlannerDirective {
338 SkipPlanner,
339 PlanFallback,
340}
341
342#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)]
343#[serde(rename_all = "snake_case")]
344pub enum ReplayFallbackReasonCode {
345 NoCandidateAfterSelect,
346 ScoreBelowThreshold,
347 CandidateHasNoCapsule,
348 MutationPayloadMissing,
349 PatchApplyFailed,
350 ValidationFailed,
351 UnmappedFallbackReason,
352}
353
354#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)]
355#[serde(rename_all = "snake_case")]
356pub enum ReplayFallbackNextAction {
357 PlanFromScratch,
358 ValidateSignalsThenPlan,
359 RebuildCapsule,
360 RegenerateMutationPayload,
361 RebasePatchAndRetry,
362 RepairAndRevalidate,
363 EscalateFailClosed,
364}
365
366#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
367pub struct ReplayFallbackContract {
368 pub reason_code: ReplayFallbackReasonCode,
369 pub fallback_reason: String,
370 pub repair_hint: String,
371 pub next_action: ReplayFallbackNextAction,
372 pub confidence: u8,
374}
375
376pub fn infer_replay_fallback_reason_code(reason: &str) -> Option<ReplayFallbackReasonCode> {
377 let normalized = reason.trim().to_ascii_lowercase();
378 if normalized.is_empty() {
379 return None;
380 }
381 if normalized == "no_candidate_after_select" || normalized.contains("no matching gene") {
382 return Some(ReplayFallbackReasonCode::NoCandidateAfterSelect);
383 }
384 if normalized == "score_below_threshold" || normalized.contains("below replay threshold") {
385 return Some(ReplayFallbackReasonCode::ScoreBelowThreshold);
386 }
387 if normalized == "candidate_has_no_capsule" || normalized.contains("has no capsule") {
388 return Some(ReplayFallbackReasonCode::CandidateHasNoCapsule);
389 }
390 if normalized == "mutation_payload_missing" || normalized.contains("payload missing") {
391 return Some(ReplayFallbackReasonCode::MutationPayloadMissing);
392 }
393 if normalized == "patch_apply_failed" || normalized.contains("patch apply failed") {
394 return Some(ReplayFallbackReasonCode::PatchApplyFailed);
395 }
396 if normalized == "validation_failed" || normalized.contains("validation failed") {
397 return Some(ReplayFallbackReasonCode::ValidationFailed);
398 }
399 None
400}
401
402pub fn normalize_replay_fallback_contract(
403 planner_directive: &ReplayPlannerDirective,
404 fallback_reason: Option<&str>,
405 reason_code: Option<ReplayFallbackReasonCode>,
406 repair_hint: Option<&str>,
407 next_action: Option<ReplayFallbackNextAction>,
408 confidence: Option<u8>,
409) -> Option<ReplayFallbackContract> {
410 if !matches!(planner_directive, ReplayPlannerDirective::PlanFallback) {
411 return None;
412 }
413
414 let normalized_reason = normalize_optional_text(fallback_reason);
415 let normalized_repair_hint = normalize_optional_text(repair_hint);
416 let mut resolved_reason_code = reason_code
417 .or_else(|| {
418 normalized_reason
419 .as_deref()
420 .and_then(infer_replay_fallback_reason_code)
421 })
422 .unwrap_or(ReplayFallbackReasonCode::UnmappedFallbackReason);
423 let mut defaults = replay_fallback_defaults(&resolved_reason_code);
424
425 let mut force_fail_closed = false;
426 if let Some(provided_action) = next_action {
427 if provided_action != defaults.next_action {
428 resolved_reason_code = ReplayFallbackReasonCode::UnmappedFallbackReason;
429 defaults = replay_fallback_defaults(&resolved_reason_code);
430 force_fail_closed = true;
431 }
432 }
433
434 Some(ReplayFallbackContract {
435 reason_code: resolved_reason_code,
436 fallback_reason: normalized_reason.unwrap_or_else(|| defaults.fallback_reason.to_string()),
437 repair_hint: normalized_repair_hint.unwrap_or_else(|| defaults.repair_hint.to_string()),
438 next_action: if force_fail_closed {
439 defaults.next_action
440 } else {
441 next_action.unwrap_or(defaults.next_action)
442 },
443 confidence: if force_fail_closed {
444 defaults.confidence
445 } else {
446 confidence.unwrap_or(defaults.confidence).min(100)
447 },
448 })
449}
450
451#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
452pub struct ReplayFeedback {
453 pub used_capsule: bool,
454 pub capsule_id: Option<String>,
455 pub planner_directive: ReplayPlannerDirective,
456 pub reasoning_steps_avoided: u64,
457 pub fallback_reason: Option<String>,
458 pub reason_code: Option<ReplayFallbackReasonCode>,
459 pub repair_hint: Option<String>,
460 pub next_action: Option<ReplayFallbackNextAction>,
461 pub confidence: Option<u8>,
462 pub task_class_id: String,
463 pub task_label: String,
464 pub summary: String,
465}
466
467#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)]
468#[serde(rename_all = "snake_case")]
469pub enum MutationNeededFailureReasonCode {
470 PolicyDenied,
471 ValidationFailed,
472 UnsafePatch,
473 Timeout,
474 MutationPayloadMissing,
475 UnknownFailClosed,
476}
477
478#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)]
479#[serde(rename_all = "snake_case")]
480pub enum MutationNeededRecoveryAction {
481 NarrowScopeAndRetry,
482 RepairAndRevalidate,
483 ProduceSafePatch,
484 ReduceExecutionBudget,
485 RegenerateMutationPayload,
486 EscalateFailClosed,
487}
488
489#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
490pub struct MutationNeededFailureContract {
491 pub reason_code: MutationNeededFailureReasonCode,
492 pub failure_reason: String,
493 pub recovery_hint: String,
494 pub recovery_action: MutationNeededRecoveryAction,
495 pub fail_closed: bool,
496}
497
498pub fn infer_mutation_needed_failure_reason_code(
499 reason: &str,
500) -> Option<MutationNeededFailureReasonCode> {
501 let normalized = reason.trim().to_ascii_lowercase();
502 if normalized.is_empty() {
503 return None;
504 }
505 if normalized.contains("mutation payload missing") || normalized == "mutation_payload_missing" {
506 return Some(MutationNeededFailureReasonCode::MutationPayloadMissing);
507 }
508 if normalized.contains("command timed out") || normalized.contains(" timeout") {
509 return Some(MutationNeededFailureReasonCode::Timeout);
510 }
511 if normalized.contains("patch rejected")
512 || normalized.contains("patch apply failed")
513 || normalized.contains("target violation")
514 || normalized.contains("unsafe patch")
515 {
516 return Some(MutationNeededFailureReasonCode::UnsafePatch);
517 }
518 if normalized.contains("validation failed") {
519 return Some(MutationNeededFailureReasonCode::ValidationFailed);
520 }
521 if normalized.contains("command denied by policy")
522 || normalized.contains("rejected task")
523 || normalized.contains("unsupported task outside the bounded scope")
524 || normalized.contains("budget exceeds bounded policy")
525 {
526 return Some(MutationNeededFailureReasonCode::PolicyDenied);
527 }
528 None
529}
530
531pub fn normalize_mutation_needed_failure_contract(
532 failure_reason: Option<&str>,
533 reason_code: Option<MutationNeededFailureReasonCode>,
534) -> MutationNeededFailureContract {
535 let normalized_reason = normalize_optional_text(failure_reason);
536 let resolved_reason_code = reason_code
537 .or_else(|| {
538 normalized_reason
539 .as_deref()
540 .and_then(infer_mutation_needed_failure_reason_code)
541 })
542 .unwrap_or(MutationNeededFailureReasonCode::UnknownFailClosed);
543 let defaults = mutation_needed_failure_defaults(&resolved_reason_code);
544
545 MutationNeededFailureContract {
546 reason_code: resolved_reason_code,
547 failure_reason: normalized_reason.unwrap_or_else(|| defaults.failure_reason.to_string()),
548 recovery_hint: defaults.recovery_hint.to_string(),
549 recovery_action: defaults.recovery_action,
550 fail_closed: true,
551 }
552}
553
554fn normalize_optional_text(value: Option<&str>) -> Option<String> {
555 let trimmed = value?.trim();
556 if trimmed.is_empty() {
557 None
558 } else {
559 Some(trimmed.to_string())
560 }
561}
562
563#[derive(Clone, Copy)]
564struct ReplayFallbackDefaults {
565 fallback_reason: &'static str,
566 repair_hint: &'static str,
567 next_action: ReplayFallbackNextAction,
568 confidence: u8,
569}
570
571fn replay_fallback_defaults(reason_code: &ReplayFallbackReasonCode) -> ReplayFallbackDefaults {
572 match reason_code {
573 ReplayFallbackReasonCode::NoCandidateAfterSelect => ReplayFallbackDefaults {
574 fallback_reason: "no matching gene",
575 repair_hint:
576 "No reusable capsule matched deterministic signals; run planner for a minimal patch.",
577 next_action: ReplayFallbackNextAction::PlanFromScratch,
578 confidence: 92,
579 },
580 ReplayFallbackReasonCode::ScoreBelowThreshold => ReplayFallbackDefaults {
581 fallback_reason: "candidate score below replay threshold",
582 repair_hint:
583 "Best replay candidate is below threshold; validate task signals and re-plan.",
584 next_action: ReplayFallbackNextAction::ValidateSignalsThenPlan,
585 confidence: 86,
586 },
587 ReplayFallbackReasonCode::CandidateHasNoCapsule => ReplayFallbackDefaults {
588 fallback_reason: "candidate gene has no capsule",
589 repair_hint: "Matched gene has no executable capsule; rebuild capsule from planner output.",
590 next_action: ReplayFallbackNextAction::RebuildCapsule,
591 confidence: 80,
592 },
593 ReplayFallbackReasonCode::MutationPayloadMissing => ReplayFallbackDefaults {
594 fallback_reason: "mutation payload missing from store",
595 repair_hint:
596 "Mutation payload is missing; regenerate and persist a minimal mutation payload.",
597 next_action: ReplayFallbackNextAction::RegenerateMutationPayload,
598 confidence: 76,
599 },
600 ReplayFallbackReasonCode::PatchApplyFailed => ReplayFallbackDefaults {
601 fallback_reason: "replay patch apply failed",
602 repair_hint: "Replay patch cannot be applied cleanly; rebase patch and retry planning.",
603 next_action: ReplayFallbackNextAction::RebasePatchAndRetry,
604 confidence: 68,
605 },
606 ReplayFallbackReasonCode::ValidationFailed => ReplayFallbackDefaults {
607 fallback_reason: "replay validation failed",
608 repair_hint: "Replay validation failed; produce a repair mutation and re-run validation.",
609 next_action: ReplayFallbackNextAction::RepairAndRevalidate,
610 confidence: 64,
611 },
612 ReplayFallbackReasonCode::UnmappedFallbackReason => ReplayFallbackDefaults {
613 fallback_reason: "unmapped replay fallback reason",
614 repair_hint:
615 "Fallback reason is unmapped; fail closed and require explicit planner intervention.",
616 next_action: ReplayFallbackNextAction::EscalateFailClosed,
617 confidence: 0,
618 },
619 }
620}
621
622#[derive(Clone, Copy)]
623struct MutationNeededFailureDefaults {
624 failure_reason: &'static str,
625 recovery_hint: &'static str,
626 recovery_action: MutationNeededRecoveryAction,
627}
628
629fn mutation_needed_failure_defaults(
630 reason_code: &MutationNeededFailureReasonCode,
631) -> MutationNeededFailureDefaults {
632 match reason_code {
633 MutationNeededFailureReasonCode::PolicyDenied => MutationNeededFailureDefaults {
634 failure_reason: "mutation needed denied by bounded execution policy",
635 recovery_hint:
636 "Narrow changed scope to the approved docs boundary and re-run with explicit approval.",
637 recovery_action: MutationNeededRecoveryAction::NarrowScopeAndRetry,
638 },
639 MutationNeededFailureReasonCode::ValidationFailed => MutationNeededFailureDefaults {
640 failure_reason: "mutation needed validation failed",
641 recovery_hint:
642 "Repair mutation and re-run validation to produce a deterministic pass before capture.",
643 recovery_action: MutationNeededRecoveryAction::RepairAndRevalidate,
644 },
645 MutationNeededFailureReasonCode::UnsafePatch => MutationNeededFailureDefaults {
646 failure_reason: "mutation needed rejected unsafe patch",
647 recovery_hint:
648 "Generate a safer minimal diff confined to approved paths and verify patch applicability.",
649 recovery_action: MutationNeededRecoveryAction::ProduceSafePatch,
650 },
651 MutationNeededFailureReasonCode::Timeout => MutationNeededFailureDefaults {
652 failure_reason: "mutation needed execution timed out",
653 recovery_hint:
654 "Reduce execution budget or split the mutation into smaller steps before retrying.",
655 recovery_action: MutationNeededRecoveryAction::ReduceExecutionBudget,
656 },
657 MutationNeededFailureReasonCode::MutationPayloadMissing => MutationNeededFailureDefaults {
658 failure_reason: "mutation payload missing from store",
659 recovery_hint: "Regenerate and persist mutation payload before retrying mutation-needed.",
660 recovery_action: MutationNeededRecoveryAction::RegenerateMutationPayload,
661 },
662 MutationNeededFailureReasonCode::UnknownFailClosed => MutationNeededFailureDefaults {
663 failure_reason: "mutation needed failed with unmapped reason",
664 recovery_hint:
665 "Unknown failure class; fail closed and require explicit maintainer triage before retry.",
666 recovery_action: MutationNeededRecoveryAction::EscalateFailClosed,
667 },
668 }
669}
670
671#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
672pub enum BoundedTaskClass {
673 DocsSingleFile,
674 DocsMultiFile,
675}
676
677#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
678pub struct HumanApproval {
679 pub approved: bool,
680 pub approver: Option<String>,
681 pub note: Option<String>,
682}
683
684#[derive(Clone, Debug, Serialize, Deserialize)]
685pub struct SupervisedDevloopRequest {
686 pub task: AgentTask,
687 pub proposal: MutationProposal,
688 pub approval: HumanApproval,
689}
690
691#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
692pub enum SupervisedDevloopStatus {
693 AwaitingApproval,
694 RejectedByPolicy,
695 FailedClosed,
696 Executed,
697}
698
699#[derive(Clone, Debug, Serialize, Deserialize)]
700pub struct SupervisedDevloopOutcome {
701 pub task_id: String,
702 pub task_class: Option<BoundedTaskClass>,
703 pub status: SupervisedDevloopStatus,
704 pub execution_feedback: Option<ExecutionFeedback>,
705 #[serde(default, skip_serializing_if = "Option::is_none")]
706 pub failure_contract: Option<MutationNeededFailureContract>,
707 pub summary: String,
708}
709
710#[cfg(test)]
711mod tests {
712 use super::*;
713
714 fn handshake_request_with_versions(versions: &[&str]) -> A2aHandshakeRequest {
715 A2aHandshakeRequest {
716 agent_id: "agent-test".into(),
717 role: AgentRole::Planner,
718 capability_level: AgentCapabilityLevel::A2,
719 supported_protocols: versions
720 .iter()
721 .map(|version| A2aProtocol {
722 name: A2A_PROTOCOL_NAME.into(),
723 version: (*version).into(),
724 })
725 .collect(),
726 advertised_capabilities: vec![A2aCapability::Coordination],
727 }
728 }
729
730 #[test]
731 fn negotiate_supported_protocol_prefers_v1_when_available() {
732 let req = handshake_request_with_versions(&[A2A_PROTOCOL_VERSION, A2A_PROTOCOL_VERSION_V1]);
733 let negotiated = req
734 .negotiate_supported_protocol()
735 .expect("expected protocol negotiation success");
736 assert_eq!(negotiated.name, A2A_PROTOCOL_NAME);
737 assert_eq!(negotiated.version, A2A_PROTOCOL_VERSION_V1);
738 }
739
740 #[test]
741 fn negotiate_supported_protocol_falls_back_to_experimental() {
742 let req = handshake_request_with_versions(&[A2A_PROTOCOL_VERSION]);
743 let negotiated = req
744 .negotiate_supported_protocol()
745 .expect("expected protocol negotiation success");
746 assert_eq!(negotiated.version, A2A_PROTOCOL_VERSION);
747 }
748
749 #[test]
750 fn negotiate_supported_protocol_returns_none_without_overlap() {
751 let req = handshake_request_with_versions(&["0.0.1"]);
752 assert!(req.negotiate_supported_protocol().is_none());
753 }
754
755 #[test]
756 fn normalize_replay_fallback_contract_maps_known_reason() {
757 let contract = normalize_replay_fallback_contract(
758 &ReplayPlannerDirective::PlanFallback,
759 Some("no matching gene"),
760 None,
761 None,
762 None,
763 None,
764 )
765 .expect("contract should exist");
766
767 assert_eq!(
768 contract.reason_code,
769 ReplayFallbackReasonCode::NoCandidateAfterSelect
770 );
771 assert_eq!(
772 contract.next_action,
773 ReplayFallbackNextAction::PlanFromScratch
774 );
775 assert_eq!(contract.confidence, 92);
776 }
777
778 #[test]
779 fn normalize_replay_fallback_contract_fails_closed_for_unknown_reason() {
780 let contract = normalize_replay_fallback_contract(
781 &ReplayPlannerDirective::PlanFallback,
782 Some("something unexpected"),
783 None,
784 None,
785 None,
786 None,
787 )
788 .expect("contract should exist");
789
790 assert_eq!(
791 contract.reason_code,
792 ReplayFallbackReasonCode::UnmappedFallbackReason
793 );
794 assert_eq!(
795 contract.next_action,
796 ReplayFallbackNextAction::EscalateFailClosed
797 );
798 assert_eq!(contract.confidence, 0);
799 }
800
801 #[test]
802 fn normalize_replay_fallback_contract_rejects_conflicting_next_action() {
803 let contract = normalize_replay_fallback_contract(
804 &ReplayPlannerDirective::PlanFallback,
805 Some("replay validation failed"),
806 Some(ReplayFallbackReasonCode::ValidationFailed),
807 None,
808 Some(ReplayFallbackNextAction::PlanFromScratch),
809 Some(88),
810 )
811 .expect("contract should exist");
812
813 assert_eq!(
814 contract.reason_code,
815 ReplayFallbackReasonCode::UnmappedFallbackReason
816 );
817 assert_eq!(
818 contract.next_action,
819 ReplayFallbackNextAction::EscalateFailClosed
820 );
821 assert_eq!(contract.confidence, 0);
822 }
823
824 #[test]
825 fn normalize_mutation_needed_failure_contract_maps_policy_denied() {
826 let contract = normalize_mutation_needed_failure_contract(
827 Some("supervised devloop rejected task because it is outside bounded scope"),
828 None,
829 );
830
831 assert_eq!(
832 contract.reason_code,
833 MutationNeededFailureReasonCode::PolicyDenied
834 );
835 assert_eq!(
836 contract.recovery_action,
837 MutationNeededRecoveryAction::NarrowScopeAndRetry
838 );
839 assert!(contract.fail_closed);
840 }
841
842 #[test]
843 fn normalize_mutation_needed_failure_contract_maps_timeout() {
844 let contract = normalize_mutation_needed_failure_contract(
845 Some("command timed out: git apply --check patch.diff"),
846 None,
847 );
848
849 assert_eq!(
850 contract.reason_code,
851 MutationNeededFailureReasonCode::Timeout
852 );
853 assert_eq!(
854 contract.recovery_action,
855 MutationNeededRecoveryAction::ReduceExecutionBudget
856 );
857 assert!(contract.fail_closed);
858 }
859
860 #[test]
861 fn normalize_mutation_needed_failure_contract_fails_closed_for_unknown_reason() {
862 let contract =
863 normalize_mutation_needed_failure_contract(Some("unexpected runner panic"), None);
864
865 assert_eq!(
866 contract.reason_code,
867 MutationNeededFailureReasonCode::UnknownFailClosed
868 );
869 assert_eq!(
870 contract.recovery_action,
871 MutationNeededRecoveryAction::EscalateFailClosed
872 );
873 assert!(contract.fail_closed);
874 }
875}
876
877#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
879pub enum HubTrustTier {
880 Full,
882 ReadOnly,
884}
885
886#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq, Hash)]
888pub enum HubOperationClass {
889 Hello,
890 Fetch,
891 Publish,
892 Revoke,
893 TaskClaim,
894 TaskComplete,
895 WorkerRegister,
896 Recipe,
897 Session,
898 Dispute,
899 Swarm,
900}
901
902impl HubOperationClass {
903 pub fn is_read_only(&self) -> bool {
905 matches!(self, HubOperationClass::Hello | HubOperationClass::Fetch)
906 }
907}
908
909#[derive(Clone, Debug, Serialize, Deserialize)]
911pub struct HubProfile {
912 pub hub_id: String,
913 pub base_url: String,
914 pub trust_tier: HubTrustTier,
915 pub priority: u32,
917 pub health_url: Option<String>,
919}
920
921impl HubProfile {
922 pub fn allows_operation(&self, operation: &HubOperationClass) -> bool {
924 match &self.trust_tier {
925 HubTrustTier::Full => true,
926 HubTrustTier::ReadOnly => operation.is_read_only(),
927 }
928 }
929}
930
931#[derive(Clone, Debug)]
933pub struct HubSelectionPolicy {
934 pub allowed_tiers_for_operation: Vec<(HubOperationClass, Vec<HubTrustTier>)>,
936 pub default_allowed_tiers: Vec<HubTrustTier>,
938}
939
940impl Default for HubSelectionPolicy {
941 fn default() -> Self {
942 Self {
943 allowed_tiers_for_operation: vec![
944 (
945 HubOperationClass::Hello,
946 vec![HubTrustTier::Full, HubTrustTier::ReadOnly],
947 ),
948 (
949 HubOperationClass::Fetch,
950 vec![HubTrustTier::Full, HubTrustTier::ReadOnly],
951 ),
952 (HubOperationClass::Publish, vec![HubTrustTier::Full]),
954 (HubOperationClass::Revoke, vec![HubTrustTier::Full]),
955 (HubOperationClass::TaskClaim, vec![HubTrustTier::Full]),
956 (HubOperationClass::TaskComplete, vec![HubTrustTier::Full]),
957 (HubOperationClass::WorkerRegister, vec![HubTrustTier::Full]),
958 (HubOperationClass::Recipe, vec![HubTrustTier::Full]),
959 (HubOperationClass::Session, vec![HubTrustTier::Full]),
960 (HubOperationClass::Dispute, vec![HubTrustTier::Full]),
961 (HubOperationClass::Swarm, vec![HubTrustTier::Full]),
962 ],
963 default_allowed_tiers: vec![HubTrustTier::Full],
964 }
965 }
966}
967
968impl HubSelectionPolicy {
969 pub fn allowed_tiers(&self, operation: &HubOperationClass) -> &[HubTrustTier] {
971 self.allowed_tiers_for_operation
972 .iter()
973 .find(|(op, _)| op == operation)
974 .map(|(_, tiers)| tiers.as_slice())
975 .unwrap_or(&self.default_allowed_tiers)
976 }
977}