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 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/// Hub trust tier - defines operational permissions for a Hub
1073#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
1074pub enum HubTrustTier {
1075    /// Full trust - allows all operations (internal/private Hub)
1076    Full,
1077    /// Read-only - allows only read operations (public Hub)
1078    ReadOnly,
1079}
1080
1081/// Hub operation class - classifies the type of A2A operation
1082#[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    /// Returns true if the operation is read-only (allowed for ReadOnly hubs)
1099    pub fn is_read_only(&self) -> bool {
1100        matches!(self, HubOperationClass::Hello | HubOperationClass::Fetch)
1101    }
1102}
1103
1104/// Hub profile - describes a Hub's capabilities and configuration
1105#[derive(Clone, Debug, Serialize, Deserialize)]
1106pub struct HubProfile {
1107    pub hub_id: String,
1108    pub base_url: String,
1109    pub trust_tier: HubTrustTier,
1110    /// Priority for hub selection (higher = preferred)
1111    pub priority: u32,
1112    /// Optional health check endpoint
1113    pub health_url: Option<String>,
1114}
1115
1116impl HubProfile {
1117    /// Check if this hub allows the given operation class
1118    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/// Hub selection policy - defines how to choose between multiple hubs
1127#[derive(Clone, Debug)]
1128pub struct HubSelectionPolicy {
1129    /// Map operation class to allowed trust tiers
1130    pub allowed_tiers_for_operation: Vec<(HubOperationClass, Vec<HubTrustTier>)>,
1131    /// Default trust tiers if no specific mapping
1132    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                // All write operations require Full trust
1148                (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    /// Get allowed trust tiers for a given operation
1165    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}