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}
675
676#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
677pub struct HumanApproval {
678 pub approved: bool,
679 pub approver: Option<String>,
680 pub note: Option<String>,
681}
682
683#[derive(Clone, Debug, Serialize, Deserialize)]
684pub struct SupervisedDevloopRequest {
685 pub task: AgentTask,
686 pub proposal: MutationProposal,
687 pub approval: HumanApproval,
688}
689
690#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
691pub enum SupervisedDevloopStatus {
692 AwaitingApproval,
693 RejectedByPolicy,
694 FailedClosed,
695 Executed,
696}
697
698#[derive(Clone, Debug, Serialize, Deserialize)]
699pub struct SupervisedDevloopOutcome {
700 pub task_id: String,
701 pub task_class: Option<BoundedTaskClass>,
702 pub status: SupervisedDevloopStatus,
703 pub execution_feedback: Option<ExecutionFeedback>,
704 #[serde(default, skip_serializing_if = "Option::is_none")]
705 pub failure_contract: Option<MutationNeededFailureContract>,
706 pub summary: String,
707}
708
709#[cfg(test)]
710mod tests {
711 use super::*;
712
713 fn handshake_request_with_versions(versions: &[&str]) -> A2aHandshakeRequest {
714 A2aHandshakeRequest {
715 agent_id: "agent-test".into(),
716 role: AgentRole::Planner,
717 capability_level: AgentCapabilityLevel::A2,
718 supported_protocols: versions
719 .iter()
720 .map(|version| A2aProtocol {
721 name: A2A_PROTOCOL_NAME.into(),
722 version: (*version).into(),
723 })
724 .collect(),
725 advertised_capabilities: vec![A2aCapability::Coordination],
726 }
727 }
728
729 #[test]
730 fn negotiate_supported_protocol_prefers_v1_when_available() {
731 let req = handshake_request_with_versions(&[A2A_PROTOCOL_VERSION, A2A_PROTOCOL_VERSION_V1]);
732 let negotiated = req
733 .negotiate_supported_protocol()
734 .expect("expected protocol negotiation success");
735 assert_eq!(negotiated.name, A2A_PROTOCOL_NAME);
736 assert_eq!(negotiated.version, A2A_PROTOCOL_VERSION_V1);
737 }
738
739 #[test]
740 fn negotiate_supported_protocol_falls_back_to_experimental() {
741 let req = handshake_request_with_versions(&[A2A_PROTOCOL_VERSION]);
742 let negotiated = req
743 .negotiate_supported_protocol()
744 .expect("expected protocol negotiation success");
745 assert_eq!(negotiated.version, A2A_PROTOCOL_VERSION);
746 }
747
748 #[test]
749 fn negotiate_supported_protocol_returns_none_without_overlap() {
750 let req = handshake_request_with_versions(&["0.0.1"]);
751 assert!(req.negotiate_supported_protocol().is_none());
752 }
753
754 #[test]
755 fn normalize_replay_fallback_contract_maps_known_reason() {
756 let contract = normalize_replay_fallback_contract(
757 &ReplayPlannerDirective::PlanFallback,
758 Some("no matching gene"),
759 None,
760 None,
761 None,
762 None,
763 )
764 .expect("contract should exist");
765
766 assert_eq!(
767 contract.reason_code,
768 ReplayFallbackReasonCode::NoCandidateAfterSelect
769 );
770 assert_eq!(
771 contract.next_action,
772 ReplayFallbackNextAction::PlanFromScratch
773 );
774 assert_eq!(contract.confidence, 92);
775 }
776
777 #[test]
778 fn normalize_replay_fallback_contract_fails_closed_for_unknown_reason() {
779 let contract = normalize_replay_fallback_contract(
780 &ReplayPlannerDirective::PlanFallback,
781 Some("something unexpected"),
782 None,
783 None,
784 None,
785 None,
786 )
787 .expect("contract should exist");
788
789 assert_eq!(
790 contract.reason_code,
791 ReplayFallbackReasonCode::UnmappedFallbackReason
792 );
793 assert_eq!(
794 contract.next_action,
795 ReplayFallbackNextAction::EscalateFailClosed
796 );
797 assert_eq!(contract.confidence, 0);
798 }
799
800 #[test]
801 fn normalize_replay_fallback_contract_rejects_conflicting_next_action() {
802 let contract = normalize_replay_fallback_contract(
803 &ReplayPlannerDirective::PlanFallback,
804 Some("replay validation failed"),
805 Some(ReplayFallbackReasonCode::ValidationFailed),
806 None,
807 Some(ReplayFallbackNextAction::PlanFromScratch),
808 Some(88),
809 )
810 .expect("contract should exist");
811
812 assert_eq!(
813 contract.reason_code,
814 ReplayFallbackReasonCode::UnmappedFallbackReason
815 );
816 assert_eq!(
817 contract.next_action,
818 ReplayFallbackNextAction::EscalateFailClosed
819 );
820 assert_eq!(contract.confidence, 0);
821 }
822
823 #[test]
824 fn normalize_mutation_needed_failure_contract_maps_policy_denied() {
825 let contract = normalize_mutation_needed_failure_contract(
826 Some("supervised devloop rejected task because it is outside bounded scope"),
827 None,
828 );
829
830 assert_eq!(
831 contract.reason_code,
832 MutationNeededFailureReasonCode::PolicyDenied
833 );
834 assert_eq!(
835 contract.recovery_action,
836 MutationNeededRecoveryAction::NarrowScopeAndRetry
837 );
838 assert!(contract.fail_closed);
839 }
840
841 #[test]
842 fn normalize_mutation_needed_failure_contract_maps_timeout() {
843 let contract = normalize_mutation_needed_failure_contract(
844 Some("command timed out: git apply --check patch.diff"),
845 None,
846 );
847
848 assert_eq!(
849 contract.reason_code,
850 MutationNeededFailureReasonCode::Timeout
851 );
852 assert_eq!(
853 contract.recovery_action,
854 MutationNeededRecoveryAction::ReduceExecutionBudget
855 );
856 assert!(contract.fail_closed);
857 }
858
859 #[test]
860 fn normalize_mutation_needed_failure_contract_fails_closed_for_unknown_reason() {
861 let contract =
862 normalize_mutation_needed_failure_contract(Some("unexpected runner panic"), None);
863
864 assert_eq!(
865 contract.reason_code,
866 MutationNeededFailureReasonCode::UnknownFailClosed
867 );
868 assert_eq!(
869 contract.recovery_action,
870 MutationNeededRecoveryAction::EscalateFailClosed
871 );
872 assert!(contract.fail_closed);
873 }
874}
875
876#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
878pub enum HubTrustTier {
879 Full,
881 ReadOnly,
883}
884
885#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq, Hash)]
887pub enum HubOperationClass {
888 Hello,
889 Fetch,
890 Publish,
891 Revoke,
892 TaskClaim,
893 TaskComplete,
894 WorkerRegister,
895 Recipe,
896 Session,
897 Dispute,
898 Swarm,
899}
900
901impl HubOperationClass {
902 pub fn is_read_only(&self) -> bool {
904 matches!(self, HubOperationClass::Hello | HubOperationClass::Fetch)
905 }
906}
907
908#[derive(Clone, Debug, Serialize, Deserialize)]
910pub struct HubProfile {
911 pub hub_id: String,
912 pub base_url: String,
913 pub trust_tier: HubTrustTier,
914 pub priority: u32,
916 pub health_url: Option<String>,
918}
919
920impl HubProfile {
921 pub fn allows_operation(&self, operation: &HubOperationClass) -> bool {
923 match &self.trust_tier {
924 HubTrustTier::Full => true,
925 HubTrustTier::ReadOnly => operation.is_read_only(),
926 }
927 }
928}
929
930#[derive(Clone, Debug)]
932pub struct HubSelectionPolicy {
933 pub allowed_tiers_for_operation: Vec<(HubOperationClass, Vec<HubTrustTier>)>,
935 pub default_allowed_tiers: Vec<HubTrustTier>,
937}
938
939impl Default for HubSelectionPolicy {
940 fn default() -> Self {
941 Self {
942 allowed_tiers_for_operation: vec![
943 (
944 HubOperationClass::Hello,
945 vec![HubTrustTier::Full, HubTrustTier::ReadOnly],
946 ),
947 (
948 HubOperationClass::Fetch,
949 vec![HubTrustTier::Full, HubTrustTier::ReadOnly],
950 ),
951 (HubOperationClass::Publish, vec![HubTrustTier::Full]),
953 (HubOperationClass::Revoke, vec![HubTrustTier::Full]),
954 (HubOperationClass::TaskClaim, vec![HubTrustTier::Full]),
955 (HubOperationClass::TaskComplete, vec![HubTrustTier::Full]),
956 (HubOperationClass::WorkerRegister, vec![HubTrustTier::Full]),
957 (HubOperationClass::Recipe, vec![HubTrustTier::Full]),
958 (HubOperationClass::Session, vec![HubTrustTier::Full]),
959 (HubOperationClass::Dispute, vec![HubTrustTier::Full]),
960 (HubOperationClass::Swarm, vec![HubTrustTier::Full]),
961 ],
962 default_allowed_tiers: vec![HubTrustTier::Full],
963 }
964 }
965}
966
967impl HubSelectionPolicy {
968 pub fn allowed_tiers(&self, operation: &HubOperationClass) -> &[HubTrustTier] {
970 self.allowed_tiers_for_operation
971 .iter()
972 .find(|(op, _)| op == operation)
973 .map(|(_, tiers)| tiers.as_slice())
974 .unwrap_or(&self.default_allowed_tiers)
975 }
976}