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 SelfEvolutionCandidateIntakeRequest {
679 pub issue_number: u64,
680 pub title: String,
681 pub body: String,
682 #[serde(default)]
683 pub labels: Vec<String>,
684 pub state: String,
685 #[serde(default)]
686 pub candidate_hint_paths: Vec<String>,
687}
688
689#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)]
690#[serde(rename_all = "snake_case")]
691pub enum SelfEvolutionSelectionReasonCode {
692 Accepted,
693 IssueClosed,
694 MissingEvolutionLabel,
695 MissingFeatureLabel,
696 ExcludedByLabel,
697 UnsupportedCandidateScope,
698 UnknownFailClosed,
699}
700
701#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
702pub struct SelfEvolutionSelectionDecision {
703 pub issue_number: u64,
704 pub selected: bool,
705 #[serde(default, skip_serializing_if = "Option::is_none")]
706 pub candidate_class: Option<BoundedTaskClass>,
707 pub summary: String,
708 #[serde(default, skip_serializing_if = "Option::is_none")]
709 pub reason_code: Option<SelfEvolutionSelectionReasonCode>,
710 #[serde(default, skip_serializing_if = "Option::is_none")]
711 pub failure_reason: Option<String>,
712 #[serde(default, skip_serializing_if = "Option::is_none")]
713 pub recovery_hint: Option<String>,
714 pub fail_closed: bool,
715}
716
717#[derive(Clone, Copy)]
718struct SelfEvolutionSelectionDefaults {
719 failure_reason: &'static str,
720 recovery_hint: &'static str,
721}
722
723fn self_evolution_selection_defaults(
724 reason_code: &SelfEvolutionSelectionReasonCode,
725) -> Option<SelfEvolutionSelectionDefaults> {
726 match reason_code {
727 SelfEvolutionSelectionReasonCode::Accepted => None,
728 SelfEvolutionSelectionReasonCode::IssueClosed => Some(SelfEvolutionSelectionDefaults {
729 failure_reason: "self-evolution candidate rejected because the issue is closed",
730 recovery_hint: "Reopen the issue or choose an active open issue before retrying selection.",
731 }),
732 SelfEvolutionSelectionReasonCode::MissingEvolutionLabel => {
733 Some(SelfEvolutionSelectionDefaults {
734 failure_reason: "self-evolution candidate rejected because the issue is missing area/evolution",
735 recovery_hint:
736 "Add the area/evolution label or choose an issue already scoped to self-evolution.",
737 })
738 }
739 SelfEvolutionSelectionReasonCode::MissingFeatureLabel => {
740 Some(SelfEvolutionSelectionDefaults {
741 failure_reason: "self-evolution candidate rejected because the issue is missing type/feature",
742 recovery_hint:
743 "Add the type/feature label or narrow the issue to a bounded feature slice before retrying.",
744 })
745 }
746 SelfEvolutionSelectionReasonCode::ExcludedByLabel => Some(SelfEvolutionSelectionDefaults {
747 failure_reason: "self-evolution candidate rejected by an excluded issue label",
748 recovery_hint:
749 "Remove the excluded label or choose a non-duplicate, non-invalid, actionable issue.",
750 }),
751 SelfEvolutionSelectionReasonCode::UnsupportedCandidateScope => {
752 Some(SelfEvolutionSelectionDefaults {
753 failure_reason:
754 "self-evolution candidate rejected because the hinted file scope is outside the bounded docs policy",
755 recovery_hint:
756 "Narrow candidate paths to the approved docs/*.md boundary before retrying selection.",
757 })
758 }
759 SelfEvolutionSelectionReasonCode::UnknownFailClosed => Some(SelfEvolutionSelectionDefaults {
760 failure_reason: "self-evolution candidate failed with an unmapped selection reason",
761 recovery_hint: "Unknown selection failure; fail closed and require explicit maintainer triage before retry.",
762 }),
763 }
764}
765
766pub fn accept_self_evolution_selection_decision(
767 issue_number: u64,
768 candidate_class: BoundedTaskClass,
769 summary: Option<&str>,
770) -> SelfEvolutionSelectionDecision {
771 let summary = normalize_optional_text(summary).unwrap_or_else(|| {
772 format!("selected GitHub issue #{issue_number} as a bounded self-evolution candidate")
773 });
774 SelfEvolutionSelectionDecision {
775 issue_number,
776 selected: true,
777 candidate_class: Some(candidate_class),
778 summary,
779 reason_code: Some(SelfEvolutionSelectionReasonCode::Accepted),
780 failure_reason: None,
781 recovery_hint: None,
782 fail_closed: false,
783 }
784}
785
786pub fn reject_self_evolution_selection_decision(
787 issue_number: u64,
788 reason_code: SelfEvolutionSelectionReasonCode,
789 failure_reason: Option<&str>,
790 summary: Option<&str>,
791) -> SelfEvolutionSelectionDecision {
792 let defaults = self_evolution_selection_defaults(&reason_code)
793 .unwrap_or(SelfEvolutionSelectionDefaults {
794 failure_reason: "self-evolution candidate rejected",
795 recovery_hint:
796 "Review candidate selection inputs and retry within the bounded self-evolution policy.",
797 });
798 let failure_reason = normalize_optional_text(failure_reason)
799 .unwrap_or_else(|| defaults.failure_reason.to_string());
800 let reason_code_key = match reason_code {
801 SelfEvolutionSelectionReasonCode::Accepted => "accepted",
802 SelfEvolutionSelectionReasonCode::IssueClosed => "issue_closed",
803 SelfEvolutionSelectionReasonCode::MissingEvolutionLabel => "missing_evolution_label",
804 SelfEvolutionSelectionReasonCode::MissingFeatureLabel => "missing_feature_label",
805 SelfEvolutionSelectionReasonCode::ExcludedByLabel => "excluded_by_label",
806 SelfEvolutionSelectionReasonCode::UnsupportedCandidateScope => {
807 "unsupported_candidate_scope"
808 }
809 SelfEvolutionSelectionReasonCode::UnknownFailClosed => "unknown_fail_closed",
810 };
811 let summary = normalize_optional_text(summary).unwrap_or_else(|| {
812 format!(
813 "rejected GitHub issue #{issue_number} as a self-evolution candidate [{reason_code_key}]"
814 )
815 });
816
817 SelfEvolutionSelectionDecision {
818 issue_number,
819 selected: false,
820 candidate_class: None,
821 summary,
822 reason_code: Some(reason_code),
823 failure_reason: Some(failure_reason),
824 recovery_hint: Some(defaults.recovery_hint.to_string()),
825 fail_closed: true,
826 }
827}
828
829#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
830pub struct HumanApproval {
831 pub approved: bool,
832 pub approver: Option<String>,
833 pub note: Option<String>,
834}
835
836#[derive(Clone, Debug, Serialize, Deserialize)]
837pub struct SupervisedDevloopRequest {
838 pub task: AgentTask,
839 pub proposal: MutationProposal,
840 pub approval: HumanApproval,
841}
842
843#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
844pub enum SupervisedDevloopStatus {
845 AwaitingApproval,
846 RejectedByPolicy,
847 FailedClosed,
848 Executed,
849}
850
851#[derive(Clone, Debug, Serialize, Deserialize)]
852pub struct SupervisedDevloopOutcome {
853 pub task_id: String,
854 pub task_class: Option<BoundedTaskClass>,
855 pub status: SupervisedDevloopStatus,
856 pub execution_feedback: Option<ExecutionFeedback>,
857 #[serde(default, skip_serializing_if = "Option::is_none")]
858 pub failure_contract: Option<MutationNeededFailureContract>,
859 pub summary: String,
860}
861
862#[cfg(test)]
863mod tests {
864 use super::*;
865
866 fn handshake_request_with_versions(versions: &[&str]) -> A2aHandshakeRequest {
867 A2aHandshakeRequest {
868 agent_id: "agent-test".into(),
869 role: AgentRole::Planner,
870 capability_level: AgentCapabilityLevel::A2,
871 supported_protocols: versions
872 .iter()
873 .map(|version| A2aProtocol {
874 name: A2A_PROTOCOL_NAME.into(),
875 version: (*version).into(),
876 })
877 .collect(),
878 advertised_capabilities: vec![A2aCapability::Coordination],
879 }
880 }
881
882 #[test]
883 fn negotiate_supported_protocol_prefers_v1_when_available() {
884 let req = handshake_request_with_versions(&[A2A_PROTOCOL_VERSION, A2A_PROTOCOL_VERSION_V1]);
885 let negotiated = req
886 .negotiate_supported_protocol()
887 .expect("expected protocol negotiation success");
888 assert_eq!(negotiated.name, A2A_PROTOCOL_NAME);
889 assert_eq!(negotiated.version, A2A_PROTOCOL_VERSION_V1);
890 }
891
892 #[test]
893 fn negotiate_supported_protocol_falls_back_to_experimental() {
894 let req = handshake_request_with_versions(&[A2A_PROTOCOL_VERSION]);
895 let negotiated = req
896 .negotiate_supported_protocol()
897 .expect("expected protocol negotiation success");
898 assert_eq!(negotiated.version, A2A_PROTOCOL_VERSION);
899 }
900
901 #[test]
902 fn negotiate_supported_protocol_returns_none_without_overlap() {
903 let req = handshake_request_with_versions(&["0.0.1"]);
904 assert!(req.negotiate_supported_protocol().is_none());
905 }
906
907 #[test]
908 fn normalize_replay_fallback_contract_maps_known_reason() {
909 let contract = normalize_replay_fallback_contract(
910 &ReplayPlannerDirective::PlanFallback,
911 Some("no matching gene"),
912 None,
913 None,
914 None,
915 None,
916 )
917 .expect("contract should exist");
918
919 assert_eq!(
920 contract.reason_code,
921 ReplayFallbackReasonCode::NoCandidateAfterSelect
922 );
923 assert_eq!(
924 contract.next_action,
925 ReplayFallbackNextAction::PlanFromScratch
926 );
927 assert_eq!(contract.confidence, 92);
928 }
929
930 #[test]
931 fn normalize_replay_fallback_contract_fails_closed_for_unknown_reason() {
932 let contract = normalize_replay_fallback_contract(
933 &ReplayPlannerDirective::PlanFallback,
934 Some("something unexpected"),
935 None,
936 None,
937 None,
938 None,
939 )
940 .expect("contract should exist");
941
942 assert_eq!(
943 contract.reason_code,
944 ReplayFallbackReasonCode::UnmappedFallbackReason
945 );
946 assert_eq!(
947 contract.next_action,
948 ReplayFallbackNextAction::EscalateFailClosed
949 );
950 assert_eq!(contract.confidence, 0);
951 }
952
953 #[test]
954 fn normalize_replay_fallback_contract_rejects_conflicting_next_action() {
955 let contract = normalize_replay_fallback_contract(
956 &ReplayPlannerDirective::PlanFallback,
957 Some("replay validation failed"),
958 Some(ReplayFallbackReasonCode::ValidationFailed),
959 None,
960 Some(ReplayFallbackNextAction::PlanFromScratch),
961 Some(88),
962 )
963 .expect("contract should exist");
964
965 assert_eq!(
966 contract.reason_code,
967 ReplayFallbackReasonCode::UnmappedFallbackReason
968 );
969 assert_eq!(
970 contract.next_action,
971 ReplayFallbackNextAction::EscalateFailClosed
972 );
973 assert_eq!(contract.confidence, 0);
974 }
975
976 #[test]
977 fn normalize_mutation_needed_failure_contract_maps_policy_denied() {
978 let contract = normalize_mutation_needed_failure_contract(
979 Some("supervised devloop rejected task because it is outside bounded scope"),
980 None,
981 );
982
983 assert_eq!(
984 contract.reason_code,
985 MutationNeededFailureReasonCode::PolicyDenied
986 );
987 assert_eq!(
988 contract.recovery_action,
989 MutationNeededRecoveryAction::NarrowScopeAndRetry
990 );
991 assert!(contract.fail_closed);
992 }
993
994 #[test]
995 fn normalize_mutation_needed_failure_contract_maps_timeout() {
996 let contract = normalize_mutation_needed_failure_contract(
997 Some("command timed out: git apply --check patch.diff"),
998 None,
999 );
1000
1001 assert_eq!(
1002 contract.reason_code,
1003 MutationNeededFailureReasonCode::Timeout
1004 );
1005 assert_eq!(
1006 contract.recovery_action,
1007 MutationNeededRecoveryAction::ReduceExecutionBudget
1008 );
1009 assert!(contract.fail_closed);
1010 }
1011
1012 #[test]
1013 fn normalize_mutation_needed_failure_contract_fails_closed_for_unknown_reason() {
1014 let contract =
1015 normalize_mutation_needed_failure_contract(Some("unexpected runner panic"), None);
1016
1017 assert_eq!(
1018 contract.reason_code,
1019 MutationNeededFailureReasonCode::UnknownFailClosed
1020 );
1021 assert_eq!(
1022 contract.recovery_action,
1023 MutationNeededRecoveryAction::EscalateFailClosed
1024 );
1025 assert!(contract.fail_closed);
1026 }
1027
1028 #[test]
1029 fn reject_self_evolution_selection_decision_maps_closed_issue_defaults() {
1030 let decision = reject_self_evolution_selection_decision(
1031 234,
1032 SelfEvolutionSelectionReasonCode::IssueClosed,
1033 None,
1034 None,
1035 );
1036
1037 assert!(!decision.selected);
1038 assert_eq!(decision.issue_number, 234);
1039 assert_eq!(
1040 decision.reason_code,
1041 Some(SelfEvolutionSelectionReasonCode::IssueClosed)
1042 );
1043 assert!(decision.fail_closed);
1044 assert!(decision
1045 .failure_reason
1046 .as_deref()
1047 .is_some_and(|reason| reason.contains("closed")));
1048 assert!(decision.recovery_hint.is_some());
1049 }
1050
1051 #[test]
1052 fn accept_self_evolution_selection_decision_marks_candidate_selected() {
1053 let decision =
1054 accept_self_evolution_selection_decision(235, BoundedTaskClass::DocsSingleFile, None);
1055
1056 assert!(decision.selected);
1057 assert_eq!(decision.issue_number, 235);
1058 assert_eq!(
1059 decision.candidate_class,
1060 Some(BoundedTaskClass::DocsSingleFile)
1061 );
1062 assert_eq!(
1063 decision.reason_code,
1064 Some(SelfEvolutionSelectionReasonCode::Accepted)
1065 );
1066 assert!(!decision.fail_closed);
1067 assert_eq!(decision.failure_reason, None);
1068 assert_eq!(decision.recovery_hint, None);
1069 }
1070}
1071
1072#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
1074pub enum HubTrustTier {
1075 Full,
1077 ReadOnly,
1079}
1080
1081#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq, Hash)]
1083pub enum HubOperationClass {
1084 Hello,
1085 Fetch,
1086 Publish,
1087 Revoke,
1088 TaskClaim,
1089 TaskComplete,
1090 WorkerRegister,
1091 Recipe,
1092 Session,
1093 Dispute,
1094 Swarm,
1095}
1096
1097impl HubOperationClass {
1098 pub fn is_read_only(&self) -> bool {
1100 matches!(self, HubOperationClass::Hello | HubOperationClass::Fetch)
1101 }
1102}
1103
1104#[derive(Clone, Debug, Serialize, Deserialize)]
1106pub struct HubProfile {
1107 pub hub_id: String,
1108 pub base_url: String,
1109 pub trust_tier: HubTrustTier,
1110 pub priority: u32,
1112 pub health_url: Option<String>,
1114}
1115
1116impl HubProfile {
1117 pub fn allows_operation(&self, operation: &HubOperationClass) -> bool {
1119 match &self.trust_tier {
1120 HubTrustTier::Full => true,
1121 HubTrustTier::ReadOnly => operation.is_read_only(),
1122 }
1123 }
1124}
1125
1126#[derive(Clone, Debug)]
1128pub struct HubSelectionPolicy {
1129 pub allowed_tiers_for_operation: Vec<(HubOperationClass, Vec<HubTrustTier>)>,
1131 pub default_allowed_tiers: Vec<HubTrustTier>,
1133}
1134
1135impl Default for HubSelectionPolicy {
1136 fn default() -> Self {
1137 Self {
1138 allowed_tiers_for_operation: vec![
1139 (
1140 HubOperationClass::Hello,
1141 vec![HubTrustTier::Full, HubTrustTier::ReadOnly],
1142 ),
1143 (
1144 HubOperationClass::Fetch,
1145 vec![HubTrustTier::Full, HubTrustTier::ReadOnly],
1146 ),
1147 (HubOperationClass::Publish, vec![HubTrustTier::Full]),
1149 (HubOperationClass::Revoke, vec![HubTrustTier::Full]),
1150 (HubOperationClass::TaskClaim, vec![HubTrustTier::Full]),
1151 (HubOperationClass::TaskComplete, vec![HubTrustTier::Full]),
1152 (HubOperationClass::WorkerRegister, vec![HubTrustTier::Full]),
1153 (HubOperationClass::Recipe, vec![HubTrustTier::Full]),
1154 (HubOperationClass::Session, vec![HubTrustTier::Full]),
1155 (HubOperationClass::Dispute, vec![HubTrustTier::Full]),
1156 (HubOperationClass::Swarm, vec![HubTrustTier::Full]),
1157 ],
1158 default_allowed_tiers: vec![HubTrustTier::Full],
1159 }
1160 }
1161}
1162
1163impl HubSelectionPolicy {
1164 pub fn allowed_tiers(&self, operation: &HubOperationClass) -> &[HubTrustTier] {
1166 self.allowed_tiers_for_operation
1167 .iter()
1168 .find(|(op, _)| op == operation)
1169 .map(|(_, tiers)| tiers.as_slice())
1170 .unwrap_or(&self.default_allowed_tiers)
1171 }
1172}