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,312 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Callable
4
+ from dataclasses import dataclass
5
+ from typing import Any, Protocol
6
+
7
+ from ..envelope import infer_outcome_positive
8
+ from .types import HandlerContext, IncomingMessage, MessageHandler, SessionInfo
9
+
10
+ # ── Evaluation ───────────────────────────────────────────────────────
11
+
12
+
13
+ @dataclass(frozen=True, slots=True)
14
+ class EvaluationResult:
15
+ """Result of evaluating a proposal."""
16
+
17
+ recommendation: str
18
+ confidence: float
19
+ reason: str
20
+
21
+
22
+ class EvaluationStrategy(Protocol):
23
+ """Protocol for evaluating proposals."""
24
+
25
+ def evaluate(self, proposal: dict[str, Any], context: SessionInfo) -> EvaluationResult: ...
26
+
27
+
28
+ _VALID_RECOMMENDATIONS = frozenset({"APPROVE", "REVIEW", "BLOCK", "REJECT"})
29
+
30
+
31
+ def evaluation_handler(strategy: EvaluationStrategy) -> MessageHandler:
32
+ """Create a MessageHandler that evaluates proposals using the given strategy.
33
+
34
+ When a ``Proposal`` message arrives, the strategy's ``evaluate()``
35
+ method is called and the result is logged via the handler context.
36
+ The caller can inspect the result via the context's projection.
37
+ """
38
+
39
+ def handler(message: IncomingMessage, ctx: HandlerContext) -> None:
40
+ result = strategy.evaluate(message.payload, ctx.session)
41
+ recommendation = result.recommendation.upper()
42
+ if recommendation not in _VALID_RECOMMENDATIONS:
43
+ raise ValueError(
44
+ f"invalid recommendation {result.recommendation!r}: "
45
+ "must be one of APPROVE, REVIEW, BLOCK, REJECT"
46
+ )
47
+ if not (0.0 <= result.confidence <= 1.0):
48
+ raise ValueError(f"confidence must be in [0.0, 1.0], got {result.confidence}")
49
+ ctx.log(
50
+ "evaluation: recommendation=%s confidence=%.2f reason=%s",
51
+ recommendation,
52
+ result.confidence,
53
+ result.reason,
54
+ )
55
+ proposal_id = message.proposal_id or message.payload.get("proposal_id", "")
56
+ ctx.actions.evaluate(
57
+ proposal_id,
58
+ recommendation,
59
+ confidence=result.confidence,
60
+ reason=result.reason,
61
+ )
62
+
63
+ return handler
64
+
65
+
66
+ def function_evaluator(
67
+ fn: Callable[[dict[str, Any], SessionInfo], EvaluationResult],
68
+ ) -> EvaluationStrategy:
69
+ """Wrap a plain function as an EvaluationStrategy."""
70
+
71
+ class _FnEvaluator:
72
+ __slots__ = ("_fn",)
73
+
74
+ def __init__(self, fn: Callable[[dict[str, Any], SessionInfo], EvaluationResult]) -> None:
75
+ self._fn = fn
76
+
77
+ def evaluate(self, proposal: dict[str, Any], context: SessionInfo) -> EvaluationResult:
78
+ return self._fn(proposal, context)
79
+
80
+ return _FnEvaluator(fn)
81
+
82
+
83
+ # ── Voting ───────────────────────────────────────────────────────────
84
+
85
+
86
+ @dataclass(frozen=True, slots=True)
87
+ class VoteDecision:
88
+ """Result of a voting decision."""
89
+
90
+ vote: str
91
+ reason: str
92
+
93
+
94
+ class VotingStrategy(Protocol):
95
+ """Protocol for deciding how to vote."""
96
+
97
+ def should_vote(self, projection: Any) -> bool: ...
98
+
99
+ def decide_vote(self, projection: Any) -> VoteDecision: ...
100
+
101
+
102
+ def voting_handler(strategy: VotingStrategy) -> MessageHandler:
103
+ """Create a MessageHandler that makes voting decisions using the given strategy.
104
+
105
+ When any message arrives, the strategy checks whether it should vote
106
+ (via ``should_vote()``). If so, ``decide_vote()`` is called and the
107
+ decision is logged.
108
+ """
109
+
110
+ def handler(message: IncomingMessage, ctx: HandlerContext) -> None:
111
+ if not strategy.should_vote(ctx.projection):
112
+ return
113
+ decision = strategy.decide_vote(ctx.projection)
114
+ ctx.log(
115
+ "vote: vote=%s reason=%s",
116
+ decision.vote,
117
+ decision.reason,
118
+ )
119
+ proposal_id = message.proposal_id or message.payload.get("proposal_id", "")
120
+ ctx.actions.vote(
121
+ proposal_id,
122
+ decision.vote,
123
+ reason=decision.reason,
124
+ )
125
+
126
+ return handler
127
+
128
+
129
+ def function_voter(
130
+ should_vote_fn: Callable[[Any], bool],
131
+ decide_fn: Callable[[Any], VoteDecision],
132
+ ) -> VotingStrategy:
133
+ """Wrap plain functions as a VotingStrategy."""
134
+
135
+ class _FnVoter:
136
+ __slots__ = ("_decide_fn", "_should_fn")
137
+
138
+ def __init__(
139
+ self,
140
+ should_fn: Callable[[Any], bool],
141
+ decide_fn: Callable[[Any], VoteDecision],
142
+ ) -> None:
143
+ self._should_fn = should_fn
144
+ self._decide_fn = decide_fn
145
+
146
+ def should_vote(self, projection: Any) -> bool:
147
+ return self._should_fn(projection)
148
+
149
+ def decide_vote(self, projection: Any) -> VoteDecision:
150
+ return self._decide_fn(projection)
151
+
152
+ return _FnVoter(should_vote_fn, decide_fn)
153
+
154
+
155
+ # ── Commitment ───────────────────────────────────────────────────────
156
+
157
+
158
+ @dataclass(frozen=True, slots=True)
159
+ class CommitmentDecision:
160
+ """Result of a commitment decision."""
161
+
162
+ action: str
163
+ authority_scope: str
164
+ reason: str
165
+ outcome_positive: bool = True
166
+
167
+
168
+ class CommitmentStrategy(Protocol):
169
+ """Protocol for deciding whether and how to commit."""
170
+
171
+ def should_commit(self, projection: Any) -> bool: ...
172
+
173
+ def decide_commitment(self, projection: Any) -> CommitmentDecision: ...
174
+
175
+
176
+ def commitment_handler(strategy: CommitmentStrategy) -> MessageHandler:
177
+ """Create a MessageHandler that makes commitment decisions using the given strategy.
178
+
179
+ When any message arrives, the strategy checks whether a commitment
180
+ should be made (via ``should_commit()``). If so, ``decide_commitment()``
181
+ is called and the decision is logged.
182
+ """
183
+
184
+ def handler(_message: IncomingMessage, ctx: HandlerContext) -> None:
185
+ # commitment decisions are projection-driven; the triggering message
186
+ # itself isn't read, but the signature must match MessageHandler.
187
+ if not strategy.should_commit(ctx.projection):
188
+ return
189
+ decision = strategy.decide_commitment(ctx.projection)
190
+ ctx.log(
191
+ "commitment: action=%s scope=%s reason=%s",
192
+ decision.action,
193
+ decision.authority_scope,
194
+ decision.reason,
195
+ )
196
+ ctx.actions.commit(
197
+ decision.action,
198
+ decision.authority_scope,
199
+ reason=decision.reason,
200
+ outcome_positive=decision.outcome_positive,
201
+ )
202
+
203
+ return handler
204
+
205
+
206
+ def function_committer(
207
+ should_commit_fn: Callable[[Any], bool],
208
+ decide_fn: Callable[[Any], CommitmentDecision],
209
+ ) -> CommitmentStrategy:
210
+ """Wrap plain functions as a CommitmentStrategy."""
211
+
212
+ class _FnCommitter:
213
+ __slots__ = ("_decide_fn", "_should_fn")
214
+
215
+ def __init__(
216
+ self,
217
+ should_fn: Callable[[Any], bool],
218
+ decide_fn: Callable[[Any], CommitmentDecision],
219
+ ) -> None:
220
+ self._should_fn = should_fn
221
+ self._decide_fn = decide_fn
222
+
223
+ def should_commit(self, projection: Any) -> bool:
224
+ return self._should_fn(projection)
225
+
226
+ def decide_commitment(self, projection: Any) -> CommitmentDecision:
227
+ return self._decide_fn(projection)
228
+
229
+ return _FnCommitter(should_commit_fn, decide_fn)
230
+
231
+
232
+ # ── Built-in strategy factories ─────────────────────────────────────
233
+
234
+
235
+ def majority_voter(
236
+ *,
237
+ positive_threshold: float = 0.5,
238
+ ) -> VotingStrategy:
239
+ """Built-in voting strategy that votes ``approve`` when the majority winner
240
+ matches the first proposal option, based on the decision projection.
241
+
242
+ Args:
243
+ positive_threshold: Fraction of votes required to trigger voting
244
+ (default ``0.5``).
245
+ """
246
+
247
+ class _MajorityVoter:
248
+ __slots__ = ("_threshold",)
249
+
250
+ def __init__(self, threshold: float) -> None:
251
+ self._threshold = threshold
252
+
253
+ def should_vote(self, projection: Any) -> bool:
254
+ if projection is None:
255
+ return False
256
+ totals = projection.vote_totals()
257
+ total_votes = sum(totals.values())
258
+ return total_votes > 0 and any(
259
+ count / total_votes >= self._threshold for count in totals.values()
260
+ )
261
+
262
+ def decide_vote(self, projection: Any) -> VoteDecision:
263
+ winner = projection.majority_winner()
264
+ if winner:
265
+ return VoteDecision(vote="APPROVE", reason=f"majority winner: {winner}")
266
+ return VoteDecision(vote="ABSTAIN", reason="no majority winner")
267
+
268
+ return _MajorityVoter(positive_threshold)
269
+
270
+
271
+ def majority_committer(
272
+ *,
273
+ quorum_size: int = 1,
274
+ action: str = "commit",
275
+ authority_scope: str = "session",
276
+ ) -> CommitmentStrategy:
277
+ """Built-in commitment strategy that commits when a majority winner exists
278
+ and the quorum has been met.
279
+
280
+ Args:
281
+ quorum_size: Minimum number of votes before commitment (default ``1``).
282
+ action: The commitment action string (default ``"commit"``).
283
+ authority_scope: The commitment authority scope (default ``"session"``).
284
+ """
285
+
286
+ class _MajorityCommitter:
287
+ __slots__ = ("_action", "_quorum", "_scope")
288
+
289
+ def __init__(self, quorum: int, commit_action: str, scope: str) -> None:
290
+ self._quorum = quorum
291
+ self._action = commit_action
292
+ self._scope = scope
293
+
294
+ def should_commit(self, projection: Any) -> bool:
295
+ if projection is None:
296
+ return False
297
+ totals = projection.vote_totals()
298
+ total_votes = sum(totals.values())
299
+ if total_votes < self._quorum:
300
+ return False
301
+ return projection.majority_winner() is not None
302
+
303
+ def decide_commitment(self, projection: Any) -> CommitmentDecision:
304
+ winner = projection.majority_winner()
305
+ return CommitmentDecision(
306
+ action=self._action,
307
+ authority_scope=self._scope,
308
+ reason=f"majority winner: {winner}",
309
+ outcome_positive=infer_outcome_positive(self._action),
310
+ )
311
+
312
+ return _MajorityCommitter(quorum_size, action, authority_scope)
@@ -0,0 +1,153 @@
1
+ """Transport adapters for the agent event loop.
2
+
3
+ Provides a :class:`TransportAdapter` protocol and two implementations:
4
+
5
+ - :class:`GrpcTransportAdapter` — uses a bidirectional ``StreamSession`` RPC.
6
+ - :class:`HttpTransportAdapter` — polls an HTTP endpoint for new envelopes.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ import time
13
+ from collections.abc import Iterator
14
+ from typing import Any, Protocol
15
+
16
+ from .._logging import logger
17
+ from ..auth import AuthConfig
18
+ from ..client import MacpClient
19
+ from .types import IncomingMessage
20
+
21
+
22
+ class TransportAdapter(Protocol):
23
+ """Protocol for delivering session envelopes to a Participant."""
24
+
25
+ def start(self) -> Iterator[IncomingMessage]:
26
+ """Yield incoming messages from the transport."""
27
+ ...
28
+
29
+ def stop(self) -> None:
30
+ """Signal the transport to stop delivering messages."""
31
+ ...
32
+
33
+
34
+ class GrpcTransportAdapter:
35
+ """Delivers messages via the bidirectional ``StreamSession`` gRPC RPC."""
36
+
37
+ def __init__(
38
+ self,
39
+ client: MacpClient,
40
+ session_id: str,
41
+ *,
42
+ auth: AuthConfig | None = None,
43
+ timeout: float | None = None,
44
+ ) -> None:
45
+ self._client = client
46
+ self._session_id = session_id
47
+ self._auth = auth
48
+ self._timeout = timeout
49
+ self._stream: Any = None
50
+ self._stopped = False
51
+
52
+ def start(self) -> Iterator[IncomingMessage]:
53
+ """Open a stream and yield messages for the target session."""
54
+ self._stream = self._client.open_stream(auth=self._auth, timeout=self._timeout)
55
+ try:
56
+ for envelope in self._stream.responses():
57
+ if self._stopped:
58
+ break
59
+ if envelope.session_id != self._session_id:
60
+ continue
61
+ yield _envelope_to_message(envelope)
62
+ finally:
63
+ if self._stream is not None:
64
+ self._stream.close()
65
+
66
+ def stop(self) -> None:
67
+ self._stopped = True
68
+ if self._stream is not None:
69
+ self._stream.close()
70
+
71
+
72
+ class HttpTransportAdapter:
73
+ """Delivers messages by polling an HTTP endpoint for new envelopes.
74
+
75
+ Expects the endpoint to return a JSON array of envelope objects at
76
+ ``GET {base_url}/sessions/{session_id}/events?after={last_seq}``.
77
+ """
78
+
79
+ def __init__(
80
+ self,
81
+ *,
82
+ base_url: str,
83
+ session_id: str,
84
+ participant_id: str,
85
+ poll_interval_ms: int = 1000,
86
+ auth_token: str | None = None,
87
+ ) -> None:
88
+ self._base_url = base_url.rstrip("/")
89
+ self._session_id = session_id
90
+ self._participant_id = participant_id
91
+ self._poll_interval = poll_interval_ms / 1000.0
92
+ self._auth_token = auth_token
93
+ self._stopped = False
94
+ self._last_seq = -1
95
+
96
+ def start(self) -> Iterator[IncomingMessage]:
97
+ """Poll the HTTP endpoint and yield messages."""
98
+ import urllib.request
99
+
100
+ url = f"{self._base_url}/sessions/{self._session_id}/events"
101
+ headers: dict[str, str] = {"Accept": "application/json"}
102
+ if self._auth_token:
103
+ headers["Authorization"] = f"Bearer {self._auth_token}"
104
+
105
+ while not self._stopped:
106
+ try:
107
+ req_url = f"{url}?after={self._last_seq}"
108
+ req = urllib.request.Request(req_url, headers=headers)
109
+ with urllib.request.urlopen(req, timeout=10) as resp:
110
+ data = json.loads(resp.read().decode())
111
+
112
+ if isinstance(data, list):
113
+ for item in data:
114
+ seq = item.get("seq", self._last_seq + 1)
115
+ if seq > self._last_seq:
116
+ self._last_seq = seq
117
+ yield IncomingMessage(
118
+ message_type=item.get("message_type", ""),
119
+ sender=item.get("sender", ""),
120
+ payload=item.get("payload", {}),
121
+ proposal_id=item.get("proposal_id"),
122
+ seq=seq,
123
+ )
124
+ except Exception:
125
+ logger.debug("http poll error, retrying in %ss", self._poll_interval)
126
+
127
+ if not self._stopped:
128
+ time.sleep(self._poll_interval)
129
+
130
+ def stop(self) -> None:
131
+ self._stopped = True
132
+
133
+
134
+ def _envelope_to_message(envelope: Any) -> IncomingMessage:
135
+ """Convert a protobuf Envelope to an IncomingMessage."""
136
+ payload_dict: dict[str, Any] = {}
137
+ if envelope.payload:
138
+ try:
139
+ payload_dict = json.loads(envelope.payload)
140
+ except (json.JSONDecodeError, UnicodeDecodeError):
141
+ payload_dict = {"_raw_bytes": envelope.payload}
142
+
143
+ proposal_id: str | None = None
144
+ if "proposal_id" in payload_dict:
145
+ proposal_id = str(payload_dict["proposal_id"])
146
+
147
+ return IncomingMessage(
148
+ message_type=envelope.message_type,
149
+ sender=envelope.sender,
150
+ payload=payload_dict,
151
+ proposal_id=proposal_id,
152
+ raw=envelope,
153
+ )
@@ -0,0 +1,64 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Callable
4
+ from dataclasses import dataclass, field
5
+ from typing import Any
6
+
7
+
8
+ @dataclass(slots=True)
9
+ class IncomingMessage:
10
+ """A message received from the MACP session event stream."""
11
+
12
+ message_type: str
13
+ sender: str
14
+ payload: dict[str, Any]
15
+ proposal_id: str | None = None
16
+ raw: Any = None
17
+ seq: int | None = None
18
+
19
+
20
+ @dataclass(slots=True)
21
+ class SessionInfo:
22
+ """Metadata about the current MACP session."""
23
+
24
+ session_id: str
25
+ mode: str
26
+ participants: list[str] = field(default_factory=list)
27
+ mode_version: str | None = None
28
+ configuration_version: str | None = None
29
+ policy_version: str | None = None
30
+
31
+
32
+ @dataclass(slots=True)
33
+ class TerminalResult:
34
+ """Represents a terminal (committed/cancelled) session outcome."""
35
+
36
+ state: str
37
+ commitment: Any | None = None
38
+
39
+
40
+ class HandlerContext:
41
+ """Context object passed to message handlers.
42
+
43
+ Provides access to participant identity, session state projection,
44
+ action methods, session metadata, and logging.
45
+ """
46
+
47
+ def __init__(
48
+ self,
49
+ participant: str,
50
+ projection: Any,
51
+ actions: Any,
52
+ session: SessionInfo,
53
+ log_fn: Callable[..., None],
54
+ ) -> None:
55
+ self.participant = participant
56
+ self.projection = projection
57
+ self.actions = actions
58
+ self.session = session
59
+ self.log = log_fn
60
+
61
+
62
+ MessageHandler = Callable[[IncomingMessage, HandlerContext], None]
63
+ TerminalHandler = Callable[[TerminalResult], None]
64
+ PhaseChangeHandler = Callable[[str, HandlerContext], None]
macp_sdk/auth.py ADDED
@@ -0,0 +1,74 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+
5
+
6
+ @dataclass(frozen=True)
7
+ class AuthConfig:
8
+ """Authentication configuration for a MACP client or session.
9
+
10
+ The ``expected_sender`` field is a client-side guardrail: when set, any
11
+ explicit ``sender=`` passed to a session helper must match it, otherwise
12
+ the SDK raises :class:`MacpIdentityMismatchError` before the envelope
13
+ reaches the wire. Per RFC-MACP-0004 §4 the runtime derives ``sender``
14
+ from authenticated identity, so mismatches are always rejected — this
15
+ check just surfaces the problem earlier and more clearly.
16
+
17
+ When ``expected_sender`` is ``None`` the check is skipped (preserves
18
+ legacy behaviour for dev/test flows that use a single shared identity).
19
+ """
20
+
21
+ bearer_token: str | None = None
22
+ agent_id: str | None = None
23
+ sender_hint: str | None = None
24
+ expected_sender: str | None = None
25
+
26
+ def __post_init__(self) -> None:
27
+ if self.bearer_token and self.agent_id:
28
+ raise ValueError("choose either bearer_token or agent_id, not both")
29
+ if not self.bearer_token and not self.agent_id:
30
+ raise ValueError("either bearer_token or agent_id is required")
31
+
32
+ @classmethod
33
+ def for_dev_agent(cls, agent_id: str, *, expected_sender: str | None = None) -> AuthConfig:
34
+ return cls(
35
+ agent_id=agent_id,
36
+ sender_hint=agent_id,
37
+ expected_sender=expected_sender or agent_id,
38
+ )
39
+
40
+ @classmethod
41
+ def for_bearer(
42
+ cls,
43
+ token: str,
44
+ *,
45
+ sender_hint: str | None = None,
46
+ expected_sender: str | None = None,
47
+ ) -> AuthConfig:
48
+ """Build a Bearer-token AuthConfig.
49
+
50
+ :param token: the Bearer token issued by the runtime
51
+ :param sender_hint: the sender string the SDK places on envelopes when
52
+ no explicit ``sender=`` is provided. Defaults to ``expected_sender``
53
+ when only the latter is supplied.
54
+ :param expected_sender: the identity the runtime will bind this token
55
+ to. When set, the SDK rejects any explicit ``sender=`` that does
56
+ not match with :class:`MacpIdentityMismatchError`.
57
+ """
58
+ return cls(
59
+ bearer_token=token,
60
+ sender_hint=sender_hint or expected_sender,
61
+ expected_sender=expected_sender,
62
+ )
63
+
64
+ @property
65
+ def sender(self) -> str | None:
66
+ return self.sender_hint or self.agent_id
67
+
68
+ def metadata(self) -> list[tuple[str, str]]:
69
+ headers: list[tuple[str, str]] = []
70
+ if self.bearer_token:
71
+ headers.append(("authorization", f"Bearer {self.bearer_token}"))
72
+ if self.agent_id:
73
+ headers.append(("x-macp-agent-id", self.agent_id))
74
+ return headers
@@ -0,0 +1,52 @@
1
+ from __future__ import annotations
2
+
3
+ from abc import ABC, abstractmethod
4
+ from typing import ClassVar
5
+
6
+ from macp.v1 import core_pb2, envelope_pb2
7
+
8
+
9
+ class BaseProjection(ABC):
10
+ """Abstract base for in-process mode state tracking.
11
+
12
+ Maintains a local transcript and delegates mode-specific message handling
13
+ to subclasses. Needed because the runtime's ``GetSession`` RPC returns
14
+ metadata only, not mode state or transcript.
15
+ """
16
+
17
+ MODE: ClassVar[str]
18
+
19
+ def __init__(self) -> None:
20
+ self.transcript: list[envelope_pb2.Envelope] = []
21
+ self.phase: str = ""
22
+ self.commitment: core_pb2.CommitmentPayload | None = None
23
+
24
+ @property
25
+ def is_committed(self) -> bool:
26
+ return self.commitment is not None
27
+
28
+ @property
29
+ def is_positive_outcome(self) -> bool | None:
30
+ """Return the outcome polarity, or ``None`` if not yet committed."""
31
+ if self.commitment is None:
32
+ return None
33
+ return getattr(self.commitment, "outcome_positive", True)
34
+
35
+ def apply_envelope(self, envelope: envelope_pb2.Envelope) -> None:
36
+ """Process an accepted envelope and update local state."""
37
+ if envelope.mode != self.MODE:
38
+ return
39
+ self.transcript.append(envelope)
40
+
41
+ if envelope.message_type == "Commitment":
42
+ payload = core_pb2.CommitmentPayload()
43
+ payload.ParseFromString(envelope.payload)
44
+ self.commitment = payload
45
+ self.phase = "Committed"
46
+ return
47
+
48
+ self._apply_mode_message(envelope)
49
+
50
+ @abstractmethod
51
+ def _apply_mode_message(self, envelope: envelope_pb2.Envelope) -> None:
52
+ """Handle a mode-specific (non-Commitment) envelope."""