agent_hypervisor 3.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- agent_hypervisor-3.1.0.dist-info/METADATA +824 -0
- agent_hypervisor-3.1.0.dist-info/RECORD +60 -0
- agent_hypervisor-3.1.0.dist-info/WHEEL +4 -0
- agent_hypervisor-3.1.0.dist-info/entry_points.txt +2 -0
- agent_hypervisor-3.1.0.dist-info/licenses/LICENSE +21 -0
- hypervisor/__init__.py +160 -0
- hypervisor/api/__init__.py +7 -0
- hypervisor/api/models.py +285 -0
- hypervisor/api/server.py +742 -0
- hypervisor/audit/__init__.py +4 -0
- hypervisor/audit/commitment.py +76 -0
- hypervisor/audit/delta.py +135 -0
- hypervisor/audit/gc.py +99 -0
- hypervisor/cli/__init__.py +3 -0
- hypervisor/cli/formatters.py +99 -0
- hypervisor/cli/session_commands.py +200 -0
- hypervisor/constants.py +106 -0
- hypervisor/core.py +352 -0
- hypervisor/integrations/__init__.py +10 -0
- hypervisor/integrations/iatp_adapter.py +142 -0
- hypervisor/integrations/nexus_adapter.py +108 -0
- hypervisor/integrations/verification_adapter.py +122 -0
- hypervisor/liability/__init__.py +142 -0
- hypervisor/liability/attribution.py +86 -0
- hypervisor/liability/ledger.py +121 -0
- hypervisor/liability/quarantine.py +119 -0
- hypervisor/liability/slashing.py +80 -0
- hypervisor/liability/vouching.py +134 -0
- hypervisor/models.py +277 -0
- hypervisor/observability/__init__.py +27 -0
- hypervisor/observability/causal_trace.py +70 -0
- hypervisor/observability/event_bus.py +222 -0
- hypervisor/observability/prometheus_collector.py +248 -0
- hypervisor/observability/saga_span_exporter.py +341 -0
- hypervisor/providers.py +121 -0
- hypervisor/py.typed +0 -0
- hypervisor/reversibility/__init__.py +3 -0
- hypervisor/reversibility/registry.py +108 -0
- hypervisor/rings/__init__.py +21 -0
- hypervisor/rings/breach_detector.py +200 -0
- hypervisor/rings/classifier.py +78 -0
- hypervisor/rings/elevation.py +219 -0
- hypervisor/rings/enforcer.py +97 -0
- hypervisor/saga/__init__.py +22 -0
- hypervisor/saga/checkpoint.py +110 -0
- hypervisor/saga/dsl.py +190 -0
- hypervisor/saga/fan_out.py +126 -0
- hypervisor/saga/orchestrator.py +229 -0
- hypervisor/saga/schema.py +244 -0
- hypervisor/saga/state_machine.py +157 -0
- hypervisor/security/__init__.py +13 -0
- hypervisor/security/kill_switch.py +200 -0
- hypervisor/security/rate_limiter.py +190 -0
- hypervisor/session/__init__.py +194 -0
- hypervisor/session/intent_locks.py +118 -0
- hypervisor/session/isolation.py +37 -0
- hypervisor/session/sso.py +169 -0
- hypervisor/session/vector_clock.py +118 -0
- hypervisor/verification/__init__.py +3 -0
- hypervisor/verification/history.py +173 -0
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
# Copyright (c) Microsoft Corporation.
|
|
2
|
+
# Licensed under the MIT License.
|
|
3
|
+
# Public Preview — basic implementation
|
|
4
|
+
"""
|
|
5
|
+
Collateral Penalty Engine — stub implementation.
|
|
6
|
+
|
|
7
|
+
Public Preview: penalty is not enforced. Penalty calls are logged only.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import uuid
|
|
13
|
+
from dataclasses import dataclass, field
|
|
14
|
+
from datetime import UTC, datetime
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass
|
|
18
|
+
class SlashResult:
|
|
19
|
+
"""Result of a penalty operation."""
|
|
20
|
+
|
|
21
|
+
slash_id: str
|
|
22
|
+
vouchee_did: str
|
|
23
|
+
vouchee_sigma_before: float
|
|
24
|
+
vouchee_sigma_after: float
|
|
25
|
+
voucher_clips: list[VoucherClip]
|
|
26
|
+
reason: str
|
|
27
|
+
session_id: str
|
|
28
|
+
timestamp: datetime = field(default_factory=lambda: datetime.now(UTC))
|
|
29
|
+
cascade_depth: int = 0
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass
|
|
33
|
+
class VoucherClip:
|
|
34
|
+
"""A collateral clip applied to a sponsor."""
|
|
35
|
+
|
|
36
|
+
voucher_did: str
|
|
37
|
+
sigma_before: float
|
|
38
|
+
sigma_after: float
|
|
39
|
+
risk_weight: float
|
|
40
|
+
vouch_id: str
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class SlashingEngine:
|
|
44
|
+
"""
|
|
45
|
+
Penalty stub (Public Preview: logs penalty events, no penalties applied).
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
MAX_CASCADE_DEPTH = 2
|
|
49
|
+
SIGMA_FLOOR = 0.05
|
|
50
|
+
|
|
51
|
+
def __init__(self, vouching_engine: object) -> None:
|
|
52
|
+
self._slash_history: list[SlashResult] = []
|
|
53
|
+
|
|
54
|
+
def slash(
|
|
55
|
+
self,
|
|
56
|
+
vouchee_did: str,
|
|
57
|
+
session_id: str,
|
|
58
|
+
vouchee_sigma: float,
|
|
59
|
+
risk_weight: float,
|
|
60
|
+
reason: str,
|
|
61
|
+
agent_scores: dict[str, float],
|
|
62
|
+
cascade_depth: int = 0,
|
|
63
|
+
) -> SlashResult:
|
|
64
|
+
"""Log a penalty event (Public Preview: no penalties applied)."""
|
|
65
|
+
result = SlashResult(
|
|
66
|
+
slash_id=f"penalize:{uuid.uuid4()}",
|
|
67
|
+
vouchee_did=vouchee_did,
|
|
68
|
+
vouchee_sigma_before=vouchee_sigma,
|
|
69
|
+
vouchee_sigma_after=vouchee_sigma,
|
|
70
|
+
voucher_clips=[],
|
|
71
|
+
reason=reason,
|
|
72
|
+
session_id=session_id,
|
|
73
|
+
cascade_depth=0,
|
|
74
|
+
)
|
|
75
|
+
self._slash_history.append(result)
|
|
76
|
+
return result
|
|
77
|
+
|
|
78
|
+
@property
|
|
79
|
+
def history(self) -> list[SlashResult]:
|
|
80
|
+
return list(self._slash_history)
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
# Copyright (c) Microsoft Corporation.
|
|
2
|
+
# Licensed under the MIT License.
|
|
3
|
+
# Public Preview — basic implementation
|
|
4
|
+
"""
|
|
5
|
+
Sponsorship Protocol — stub implementation.
|
|
6
|
+
|
|
7
|
+
Public Preview: sponsorship is not enforced. All requests are approved.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import uuid
|
|
13
|
+
from dataclasses import dataclass, field
|
|
14
|
+
from datetime import UTC, datetime
|
|
15
|
+
|
|
16
|
+
from hypervisor.constants import (
|
|
17
|
+
VOUCHING_DEFAULT_BOND_PCT,
|
|
18
|
+
VOUCHING_DEFAULT_MAX_EXPOSURE,
|
|
19
|
+
VOUCHING_MIN_VOUCHER_SCORE,
|
|
20
|
+
VOUCHING_SCORE_SCALE,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass
|
|
25
|
+
class VouchRecord:
|
|
26
|
+
"""A record of one agent sponsorship for another within a session."""
|
|
27
|
+
|
|
28
|
+
vouch_id: str
|
|
29
|
+
voucher_did: str
|
|
30
|
+
vouchee_did: str
|
|
31
|
+
session_id: str
|
|
32
|
+
bonded_sigma_pct: float
|
|
33
|
+
bonded_amount: float
|
|
34
|
+
created_at: datetime = field(default_factory=lambda: datetime.now(UTC))
|
|
35
|
+
expiry: datetime | None = None
|
|
36
|
+
is_active: bool = True
|
|
37
|
+
released_at: datetime | None = None
|
|
38
|
+
|
|
39
|
+
@property
|
|
40
|
+
def is_expired(self) -> bool:
|
|
41
|
+
if self.expiry is None:
|
|
42
|
+
return False
|
|
43
|
+
return datetime.now(UTC) > self.expiry
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class VouchingEngine:
|
|
47
|
+
"""
|
|
48
|
+
Sponsorship stub (Public Preview: approves all, no bonding).
|
|
49
|
+
"""
|
|
50
|
+
|
|
51
|
+
SCORE_SCALE = VOUCHING_SCORE_SCALE
|
|
52
|
+
MIN_VOUCHER_SCORE = VOUCHING_MIN_VOUCHER_SCORE
|
|
53
|
+
DEFAULT_BOND_PCT = VOUCHING_DEFAULT_BOND_PCT
|
|
54
|
+
DEFAULT_MAX_EXPOSURE = VOUCHING_DEFAULT_MAX_EXPOSURE
|
|
55
|
+
|
|
56
|
+
def __init__(self, max_exposure: float | None = None) -> None:
|
|
57
|
+
self._vouches: dict[str, VouchRecord] = {}
|
|
58
|
+
self.max_exposure = max_exposure or self.DEFAULT_MAX_EXPOSURE
|
|
59
|
+
|
|
60
|
+
def vouch(
|
|
61
|
+
self,
|
|
62
|
+
voucher_did: str,
|
|
63
|
+
vouchee_did: str,
|
|
64
|
+
session_id: str,
|
|
65
|
+
voucher_sigma: float,
|
|
66
|
+
bond_pct: float | None = None,
|
|
67
|
+
expiry: datetime | None = None,
|
|
68
|
+
) -> VouchRecord:
|
|
69
|
+
"""Create a sponsorship record (Public Preview: always succeeds, no bonding)."""
|
|
70
|
+
record = VouchRecord(
|
|
71
|
+
vouch_id=f"sponsor:{uuid.uuid4()}",
|
|
72
|
+
voucher_did=voucher_did,
|
|
73
|
+
vouchee_did=vouchee_did,
|
|
74
|
+
session_id=session_id,
|
|
75
|
+
bonded_sigma_pct=0.0,
|
|
76
|
+
bonded_amount=0.0,
|
|
77
|
+
)
|
|
78
|
+
self._vouches[record.vouch_id] = record
|
|
79
|
+
return record
|
|
80
|
+
|
|
81
|
+
def compute_eff_score(
|
|
82
|
+
self,
|
|
83
|
+
vouchee_did: str,
|
|
84
|
+
session_id: str,
|
|
85
|
+
vouchee_sigma: float,
|
|
86
|
+
risk_weight: float,
|
|
87
|
+
) -> float:
|
|
88
|
+
"""Return sponsored agent's own score (Public Preview: no sponsor boost)."""
|
|
89
|
+
return vouchee_sigma
|
|
90
|
+
|
|
91
|
+
def get_vouchers_for(self, agent_did: str, session_id: str) -> list[VouchRecord]:
|
|
92
|
+
"""Get all sponsors for an agent in a session."""
|
|
93
|
+
return [
|
|
94
|
+
v for v in self._vouches.values()
|
|
95
|
+
if v.vouchee_did == agent_did
|
|
96
|
+
and v.session_id == session_id
|
|
97
|
+
and v.is_active
|
|
98
|
+
]
|
|
99
|
+
|
|
100
|
+
def get_total_exposure(self, voucher_did: str, session_id: str) -> float:
|
|
101
|
+
"""Always zero in Public Preview."""
|
|
102
|
+
return 0.0
|
|
103
|
+
|
|
104
|
+
def release_bond(self, vouch_id: str) -> None:
|
|
105
|
+
"""Release a sponsorship bond."""
|
|
106
|
+
if vouch_id not in self._vouches:
|
|
107
|
+
raise VouchingError(f"Sponsor {vouch_id} not found")
|
|
108
|
+
record = self._vouches[vouch_id]
|
|
109
|
+
record.is_active = False
|
|
110
|
+
record.released_at = datetime.now(UTC)
|
|
111
|
+
|
|
112
|
+
def release_session_bonds(self, session_id: str) -> int:
|
|
113
|
+
"""Release all bonds for a session."""
|
|
114
|
+
count = 0
|
|
115
|
+
for v in self._vouches.values():
|
|
116
|
+
if v.session_id == session_id and v.is_active:
|
|
117
|
+
v.is_active = False
|
|
118
|
+
v.released_at = datetime.now(UTC)
|
|
119
|
+
count += 1
|
|
120
|
+
return count
|
|
121
|
+
|
|
122
|
+
def _active_vouches_for(
|
|
123
|
+
self, agent_did: str, session_id: str
|
|
124
|
+
) -> list[VouchRecord]:
|
|
125
|
+
return self.get_vouchers_for(agent_did, session_id)
|
|
126
|
+
|
|
127
|
+
def _creates_cycle(
|
|
128
|
+
self, voucher_did: str, vouchee_did: str, session_id: str
|
|
129
|
+
) -> bool:
|
|
130
|
+
return False
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
class VouchingError(Exception):
|
|
134
|
+
"""Raised for sponsorship protocol violations."""
|
hypervisor/models.py
ADDED
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
# Copyright (c) Microsoft Corporation.
|
|
2
|
+
# Licensed under the MIT License.
|
|
3
|
+
"""Core data models for the Agent Hypervisor."""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import re
|
|
8
|
+
from dataclasses import dataclass, field
|
|
9
|
+
from datetime import UTC, datetime
|
|
10
|
+
from enum import Enum
|
|
11
|
+
|
|
12
|
+
from hypervisor.constants import (
|
|
13
|
+
MAX_AGENT_ID_LENGTH,
|
|
14
|
+
MAX_API_PATH_LENGTH,
|
|
15
|
+
MAX_DURATION_LIMIT,
|
|
16
|
+
MAX_NAME_LENGTH,
|
|
17
|
+
MAX_PARTICIPANTS_LIMIT,
|
|
18
|
+
MAX_UNDO_WINDOW,
|
|
19
|
+
RING_1_TRUST_THRESHOLD,
|
|
20
|
+
RING_2_TRUST_THRESHOLD,
|
|
21
|
+
RISK_WEIGHT_FULL,
|
|
22
|
+
RISK_WEIGHT_NONE,
|
|
23
|
+
RISK_WEIGHT_PARTIAL,
|
|
24
|
+
SESSION_DEFAULT_MIN_EFF_SCORE,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
# Agent ID: DID format (did:method:id) or simple alphanumeric identifiers.
|
|
28
|
+
# Restrict to safe characters — no @, no consecutive special chars.
|
|
29
|
+
_AGENT_ID_PATTERN = re.compile(r"^[a-zA-Z0-9](?:[a-zA-Z0-9._:-]*[a-zA-Z0-9])?$")
|
|
30
|
+
# Aliases for backward compatibility
|
|
31
|
+
_MAX_AGENT_ID_LENGTH = MAX_AGENT_ID_LENGTH
|
|
32
|
+
_MAX_NAME_LENGTH = MAX_NAME_LENGTH
|
|
33
|
+
_MAX_API_PATH_LENGTH = MAX_API_PATH_LENGTH
|
|
34
|
+
_MAX_PARTICIPANTS_LIMIT = MAX_PARTICIPANTS_LIMIT
|
|
35
|
+
_MAX_DURATION_LIMIT = MAX_DURATION_LIMIT
|
|
36
|
+
_MAX_UNDO_WINDOW = MAX_UNDO_WINDOW
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class ConsistencyMode(str, Enum):
|
|
40
|
+
"""Session consistency mode. Strong requires consensus; Eventual uses gossip."""
|
|
41
|
+
|
|
42
|
+
STRONG = "strong"
|
|
43
|
+
EVENTUAL = "eventual"
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class ExecutionRing(int, Enum):
|
|
47
|
+
"""
|
|
48
|
+
Hardware-inspired execution privilege rings.
|
|
49
|
+
|
|
50
|
+
Ring 0 (Root): Hypervisor config & penalty — requires SRE Witness.
|
|
51
|
+
Ring 1 (Privileged): Non-reversible actions — requires eff_score > 0.95 + consensus.
|
|
52
|
+
Ring 2 (Standard): Reversible actions — requires eff_score > 0.60.
|
|
53
|
+
Ring 3 (Sandbox): Read-only / research — default for unknown agents.
|
|
54
|
+
"""
|
|
55
|
+
|
|
56
|
+
RING_0_ROOT = 0
|
|
57
|
+
RING_1_PRIVILEGED = 1
|
|
58
|
+
RING_2_STANDARD = 2
|
|
59
|
+
RING_3_SANDBOX = 3
|
|
60
|
+
|
|
61
|
+
@classmethod
|
|
62
|
+
def from_eff_score(cls, eff_score: float, has_consensus: bool = False) -> ExecutionRing:
|
|
63
|
+
"""Derive ring level from effective reputation score."""
|
|
64
|
+
if eff_score > RING_1_TRUST_THRESHOLD and has_consensus:
|
|
65
|
+
return cls.RING_1_PRIVILEGED
|
|
66
|
+
elif eff_score > RING_2_TRUST_THRESHOLD:
|
|
67
|
+
return cls.RING_2_STANDARD
|
|
68
|
+
else:
|
|
69
|
+
return cls.RING_3_SANDBOX
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class ReversibilityLevel(str, Enum):
|
|
73
|
+
"""How reversible an action is."""
|
|
74
|
+
|
|
75
|
+
FULL = "full"
|
|
76
|
+
PARTIAL = "partial"
|
|
77
|
+
NONE = "none"
|
|
78
|
+
|
|
79
|
+
@property
|
|
80
|
+
def risk_weight_range(self) -> tuple[float, float]:
|
|
81
|
+
"""Return the (min, max) risk weight ω for this reversibility level."""
|
|
82
|
+
if self == ReversibilityLevel.FULL:
|
|
83
|
+
return RISK_WEIGHT_FULL
|
|
84
|
+
elif self == ReversibilityLevel.PARTIAL:
|
|
85
|
+
return RISK_WEIGHT_PARTIAL
|
|
86
|
+
else:
|
|
87
|
+
return RISK_WEIGHT_NONE
|
|
88
|
+
|
|
89
|
+
@property
|
|
90
|
+
def default_risk_weight(self) -> float:
|
|
91
|
+
"""Return the default ω for this level."""
|
|
92
|
+
lo, hi = self.risk_weight_range
|
|
93
|
+
return (lo + hi) / 2
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
class SessionState(str, Enum):
|
|
97
|
+
"""Lifecycle state of a Shared Session."""
|
|
98
|
+
|
|
99
|
+
CREATED = "created"
|
|
100
|
+
HANDSHAKING = "handshaking"
|
|
101
|
+
ACTIVE = "active"
|
|
102
|
+
TERMINATING = "terminating"
|
|
103
|
+
ARCHIVED = "archived"
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _validate_identifier(value: str, field_name: str) -> None:
|
|
107
|
+
"""Validate an identifier string (agent DID, action ID, etc.)."""
|
|
108
|
+
if not isinstance(value, str):
|
|
109
|
+
raise TypeError(f"{field_name} must be a string, got {type(value).__name__}")
|
|
110
|
+
if not value or not value.strip():
|
|
111
|
+
raise ValueError(f"{field_name} must not be empty")
|
|
112
|
+
if len(value) > _MAX_AGENT_ID_LENGTH:
|
|
113
|
+
raise ValueError(
|
|
114
|
+
f"{field_name} exceeds maximum length of {_MAX_AGENT_ID_LENGTH} characters"
|
|
115
|
+
)
|
|
116
|
+
if not _AGENT_ID_PATTERN.match(value):
|
|
117
|
+
raise ValueError(
|
|
118
|
+
f"{field_name} contains invalid characters: {value!r}. "
|
|
119
|
+
f"Only alphanumeric, hyphens, underscores, colons, and dots are allowed."
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _validate_api_path(value: str, field_name: str) -> None:
|
|
124
|
+
"""Validate an API path string."""
|
|
125
|
+
if not isinstance(value, str):
|
|
126
|
+
raise TypeError(f"{field_name} must be a string, got {type(value).__name__}")
|
|
127
|
+
if not value or not value.strip():
|
|
128
|
+
raise ValueError(f"{field_name} must not be empty")
|
|
129
|
+
if len(value) > _MAX_API_PATH_LENGTH:
|
|
130
|
+
raise ValueError(
|
|
131
|
+
f"{field_name} exceeds maximum length of {_MAX_API_PATH_LENGTH} characters"
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
@dataclass
|
|
136
|
+
class SessionConfig:
|
|
137
|
+
"""Configuration for a new Shared Session."""
|
|
138
|
+
|
|
139
|
+
consistency_mode: ConsistencyMode = ConsistencyMode.EVENTUAL
|
|
140
|
+
max_participants: int = 10
|
|
141
|
+
max_duration_seconds: int = 3600
|
|
142
|
+
min_eff_score: float = SESSION_DEFAULT_MIN_EFF_SCORE
|
|
143
|
+
enable_audit: bool = True
|
|
144
|
+
enable_blockchain_commitment: bool = False
|
|
145
|
+
|
|
146
|
+
def __post_init__(self) -> None:
|
|
147
|
+
if not isinstance(self.max_participants, int):
|
|
148
|
+
raise TypeError(
|
|
149
|
+
f"max_participants must be an integer, got {type(self.max_participants).__name__}"
|
|
150
|
+
)
|
|
151
|
+
if self.max_participants < 1:
|
|
152
|
+
raise ValueError(
|
|
153
|
+
f"max_participants must be at least 1, got {self.max_participants}"
|
|
154
|
+
)
|
|
155
|
+
if self.max_participants > _MAX_PARTICIPANTS_LIMIT:
|
|
156
|
+
raise ValueError(
|
|
157
|
+
f"max_participants must not exceed {_MAX_PARTICIPANTS_LIMIT}, "
|
|
158
|
+
f"got {self.max_participants}"
|
|
159
|
+
)
|
|
160
|
+
if not isinstance(self.max_duration_seconds, int):
|
|
161
|
+
raise TypeError(
|
|
162
|
+
f"max_duration_seconds must be an integer, "
|
|
163
|
+
f"got {type(self.max_duration_seconds).__name__}"
|
|
164
|
+
)
|
|
165
|
+
if self.max_duration_seconds < 1:
|
|
166
|
+
raise ValueError(
|
|
167
|
+
f"max_duration_seconds must be at least 1, got {self.max_duration_seconds}"
|
|
168
|
+
)
|
|
169
|
+
if self.max_duration_seconds > _MAX_DURATION_LIMIT:
|
|
170
|
+
raise ValueError(
|
|
171
|
+
f"max_duration_seconds must not exceed {_MAX_DURATION_LIMIT} (7 days), "
|
|
172
|
+
f"got {self.max_duration_seconds}"
|
|
173
|
+
)
|
|
174
|
+
if not isinstance(self.min_eff_score, (int, float)):
|
|
175
|
+
raise TypeError(
|
|
176
|
+
f"min_eff_score must be a number, got {type(self.min_eff_score).__name__}"
|
|
177
|
+
)
|
|
178
|
+
if not (0.0 <= self.min_eff_score <= 1.0):
|
|
179
|
+
raise ValueError(
|
|
180
|
+
f"min_eff_score must be between 0.0 and 1.0, got {self.min_eff_score}"
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
@dataclass
|
|
185
|
+
class SessionParticipant:
|
|
186
|
+
"""An agent participating in a session."""
|
|
187
|
+
|
|
188
|
+
agent_did: str
|
|
189
|
+
ring: ExecutionRing = ExecutionRing.RING_3_SANDBOX
|
|
190
|
+
sigma_raw: float = 0.0
|
|
191
|
+
eff_score: float = 0.0
|
|
192
|
+
joined_at: datetime = field(default_factory=lambda: datetime.now(UTC))
|
|
193
|
+
is_active: bool = True
|
|
194
|
+
|
|
195
|
+
def __post_init__(self) -> None:
|
|
196
|
+
_validate_identifier(self.agent_did, "agent_did")
|
|
197
|
+
if not isinstance(self.ring, ExecutionRing):
|
|
198
|
+
try:
|
|
199
|
+
self.ring = ExecutionRing(self.ring)
|
|
200
|
+
except (ValueError, KeyError):
|
|
201
|
+
raise ValueError(
|
|
202
|
+
f"ring must be a valid ExecutionRing (0-3), got {self.ring!r}"
|
|
203
|
+
)
|
|
204
|
+
if not isinstance(self.sigma_raw, (int, float)):
|
|
205
|
+
raise TypeError(
|
|
206
|
+
f"sigma_raw must be a number, got {type(self.sigma_raw).__name__}"
|
|
207
|
+
)
|
|
208
|
+
if not (0.0 <= self.sigma_raw <= 1.0):
|
|
209
|
+
raise ValueError(
|
|
210
|
+
f"sigma_raw must be between 0.0 and 1.0, got {self.sigma_raw}"
|
|
211
|
+
)
|
|
212
|
+
if not isinstance(self.eff_score, (int, float)):
|
|
213
|
+
raise TypeError(
|
|
214
|
+
f"eff_score must be a number, got {type(self.eff_score).__name__}"
|
|
215
|
+
)
|
|
216
|
+
if not (0.0 <= self.eff_score <= 1.0):
|
|
217
|
+
raise ValueError(
|
|
218
|
+
f"eff_score must be between 0.0 and 1.0, got {self.eff_score}"
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
@dataclass
|
|
223
|
+
class ActionDescriptor:
|
|
224
|
+
"""Describes an action from an IATP Capability Manifest."""
|
|
225
|
+
|
|
226
|
+
action_id: str
|
|
227
|
+
name: str
|
|
228
|
+
execute_api: str
|
|
229
|
+
undo_api: str | None = None
|
|
230
|
+
reversibility: ReversibilityLevel = ReversibilityLevel.NONE
|
|
231
|
+
undo_window_seconds: int = 0
|
|
232
|
+
compensation_method: str | None = None
|
|
233
|
+
is_read_only: bool = False
|
|
234
|
+
is_admin: bool = False
|
|
235
|
+
|
|
236
|
+
def __post_init__(self) -> None:
|
|
237
|
+
_validate_identifier(self.action_id, "action_id")
|
|
238
|
+
if not isinstance(self.name, str) or not self.name.strip():
|
|
239
|
+
raise ValueError("name must be a non-empty string")
|
|
240
|
+
if len(self.name) > _MAX_NAME_LENGTH:
|
|
241
|
+
raise ValueError(
|
|
242
|
+
f"name exceeds maximum length of {_MAX_NAME_LENGTH} characters"
|
|
243
|
+
)
|
|
244
|
+
_validate_api_path(self.execute_api, "execute_api")
|
|
245
|
+
if self.undo_api is not None:
|
|
246
|
+
_validate_api_path(self.undo_api, "undo_api")
|
|
247
|
+
if not isinstance(self.undo_window_seconds, int):
|
|
248
|
+
raise TypeError(
|
|
249
|
+
f"undo_window_seconds must be an integer, "
|
|
250
|
+
f"got {type(self.undo_window_seconds).__name__}"
|
|
251
|
+
)
|
|
252
|
+
if self.undo_window_seconds < 0:
|
|
253
|
+
raise ValueError(
|
|
254
|
+
f"undo_window_seconds must not be negative, got {self.undo_window_seconds}"
|
|
255
|
+
)
|
|
256
|
+
if self.undo_window_seconds > _MAX_UNDO_WINDOW:
|
|
257
|
+
raise ValueError(
|
|
258
|
+
f"undo_window_seconds must not exceed {_MAX_UNDO_WINDOW} (24 hours), "
|
|
259
|
+
f"got {self.undo_window_seconds}"
|
|
260
|
+
)
|
|
261
|
+
|
|
262
|
+
@property
|
|
263
|
+
def risk_weight(self) -> float:
|
|
264
|
+
"""Compute ω from reversibility level."""
|
|
265
|
+
return self.reversibility.default_risk_weight
|
|
266
|
+
|
|
267
|
+
@property
|
|
268
|
+
def required_ring(self) -> ExecutionRing:
|
|
269
|
+
"""Determine minimum ring required for this action."""
|
|
270
|
+
if self.is_admin:
|
|
271
|
+
return ExecutionRing.RING_0_ROOT
|
|
272
|
+
elif self.reversibility == ReversibilityLevel.NONE and not self.is_read_only:
|
|
273
|
+
return ExecutionRing.RING_1_PRIVILEGED
|
|
274
|
+
elif self.is_read_only:
|
|
275
|
+
return ExecutionRing.RING_3_SANDBOX
|
|
276
|
+
else:
|
|
277
|
+
return ExecutionRing.RING_2_STANDARD
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# Copyright (c) Microsoft Corporation.
|
|
2
|
+
# Licensed under the MIT License.
|
|
3
|
+
"""Observability module — structured event bus, causal tracing, and metrics."""
|
|
4
|
+
|
|
5
|
+
from hypervisor.observability.causal_trace import CausalTraceId
|
|
6
|
+
from hypervisor.observability.event_bus import (
|
|
7
|
+
EventType,
|
|
8
|
+
HypervisorEvent,
|
|
9
|
+
HypervisorEventBus,
|
|
10
|
+
)
|
|
11
|
+
from hypervisor.observability.prometheus_collector import RingMetricsCollector
|
|
12
|
+
from hypervisor.observability.saga_span_exporter import (
|
|
13
|
+
SagaSpanExporter,
|
|
14
|
+
SagaSpanRecord,
|
|
15
|
+
SpanSink,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
__all__ = [
|
|
19
|
+
"EventType",
|
|
20
|
+
"HypervisorEvent",
|
|
21
|
+
"HypervisorEventBus",
|
|
22
|
+
"CausalTraceId",
|
|
23
|
+
"RingMetricsCollector",
|
|
24
|
+
"SagaSpanExporter",
|
|
25
|
+
"SagaSpanRecord",
|
|
26
|
+
"SpanSink",
|
|
27
|
+
]
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# Copyright (c) Microsoft Corporation.
|
|
2
|
+
# Licensed under the MIT License.
|
|
3
|
+
"""
|
|
4
|
+
Causal trace IDs for cross-agent distributed tracing.
|
|
5
|
+
|
|
6
|
+
Unlike simple correlation IDs, causal trace IDs encode the full
|
|
7
|
+
spawn/delegation tree, making distributed traces genuinely readable.
|
|
8
|
+
|
|
9
|
+
Format: {trace_id}/{span_id}[/{parent_span_id}]
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import uuid
|
|
15
|
+
from dataclasses import dataclass, field
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass(frozen=True)
|
|
19
|
+
class CausalTraceId:
|
|
20
|
+
"""Encodes the full spawn/delegation tree for a trace."""
|
|
21
|
+
|
|
22
|
+
trace_id: str = field(default_factory=lambda: uuid.uuid4().hex[:12])
|
|
23
|
+
span_id: str = field(default_factory=lambda: uuid.uuid4().hex[:8])
|
|
24
|
+
parent_span_id: str | None = None
|
|
25
|
+
depth: int = 0
|
|
26
|
+
|
|
27
|
+
def child(self) -> CausalTraceId:
|
|
28
|
+
"""Create a child span (e.g., when spawning a sub-agent or delegating)."""
|
|
29
|
+
return CausalTraceId(
|
|
30
|
+
trace_id=self.trace_id,
|
|
31
|
+
span_id=uuid.uuid4().hex[:8],
|
|
32
|
+
parent_span_id=self.span_id,
|
|
33
|
+
depth=self.depth + 1,
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
def sibling(self) -> CausalTraceId:
|
|
37
|
+
"""Create a sibling span (same parent, different operation)."""
|
|
38
|
+
return CausalTraceId(
|
|
39
|
+
trace_id=self.trace_id,
|
|
40
|
+
span_id=uuid.uuid4().hex[:8],
|
|
41
|
+
parent_span_id=self.parent_span_id,
|
|
42
|
+
depth=self.depth,
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
@property
|
|
46
|
+
def full_id(self) -> str:
|
|
47
|
+
"""Full trace path: trace_id/span_id[/parent_span_id]."""
|
|
48
|
+
parts = [self.trace_id, self.span_id]
|
|
49
|
+
if self.parent_span_id:
|
|
50
|
+
parts.append(self.parent_span_id)
|
|
51
|
+
return "/".join(parts)
|
|
52
|
+
|
|
53
|
+
@classmethod
|
|
54
|
+
def from_string(cls, s: str) -> CausalTraceId:
|
|
55
|
+
"""Parse a CausalTraceId from its string representation."""
|
|
56
|
+
parts = s.split("/")
|
|
57
|
+
if len(parts) < 2:
|
|
58
|
+
raise ValueError(f"Invalid causal trace ID: {s}")
|
|
59
|
+
return cls(
|
|
60
|
+
trace_id=parts[0],
|
|
61
|
+
span_id=parts[1],
|
|
62
|
+
parent_span_id=parts[2] if len(parts) > 2 else None,
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
def is_ancestor_of(self, other: CausalTraceId) -> bool:
|
|
66
|
+
"""Check if this trace is an ancestor of another (same trace tree)."""
|
|
67
|
+
return self.trace_id == other.trace_id and other.depth > self.depth
|
|
68
|
+
|
|
69
|
+
def __str__(self) -> str:
|
|
70
|
+
return self.full_id
|