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/proposal.py ADDED
@@ -0,0 +1,305 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+
5
+ from macp.modes.proposal.v1 import proposal_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_PROPOSAL
12
+ from .envelope import build_envelope, serialize_message
13
+ from .errors import MacpSessionError
14
+
15
+ # ---------------------------------------------------------------------------
16
+ # Projection records
17
+ # ---------------------------------------------------------------------------
18
+
19
+
20
+ @dataclass(slots=True)
21
+ class ProposalRecord:
22
+ proposal_id: str
23
+ title: str
24
+ summary: str
25
+ proposer: str
26
+ supersedes: str # "" if original
27
+ status: str # "open" | "accepted" | "rejected" | "withdrawn"
28
+ tags: list[str]
29
+
30
+
31
+ @dataclass(slots=True)
32
+ class RejectRecord:
33
+ proposal_id: str
34
+ reason: str
35
+ sender: str
36
+ terminal: bool
37
+
38
+
39
+ @dataclass(slots=True)
40
+ class AcceptRecord:
41
+ proposal_id: str
42
+ reason: str
43
+ sender: str
44
+
45
+
46
+ # ---------------------------------------------------------------------------
47
+ # Projection
48
+ # ---------------------------------------------------------------------------
49
+
50
+
51
+ class ProposalProjection(BaseProjection):
52
+ """In-process state tracking for Proposal mode sessions."""
53
+
54
+ MODE = MODE_PROPOSAL
55
+
56
+ def __init__(self) -> None:
57
+ super().__init__()
58
+ self.phase = "Negotiating"
59
+ self.proposals: dict[str, ProposalRecord] = {}
60
+ self.accepts: list[AcceptRecord] = []
61
+ self.rejections: list[RejectRecord] = []
62
+
63
+ def _apply_mode_message(self, envelope: envelope_pb2.Envelope) -> None:
64
+ mt = envelope.message_type
65
+
66
+ if mt == "Proposal":
67
+ p = proposal_pb2.ProposalPayload()
68
+ p.ParseFromString(envelope.payload)
69
+ self.proposals[p.proposal_id] = ProposalRecord(
70
+ proposal_id=p.proposal_id,
71
+ title=p.title,
72
+ summary=p.summary,
73
+ proposer=envelope.sender,
74
+ supersedes="",
75
+ status="open",
76
+ tags=list(p.tags),
77
+ )
78
+ return
79
+
80
+ if mt == "CounterProposal":
81
+ p = proposal_pb2.CounterProposalPayload()
82
+ p.ParseFromString(envelope.payload)
83
+ self.proposals[p.proposal_id] = ProposalRecord(
84
+ proposal_id=p.proposal_id,
85
+ title=p.title,
86
+ summary=p.summary,
87
+ proposer=envelope.sender,
88
+ supersedes=p.supersedes_proposal_id,
89
+ status="open",
90
+ tags=[],
91
+ )
92
+ return
93
+
94
+ if mt == "Accept":
95
+ p = proposal_pb2.AcceptPayload()
96
+ p.ParseFromString(envelope.payload)
97
+ self.accepts.append(
98
+ AcceptRecord(
99
+ proposal_id=p.proposal_id,
100
+ reason=p.reason,
101
+ sender=envelope.sender,
102
+ )
103
+ )
104
+ return
105
+
106
+ if mt == "Reject":
107
+ p = proposal_pb2.RejectPayload()
108
+ p.ParseFromString(envelope.payload)
109
+ self.rejections.append(
110
+ RejectRecord(
111
+ proposal_id=p.proposal_id,
112
+ reason=p.reason,
113
+ sender=envelope.sender,
114
+ terminal=p.terminal,
115
+ )
116
+ )
117
+ if p.terminal:
118
+ rec = self.proposals.get(p.proposal_id)
119
+ if rec is not None:
120
+ rec.status = "rejected"
121
+ self.phase = "TerminalRejected"
122
+ return
123
+
124
+ if mt == "Withdraw":
125
+ p = proposal_pb2.WithdrawPayload()
126
+ p.ParseFromString(envelope.payload)
127
+ rec = self.proposals.get(p.proposal_id)
128
+ if rec is not None:
129
+ rec.status = "withdrawn"
130
+
131
+ # -- State query helpers --
132
+
133
+ def live_proposals(self) -> dict[str, ProposalRecord]:
134
+ """Return proposals that have not been withdrawn."""
135
+ return {k: v for k, v in self.proposals.items() if v.status != "withdrawn"}
136
+
137
+ def accepted_proposal(self) -> str | None:
138
+ """Return the proposal_id that all accepting senders agree on, or None."""
139
+ if not self.accepts:
140
+ return None
141
+ ids = {a.proposal_id for a in self.accepts}
142
+ if len(ids) == 1:
143
+ return ids.pop()
144
+ return None
145
+
146
+ def has_terminal_rejection(self) -> bool:
147
+ return any(r.terminal for r in self.rejections)
148
+
149
+ def active_proposals(self) -> list[ProposalRecord]:
150
+ """Return proposals whose status is 'open'."""
151
+ return [p for p in self.proposals.values() if p.status == "open"]
152
+
153
+ def latest_proposal(self) -> ProposalRecord | None:
154
+ """Return the most recently added proposal, or None."""
155
+ if not self.proposals:
156
+ return None
157
+ return list(self.proposals.values())[-1]
158
+
159
+ def is_accepted(self, proposal_id: str) -> bool:
160
+ """True if any accept record references *proposal_id*."""
161
+ return any(a.proposal_id == proposal_id for a in self.accepts)
162
+
163
+ def is_terminally_rejected(self, proposal_id: str) -> bool:
164
+ """True if a terminal rejection exists for *proposal_id*."""
165
+ return any(r.proposal_id == proposal_id and r.terminal for r in self.rejections)
166
+
167
+
168
+ # ---------------------------------------------------------------------------
169
+ # Session helper
170
+ # ---------------------------------------------------------------------------
171
+
172
+
173
+ class ProposalSession(BaseSession):
174
+ """High-level helper for Proposal mode sessions."""
175
+
176
+ MODE = MODE_PROPOSAL
177
+
178
+ def _create_projection(self) -> BaseProjection:
179
+ return ProposalProjection()
180
+
181
+ @property
182
+ def proposal_projection(self) -> ProposalProjection:
183
+ assert isinstance(self.projection, ProposalProjection)
184
+ return self.projection
185
+
186
+ def propose(
187
+ self,
188
+ proposal_id: str,
189
+ title: str,
190
+ *,
191
+ summary: str = "",
192
+ details: bytes = b"",
193
+ tags: list[str] | None = None,
194
+ sender: str | None = None,
195
+ auth: AuthConfig | None = None,
196
+ ) -> envelope_pb2.Ack:
197
+ payload = proposal_pb2.ProposalPayload(
198
+ proposal_id=proposal_id,
199
+ title=title,
200
+ summary=summary,
201
+ details=details,
202
+ tags=tags or [],
203
+ )
204
+ envelope = build_envelope(
205
+ mode=self.MODE,
206
+ message_type="Proposal",
207
+ session_id=self.session_id,
208
+ sender=self._sender_for(sender, auth=auth),
209
+ payload=serialize_message(payload),
210
+ )
211
+ return self._send_and_track(envelope, auth=auth)
212
+
213
+ def counter_propose(
214
+ self,
215
+ proposal_id: str,
216
+ supersedes_proposal_id: str,
217
+ title: str,
218
+ *,
219
+ summary: str = "",
220
+ details: bytes = b"",
221
+ sender: str | None = None,
222
+ auth: AuthConfig | None = None,
223
+ ) -> envelope_pb2.Ack:
224
+ payload = proposal_pb2.CounterProposalPayload(
225
+ proposal_id=proposal_id,
226
+ supersedes_proposal_id=supersedes_proposal_id,
227
+ title=title,
228
+ summary=summary,
229
+ details=details,
230
+ )
231
+ envelope = build_envelope(
232
+ mode=self.MODE,
233
+ message_type="CounterProposal",
234
+ session_id=self.session_id,
235
+ sender=self._sender_for(sender, auth=auth),
236
+ payload=serialize_message(payload),
237
+ )
238
+ return self._send_and_track(envelope, auth=auth)
239
+
240
+ def accept(
241
+ self,
242
+ proposal_id: str,
243
+ *,
244
+ reason: str = "",
245
+ sender: str | None = None,
246
+ auth: AuthConfig | None = None,
247
+ ) -> envelope_pb2.Ack:
248
+ payload = proposal_pb2.AcceptPayload(
249
+ proposal_id=proposal_id,
250
+ reason=reason,
251
+ )
252
+ envelope = build_envelope(
253
+ mode=self.MODE,
254
+ message_type="Accept",
255
+ session_id=self.session_id,
256
+ sender=self._sender_for(sender, auth=auth),
257
+ payload=serialize_message(payload),
258
+ )
259
+ return self._send_and_track(envelope, auth=auth)
260
+
261
+ def reject(
262
+ self,
263
+ proposal_id: str,
264
+ *,
265
+ terminal: bool = False,
266
+ reason: str = "",
267
+ sender: str | None = None,
268
+ auth: AuthConfig | None = None,
269
+ ) -> envelope_pb2.Ack:
270
+ payload = proposal_pb2.RejectPayload(
271
+ proposal_id=proposal_id,
272
+ terminal=terminal,
273
+ reason=reason,
274
+ )
275
+ envelope = build_envelope(
276
+ mode=self.MODE,
277
+ message_type="Reject",
278
+ session_id=self.session_id,
279
+ sender=self._sender_for(sender, auth=auth),
280
+ payload=serialize_message(payload),
281
+ )
282
+ return self._send_and_track(envelope, auth=auth)
283
+
284
+ def withdraw(
285
+ self,
286
+ proposal_id: str,
287
+ *,
288
+ reason: str = "",
289
+ sender: str | None = None,
290
+ auth: AuthConfig | None = None,
291
+ ) -> envelope_pb2.Ack:
292
+ if not proposal_id or not proposal_id.strip():
293
+ raise MacpSessionError("proposal_id must be non-empty for withdraw")
294
+ payload = proposal_pb2.WithdrawPayload(
295
+ proposal_id=proposal_id,
296
+ reason=reason,
297
+ )
298
+ envelope = build_envelope(
299
+ mode=self.MODE,
300
+ message_type="Withdraw",
301
+ session_id=self.session_id,
302
+ sender=self._sender_for(sender, auth=auth),
303
+ payload=serialize_message(payload),
304
+ )
305
+ return self._send_and_track(envelope, auth=auth)
@@ -0,0 +1,150 @@
1
+ """Centralized protobuf encode/decode registry for MACP message types.
2
+
3
+ Uses compiled ``_pb2`` modules (via ``google.protobuf.symbol_database``) to
4
+ look up message classes by fully-qualified type name.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import base64
10
+ import importlib
11
+ import json
12
+ from typing import Any
13
+
14
+ from google.protobuf import json_format, symbol_database # type: ignore[import-untyped]
15
+
16
+ from .constants import (
17
+ MODE_DECISION,
18
+ MODE_HANDOFF,
19
+ MODE_MULTI_ROUND,
20
+ MODE_PROPOSAL,
21
+ MODE_QUORUM,
22
+ MODE_TASK,
23
+ )
24
+
25
+ # ── Type-name mappings (mirrors TypeScript CORE_MAP / MODE_MAP) ──────
26
+
27
+ CORE_MAP: dict[str, str] = {
28
+ "SessionStart": "macp.v1.SessionStartPayload",
29
+ "Commitment": "macp.v1.CommitmentPayload",
30
+ "Signal": "macp.v1.SignalPayload",
31
+ "Progress": "macp.v1.ProgressPayload",
32
+ }
33
+
34
+ MODE_MAP: dict[str, dict[str, str]] = {
35
+ MODE_DECISION: {
36
+ "Proposal": "macp.modes.decision.v1.ProposalPayload",
37
+ "Evaluation": "macp.modes.decision.v1.EvaluationPayload",
38
+ "Objection": "macp.modes.decision.v1.ObjectionPayload",
39
+ "Vote": "macp.modes.decision.v1.VotePayload",
40
+ },
41
+ MODE_PROPOSAL: {
42
+ "Proposal": "macp.modes.proposal.v1.ProposalPayload",
43
+ "CounterProposal": "macp.modes.proposal.v1.CounterProposalPayload",
44
+ "Accept": "macp.modes.proposal.v1.AcceptPayload",
45
+ "Reject": "macp.modes.proposal.v1.RejectPayload",
46
+ "Withdraw": "macp.modes.proposal.v1.WithdrawPayload",
47
+ },
48
+ MODE_TASK: {
49
+ "TaskRequest": "macp.modes.task.v1.TaskRequestPayload",
50
+ "TaskAccept": "macp.modes.task.v1.TaskAcceptPayload",
51
+ "TaskReject": "macp.modes.task.v1.TaskRejectPayload",
52
+ "TaskUpdate": "macp.modes.task.v1.TaskUpdatePayload",
53
+ "TaskComplete": "macp.modes.task.v1.TaskCompletePayload",
54
+ "TaskFail": "macp.modes.task.v1.TaskFailPayload",
55
+ },
56
+ MODE_HANDOFF: {
57
+ "HandoffOffer": "macp.modes.handoff.v1.HandoffOfferPayload",
58
+ "HandoffContext": "macp.modes.handoff.v1.HandoffContextPayload",
59
+ "HandoffAccept": "macp.modes.handoff.v1.HandoffAcceptPayload",
60
+ "HandoffDecline": "macp.modes.handoff.v1.HandoffDeclinePayload",
61
+ },
62
+ MODE_QUORUM: {
63
+ "ApprovalRequest": "macp.modes.quorum.v1.ApprovalRequestPayload",
64
+ "Approve": "macp.modes.quorum.v1.ApprovePayload",
65
+ "Reject": "macp.modes.quorum.v1.RejectPayload",
66
+ "Abstain": "macp.modes.quorum.v1.AbstainPayload",
67
+ },
68
+ MODE_MULTI_ROUND: {
69
+ "Contribute": "__json__",
70
+ },
71
+ }
72
+
73
+ # Ensure all _pb2 modules are imported so descriptors are registered.
74
+ _PB2_MODULES_LOADED = False
75
+
76
+
77
+ def _ensure_pb2_imports() -> None:
78
+ global _PB2_MODULES_LOADED
79
+ if _PB2_MODULES_LOADED:
80
+ return
81
+ # Import all proto modules to register their descriptors in the global pool.
82
+ for _mod in (
83
+ "macp.v1.core_pb2",
84
+ "macp.v1.envelope_pb2",
85
+ "macp.v1.policy_pb2",
86
+ "macp.modes.decision.v1.decision_pb2",
87
+ "macp.modes.proposal.v1.proposal_pb2",
88
+ "macp.modes.task.v1.task_pb2",
89
+ "macp.modes.handoff.v1.handoff_pb2",
90
+ "macp.modes.quorum.v1.quorum_pb2",
91
+ ):
92
+ importlib.import_module(_mod)
93
+ _PB2_MODULES_LOADED = True
94
+
95
+
96
+ class ProtoRegistry:
97
+ """Registry for protobuf type-name-based encode/decode of MACP payloads."""
98
+
99
+ def __init__(self) -> None:
100
+ _ensure_pb2_imports()
101
+ self._db = symbol_database.Default()
102
+
103
+ def get_known_type_name(self, mode: str, message_type: str) -> str | None:
104
+ """Return the fully-qualified protobuf type name, or None if unknown."""
105
+ return MODE_MAP.get(mode, {}).get(message_type) or CORE_MAP.get(message_type)
106
+
107
+ def encode_message(self, type_name: str, value: dict[str, Any]) -> bytes:
108
+ """Encode *value* as a protobuf message identified by *type_name*."""
109
+ cls = self._db.GetSymbol(type_name)
110
+ msg = json_format.ParseDict(value, cls())
111
+ return msg.SerializeToString()
112
+
113
+ def decode_message(self, type_name: str, payload: bytes) -> dict[str, Any]:
114
+ """Decode *payload* into a dict using the message class for *type_name*."""
115
+ cls = self._db.GetSymbol(type_name)
116
+ msg = cls()
117
+ msg.ParseFromString(payload)
118
+ return json_format.MessageToDict(msg, preserving_proto_field_name=True)
119
+
120
+ def encode_known_payload(self, mode: str, message_type: str, value: dict[str, Any]) -> bytes:
121
+ """Encode *value* using the known type mapping for *mode*/*message_type*."""
122
+ type_name = self.get_known_type_name(mode, message_type)
123
+ if type_name is None:
124
+ raise ValueError(f"unknown payload mapping for {mode}/{message_type}")
125
+ if type_name == "__json__":
126
+ return json.dumps(value).encode("utf-8")
127
+ return self.encode_message(type_name, value)
128
+
129
+ def decode_known_payload(
130
+ self, mode: str, message_type: str, payload: bytes
131
+ ) -> dict[str, Any] | None:
132
+ """Decode *payload* using the known type mapping, or try UTF-8 fallback."""
133
+ type_name = self.get_known_type_name(mode, message_type)
134
+ if type_name is None or type_name == "__json__":
135
+ return self._try_decode_utf8(payload)
136
+ return self.decode_message(type_name, payload)
137
+
138
+ @staticmethod
139
+ def _try_decode_utf8(payload: bytes) -> dict[str, Any] | None:
140
+ if not payload:
141
+ return None
142
+ text = payload.decode("utf-8")
143
+ try:
144
+ return {"encoding": "json", "json": json.loads(text)}
145
+ except (json.JSONDecodeError, ValueError):
146
+ return {
147
+ "encoding": "text",
148
+ "text": text,
149
+ "payload_base64": base64.b64encode(payload).decode(),
150
+ }
macp_sdk/py.typed ADDED
File without changes