macp-sdk-python 0.2.1__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.
- macp_sdk/__init__.py +207 -0
- macp_sdk/_logging.py +16 -0
- macp_sdk/agent/__init__.py +64 -0
- macp_sdk/agent/dispatcher.py +105 -0
- macp_sdk/agent/participant.py +404 -0
- macp_sdk/agent/runner.py +100 -0
- macp_sdk/agent/strategies.py +312 -0
- macp_sdk/agent/transports.py +153 -0
- macp_sdk/agent/types.py +64 -0
- macp_sdk/auth.py +74 -0
- macp_sdk/base_projection.py +52 -0
- macp_sdk/base_session.py +169 -0
- macp_sdk/client.py +625 -0
- macp_sdk/constants.py +20 -0
- macp_sdk/decision.py +154 -0
- macp_sdk/envelope.py +180 -0
- macp_sdk/errors.py +101 -0
- macp_sdk/handoff.py +241 -0
- macp_sdk/policy.py +292 -0
- macp_sdk/projections.py +183 -0
- macp_sdk/proposal.py +305 -0
- macp_sdk/proto_registry.py +150 -0
- macp_sdk/py.typed +0 -0
- macp_sdk/quorum.py +258 -0
- macp_sdk/retry.py +64 -0
- macp_sdk/task.py +368 -0
- macp_sdk/validation.py +117 -0
- macp_sdk/watchers.py +122 -0
- macp_sdk_python-0.2.1.dist-info/METADATA +165 -0
- macp_sdk_python-0.2.1.dist-info/RECORD +33 -0
- macp_sdk_python-0.2.1.dist-info/WHEEL +5 -0
- macp_sdk_python-0.2.1.dist-info/licenses/LICENSE +201 -0
- macp_sdk_python-0.2.1.dist-info/top_level.txt +1 -0
macp_sdk/decision.py
ADDED
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from macp.modes.decision.v1 import decision_pb2
|
|
4
|
+
from macp.v1 import envelope_pb2
|
|
5
|
+
|
|
6
|
+
from .auth import AuthConfig
|
|
7
|
+
from .base_projection import BaseProjection
|
|
8
|
+
from .base_session import BaseSession
|
|
9
|
+
from .constants import MODE_DECISION
|
|
10
|
+
from .envelope import build_envelope, serialize_message
|
|
11
|
+
from .errors import MacpSessionError
|
|
12
|
+
from .projections import DecisionProjection
|
|
13
|
+
|
|
14
|
+
_VALID_VOTES = frozenset({"APPROVE", "REJECT", "ABSTAIN"})
|
|
15
|
+
_VALID_RECOMMENDATIONS = frozenset({"APPROVE", "REVIEW", "BLOCK", "REJECT"})
|
|
16
|
+
_VALID_SEVERITIES = frozenset({"critical", "high", "medium", "low"})
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class DecisionSession(BaseSession):
|
|
20
|
+
"""High-level helper for Decision mode sessions.
|
|
21
|
+
|
|
22
|
+
Inherits ``start``, ``commit``, ``cancel``, ``metadata``, and ``open_stream``
|
|
23
|
+
from :class:`BaseSession`. Adds decision-specific actions: ``propose``,
|
|
24
|
+
``evaluate``, ``raise_objection``, and ``vote``.
|
|
25
|
+
|
|
26
|
+
Note: The initiator must be included in the ``participants`` list passed
|
|
27
|
+
to ``start()`` in order to propose. The runtime no longer grants
|
|
28
|
+
implicit proposal authority to the initiator.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
MODE = MODE_DECISION
|
|
32
|
+
|
|
33
|
+
def _create_projection(self) -> BaseProjection:
|
|
34
|
+
return DecisionProjection()
|
|
35
|
+
|
|
36
|
+
# Narrow the type for callers that want decision-specific queries.
|
|
37
|
+
@property
|
|
38
|
+
def decision_projection(self) -> DecisionProjection:
|
|
39
|
+
assert isinstance(self.projection, DecisionProjection)
|
|
40
|
+
return self.projection
|
|
41
|
+
|
|
42
|
+
def propose(
|
|
43
|
+
self,
|
|
44
|
+
proposal_id: str,
|
|
45
|
+
option: str,
|
|
46
|
+
*,
|
|
47
|
+
rationale: str = "",
|
|
48
|
+
supporting_data: bytes = b"",
|
|
49
|
+
sender: str | None = None,
|
|
50
|
+
auth: AuthConfig | None = None,
|
|
51
|
+
) -> envelope_pb2.Ack:
|
|
52
|
+
payload = decision_pb2.ProposalPayload(
|
|
53
|
+
proposal_id=proposal_id,
|
|
54
|
+
option=option,
|
|
55
|
+
rationale=rationale,
|
|
56
|
+
supporting_data=supporting_data,
|
|
57
|
+
)
|
|
58
|
+
envelope = build_envelope(
|
|
59
|
+
mode=self.MODE,
|
|
60
|
+
message_type="Proposal",
|
|
61
|
+
session_id=self.session_id,
|
|
62
|
+
sender=self._sender_for(sender, auth=auth),
|
|
63
|
+
payload=serialize_message(payload),
|
|
64
|
+
)
|
|
65
|
+
return self._send_and_track(envelope, auth=auth)
|
|
66
|
+
|
|
67
|
+
def evaluate(
|
|
68
|
+
self,
|
|
69
|
+
proposal_id: str,
|
|
70
|
+
recommendation: str,
|
|
71
|
+
*,
|
|
72
|
+
confidence: float,
|
|
73
|
+
reason: str = "",
|
|
74
|
+
sender: str | None = None,
|
|
75
|
+
auth: AuthConfig | None = None,
|
|
76
|
+
) -> envelope_pb2.Ack:
|
|
77
|
+
normalized_rec = recommendation.upper()
|
|
78
|
+
if normalized_rec not in _VALID_RECOMMENDATIONS:
|
|
79
|
+
raise MacpSessionError(
|
|
80
|
+
f"invalid recommendation {recommendation!r}: "
|
|
81
|
+
"must be one of APPROVE, REVIEW, BLOCK, REJECT"
|
|
82
|
+
)
|
|
83
|
+
if not (0.0 <= confidence <= 1.0):
|
|
84
|
+
raise MacpSessionError(f"confidence must be in [0.0, 1.0], got {confidence}")
|
|
85
|
+
payload = decision_pb2.EvaluationPayload(
|
|
86
|
+
proposal_id=proposal_id,
|
|
87
|
+
recommendation=normalized_rec,
|
|
88
|
+
confidence=confidence,
|
|
89
|
+
reason=reason,
|
|
90
|
+
)
|
|
91
|
+
envelope = build_envelope(
|
|
92
|
+
mode=self.MODE,
|
|
93
|
+
message_type="Evaluation",
|
|
94
|
+
session_id=self.session_id,
|
|
95
|
+
sender=self._sender_for(sender, auth=auth),
|
|
96
|
+
payload=serialize_message(payload),
|
|
97
|
+
)
|
|
98
|
+
return self._send_and_track(envelope, auth=auth)
|
|
99
|
+
|
|
100
|
+
def raise_objection(
|
|
101
|
+
self,
|
|
102
|
+
proposal_id: str,
|
|
103
|
+
*,
|
|
104
|
+
reason: str,
|
|
105
|
+
severity: str = "medium",
|
|
106
|
+
sender: str | None = None,
|
|
107
|
+
auth: AuthConfig | None = None,
|
|
108
|
+
) -> envelope_pb2.Ack:
|
|
109
|
+
normalized_sev = severity.lower()
|
|
110
|
+
if normalized_sev not in _VALID_SEVERITIES:
|
|
111
|
+
raise MacpSessionError(
|
|
112
|
+
f"invalid severity {severity!r}: must be one of critical, high, medium, low"
|
|
113
|
+
)
|
|
114
|
+
payload = decision_pb2.ObjectionPayload(
|
|
115
|
+
proposal_id=proposal_id,
|
|
116
|
+
reason=reason,
|
|
117
|
+
severity=normalized_sev,
|
|
118
|
+
)
|
|
119
|
+
envelope = build_envelope(
|
|
120
|
+
mode=self.MODE,
|
|
121
|
+
message_type="Objection",
|
|
122
|
+
session_id=self.session_id,
|
|
123
|
+
sender=self._sender_for(sender, auth=auth),
|
|
124
|
+
payload=serialize_message(payload),
|
|
125
|
+
)
|
|
126
|
+
return self._send_and_track(envelope, auth=auth)
|
|
127
|
+
|
|
128
|
+
def vote(
|
|
129
|
+
self,
|
|
130
|
+
proposal_id: str,
|
|
131
|
+
vote: str,
|
|
132
|
+
*,
|
|
133
|
+
reason: str = "",
|
|
134
|
+
sender: str | None = None,
|
|
135
|
+
auth: AuthConfig | None = None,
|
|
136
|
+
) -> envelope_pb2.Ack:
|
|
137
|
+
normalized_vote = vote.upper()
|
|
138
|
+
if normalized_vote not in _VALID_VOTES:
|
|
139
|
+
raise MacpSessionError(
|
|
140
|
+
f"invalid vote value {vote!r}: must be one of APPROVE, REJECT, ABSTAIN"
|
|
141
|
+
)
|
|
142
|
+
payload = decision_pb2.VotePayload(
|
|
143
|
+
proposal_id=proposal_id,
|
|
144
|
+
vote=normalized_vote,
|
|
145
|
+
reason=reason,
|
|
146
|
+
)
|
|
147
|
+
envelope = build_envelope(
|
|
148
|
+
mode=self.MODE,
|
|
149
|
+
message_type="Vote",
|
|
150
|
+
session_id=self.session_id,
|
|
151
|
+
sender=self._sender_for(sender, auth=auth),
|
|
152
|
+
payload=serialize_message(payload),
|
|
153
|
+
)
|
|
154
|
+
return self._send_and_track(envelope, auth=auth)
|
macp_sdk/envelope.py
ADDED
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import time
|
|
5
|
+
import uuid
|
|
6
|
+
from collections.abc import Iterable, Mapping, Sequence
|
|
7
|
+
|
|
8
|
+
from macp.v1 import core_pb2, envelope_pb2
|
|
9
|
+
|
|
10
|
+
from .constants import (
|
|
11
|
+
DEFAULT_CONFIGURATION_VERSION,
|
|
12
|
+
DEFAULT_MODE_VERSION,
|
|
13
|
+
DEFAULT_POLICY_VERSION,
|
|
14
|
+
MACP_VERSION,
|
|
15
|
+
)
|
|
16
|
+
from .errors import MacpSessionError
|
|
17
|
+
|
|
18
|
+
# ── Outcome inference ────────────────────────────────────────────────
|
|
19
|
+
|
|
20
|
+
_NEGATIVE_SUFFIXES = ("rejected", "failed", "declined")
|
|
21
|
+
_POSITIVE_SUFFIXES = ("selected", "accepted", "completed", "approved")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def infer_outcome_positive(action: str) -> bool:
|
|
25
|
+
"""Infer ``outcome_positive`` from the action suffix.
|
|
26
|
+
|
|
27
|
+
Actions ending in *rejected*, *failed*, or *declined* are negative.
|
|
28
|
+
Everything else (including unknown suffixes) defaults to positive.
|
|
29
|
+
"""
|
|
30
|
+
lower = action.lower()
|
|
31
|
+
return not any(lower.endswith(s) for s in _NEGATIVE_SUFFIXES)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def new_session_id() -> str:
|
|
35
|
+
return str(uuid.uuid4())
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def new_message_id() -> str:
|
|
39
|
+
return str(uuid.uuid4())
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def new_commitment_id() -> str:
|
|
43
|
+
return str(uuid.uuid4())
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def now_unix_ms() -> int:
|
|
47
|
+
return int(time.time() * 1000)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def encode_context(context: bytes | str | Mapping[str, object] | None) -> bytes:
|
|
51
|
+
if context is None:
|
|
52
|
+
return b""
|
|
53
|
+
if isinstance(context, bytes):
|
|
54
|
+
return context
|
|
55
|
+
if isinstance(context, str):
|
|
56
|
+
return context.encode("utf-8")
|
|
57
|
+
return json.dumps(context).encode("utf-8")
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def build_root(uri: str, name: str = "") -> core_pb2.Root:
|
|
61
|
+
return core_pb2.Root(uri=uri, name=name)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def build_session_start_payload(
|
|
65
|
+
*,
|
|
66
|
+
intent: str,
|
|
67
|
+
participants: Sequence[str],
|
|
68
|
+
ttl_ms: int,
|
|
69
|
+
mode_version: str = DEFAULT_MODE_VERSION,
|
|
70
|
+
configuration_version: str = DEFAULT_CONFIGURATION_VERSION,
|
|
71
|
+
policy_version: str = DEFAULT_POLICY_VERSION,
|
|
72
|
+
context: bytes | str | Mapping[str, object] | None = None,
|
|
73
|
+
roots: Iterable[core_pb2.Root] | None = None,
|
|
74
|
+
) -> core_pb2.SessionStartPayload:
|
|
75
|
+
return core_pb2.SessionStartPayload(
|
|
76
|
+
intent=intent,
|
|
77
|
+
participants=list(participants),
|
|
78
|
+
mode_version=mode_version,
|
|
79
|
+
configuration_version=configuration_version,
|
|
80
|
+
policy_version=policy_version,
|
|
81
|
+
ttl_ms=ttl_ms,
|
|
82
|
+
context=encode_context(context),
|
|
83
|
+
roots=list(roots or []),
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _has_outcome_positive_field() -> bool:
|
|
88
|
+
"""Check if the proto schema supports outcome_positive."""
|
|
89
|
+
return any(f.name == "outcome_positive" for f in core_pb2.CommitmentPayload.DESCRIPTOR.fields)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def build_commitment_payload(
|
|
93
|
+
*,
|
|
94
|
+
action: str,
|
|
95
|
+
authority_scope: str,
|
|
96
|
+
reason: str,
|
|
97
|
+
commitment_id: str | None = None,
|
|
98
|
+
mode_version: str = DEFAULT_MODE_VERSION,
|
|
99
|
+
configuration_version: str = DEFAULT_CONFIGURATION_VERSION,
|
|
100
|
+
policy_version: str = DEFAULT_POLICY_VERSION,
|
|
101
|
+
outcome_positive: bool | None = None,
|
|
102
|
+
) -> core_pb2.CommitmentPayload:
|
|
103
|
+
if outcome_positive is None:
|
|
104
|
+
outcome_positive = infer_outcome_positive(action)
|
|
105
|
+
kwargs: dict[str, object] = dict(
|
|
106
|
+
commitment_id=commitment_id or new_commitment_id(),
|
|
107
|
+
action=action,
|
|
108
|
+
authority_scope=authority_scope,
|
|
109
|
+
reason=reason,
|
|
110
|
+
mode_version=mode_version,
|
|
111
|
+
configuration_version=configuration_version,
|
|
112
|
+
policy_version=policy_version,
|
|
113
|
+
)
|
|
114
|
+
if _has_outcome_positive_field():
|
|
115
|
+
kwargs["outcome_positive"] = outcome_positive
|
|
116
|
+
return core_pb2.CommitmentPayload(**kwargs)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def build_signal_payload(
|
|
120
|
+
*,
|
|
121
|
+
signal_type: str,
|
|
122
|
+
data: bytes = b"",
|
|
123
|
+
confidence: float = 0.0,
|
|
124
|
+
correlation_session_id: str = "",
|
|
125
|
+
) -> core_pb2.SignalPayload:
|
|
126
|
+
if data and not signal_type.strip():
|
|
127
|
+
raise MacpSessionError("signal_type must be non-empty when data is present")
|
|
128
|
+
return core_pb2.SignalPayload(
|
|
129
|
+
signal_type=signal_type,
|
|
130
|
+
data=data,
|
|
131
|
+
confidence=confidence,
|
|
132
|
+
correlation_session_id=correlation_session_id,
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def build_progress_payload(
|
|
137
|
+
*,
|
|
138
|
+
progress_token: str,
|
|
139
|
+
progress: float,
|
|
140
|
+
total: float,
|
|
141
|
+
message: str = "",
|
|
142
|
+
target_message_id: str = "",
|
|
143
|
+
) -> core_pb2.ProgressPayload:
|
|
144
|
+
return core_pb2.ProgressPayload(
|
|
145
|
+
progress_token=progress_token,
|
|
146
|
+
progress=progress,
|
|
147
|
+
total=total,
|
|
148
|
+
message=message,
|
|
149
|
+
target_message_id=target_message_id,
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def serialize_message(message: object) -> bytes:
|
|
154
|
+
serializer = getattr(message, "SerializeToString", None)
|
|
155
|
+
if serializer is None:
|
|
156
|
+
raise TypeError(f"object {type(message)!r} is not a protobuf message")
|
|
157
|
+
return serializer()
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def build_envelope(
|
|
161
|
+
*,
|
|
162
|
+
mode: str,
|
|
163
|
+
message_type: str,
|
|
164
|
+
session_id: str,
|
|
165
|
+
payload: bytes,
|
|
166
|
+
sender: str = "",
|
|
167
|
+
message_id: str | None = None,
|
|
168
|
+
macp_version: str = MACP_VERSION,
|
|
169
|
+
timestamp_unix_ms: int | None = None,
|
|
170
|
+
) -> envelope_pb2.Envelope:
|
|
171
|
+
return envelope_pb2.Envelope(
|
|
172
|
+
macp_version=macp_version,
|
|
173
|
+
mode=mode,
|
|
174
|
+
message_type=message_type,
|
|
175
|
+
message_id=message_id or new_message_id(),
|
|
176
|
+
session_id=session_id,
|
|
177
|
+
sender=sender,
|
|
178
|
+
timestamp_unix_ms=timestamp_unix_ms or now_unix_ms(),
|
|
179
|
+
payload=payload,
|
|
180
|
+
)
|
macp_sdk/errors.py
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
|
|
5
|
+
# ── Well-known error codes ───────────────────────────────────────────
|
|
6
|
+
|
|
7
|
+
SESSION_ALREADY_EXISTS = "SESSION_ALREADY_EXISTS"
|
|
8
|
+
POLICY_DENIED = "POLICY_DENIED"
|
|
9
|
+
UNKNOWN_POLICY_VERSION = "UNKNOWN_POLICY_VERSION"
|
|
10
|
+
INVALID_POLICY_DEFINITION = "INVALID_POLICY_DEFINITION"
|
|
11
|
+
UNSUPPORTED_PROTOCOL_VERSION = "UNSUPPORTED_PROTOCOL_VERSION"
|
|
12
|
+
INVALID_ENVELOPE = "INVALID_ENVELOPE"
|
|
13
|
+
SESSION_NOT_FOUND = "SESSION_NOT_FOUND"
|
|
14
|
+
SESSION_NOT_OPEN = "SESSION_NOT_OPEN"
|
|
15
|
+
MODE_NOT_SUPPORTED = "MODE_NOT_SUPPORTED"
|
|
16
|
+
FORBIDDEN = "FORBIDDEN"
|
|
17
|
+
UNAUTHENTICATED = "UNAUTHENTICATED"
|
|
18
|
+
DUPLICATE_MESSAGE = "DUPLICATE_MESSAGE"
|
|
19
|
+
PAYLOAD_TOO_LARGE = "PAYLOAD_TOO_LARGE"
|
|
20
|
+
RATE_LIMITED = "RATE_LIMITED"
|
|
21
|
+
INTERNAL_ERROR = "INTERNAL_ERROR"
|
|
22
|
+
INVALID_SESSION_ID = "INVALID_SESSION_ID"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class MacpSdkError(Exception):
|
|
26
|
+
"""Base SDK exception."""
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass(slots=True)
|
|
30
|
+
class AckFailure:
|
|
31
|
+
code: str
|
|
32
|
+
message: str
|
|
33
|
+
session_id: str = ""
|
|
34
|
+
message_id: str = ""
|
|
35
|
+
reasons: list[str] = field(default_factory=list)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class MacpAckError(MacpSdkError):
|
|
39
|
+
"""Runtime rejected the message (NACK)."""
|
|
40
|
+
|
|
41
|
+
def __init__(
|
|
42
|
+
self,
|
|
43
|
+
failure: AckFailure,
|
|
44
|
+
*,
|
|
45
|
+
mode: str = "",
|
|
46
|
+
message_type: str = "",
|
|
47
|
+
):
|
|
48
|
+
self.failure = failure
|
|
49
|
+
self.mode = mode
|
|
50
|
+
self.message_type = message_type
|
|
51
|
+
super().__init__(f"{failure.code}: {failure.message}")
|
|
52
|
+
|
|
53
|
+
@property
|
|
54
|
+
def reasons(self) -> list[str]:
|
|
55
|
+
"""Structured denial reasons (populated for POLICY_DENIED)."""
|
|
56
|
+
return self.failure.reasons
|
|
57
|
+
|
|
58
|
+
def __repr__(self) -> str:
|
|
59
|
+
parts = [f"code={self.failure.code!r}", f"message={self.failure.message!r}"]
|
|
60
|
+
if self.failure.session_id:
|
|
61
|
+
parts.append(f"session_id={self.failure.session_id!r}")
|
|
62
|
+
if self.mode:
|
|
63
|
+
parts.append(f"mode={self.mode!r}")
|
|
64
|
+
if self.message_type:
|
|
65
|
+
parts.append(f"message_type={self.message_type!r}")
|
|
66
|
+
if self.failure.reasons:
|
|
67
|
+
parts.append(f"reasons={self.failure.reasons!r}")
|
|
68
|
+
return f"MacpAckError({', '.join(parts)})"
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class MacpTransportError(MacpSdkError):
|
|
72
|
+
"""gRPC communication failure."""
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class MacpSessionError(MacpSdkError):
|
|
76
|
+
"""Session-level error (wrong state, not started, already committed)."""
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class MacpIdentityMismatchError(MacpSdkError):
|
|
80
|
+
"""Envelope ``sender`` does not match the auth identity's ``expected_sender``.
|
|
81
|
+
|
|
82
|
+
The runtime derives ``sender`` from authenticated identity and rejects any
|
|
83
|
+
value that does not match (RFC-MACP-0004 §4). Catching this mismatch client-side
|
|
84
|
+
surfaces a clearer error than an opaque ``UNAUTHENTICATED`` from the runtime.
|
|
85
|
+
"""
|
|
86
|
+
|
|
87
|
+
def __init__(self, *, expected: str, actual: str) -> None:
|
|
88
|
+
self.expected = expected
|
|
89
|
+
self.actual = actual
|
|
90
|
+
super().__init__(
|
|
91
|
+
f"sender {actual!r} does not match auth identity {expected!r}; "
|
|
92
|
+
f"the runtime will reject this envelope as UNAUTHENTICATED"
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
class MacpTimeoutError(MacpTransportError):
|
|
97
|
+
"""Operation timed out."""
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
class MacpRetryError(MacpTransportError):
|
|
101
|
+
"""All retry attempts exhausted."""
|
macp_sdk/handoff.py
ADDED
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
|
|
5
|
+
from macp.modes.handoff.v1 import handoff_pb2
|
|
6
|
+
from macp.v1 import envelope_pb2
|
|
7
|
+
|
|
8
|
+
from .auth import AuthConfig
|
|
9
|
+
from .base_projection import BaseProjection
|
|
10
|
+
from .base_session import BaseSession
|
|
11
|
+
from .constants import MODE_HANDOFF
|
|
12
|
+
from .envelope import build_envelope, serialize_message
|
|
13
|
+
|
|
14
|
+
# ---------------------------------------------------------------------------
|
|
15
|
+
# Projection records
|
|
16
|
+
# ---------------------------------------------------------------------------
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass(slots=True)
|
|
20
|
+
class HandoffRecord:
|
|
21
|
+
handoff_id: str
|
|
22
|
+
target_participant: str
|
|
23
|
+
scope: str
|
|
24
|
+
reason: str
|
|
25
|
+
sender: str
|
|
26
|
+
status: str # "offered" | "context_sent" | "accepted" | "declined"
|
|
27
|
+
context_content_type: str | None
|
|
28
|
+
accepted_by: str | None
|
|
29
|
+
declined_by: str | None
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
# ---------------------------------------------------------------------------
|
|
33
|
+
# Projection
|
|
34
|
+
# ---------------------------------------------------------------------------
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class HandoffProjection(BaseProjection):
|
|
38
|
+
"""In-process state tracking for Handoff mode sessions."""
|
|
39
|
+
|
|
40
|
+
MODE = MODE_HANDOFF
|
|
41
|
+
|
|
42
|
+
def __init__(self) -> None:
|
|
43
|
+
super().__init__()
|
|
44
|
+
self.phase = "Pending"
|
|
45
|
+
self.handoffs: dict[str, HandoffRecord] = {}
|
|
46
|
+
|
|
47
|
+
def _apply_mode_message(self, envelope: envelope_pb2.Envelope) -> None:
|
|
48
|
+
mt = envelope.message_type
|
|
49
|
+
|
|
50
|
+
if mt == "HandoffOffer":
|
|
51
|
+
p = handoff_pb2.HandoffOfferPayload()
|
|
52
|
+
p.ParseFromString(envelope.payload)
|
|
53
|
+
self.handoffs[p.handoff_id] = HandoffRecord(
|
|
54
|
+
handoff_id=p.handoff_id,
|
|
55
|
+
target_participant=p.target_participant,
|
|
56
|
+
scope=p.scope,
|
|
57
|
+
reason=p.reason,
|
|
58
|
+
sender=envelope.sender,
|
|
59
|
+
status="offered",
|
|
60
|
+
context_content_type=None,
|
|
61
|
+
accepted_by=None,
|
|
62
|
+
declined_by=None,
|
|
63
|
+
)
|
|
64
|
+
self.phase = "OfferPending"
|
|
65
|
+
return
|
|
66
|
+
|
|
67
|
+
if mt == "HandoffContext":
|
|
68
|
+
p = handoff_pb2.HandoffContextPayload()
|
|
69
|
+
p.ParseFromString(envelope.payload)
|
|
70
|
+
handoff = self.handoffs.get(p.handoff_id)
|
|
71
|
+
if handoff is not None:
|
|
72
|
+
if handoff.status == "offered":
|
|
73
|
+
handoff.status = "context_sent"
|
|
74
|
+
handoff.context_content_type = p.content_type
|
|
75
|
+
if self.phase == "OfferPending":
|
|
76
|
+
self.phase = "ContextSharing"
|
|
77
|
+
return
|
|
78
|
+
|
|
79
|
+
if mt == "HandoffAccept":
|
|
80
|
+
p = handoff_pb2.HandoffAcceptPayload()
|
|
81
|
+
p.ParseFromString(envelope.payload)
|
|
82
|
+
handoff = self.handoffs.get(p.handoff_id)
|
|
83
|
+
if handoff is not None:
|
|
84
|
+
handoff.status = "accepted"
|
|
85
|
+
handoff.accepted_by = p.accepted_by
|
|
86
|
+
self.phase = "Accepted"
|
|
87
|
+
return
|
|
88
|
+
|
|
89
|
+
if mt == "HandoffDecline":
|
|
90
|
+
p = handoff_pb2.HandoffDeclinePayload()
|
|
91
|
+
p.ParseFromString(envelope.payload)
|
|
92
|
+
handoff = self.handoffs.get(p.handoff_id)
|
|
93
|
+
if handoff is not None:
|
|
94
|
+
handoff.status = "declined"
|
|
95
|
+
handoff.declined_by = p.declined_by
|
|
96
|
+
self.phase = "Declined"
|
|
97
|
+
|
|
98
|
+
# -- State query helpers --
|
|
99
|
+
|
|
100
|
+
def has_accepted_offer(self, handoff_id: str | None = None) -> bool:
|
|
101
|
+
"""True if any offer (or a specific one) has been accepted."""
|
|
102
|
+
if handoff_id is not None:
|
|
103
|
+
handoff = self.handoffs.get(handoff_id)
|
|
104
|
+
return handoff is not None and handoff.status == "accepted"
|
|
105
|
+
return any(h.status == "accepted" for h in self.handoffs.values())
|
|
106
|
+
|
|
107
|
+
def active_offer(self) -> HandoffRecord | None:
|
|
108
|
+
"""Return the most recent pending offer, or None."""
|
|
109
|
+
for handoff in reversed(list(self.handoffs.values())):
|
|
110
|
+
if handoff.status in ("offered", "context_sent"):
|
|
111
|
+
return handoff
|
|
112
|
+
return None
|
|
113
|
+
|
|
114
|
+
def is_accepted(self, handoff_id: str) -> bool:
|
|
115
|
+
handoff = self.handoffs.get(handoff_id)
|
|
116
|
+
return handoff is not None and handoff.status == "accepted"
|
|
117
|
+
|
|
118
|
+
def is_declined(self, handoff_id: str) -> bool:
|
|
119
|
+
handoff = self.handoffs.get(handoff_id)
|
|
120
|
+
return handoff is not None and handoff.status == "declined"
|
|
121
|
+
|
|
122
|
+
def get_handoff(self, handoff_id: str) -> HandoffRecord | None:
|
|
123
|
+
"""Return the handoff record for *handoff_id*, or None."""
|
|
124
|
+
return self.handoffs.get(handoff_id)
|
|
125
|
+
|
|
126
|
+
def pending_handoffs(self) -> list[HandoffRecord]:
|
|
127
|
+
"""Return handoffs that are still pending (offered or context_sent)."""
|
|
128
|
+
return [h for h in self.handoffs.values() if h.status in ("offered", "context_sent")]
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
# ---------------------------------------------------------------------------
|
|
132
|
+
# Session helper
|
|
133
|
+
# ---------------------------------------------------------------------------
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
class HandoffSession(BaseSession):
|
|
137
|
+
"""High-level helper for Handoff mode sessions."""
|
|
138
|
+
|
|
139
|
+
MODE = MODE_HANDOFF
|
|
140
|
+
|
|
141
|
+
def _create_projection(self) -> BaseProjection:
|
|
142
|
+
return HandoffProjection()
|
|
143
|
+
|
|
144
|
+
@property
|
|
145
|
+
def handoff_projection(self) -> HandoffProjection:
|
|
146
|
+
assert isinstance(self.projection, HandoffProjection)
|
|
147
|
+
return self.projection
|
|
148
|
+
|
|
149
|
+
def offer(
|
|
150
|
+
self,
|
|
151
|
+
handoff_id: str,
|
|
152
|
+
target_participant: str,
|
|
153
|
+
*,
|
|
154
|
+
scope: str = "",
|
|
155
|
+
reason: str = "",
|
|
156
|
+
sender: str | None = None,
|
|
157
|
+
auth: AuthConfig | None = None,
|
|
158
|
+
) -> envelope_pb2.Ack:
|
|
159
|
+
payload = handoff_pb2.HandoffOfferPayload(
|
|
160
|
+
handoff_id=handoff_id,
|
|
161
|
+
target_participant=target_participant,
|
|
162
|
+
scope=scope,
|
|
163
|
+
reason=reason,
|
|
164
|
+
)
|
|
165
|
+
envelope = build_envelope(
|
|
166
|
+
mode=self.MODE,
|
|
167
|
+
message_type="HandoffOffer",
|
|
168
|
+
session_id=self.session_id,
|
|
169
|
+
sender=self._sender_for(sender, auth=auth),
|
|
170
|
+
payload=serialize_message(payload),
|
|
171
|
+
)
|
|
172
|
+
return self._send_and_track(envelope, auth=auth)
|
|
173
|
+
|
|
174
|
+
def add_context(
|
|
175
|
+
self,
|
|
176
|
+
handoff_id: str,
|
|
177
|
+
*,
|
|
178
|
+
content_type: str = "application/octet-stream",
|
|
179
|
+
context: bytes = b"",
|
|
180
|
+
sender: str | None = None,
|
|
181
|
+
auth: AuthConfig | None = None,
|
|
182
|
+
) -> envelope_pb2.Ack:
|
|
183
|
+
payload = handoff_pb2.HandoffContextPayload(
|
|
184
|
+
handoff_id=handoff_id,
|
|
185
|
+
content_type=content_type,
|
|
186
|
+
context=context,
|
|
187
|
+
)
|
|
188
|
+
envelope = build_envelope(
|
|
189
|
+
mode=self.MODE,
|
|
190
|
+
message_type="HandoffContext",
|
|
191
|
+
session_id=self.session_id,
|
|
192
|
+
sender=self._sender_for(sender, auth=auth),
|
|
193
|
+
payload=serialize_message(payload),
|
|
194
|
+
)
|
|
195
|
+
return self._send_and_track(envelope, auth=auth)
|
|
196
|
+
|
|
197
|
+
def accept_handoff(
|
|
198
|
+
self,
|
|
199
|
+
handoff_id: str,
|
|
200
|
+
*,
|
|
201
|
+
accepted_by: str = "",
|
|
202
|
+
reason: str = "",
|
|
203
|
+
sender: str | None = None,
|
|
204
|
+
auth: AuthConfig | None = None,
|
|
205
|
+
) -> envelope_pb2.Ack:
|
|
206
|
+
payload = handoff_pb2.HandoffAcceptPayload(
|
|
207
|
+
handoff_id=handoff_id,
|
|
208
|
+
accepted_by=accepted_by or self._sender_for(sender, auth=auth),
|
|
209
|
+
reason=reason,
|
|
210
|
+
)
|
|
211
|
+
envelope = build_envelope(
|
|
212
|
+
mode=self.MODE,
|
|
213
|
+
message_type="HandoffAccept",
|
|
214
|
+
session_id=self.session_id,
|
|
215
|
+
sender=self._sender_for(sender, auth=auth),
|
|
216
|
+
payload=serialize_message(payload),
|
|
217
|
+
)
|
|
218
|
+
return self._send_and_track(envelope, auth=auth)
|
|
219
|
+
|
|
220
|
+
def decline(
|
|
221
|
+
self,
|
|
222
|
+
handoff_id: str,
|
|
223
|
+
*,
|
|
224
|
+
declined_by: str = "",
|
|
225
|
+
reason: str = "",
|
|
226
|
+
sender: str | None = None,
|
|
227
|
+
auth: AuthConfig | None = None,
|
|
228
|
+
) -> envelope_pb2.Ack:
|
|
229
|
+
payload = handoff_pb2.HandoffDeclinePayload(
|
|
230
|
+
handoff_id=handoff_id,
|
|
231
|
+
declined_by=declined_by or self._sender_for(sender, auth=auth),
|
|
232
|
+
reason=reason,
|
|
233
|
+
)
|
|
234
|
+
envelope = build_envelope(
|
|
235
|
+
mode=self.MODE,
|
|
236
|
+
message_type="HandoffDecline",
|
|
237
|
+
session_id=self.session_id,
|
|
238
|
+
sender=self._sender_for(sender, auth=auth),
|
|
239
|
+
payload=serialize_message(payload),
|
|
240
|
+
)
|
|
241
|
+
return self._send_and_track(envelope, auth=auth)
|