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.
@@ -0,0 +1,404 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+ from .._logging import logger
6
+ from ..auth import AuthConfig
7
+ from ..base_projection import BaseProjection
8
+ from ..client import MacpClient
9
+ from ..constants import (
10
+ MODE_DECISION,
11
+ MODE_HANDOFF,
12
+ MODE_PROPOSAL,
13
+ MODE_QUORUM,
14
+ MODE_TASK,
15
+ )
16
+ from ..envelope import build_commitment_payload, build_envelope, serialize_message
17
+ from ..handoff import HandoffProjection
18
+ from ..projections import DecisionProjection
19
+ from ..proposal import ProposalProjection
20
+ from ..quorum import QuorumProjection
21
+ from ..task import TaskProjection
22
+ from .dispatcher import Dispatcher
23
+ from .transports import GrpcTransportAdapter, TransportAdapter, _envelope_to_message
24
+ from .types import (
25
+ HandlerContext,
26
+ IncomingMessage,
27
+ MessageHandler,
28
+ PhaseChangeHandler,
29
+ SessionInfo,
30
+ TerminalHandler,
31
+ TerminalResult,
32
+ )
33
+
34
+ _MODE_PROJECTIONS: dict[str, type[BaseProjection]] = {
35
+ MODE_DECISION: DecisionProjection,
36
+ MODE_PROPOSAL: ProposalProjection,
37
+ MODE_QUORUM: QuorumProjection,
38
+ MODE_TASK: TaskProjection,
39
+ MODE_HANDOFF: HandoffProjection,
40
+ }
41
+
42
+
43
+ class ParticipantActions:
44
+ """Thin wrapper providing action methods bound to a participant's session."""
45
+
46
+ def __init__(
47
+ self,
48
+ client: MacpClient,
49
+ session_id: str,
50
+ auth: AuthConfig | None,
51
+ *,
52
+ mode: str = "",
53
+ participant_id: str = "",
54
+ ) -> None:
55
+ self._client = client
56
+ self._session_id = session_id
57
+ self._auth = auth
58
+ self._mode = mode
59
+ self._participant_id = participant_id
60
+
61
+ def send_envelope(self, envelope: Any) -> Any:
62
+ """Send a pre-built envelope via the MACP client."""
63
+ return self._client.send(envelope, auth=self._auth)
64
+
65
+ def get_session(self) -> Any:
66
+ """Query session metadata from the runtime."""
67
+ return self._client.get_session(self._session_id, auth=self._auth)
68
+
69
+ def cancel_session(self, reason: str = "") -> Any:
70
+ """Cancel the session."""
71
+ return self._client.cancel_session(self._session_id, reason=reason, auth=self._auth)
72
+
73
+ def evaluate(
74
+ self,
75
+ proposal_id: str,
76
+ recommendation: str,
77
+ *,
78
+ confidence: float,
79
+ reason: str = "",
80
+ ) -> Any:
81
+ """Send an Evaluation envelope for a decision-mode session."""
82
+ from macp.modes.decision.v1 import decision_pb2
83
+
84
+ payload = decision_pb2.EvaluationPayload(
85
+ proposal_id=proposal_id,
86
+ recommendation=recommendation.upper(),
87
+ confidence=confidence,
88
+ reason=reason,
89
+ )
90
+ envelope = build_envelope(
91
+ mode=self._mode,
92
+ message_type="Evaluation",
93
+ session_id=self._session_id,
94
+ sender=self._participant_id,
95
+ payload=serialize_message(payload),
96
+ )
97
+ return self.send_envelope(envelope)
98
+
99
+ def vote(
100
+ self,
101
+ proposal_id: str,
102
+ vote: str,
103
+ *,
104
+ reason: str = "",
105
+ ) -> Any:
106
+ """Send a Vote envelope for a decision-mode session."""
107
+ from macp.modes.decision.v1 import decision_pb2
108
+
109
+ payload = decision_pb2.VotePayload(
110
+ proposal_id=proposal_id,
111
+ vote=vote.upper(),
112
+ reason=reason,
113
+ )
114
+ envelope = build_envelope(
115
+ mode=self._mode,
116
+ message_type="Vote",
117
+ session_id=self._session_id,
118
+ sender=self._participant_id,
119
+ payload=serialize_message(payload),
120
+ )
121
+ return self.send_envelope(envelope)
122
+
123
+ def raise_objection(
124
+ self,
125
+ proposal_id: str,
126
+ *,
127
+ reason: str,
128
+ severity: str = "medium",
129
+ ) -> Any:
130
+ """Send an Objection envelope for a decision-mode session."""
131
+ from macp.modes.decision.v1 import decision_pb2
132
+
133
+ payload = decision_pb2.ObjectionPayload(
134
+ proposal_id=proposal_id,
135
+ reason=reason,
136
+ severity=severity.lower(),
137
+ )
138
+ envelope = build_envelope(
139
+ mode=self._mode,
140
+ message_type="Objection",
141
+ session_id=self._session_id,
142
+ sender=self._participant_id,
143
+ payload=serialize_message(payload),
144
+ )
145
+ return self.send_envelope(envelope)
146
+
147
+ def propose(
148
+ self,
149
+ proposal_id: str,
150
+ option: str,
151
+ *,
152
+ rationale: str = "",
153
+ supporting_data: bytes = b"",
154
+ ) -> Any:
155
+ """Send a Proposal envelope for a decision-mode session."""
156
+ from macp.modes.decision.v1 import decision_pb2
157
+
158
+ payload = decision_pb2.ProposalPayload(
159
+ proposal_id=proposal_id,
160
+ option=option,
161
+ rationale=rationale,
162
+ supporting_data=supporting_data,
163
+ )
164
+ envelope = build_envelope(
165
+ mode=self._mode,
166
+ message_type="Proposal",
167
+ session_id=self._session_id,
168
+ sender=self._participant_id,
169
+ payload=serialize_message(payload),
170
+ )
171
+ return self.send_envelope(envelope)
172
+
173
+ def commit(
174
+ self,
175
+ action: str,
176
+ authority_scope: str,
177
+ *,
178
+ reason: str = "",
179
+ commitment_id: str | None = None,
180
+ outcome_positive: bool = True,
181
+ ) -> Any:
182
+ """Send a Commitment envelope for the session."""
183
+ commitment_payload = build_commitment_payload(
184
+ action=action,
185
+ authority_scope=authority_scope,
186
+ reason=reason,
187
+ commitment_id=commitment_id,
188
+ outcome_positive=outcome_positive,
189
+ )
190
+ envelope = build_envelope(
191
+ mode=self._mode,
192
+ message_type="Commitment",
193
+ session_id=self._session_id,
194
+ sender=self._participant_id,
195
+ payload=serialize_message(commitment_payload),
196
+ )
197
+ return self.send_envelope(envelope)
198
+
199
+
200
+ class Participant:
201
+ """High-level agent abstraction for participating in MACP sessions.
202
+
203
+ Wraps a :class:`MacpClient`, a :class:`Dispatcher`, and a mode-specific
204
+ projection. Handlers are registered via ``on()``, ``on_phase_change()``,
205
+ and ``on_terminal()`` with a fluent API.
206
+
207
+ The ``run()`` method enters a blocking event loop that polls the
208
+ control-plane for session events and dispatches them to handlers.
209
+ Call ``stop()`` to signal the loop to exit.
210
+ """
211
+
212
+ def __init__(
213
+ self,
214
+ *,
215
+ participant_id: str,
216
+ session_id: str,
217
+ mode: str,
218
+ client: MacpClient,
219
+ auth: AuthConfig | None = None,
220
+ participants: list[str] | None = None,
221
+ mode_version: str | None = None,
222
+ configuration_version: str | None = None,
223
+ policy_version: str | None = None,
224
+ transport: TransportAdapter | None = None,
225
+ ) -> None:
226
+ self._participant_id = participant_id
227
+ self._session_id = session_id
228
+ self._mode = mode
229
+ self._client = client
230
+ self._auth = auth
231
+ self._stopped = False
232
+
233
+ self._dispatcher = Dispatcher()
234
+ self._session = SessionInfo(
235
+ session_id=session_id,
236
+ mode=mode,
237
+ participants=list(participants or []),
238
+ mode_version=mode_version,
239
+ configuration_version=configuration_version,
240
+ policy_version=policy_version,
241
+ )
242
+
243
+ projection_cls = _MODE_PROJECTIONS.get(mode)
244
+ if projection_cls is not None:
245
+ self._projection: BaseProjection | None = projection_cls()
246
+ else:
247
+ self._projection = None
248
+
249
+ self._actions = ParticipantActions(
250
+ client,
251
+ session_id,
252
+ auth,
253
+ mode=mode,
254
+ participant_id=participant_id,
255
+ )
256
+ self._last_phase: str | None = None
257
+ self._transport = transport
258
+
259
+ @property
260
+ def participant_id(self) -> str:
261
+ return self._participant_id
262
+
263
+ @property
264
+ def session_id(self) -> str:
265
+ return self._session_id
266
+
267
+ @property
268
+ def mode(self) -> str:
269
+ return self._mode
270
+
271
+ @property
272
+ def projection(self) -> BaseProjection | None:
273
+ return self._projection
274
+
275
+ @property
276
+ def actions(self) -> ParticipantActions:
277
+ return self._actions
278
+
279
+ @property
280
+ def session(self) -> SessionInfo:
281
+ return self._session
282
+
283
+ @property
284
+ def is_stopped(self) -> bool:
285
+ return self._stopped
286
+
287
+ def on(self, message_type: str, handler: MessageHandler) -> Participant:
288
+ """Register a handler for a message type (fluent API)."""
289
+ self._dispatcher.on(message_type, handler)
290
+ return self
291
+
292
+ def on_phase_change(self, phase: str, handler: PhaseChangeHandler) -> Participant:
293
+ """Register a handler for a phase change (fluent API)."""
294
+ self._dispatcher.on_phase_change(phase, handler)
295
+ return self
296
+
297
+ def on_terminal(self, handler: TerminalHandler) -> Participant:
298
+ """Register the terminal handler (fluent API)."""
299
+ self._dispatcher.on_terminal(handler)
300
+ return self
301
+
302
+ def _build_context(self) -> HandlerContext:
303
+ return HandlerContext(
304
+ participant=self._participant_id,
305
+ projection=self._projection,
306
+ actions=self._actions,
307
+ session=self._session,
308
+ log_fn=logger.info,
309
+ )
310
+
311
+ def _process_envelope(self, envelope: Any) -> None:
312
+ """Process a single envelope: update projection, dispatch handlers."""
313
+ # Update the projection if available
314
+ if self._projection is not None:
315
+ self._projection.apply_envelope(envelope)
316
+
317
+ # Check for terminal messages
318
+ if envelope.message_type == "Commitment":
319
+ result = TerminalResult(
320
+ state="Committed",
321
+ commitment=envelope,
322
+ )
323
+ self._dispatcher.dispatch_terminal(result)
324
+ self._stopped = True
325
+ return
326
+
327
+ if envelope.message_type == "SessionCancel":
328
+ result = TerminalResult(state="Cancelled")
329
+ self._dispatcher.dispatch_terminal(result)
330
+ self._stopped = True
331
+ return
332
+
333
+ # Build the message and context
334
+ message = _envelope_to_message(envelope)
335
+ ctx = self._build_context()
336
+
337
+ # Dispatch message handler
338
+ self._dispatcher.dispatch(message, ctx)
339
+
340
+ # Check for phase changes
341
+ if self._projection is not None:
342
+ current_phase = self._projection.phase
343
+ if current_phase and current_phase != self._last_phase:
344
+ self._dispatcher.dispatch_phase_change(current_phase, ctx)
345
+ self._last_phase = current_phase
346
+
347
+ def _process_message(self, message: IncomingMessage) -> None:
348
+ """Process a pre-built IncomingMessage (from HTTP transport)."""
349
+ ctx = self._build_context()
350
+
351
+ if message.message_type == "Commitment":
352
+ result = TerminalResult(state="Committed")
353
+ self._dispatcher.dispatch_terminal(result)
354
+ self._stopped = True
355
+ return
356
+
357
+ if message.message_type == "SessionCancel":
358
+ result = TerminalResult(state="Cancelled")
359
+ self._dispatcher.dispatch_terminal(result)
360
+ self._stopped = True
361
+ return
362
+
363
+ self._dispatcher.dispatch(message, ctx)
364
+
365
+ def run(self) -> None:
366
+ """Enter the blocking event loop.
367
+
368
+ If a :class:`TransportAdapter` was provided, it is used to receive
369
+ messages. Otherwise a gRPC ``StreamSession`` is opened.
370
+
371
+ Dispatches received events to registered handlers until the session
372
+ reaches a terminal state or ``stop()`` is called.
373
+ """
374
+ logger.info(
375
+ "participant %s joining session %s (mode=%s)",
376
+ self._participant_id,
377
+ self._session_id,
378
+ self._mode,
379
+ )
380
+
381
+ transport = self._transport or GrpcTransportAdapter(
382
+ self._client,
383
+ self._session_id,
384
+ auth=self._auth,
385
+ )
386
+ try:
387
+ for message in transport.start():
388
+ if self._stopped:
389
+ break
390
+ # If the transport yields raw envelopes (gRPC), process as envelope
391
+ if message.raw is not None:
392
+ self._process_envelope(message.raw)
393
+ else:
394
+ self._process_message(message)
395
+ finally:
396
+ transport.stop()
397
+
398
+ def process_event(self, envelope: Any) -> None:
399
+ """Manually process a single envelope (for testing or polling transports)."""
400
+ self._process_envelope(envelope)
401
+
402
+ def stop(self) -> None:
403
+ """Signal the event loop to stop."""
404
+ self._stopped = True
@@ -0,0 +1,100 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import os
5
+
6
+ from ..auth import AuthConfig
7
+ from ..client import MacpClient
8
+ from ..constants import DEFAULT_POLICY_VERSION
9
+ from .participant import Participant
10
+
11
+
12
+ def from_bootstrap(bootstrap_path: str | None = None) -> Participant:
13
+ """Create a Participant from a bootstrap context file.
14
+
15
+ The bootstrap file is a JSON document produced by the MACP hosting
16
+ infrastructure containing the information needed to connect and
17
+ participate in a session.
18
+
19
+ If ``bootstrap_path`` is not provided, the ``MACP_BOOTSTRAP_FILE``
20
+ environment variable is used.
21
+
22
+ Expected bootstrap JSON structure::
23
+
24
+ {
25
+ "participant_id": "...",
26
+ "session_id": "...",
27
+ "mode": "macp.mode.decision.v1",
28
+ "runtime_url": "localhost:50051",
29
+ "auth": {
30
+ "bearer_token": "...", // or "agent_id": "..."
31
+ },
32
+ "participants": ["agent-a", "agent-b"],
33
+ "policy_version": "policy.default",
34
+ "secure": true, // default; set false only for local dev
35
+ "allow_insecure": false // required if secure=false
36
+ }
37
+
38
+ Transport security follows RFC-MACP-0006 §3: ``secure`` defaults to
39
+ ``true``. Setting ``secure: false`` additionally requires
40
+ ``allow_insecure: true`` in the bootstrap (opt-in for local dev only).
41
+ """
42
+ path = bootstrap_path or os.environ.get("MACP_BOOTSTRAP_FILE")
43
+ if not path:
44
+ raise ValueError("No bootstrap path provided and MACP_BOOTSTRAP_FILE not set")
45
+
46
+ with open(path) as f:
47
+ ctx: dict[str, object] = json.load(f)
48
+
49
+ participant_id = str(ctx["participant_id"])
50
+ session_id = str(ctx["session_id"])
51
+ mode = str(ctx["mode"])
52
+ runtime_url = str(ctx.get("runtime_url", "localhost:50051"))
53
+ secure = bool(ctx.get("secure", True))
54
+ allow_insecure = bool(ctx.get("allow_insecure", False))
55
+
56
+ # Build auth config
57
+ auth_data = ctx.get("auth")
58
+ auth: AuthConfig | None = None
59
+ if isinstance(auth_data, dict):
60
+ bearer = auth_data.get("bearer_token")
61
+ agent_id = auth_data.get("agent_id")
62
+ expected_sender = auth_data.get("expected_sender") or participant_id
63
+ if bearer:
64
+ auth = AuthConfig.for_bearer(
65
+ str(bearer),
66
+ sender_hint=participant_id,
67
+ expected_sender=str(expected_sender),
68
+ )
69
+ elif agent_id:
70
+ auth = AuthConfig.for_dev_agent(str(agent_id), expected_sender=str(expected_sender))
71
+
72
+ # Build client
73
+ client = MacpClient(
74
+ target=runtime_url,
75
+ secure=secure,
76
+ allow_insecure=allow_insecure,
77
+ auth=auth,
78
+ )
79
+
80
+ # Extract optional fields
81
+ raw_participants = ctx.get("participants")
82
+ participants: list[str] = []
83
+ if isinstance(raw_participants, list):
84
+ participants = [str(p) for p in raw_participants]
85
+
86
+ mode_version = ctx.get("mode_version")
87
+ configuration_version = ctx.get("configuration_version")
88
+ policy_version = ctx.get("policy_version")
89
+
90
+ return Participant(
91
+ participant_id=participant_id,
92
+ session_id=session_id,
93
+ mode=mode,
94
+ client=client,
95
+ auth=auth,
96
+ participants=participants,
97
+ mode_version=str(mode_version) if mode_version else None,
98
+ configuration_version=str(configuration_version) if configuration_version else None,
99
+ policy_version=str(policy_version) if policy_version else DEFAULT_POLICY_VERSION,
100
+ )