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/quorum.py ADDED
@@ -0,0 +1,258 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+
5
+ from macp.modes.quorum.v1 import quorum_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_QUORUM
12
+ from .envelope import build_envelope, serialize_message
13
+
14
+ # ---------------------------------------------------------------------------
15
+ # Projection records
16
+ # ---------------------------------------------------------------------------
17
+
18
+
19
+ @dataclass(slots=True)
20
+ class ApprovalRequestRecord:
21
+ request_id: str
22
+ action: str
23
+ summary: str
24
+ required_approvals: int
25
+ requester: str
26
+
27
+
28
+ @dataclass(slots=True)
29
+ class BallotRecord:
30
+ request_id: str
31
+ vote: str # "approve" | "reject" | "abstain"
32
+ reason: str
33
+ sender: str
34
+
35
+
36
+ # ---------------------------------------------------------------------------
37
+ # Projection
38
+ # ---------------------------------------------------------------------------
39
+
40
+
41
+ class QuorumProjection(BaseProjection):
42
+ """In-process state tracking for Quorum mode sessions.
43
+
44
+ Supports multiple concurrent approval requests within a single session.
45
+ Query methods accept a ``request_id`` parameter to target a specific
46
+ request.
47
+ """
48
+
49
+ MODE = MODE_QUORUM
50
+
51
+ def __init__(self) -> None:
52
+ super().__init__()
53
+ self.phase = "Pending"
54
+ self.requests: dict[str, ApprovalRequestRecord] = {}
55
+ self.ballots: dict[str, dict[str, BallotRecord]] = {} # request_id -> sender -> ballot
56
+
57
+ def _apply_mode_message(self, envelope: envelope_pb2.Envelope) -> None:
58
+ mt = envelope.message_type
59
+
60
+ if mt == "ApprovalRequest":
61
+ p = quorum_pb2.ApprovalRequestPayload()
62
+ p.ParseFromString(envelope.payload)
63
+ self.requests[p.request_id] = ApprovalRequestRecord(
64
+ request_id=p.request_id,
65
+ action=p.action,
66
+ summary=p.summary,
67
+ required_approvals=p.required_approvals,
68
+ requester=envelope.sender,
69
+ )
70
+ self.phase = "Voting"
71
+ return
72
+
73
+ if mt == "Approve":
74
+ p = quorum_pb2.ApprovePayload()
75
+ p.ParseFromString(envelope.payload)
76
+ self._set_ballot(p.request_id, envelope.sender, "approve", p.reason)
77
+ return
78
+
79
+ if mt == "Reject":
80
+ p = quorum_pb2.RejectPayload()
81
+ p.ParseFromString(envelope.payload)
82
+ self._set_ballot(p.request_id, envelope.sender, "reject", p.reason)
83
+ return
84
+
85
+ if mt == "Abstain":
86
+ p = quorum_pb2.AbstainPayload()
87
+ p.ParseFromString(envelope.payload)
88
+ self._set_ballot(p.request_id, envelope.sender, "abstain", p.reason)
89
+
90
+ def _set_ballot(self, request_id: str, sender: str, vote: str, reason: str) -> None:
91
+ sender_map = self.ballots.setdefault(request_id, {})
92
+ sender_map[sender] = BallotRecord(
93
+ request_id=request_id,
94
+ vote=vote,
95
+ reason=reason,
96
+ sender=sender,
97
+ )
98
+
99
+ def _count_votes(self, request_id: str, vote: str) -> int:
100
+ sender_map = self.ballots.get(request_id)
101
+ if not sender_map:
102
+ return 0
103
+ return sum(1 for b in sender_map.values() if b.vote == vote)
104
+
105
+ # -- State query helpers --
106
+
107
+ def approval_count(self, request_id: str) -> int:
108
+ return self._count_votes(request_id, "approve")
109
+
110
+ def rejection_count(self, request_id: str) -> int:
111
+ return self._count_votes(request_id, "reject")
112
+
113
+ def abstention_count(self, request_id: str) -> int:
114
+ return self._count_votes(request_id, "abstain")
115
+
116
+ def is_threshold_reached(self, request_id: str) -> bool:
117
+ req = self.requests.get(request_id)
118
+ if req is None:
119
+ return False
120
+ return self.approval_count(request_id) >= req.required_approvals
121
+
122
+ def is_threshold_unreachable(self, request_id: str, total_eligible: int) -> bool:
123
+ """True if remaining possible approvals cannot reach the threshold."""
124
+ req = self.requests.get(request_id)
125
+ if req is None:
126
+ return False
127
+ remaining = total_eligible - len(self.voted_senders(request_id))
128
+ return self.approval_count(request_id) + remaining < req.required_approvals
129
+
130
+ def commitment_ready(self, request_id: str) -> bool:
131
+ """True if the threshold is reached."""
132
+ return self.is_threshold_reached(request_id)
133
+
134
+ def threshold(self, request_id: str) -> int:
135
+ """Return the required approval count, or 0 if no request yet."""
136
+ req = self.requests.get(request_id)
137
+ return req.required_approvals if req else 0
138
+
139
+ def voted_senders(self, request_id: str) -> list[str]:
140
+ """Return list of senders who have cast a ballot for this request."""
141
+ sender_map = self.ballots.get(request_id)
142
+ return list(sender_map.keys()) if sender_map else []
143
+
144
+ def remaining_votes_needed(self, request_id: str) -> int:
145
+ """Return how many more approvals are needed to reach quorum."""
146
+ req = self.requests.get(request_id)
147
+ if req is None:
148
+ return 0
149
+ return max(0, req.required_approvals - self.approval_count(request_id))
150
+
151
+
152
+ # ---------------------------------------------------------------------------
153
+ # Session helper
154
+ # ---------------------------------------------------------------------------
155
+
156
+
157
+ class QuorumSession(BaseSession):
158
+ """High-level helper for Quorum mode sessions."""
159
+
160
+ MODE = MODE_QUORUM
161
+
162
+ def _create_projection(self) -> BaseProjection:
163
+ return QuorumProjection()
164
+
165
+ @property
166
+ def quorum_projection(self) -> QuorumProjection:
167
+ assert isinstance(self.projection, QuorumProjection)
168
+ return self.projection
169
+
170
+ def request_approval(
171
+ self,
172
+ request_id: str,
173
+ action: str,
174
+ *,
175
+ summary: str = "",
176
+ details: bytes = b"",
177
+ required_approvals: int,
178
+ sender: str | None = None,
179
+ auth: AuthConfig | None = None,
180
+ ) -> envelope_pb2.Ack:
181
+ payload = quorum_pb2.ApprovalRequestPayload(
182
+ request_id=request_id,
183
+ action=action,
184
+ summary=summary,
185
+ details=details,
186
+ required_approvals=required_approvals,
187
+ )
188
+ envelope = build_envelope(
189
+ mode=self.MODE,
190
+ message_type="ApprovalRequest",
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 approve(
198
+ self,
199
+ request_id: str,
200
+ *,
201
+ reason: str = "",
202
+ sender: str | None = None,
203
+ auth: AuthConfig | None = None,
204
+ ) -> envelope_pb2.Ack:
205
+ payload = quorum_pb2.ApprovePayload(
206
+ request_id=request_id,
207
+ reason=reason,
208
+ )
209
+ envelope = build_envelope(
210
+ mode=self.MODE,
211
+ message_type="Approve",
212
+ session_id=self.session_id,
213
+ sender=self._sender_for(sender, auth=auth),
214
+ payload=serialize_message(payload),
215
+ )
216
+ return self._send_and_track(envelope, auth=auth)
217
+
218
+ def reject(
219
+ self,
220
+ request_id: str,
221
+ *,
222
+ reason: str = "",
223
+ sender: str | None = None,
224
+ auth: AuthConfig | None = None,
225
+ ) -> envelope_pb2.Ack:
226
+ payload = quorum_pb2.RejectPayload(
227
+ request_id=request_id,
228
+ reason=reason,
229
+ )
230
+ envelope = build_envelope(
231
+ mode=self.MODE,
232
+ message_type="Reject",
233
+ session_id=self.session_id,
234
+ sender=self._sender_for(sender, auth=auth),
235
+ payload=serialize_message(payload),
236
+ )
237
+ return self._send_and_track(envelope, auth=auth)
238
+
239
+ def abstain(
240
+ self,
241
+ request_id: str,
242
+ *,
243
+ reason: str = "",
244
+ sender: str | None = None,
245
+ auth: AuthConfig | None = None,
246
+ ) -> envelope_pb2.Ack:
247
+ payload = quorum_pb2.AbstainPayload(
248
+ request_id=request_id,
249
+ reason=reason,
250
+ )
251
+ envelope = build_envelope(
252
+ mode=self.MODE,
253
+ message_type="Abstain",
254
+ session_id=self.session_id,
255
+ sender=self._sender_for(sender, auth=auth),
256
+ payload=serialize_message(payload),
257
+ )
258
+ return self._send_and_track(envelope, auth=auth)
macp_sdk/retry.py ADDED
@@ -0,0 +1,64 @@
1
+ from __future__ import annotations
2
+
3
+ import time
4
+ from dataclasses import dataclass, field
5
+ from typing import Any
6
+
7
+ from ._logging import logger
8
+ from .auth import AuthConfig
9
+ from .errors import MacpRetryError, MacpTransportError
10
+
11
+ # Re-export for convenience
12
+ __all__ = ["RetryPolicy", "retry_send"]
13
+
14
+
15
+ @dataclass(frozen=True)
16
+ class RetryPolicy:
17
+ """Configuration for retry behaviour on transient failures."""
18
+
19
+ max_retries: int = 3
20
+ backoff_base: float = 0.1
21
+ backoff_max: float = 2.0
22
+ retryable_codes: frozenset[str] = field(
23
+ default_factory=lambda: frozenset({"RATE_LIMITED", "INTERNAL_ERROR"})
24
+ )
25
+
26
+
27
+ def retry_send(
28
+ client: Any,
29
+ envelope: Any,
30
+ *,
31
+ policy: RetryPolicy | None = None,
32
+ auth: AuthConfig | None = None,
33
+ ) -> Any:
34
+ """Send an envelope with retries on transient failures.
35
+
36
+ Uses the client's ``send`` method. On ``MacpTransportError`` or retryable
37
+ NACK codes, backs off and retries up to ``policy.max_retries`` times.
38
+ """
39
+ from .errors import MacpAckError # avoid circular
40
+
41
+ pol = policy or RetryPolicy()
42
+ last_error: Exception | None = None
43
+
44
+ for attempt in range(1 + pol.max_retries):
45
+ try:
46
+ return client.send(envelope, auth=auth)
47
+ except MacpTransportError as exc:
48
+ last_error = exc
49
+ except MacpAckError as exc:
50
+ if exc.failure.code not in pol.retryable_codes:
51
+ raise
52
+ last_error = exc
53
+
54
+ if attempt < pol.max_retries:
55
+ delay = min(pol.backoff_base * (2**attempt), pol.backoff_max)
56
+ logger.debug(
57
+ "retry attempt=%d delay=%.2fs error=%s",
58
+ attempt + 1,
59
+ delay,
60
+ last_error,
61
+ )
62
+ time.sleep(delay)
63
+
64
+ raise MacpRetryError(f"retries exhausted after {pol.max_retries} attempts") from last_error
macp_sdk/task.py ADDED
@@ -0,0 +1,368 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+
5
+ from macp.modes.task.v1 import task_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_TASK
12
+ from .envelope import build_envelope, serialize_message
13
+
14
+ # ---------------------------------------------------------------------------
15
+ # Projection records
16
+ # ---------------------------------------------------------------------------
17
+
18
+
19
+ @dataclass(slots=True)
20
+ class TaskRequestRecord:
21
+ task_id: str
22
+ title: str
23
+ instructions: str
24
+ requested_assignee: str
25
+ requester: str
26
+
27
+
28
+ @dataclass(slots=True)
29
+ class TaskRejectRecord:
30
+ task_id: str
31
+ assignee: str
32
+ reason: str
33
+
34
+
35
+ @dataclass(slots=True)
36
+ class TaskUpdateRecord:
37
+ task_id: str
38
+ status: str
39
+ progress: float
40
+ message: str
41
+
42
+
43
+ @dataclass(slots=True)
44
+ class TaskCompleteRecord:
45
+ task_id: str
46
+ assignee: str
47
+ summary: str
48
+ output: bytes
49
+
50
+
51
+ @dataclass(slots=True)
52
+ class TaskFailRecord:
53
+ task_id: str
54
+ assignee: str
55
+ error_code: str
56
+ reason: str
57
+ retryable: bool
58
+
59
+
60
+ # ---------------------------------------------------------------------------
61
+ # Projection
62
+ # ---------------------------------------------------------------------------
63
+
64
+
65
+ class TaskProjection(BaseProjection):
66
+ """In-process state tracking for Task mode sessions.
67
+
68
+ Supports multiple tasks within a single session. Each task is tracked
69
+ independently with its own status and progress.
70
+ """
71
+
72
+ MODE = MODE_TASK
73
+
74
+ def __init__(self) -> None:
75
+ super().__init__()
76
+ self.phase = "Pending"
77
+ self.tasks: dict[str, TaskRequestRecord] = {}
78
+ self.updates: list[TaskUpdateRecord] = []
79
+ self.completions: list[TaskCompleteRecord] = []
80
+ self.failures: list[TaskFailRecord] = []
81
+ # Per-task mutable state
82
+ self._assignees: dict[str, str] = {} # task_id -> assignee
83
+ self._statuses: dict[str, str] = {} # task_id -> status
84
+ self._progress: dict[str, float] = {} # task_id -> progress
85
+
86
+ def _apply_mode_message(self, envelope: envelope_pb2.Envelope) -> None:
87
+ mt = envelope.message_type
88
+
89
+ if mt == "TaskRequest":
90
+ p = task_pb2.TaskRequestPayload()
91
+ p.ParseFromString(envelope.payload)
92
+ self.tasks[p.task_id] = TaskRequestRecord(
93
+ task_id=p.task_id,
94
+ title=p.title,
95
+ instructions=p.instructions,
96
+ requested_assignee=p.requested_assignee,
97
+ requester=envelope.sender,
98
+ )
99
+ self._statuses[p.task_id] = "requested"
100
+ self._progress[p.task_id] = 0.0
101
+ self.phase = "Requested"
102
+ return
103
+
104
+ if mt == "TaskAccept":
105
+ p = task_pb2.TaskAcceptPayload()
106
+ p.ParseFromString(envelope.payload)
107
+ assignee = p.assignee or envelope.sender
108
+ self._assignees[p.task_id] = assignee
109
+ self._statuses[p.task_id] = "accepted"
110
+ self.phase = "InProgress"
111
+ return
112
+
113
+ if mt == "TaskReject":
114
+ p = task_pb2.TaskRejectPayload()
115
+ p.ParseFromString(envelope.payload)
116
+ self._statuses[p.task_id] = "rejected"
117
+ return
118
+
119
+ if mt == "TaskUpdate":
120
+ p = task_pb2.TaskUpdatePayload()
121
+ p.ParseFromString(envelope.payload)
122
+ self.updates.append(
123
+ TaskUpdateRecord(
124
+ task_id=p.task_id,
125
+ status=p.status,
126
+ progress=p.progress,
127
+ message=p.message,
128
+ )
129
+ )
130
+ self._statuses[p.task_id] = "in_progress"
131
+ self._progress[p.task_id] = p.progress
132
+ return
133
+
134
+ if mt == "TaskComplete":
135
+ p = task_pb2.TaskCompletePayload()
136
+ p.ParseFromString(envelope.payload)
137
+ self.completions.append(
138
+ TaskCompleteRecord(
139
+ task_id=p.task_id,
140
+ assignee=p.assignee or envelope.sender,
141
+ summary=p.summary,
142
+ output=p.output,
143
+ )
144
+ )
145
+ self._statuses[p.task_id] = "completed"
146
+ self._progress[p.task_id] = 1.0
147
+ self.phase = "Completed"
148
+ return
149
+
150
+ if mt == "TaskFail":
151
+ p = task_pb2.TaskFailPayload()
152
+ p.ParseFromString(envelope.payload)
153
+ self.failures.append(
154
+ TaskFailRecord(
155
+ task_id=p.task_id,
156
+ assignee=p.assignee or envelope.sender,
157
+ error_code=p.error_code,
158
+ reason=p.reason,
159
+ retryable=p.retryable,
160
+ )
161
+ )
162
+ self._statuses[p.task_id] = "failed"
163
+ self.phase = "Failed"
164
+
165
+ # -- State query helpers --
166
+
167
+ def get_task(self, task_id: str) -> TaskRequestRecord | None:
168
+ """Return the task request record for *task_id*, or None."""
169
+ return self.tasks.get(task_id)
170
+
171
+ def is_accepted(self, task_id: str) -> bool:
172
+ status = self._statuses.get(task_id)
173
+ return status == "accepted" or status == "in_progress"
174
+
175
+ def is_completed(self, task_id: str) -> bool:
176
+ return self._statuses.get(task_id) == "completed"
177
+
178
+ def is_failed(self, task_id: str) -> bool:
179
+ return self._statuses.get(task_id) == "failed"
180
+
181
+ def is_retryable(self, task_id: str) -> bool:
182
+ """True if the task failed with ``retryable=True``."""
183
+ return any(f.task_id == task_id and f.retryable for f in self.failures)
184
+
185
+ def progress_of(self, task_id: str) -> float:
186
+ """Return the latest progress value for *task_id*, or 0 if unknown."""
187
+ return self._progress.get(task_id, 0.0)
188
+
189
+ def latest_progress(self) -> float | None:
190
+ return self.updates[-1].progress if self.updates else None
191
+
192
+ def active_tasks(self) -> list[TaskRequestRecord]:
193
+ """Return task records that are not in a terminal state."""
194
+ active_statuses = {"requested", "accepted", "in_progress"}
195
+ return [t for t in self.tasks.values() if self._statuses.get(t.task_id) in active_statuses]
196
+
197
+
198
+ # ---------------------------------------------------------------------------
199
+ # Session helper
200
+ # ---------------------------------------------------------------------------
201
+
202
+
203
+ class TaskSession(BaseSession):
204
+ """High-level helper for Task mode sessions."""
205
+
206
+ MODE = MODE_TASK
207
+
208
+ def _create_projection(self) -> BaseProjection:
209
+ return TaskProjection()
210
+
211
+ @property
212
+ def task_projection(self) -> TaskProjection:
213
+ assert isinstance(self.projection, TaskProjection)
214
+ return self.projection
215
+
216
+ def request(
217
+ self,
218
+ task_id: str,
219
+ title: str,
220
+ *,
221
+ instructions: str = "",
222
+ requested_assignee: str = "",
223
+ input_data: bytes = b"",
224
+ deadline_unix_ms: int = 0,
225
+ sender: str | None = None,
226
+ auth: AuthConfig | None = None,
227
+ ) -> envelope_pb2.Ack:
228
+ payload = task_pb2.TaskRequestPayload(
229
+ task_id=task_id,
230
+ title=title,
231
+ instructions=instructions,
232
+ requested_assignee=requested_assignee,
233
+ input=input_data,
234
+ deadline_unix_ms=deadline_unix_ms,
235
+ )
236
+ envelope = build_envelope(
237
+ mode=self.MODE,
238
+ message_type="TaskRequest",
239
+ session_id=self.session_id,
240
+ sender=self._sender_for(sender, auth=auth),
241
+ payload=serialize_message(payload),
242
+ )
243
+ return self._send_and_track(envelope, auth=auth)
244
+
245
+ def accept_task(
246
+ self,
247
+ task_id: str,
248
+ *,
249
+ assignee: str = "",
250
+ reason: str = "",
251
+ sender: str | None = None,
252
+ auth: AuthConfig | None = None,
253
+ ) -> envelope_pb2.Ack:
254
+ payload = task_pb2.TaskAcceptPayload(
255
+ task_id=task_id,
256
+ assignee=assignee or self._sender_for(sender, auth=auth),
257
+ reason=reason,
258
+ )
259
+ envelope = build_envelope(
260
+ mode=self.MODE,
261
+ message_type="TaskAccept",
262
+ session_id=self.session_id,
263
+ sender=self._sender_for(sender, auth=auth),
264
+ payload=serialize_message(payload),
265
+ )
266
+ return self._send_and_track(envelope, auth=auth)
267
+
268
+ def reject_task(
269
+ self,
270
+ task_id: str,
271
+ *,
272
+ assignee: str = "",
273
+ reason: str = "",
274
+ sender: str | None = None,
275
+ auth: AuthConfig | None = None,
276
+ ) -> envelope_pb2.Ack:
277
+ payload = task_pb2.TaskRejectPayload(
278
+ task_id=task_id,
279
+ assignee=assignee or self._sender_for(sender, auth=auth),
280
+ reason=reason,
281
+ )
282
+ envelope = build_envelope(
283
+ mode=self.MODE,
284
+ message_type="TaskReject",
285
+ session_id=self.session_id,
286
+ sender=self._sender_for(sender, auth=auth),
287
+ payload=serialize_message(payload),
288
+ )
289
+ return self._send_and_track(envelope, auth=auth)
290
+
291
+ def update(
292
+ self,
293
+ task_id: str,
294
+ *,
295
+ status: str = "",
296
+ progress: float = 0.0,
297
+ message: str = "",
298
+ partial_output: bytes = b"",
299
+ sender: str | None = None,
300
+ auth: AuthConfig | None = None,
301
+ ) -> envelope_pb2.Ack:
302
+ payload = task_pb2.TaskUpdatePayload(
303
+ task_id=task_id,
304
+ status=status,
305
+ progress=progress,
306
+ message=message,
307
+ partial_output=partial_output,
308
+ )
309
+ envelope = build_envelope(
310
+ mode=self.MODE,
311
+ message_type="TaskUpdate",
312
+ session_id=self.session_id,
313
+ sender=self._sender_for(sender, auth=auth),
314
+ payload=serialize_message(payload),
315
+ )
316
+ return self._send_and_track(envelope, auth=auth)
317
+
318
+ def complete(
319
+ self,
320
+ task_id: str,
321
+ *,
322
+ assignee: str = "",
323
+ output: bytes = b"",
324
+ summary: str = "",
325
+ sender: str | None = None,
326
+ auth: AuthConfig | None = None,
327
+ ) -> envelope_pb2.Ack:
328
+ payload = task_pb2.TaskCompletePayload(
329
+ task_id=task_id,
330
+ assignee=assignee or self._sender_for(sender, auth=auth),
331
+ output=output,
332
+ summary=summary,
333
+ )
334
+ envelope = build_envelope(
335
+ mode=self.MODE,
336
+ message_type="TaskComplete",
337
+ session_id=self.session_id,
338
+ sender=self._sender_for(sender, auth=auth),
339
+ payload=serialize_message(payload),
340
+ )
341
+ return self._send_and_track(envelope, auth=auth)
342
+
343
+ def fail(
344
+ self,
345
+ task_id: str,
346
+ *,
347
+ assignee: str = "",
348
+ error_code: str = "",
349
+ reason: str = "",
350
+ retryable: bool = False,
351
+ sender: str | None = None,
352
+ auth: AuthConfig | None = None,
353
+ ) -> envelope_pb2.Ack:
354
+ payload = task_pb2.TaskFailPayload(
355
+ task_id=task_id,
356
+ assignee=assignee or self._sender_for(sender, auth=auth),
357
+ error_code=error_code,
358
+ reason=reason,
359
+ retryable=retryable,
360
+ )
361
+ envelope = build_envelope(
362
+ mode=self.MODE,
363
+ message_type="TaskFail",
364
+ session_id=self.session_id,
365
+ sender=self._sender_for(sender, auth=auth),
366
+ payload=serialize_message(payload),
367
+ )
368
+ return self._send_and_track(envelope, auth=auth)