Skip to main content

oris_agent_contract/
lib.rs

1//! Proposal-only runtime contract for external agents.
2
3use 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    /// Confidence score in [0, 100].
373    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/// Hub trust tier - defines operational permissions for a Hub
877#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
878pub enum HubTrustTier {
879    /// Full trust - allows all operations (internal/private Hub)
880    Full,
881    /// Read-only - allows only read operations (public Hub)
882    ReadOnly,
883}
884
885/// Hub operation class - classifies the type of A2A operation
886#[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    /// Returns true if the operation is read-only (allowed for ReadOnly hubs)
903    pub fn is_read_only(&self) -> bool {
904        matches!(self, HubOperationClass::Hello | HubOperationClass::Fetch)
905    }
906}
907
908/// Hub profile - describes a Hub's capabilities and configuration
909#[derive(Clone, Debug, Serialize, Deserialize)]
910pub struct HubProfile {
911    pub hub_id: String,
912    pub base_url: String,
913    pub trust_tier: HubTrustTier,
914    /// Priority for hub selection (higher = preferred)
915    pub priority: u32,
916    /// Optional health check endpoint
917    pub health_url: Option<String>,
918}
919
920impl HubProfile {
921    /// Check if this hub allows the given operation class
922    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/// Hub selection policy - defines how to choose between multiple hubs
931#[derive(Clone, Debug)]
932pub struct HubSelectionPolicy {
933    /// Map operation class to allowed trust tiers
934    pub allowed_tiers_for_operation: Vec<(HubOperationClass, Vec<HubTrustTier>)>,
935    /// Default trust tiers if no specific mapping
936    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                // All write operations require Full trust
952                (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    /// Get allowed trust tiers for a given operation
969    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}