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/__init__.py +207 -0
- macp_sdk/_logging.py +16 -0
- macp_sdk/agent/__init__.py +64 -0
- macp_sdk/agent/dispatcher.py +105 -0
- macp_sdk/agent/participant.py +404 -0
- macp_sdk/agent/runner.py +100 -0
- macp_sdk/agent/strategies.py +312 -0
- macp_sdk/agent/transports.py +153 -0
- macp_sdk/agent/types.py +64 -0
- macp_sdk/auth.py +74 -0
- macp_sdk/base_projection.py +52 -0
- macp_sdk/base_session.py +169 -0
- macp_sdk/client.py +625 -0
- macp_sdk/constants.py +20 -0
- macp_sdk/decision.py +154 -0
- macp_sdk/envelope.py +180 -0
- macp_sdk/errors.py +101 -0
- macp_sdk/handoff.py +241 -0
- macp_sdk/policy.py +292 -0
- macp_sdk/projections.py +183 -0
- macp_sdk/proposal.py +305 -0
- macp_sdk/proto_registry.py +150 -0
- macp_sdk/py.typed +0 -0
- macp_sdk/quorum.py +258 -0
- macp_sdk/retry.py +64 -0
- macp_sdk/task.py +368 -0
- macp_sdk/validation.py +117 -0
- macp_sdk/watchers.py +122 -0
- macp_sdk_python-0.2.1.dist-info/METADATA +165 -0
- macp_sdk_python-0.2.1.dist-info/RECORD +33 -0
- macp_sdk_python-0.2.1.dist-info/WHEEL +5 -0
- macp_sdk_python-0.2.1.dist-info/licenses/LICENSE +201 -0
- macp_sdk_python-0.2.1.dist-info/top_level.txt +1 -0
|
@@ -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
|
+
)
|
macp_sdk/agent/types.py
ADDED
|
@@ -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."""
|