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/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)