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