macp-sdk-python 0.13.0__py3-none-any.whl → 0.14.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 +4 -0
- macp_sdk/agent/participant.py +97 -14
- macp_sdk/agent/runner.py +22 -0
- macp_sdk/agent/strategies.py +47 -24
- macp_sdk/auth.py +3 -1
- macp_sdk/client.py +39 -6
- macp_sdk/envelope.py +2 -5
- macp_sdk/handoff.py +2 -2
- macp_sdk/projections.py +36 -0
- macp_sdk/quorum.py +27 -2
- macp_sdk/task.py +30 -2
- macp_sdk/validation.py +31 -3
- macp_sdk/watchers.py +115 -51
- {macp_sdk_python-0.13.0.dist-info → macp_sdk_python-0.14.1.dist-info}/METADATA +1 -1
- {macp_sdk_python-0.13.0.dist-info → macp_sdk_python-0.14.1.dist-info}/RECORD +18 -18
- {macp_sdk_python-0.13.0.dist-info → macp_sdk_python-0.14.1.dist-info}/WHEEL +0 -0
- {macp_sdk_python-0.13.0.dist-info → macp_sdk_python-0.14.1.dist-info}/licenses/LICENSE +0 -0
- {macp_sdk_python-0.13.0.dist-info → macp_sdk_python-0.14.1.dist-info}/top_level.txt +0 -0
macp_sdk/__init__.py
CHANGED
|
@@ -120,6 +120,7 @@ from .task import (
|
|
|
120
120
|
from .validation import (
|
|
121
121
|
validate_commitment_hash,
|
|
122
122
|
validate_confidence,
|
|
123
|
+
validate_max_suspend_ms,
|
|
123
124
|
validate_participant_count,
|
|
124
125
|
validate_participants,
|
|
125
126
|
validate_progress_scope,
|
|
@@ -133,6 +134,7 @@ from .validation import (
|
|
|
133
134
|
validate_vote,
|
|
134
135
|
)
|
|
135
136
|
from .watchers import (
|
|
137
|
+
TERMINAL_SESSION_LIFECYCLE_EVENT_NAMES,
|
|
136
138
|
ModeRegistryWatcher,
|
|
137
139
|
PolicyChange,
|
|
138
140
|
PolicyWatcher,
|
|
@@ -173,6 +175,7 @@ __all__ = [
|
|
|
173
175
|
"SESSION_NOT_FOUND",
|
|
174
176
|
"SESSION_NOT_OPEN",
|
|
175
177
|
"STANDARD_MODES",
|
|
178
|
+
"TERMINAL_SESSION_LIFECYCLE_EVENT_NAMES",
|
|
176
179
|
"UNAUTHENTICATED",
|
|
177
180
|
"UNBOUNDED",
|
|
178
181
|
"UNKNOWN_POLICY_VERSION",
|
|
@@ -263,6 +266,7 @@ __all__ = [
|
|
|
263
266
|
"serialize_message",
|
|
264
267
|
"validate_commitment_hash",
|
|
265
268
|
"validate_confidence",
|
|
269
|
+
"validate_max_suspend_ms",
|
|
266
270
|
"validate_participant_count",
|
|
267
271
|
"validate_participants",
|
|
268
272
|
"validate_progress_scope",
|
macp_sdk/agent/participant.py
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
+
import threading
|
|
3
4
|
from dataclasses import dataclass, field
|
|
4
5
|
from typing import Any
|
|
5
6
|
|
|
@@ -418,9 +419,22 @@ class Participant:
|
|
|
418
419
|
mode=mode,
|
|
419
420
|
participant_id=participant_id,
|
|
420
421
|
)
|
|
421
|
-
|
|
422
|
+
# Seed from the projection's own initial phase: that phase is a
|
|
423
|
+
# constructor artifact, not an observed transition, so firing
|
|
424
|
+
# on_phase_change for it would report a change that never happened.
|
|
425
|
+
# This matters most for a mid-session joiner, whose first envelope
|
|
426
|
+
# otherwise re-announces a phase the session entered before it
|
|
427
|
+
# attached. None when no projection is registered for the mode -- the
|
|
428
|
+
# phase path is skipped entirely in that case.
|
|
429
|
+
self._last_phase: str | None = (
|
|
430
|
+
self._projection.phase if self._projection is not None else None
|
|
431
|
+
)
|
|
422
432
|
self._transport = transport
|
|
423
433
|
self._cancel_callback_server: Any | None = None
|
|
434
|
+
# Non-reentrant by design: an RLock would let a handler calling
|
|
435
|
+
# run() recursively (same thread) through, only to deadlock on the
|
|
436
|
+
# transport instead of raising a clear error.
|
|
437
|
+
self._run_lock = threading.Lock()
|
|
424
438
|
|
|
425
439
|
@property
|
|
426
440
|
def participant_id(self) -> str:
|
|
@@ -484,6 +498,9 @@ class Participant:
|
|
|
484
498
|
point. As a fallback (for envelopes projections don't model — e.g.
|
|
485
499
|
``SessionCancel``), we fire terminal on the message type itself so
|
|
486
500
|
clients always get a ``stop`` signal for end-of-session envelopes.
|
|
501
|
+
The fallback only fires when the projection has not already
|
|
502
|
+
reported a terminal phase, so a ``SessionCancel`` arriving after a
|
|
503
|
+
``Commitment`` cannot produce a second ``on_terminal``.
|
|
487
504
|
"""
|
|
488
505
|
if self._projection is not None:
|
|
489
506
|
self._projection.apply_envelope(envelope)
|
|
@@ -496,6 +513,9 @@ class Participant:
|
|
|
496
513
|
self._dispatcher.dispatch(message, ctx)
|
|
497
514
|
|
|
498
515
|
fired_terminal = False
|
|
516
|
+
already_terminal = (
|
|
517
|
+
self._projection is not None and self._projection.phase in TERMINAL_PHASES
|
|
518
|
+
)
|
|
499
519
|
|
|
500
520
|
# Phase transition path — drives both on_phase_change and on_terminal.
|
|
501
521
|
if self._projection is not None:
|
|
@@ -521,7 +541,7 @@ class Participant:
|
|
|
521
541
|
# Fallback for envelopes projections don't transition phase on —
|
|
522
542
|
# principally ``SessionCancel``. Keeps terminal dispatch reliable
|
|
523
543
|
# while the phase-driven path remains primary.
|
|
524
|
-
if not fired_terminal and envelope.message_type == "SessionCancel":
|
|
544
|
+
if not fired_terminal and not already_terminal and envelope.message_type == "SessionCancel":
|
|
525
545
|
self._dispatcher.dispatch_terminal(TerminalResult(state="Cancelled"))
|
|
526
546
|
self._stopped = True
|
|
527
547
|
|
|
@@ -566,8 +586,39 @@ class Participant:
|
|
|
566
586
|
emits SessionStart + kickoff before opening the stream.
|
|
567
587
|
|
|
568
588
|
Dispatches received events to registered handlers until the session
|
|
569
|
-
reaches a terminal state or ``stop()`` is called.
|
|
589
|
+
reaches a terminal state or ``stop()`` is called. The loop exits
|
|
590
|
+
after the current envelope finishes dispatching, not after the
|
|
591
|
+
next one arrives.
|
|
592
|
+
|
|
593
|
+
Teardown contract: a ``run()`` that ends with the participant
|
|
594
|
+
stopped also releases the cancel-callback listener (if one was
|
|
595
|
+
attached); a ``run()`` that ends with the participant still
|
|
596
|
+
runnable leaves it bound so a subsequent ``run()`` keeps its
|
|
597
|
+
cancel endpoint.
|
|
598
|
+
|
|
599
|
+
Not re-entrant: a *concurrent* call (from another thread, while
|
|
600
|
+
this one is still inside the loop) raises :class:`MacpSessionError`
|
|
601
|
+
instead of silently starting a second transport and interleaving
|
|
602
|
+
dispatches into shared state. This is a deliberate divergence from
|
|
603
|
+
``macp-sdk-typescript``, whose ``run()`` returns silently in the
|
|
604
|
+
same situation -- a no-op is tolerable there because its single
|
|
605
|
+
event loop makes a second call almost always a same-task
|
|
606
|
+
programmer mistake, whereas a second Python thread believing it is
|
|
607
|
+
running an agent that is in fact doing nothing is a silent
|
|
608
|
+
liveness bug. A *sequential* call, made after a prior ``run()`` has
|
|
609
|
+
returned, is unaffected and behaves exactly as before.
|
|
570
610
|
"""
|
|
611
|
+
if not self._run_lock.acquire(blocking=False):
|
|
612
|
+
raise MacpSessionError(
|
|
613
|
+
f"Participant.run() is already executing for session {self._session_id!r}; "
|
|
614
|
+
"run() is not re-entrant"
|
|
615
|
+
)
|
|
616
|
+
try:
|
|
617
|
+
self._run()
|
|
618
|
+
finally:
|
|
619
|
+
self._run_lock.release()
|
|
620
|
+
|
|
621
|
+
def _run(self) -> None:
|
|
571
622
|
logger.info(
|
|
572
623
|
"participant %s joining session %s (mode=%s, initiator=%s)",
|
|
573
624
|
self._participant_id,
|
|
@@ -600,8 +651,27 @@ class Participant:
|
|
|
600
651
|
self._process_envelope(message.raw)
|
|
601
652
|
else:
|
|
602
653
|
self._process_message(message)
|
|
654
|
+
if self._stopped:
|
|
655
|
+
# A handler (or another thread) called stop() while this
|
|
656
|
+
# envelope was being dispatched -- including the terminal
|
|
657
|
+
# dispatch in _process_envelope, which sets _stopped itself.
|
|
658
|
+
# Without this check the loop blocks on transport.start()'s
|
|
659
|
+
# next yield, which on a quiet session may never come.
|
|
660
|
+
break
|
|
603
661
|
finally:
|
|
604
|
-
|
|
662
|
+
try:
|
|
663
|
+
transport.stop()
|
|
664
|
+
except Exception:
|
|
665
|
+
logger.debug("transport stop failed during run() teardown", exc_info=True)
|
|
666
|
+
if self._stopped:
|
|
667
|
+
# Release the cancel-callback listener only when this
|
|
668
|
+
# participant has actually stopped -- which is also exactly
|
|
669
|
+
# when a further run() would be a no-op (the one-shot early
|
|
670
|
+
# return above reads the same flag). So the server is still
|
|
671
|
+
# bound on every exit from which a sequential run() can still
|
|
672
|
+
# do something, and is released on every exit after which it
|
|
673
|
+
# cannot.
|
|
674
|
+
self._close_cancel_callback_server()
|
|
605
675
|
|
|
606
676
|
def _emit_initiator_envelopes(self) -> None:
|
|
607
677
|
"""Emit SessionStart + kickoff envelope as the initiator."""
|
|
@@ -658,6 +728,21 @@ class Participant:
|
|
|
658
728
|
"""Manually process a single envelope (for testing or polling transports)."""
|
|
659
729
|
self._process_envelope(envelope)
|
|
660
730
|
|
|
731
|
+
def _close_cancel_callback_server(self) -> None:
|
|
732
|
+
"""Close the bound cancel-callback HTTP server, if one was attached.
|
|
733
|
+
|
|
734
|
+
Idempotent and exception-safe: called from both :meth:`stop` and
|
|
735
|
+
``run()``'s exit path (the latter only when this participant has
|
|
736
|
+
actually stopped), either of which may run first, or both.
|
|
737
|
+
"""
|
|
738
|
+
server = self._cancel_callback_server
|
|
739
|
+
if server is not None:
|
|
740
|
+
self._cancel_callback_server = None
|
|
741
|
+
try:
|
|
742
|
+
server.close()
|
|
743
|
+
except Exception:
|
|
744
|
+
logger.exception("cancel_callback server close failed")
|
|
745
|
+
|
|
661
746
|
def stop(self) -> None:
|
|
662
747
|
"""Signal the event loop to stop.
|
|
663
748
|
|
|
@@ -680,19 +765,17 @@ class Participant:
|
|
|
680
765
|
cancel = getattr(transport, "cancel", None)
|
|
681
766
|
if callable(cancel):
|
|
682
767
|
cancel()
|
|
683
|
-
|
|
684
|
-
if server is not None:
|
|
685
|
-
self._cancel_callback_server = None
|
|
686
|
-
try:
|
|
687
|
-
server.close()
|
|
688
|
-
except Exception:
|
|
689
|
-
logger.exception("cancel_callback server close failed")
|
|
768
|
+
self._close_cancel_callback_server()
|
|
690
769
|
|
|
691
770
|
def attach_cancel_callback_server(self, server: Any) -> None:
|
|
692
771
|
"""Attach a :class:`CancelCallbackServer` to this participant.
|
|
693
772
|
|
|
694
|
-
The server's lifetime is then tied to
|
|
695
|
-
|
|
696
|
-
it down
|
|
773
|
+
The server's lifetime is then tied to an actual stop: an
|
|
774
|
+
incoming cancel POST (or any other caller of :meth:`stop`)
|
|
775
|
+
shuts it down, and so does ``run()`` returning with the
|
|
776
|
+
participant stopped (e.g. a terminal envelope). A ``run()``
|
|
777
|
+
that returns with the participant still runnable leaves it
|
|
778
|
+
bound, so a subsequent ``run()`` keeps the same cancel
|
|
779
|
+
endpoint.
|
|
697
780
|
"""
|
|
698
781
|
self._cancel_callback_server = server
|
macp_sdk/agent/runner.py
CHANGED
|
@@ -6,6 +6,7 @@ import json
|
|
|
6
6
|
import os
|
|
7
7
|
from typing import Any
|
|
8
8
|
|
|
9
|
+
from .._logging import logger
|
|
9
10
|
from ..auth import AuthConfig
|
|
10
11
|
from ..client import MacpClient
|
|
11
12
|
from ..constants import DEFAULT_POLICY_VERSION
|
|
@@ -36,6 +37,23 @@ def _decode_extensions(raw: Any) -> dict[str, bytes]:
|
|
|
36
37
|
bootstrap) defaults to ``{}``; a *present* value of the wrong type (a
|
|
37
38
|
list, a string, ...) is equally malformed and raises rather than
|
|
38
39
|
silently vanishing (#97 follow-up).
|
|
40
|
+
|
|
41
|
+
**Known, accepted ambiguity (issue #121):** trying base64 first means a
|
|
42
|
+
plain string that *happens* to also be syntactically valid base64 (e.g.
|
|
43
|
+
``"abcd"`` or ``"pack"`` -- any string whose length is a multiple of 4
|
|
44
|
+
over the base64 alphabet) silently decodes as base64 bytes instead of
|
|
45
|
+
being treated as the literal string a bootstrap author intended. There
|
|
46
|
+
is no way to tell the two apart from the string alone, and this SDK is
|
|
47
|
+
the canonical source ``macp-sdk-typescript`` mirrors for interop, so
|
|
48
|
+
changing the heuristic would need a coordinated, versioned decision
|
|
49
|
+
across both SDKs (and likely a wire-shape change), not a local fix.
|
|
50
|
+
Decided: keep the heuristic as-is rather than add a disambiguation
|
|
51
|
+
mechanism -- it is a diagnosability/correctness-on-the-margins wart, not
|
|
52
|
+
a live bug, and the ``logger.debug`` call below at least makes a
|
|
53
|
+
base64-decode *failure* observable. A value that round-trips through
|
|
54
|
+
*both* interpretations without the caller noticing is the accepted
|
|
55
|
+
cost; callers who need an unambiguous literal string should route it
|
|
56
|
+
through a different field instead of ``extensions``.
|
|
39
57
|
"""
|
|
40
58
|
if raw is None:
|
|
41
59
|
return {}
|
|
@@ -51,6 +69,10 @@ def _decode_extensions(raw: Any) -> dict[str, bytes]:
|
|
|
51
69
|
try:
|
|
52
70
|
decoded[str(key)] = base64.b64decode(value, validate=True)
|
|
53
71
|
except (binascii.Error, ValueError):
|
|
72
|
+
logger.debug(
|
|
73
|
+
"bootstrap extensions[%r] is not valid base64; using its raw UTF-8 bytes",
|
|
74
|
+
key,
|
|
75
|
+
)
|
|
54
76
|
decoded[str(key)] = value.encode("utf-8")
|
|
55
77
|
else:
|
|
56
78
|
raise ValueError(
|
macp_sdk/agent/strategies.py
CHANGED
|
@@ -6,6 +6,7 @@ from dataclasses import dataclass
|
|
|
6
6
|
from typing import Any, Protocol
|
|
7
7
|
|
|
8
8
|
from ..envelope import infer_outcome_positive
|
|
9
|
+
from ..validation import validate_confidence, validate_recommendation
|
|
9
10
|
from .types import HandlerContext, IncomingMessage, MessageHandler, SessionInfo
|
|
10
11
|
|
|
11
12
|
# ── Evaluation ───────────────────────────────────────────────────────
|
|
@@ -26,9 +27,6 @@ class EvaluationStrategy(Protocol):
|
|
|
26
27
|
def evaluate(self, proposal: dict[str, Any], context: SessionInfo) -> EvaluationResult: ...
|
|
27
28
|
|
|
28
29
|
|
|
29
|
-
_VALID_RECOMMENDATIONS = frozenset({"APPROVE", "REVIEW", "BLOCK", "REJECT"})
|
|
30
|
-
|
|
31
|
-
|
|
32
30
|
def evaluation_handler(strategy: EvaluationStrategy) -> MessageHandler:
|
|
33
31
|
"""Create a MessageHandler that evaluates proposals using the given strategy.
|
|
34
32
|
|
|
@@ -41,14 +39,8 @@ def evaluation_handler(strategy: EvaluationStrategy) -> MessageHandler:
|
|
|
41
39
|
if message.message_type != "Proposal":
|
|
42
40
|
return
|
|
43
41
|
result = strategy.evaluate(message.payload, ctx.session)
|
|
44
|
-
recommendation = result.recommendation
|
|
45
|
-
|
|
46
|
-
raise ValueError(
|
|
47
|
-
f"invalid recommendation {result.recommendation!r}: "
|
|
48
|
-
"must be one of APPROVE, REVIEW, BLOCK, REJECT"
|
|
49
|
-
)
|
|
50
|
-
if not (0.0 <= result.confidence <= 1.0):
|
|
51
|
-
raise ValueError(f"confidence must be in [0.0, 1.0], got {result.confidence}")
|
|
42
|
+
recommendation = validate_recommendation(result.recommendation)
|
|
43
|
+
validate_confidence(result.confidence)
|
|
52
44
|
ctx.log(
|
|
53
45
|
"evaluation: recommendation=%s confidence=%.2f reason=%s",
|
|
54
46
|
recommendation,
|
|
@@ -254,7 +246,9 @@ def majority_voter(
|
|
|
254
246
|
"""Built-in voting strategy that votes ``APPROVE`` once the fraction of
|
|
255
247
|
qualifying (non-``REVIEW``) evaluations recommending ``APPROVE``, for the
|
|
256
248
|
most recently evaluated proposal, meets ``positive_threshold`` --
|
|
257
|
-
otherwise ``ABSTAIN``.
|
|
249
|
+
otherwise ``ABSTAIN``. ``should_vote`` applies the same qualifying/latest-
|
|
250
|
+
proposal rule: it returns ``True`` only when there is at least one
|
|
251
|
+
qualifying evaluation for ``decide_vote`` to actually decide from.
|
|
258
252
|
|
|
259
253
|
Both ``should_vote`` and ``decide_vote`` read only from
|
|
260
254
|
``projection.evaluations``, never from votes already cast (issue #93 item
|
|
@@ -279,13 +273,29 @@ def majority_voter(
|
|
|
279
273
|
def should_vote(self, projection: Any) -> bool:
|
|
280
274
|
if projection is None:
|
|
281
275
|
return False
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
#
|
|
286
|
-
#
|
|
287
|
-
|
|
288
|
-
|
|
276
|
+
evaluations = list(getattr(projection, "evaluations", None) or [])
|
|
277
|
+
if not evaluations:
|
|
278
|
+
return False
|
|
279
|
+
# Agree with decide_vote(): it votes on the most recently evaluated
|
|
280
|
+
# proposal and counts only qualifying (non-REVIEW) evaluations for
|
|
281
|
+
# that proposal, so should_vote() must ask the same question.
|
|
282
|
+
# RFC-MACP-0007 §4 (rfcs/RFC-MACP-0007-decision-mode.md:73): REVIEW
|
|
283
|
+
# evaluations "do not block or approve a proposal; they serve as
|
|
284
|
+
# informational analysis records only" -- a set of only REVIEWs has
|
|
285
|
+
# nothing decisive to vote on.
|
|
286
|
+
#
|
|
287
|
+
# Cross-SDK note: macp-sdk-typescript's majorityVoter
|
|
288
|
+
# (src/agent/strategies.ts:90-98) applies the same REVIEW filter but
|
|
289
|
+
# does NOT scope to a proposal -- its decideVote counts every
|
|
290
|
+
# decisive evaluation in the session. Python scopes both methods to
|
|
291
|
+
# the latest proposal, which is the stricter and more correct
|
|
292
|
+
# behaviour for a multi-proposal session (the vote is cast for one
|
|
293
|
+
# proposal_id). The difference is deliberate; see plan issue #121.
|
|
294
|
+
proposal_id = evaluations[-1].proposal_id
|
|
295
|
+
return any(
|
|
296
|
+
e.proposal_id == proposal_id and e.recommendation.upper() != "REVIEW"
|
|
297
|
+
for e in evaluations
|
|
298
|
+
)
|
|
289
299
|
|
|
290
300
|
def decide_vote(self, projection: Any) -> VoteResult:
|
|
291
301
|
evaluations = list(getattr(projection, "evaluations", None) or [])
|
|
@@ -334,7 +344,11 @@ def majority_committer(
|
|
|
334
344
|
and the quorum has been met.
|
|
335
345
|
|
|
336
346
|
Args:
|
|
337
|
-
quorum_size: Minimum number of votes
|
|
347
|
+
quorum_size: Minimum number of positive votes for the winning proposal
|
|
348
|
+
required before committing (default ``1``). Counted from
|
|
349
|
+
``vote_totals().get(winner, 0)``, not from the session's total
|
|
350
|
+
vote count, so votes cast for proposals that lost no longer help
|
|
351
|
+
clear the bar.
|
|
338
352
|
action: The commitment action string (default ``"commit"``).
|
|
339
353
|
authority_scope: The commitment authority scope (default ``"session"``).
|
|
340
354
|
"""
|
|
@@ -350,11 +364,20 @@ def majority_committer(
|
|
|
350
364
|
def should_commit(self, projection: Any) -> bool:
|
|
351
365
|
if projection is None:
|
|
352
366
|
return False
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
if total_votes < self._quorum:
|
|
367
|
+
winner = projection.majority_winner()
|
|
368
|
+
if winner is None:
|
|
356
369
|
return False
|
|
357
|
-
|
|
370
|
+
# quorum_size is the bar for the WINNING proposal, not for the
|
|
371
|
+
# session's total vote count: vote_totals() is keyed by
|
|
372
|
+
# proposal_id (projections.py:167-181), so summing its values
|
|
373
|
+
# across proposals let the bar be cleared by votes cast for
|
|
374
|
+
# proposals that lost. Worked example, measured against a real
|
|
375
|
+
# DecisionProjection: p1=2 APPROVE, p2=1 APPROVE, quorum_size=3
|
|
376
|
+
# -> the old sum is 2+1=3 >= 3 and majority_winner() is "p1", so
|
|
377
|
+
# it committed a proposal holding 2 of the 3 votes the caller
|
|
378
|
+
# asked for. Matches macp-sdk-typescript's majorityCommitter
|
|
379
|
+
# (src/agent/strategies.ts:163-168).
|
|
380
|
+
return projection.vote_totals().get(winner, 0) >= self._quorum
|
|
358
381
|
|
|
359
382
|
def decide_commitment(self, projection: Any) -> CommitmentResult:
|
|
360
383
|
winner = projection.majority_winner()
|
macp_sdk/auth.py
CHANGED
|
@@ -2,6 +2,8 @@ from __future__ import annotations
|
|
|
2
2
|
|
|
3
3
|
from dataclasses import dataclass
|
|
4
4
|
|
|
5
|
+
from .errors import MacpSessionError
|
|
6
|
+
|
|
5
7
|
|
|
6
8
|
@dataclass(frozen=True)
|
|
7
9
|
class AuthConfig:
|
|
@@ -23,7 +25,7 @@ class AuthConfig:
|
|
|
23
25
|
|
|
24
26
|
def __post_init__(self) -> None:
|
|
25
27
|
if not self.bearer_token:
|
|
26
|
-
raise
|
|
28
|
+
raise MacpSessionError("bearer_token is required")
|
|
27
29
|
|
|
28
30
|
@classmethod
|
|
29
31
|
def for_dev_agent(cls, agent_id: str, *, expected_sender: str | None = None) -> AuthConfig:
|
macp_sdk/client.py
CHANGED
|
@@ -2,7 +2,7 @@ from __future__ import annotations
|
|
|
2
2
|
|
|
3
3
|
import queue
|
|
4
4
|
import threading
|
|
5
|
-
from collections.abc import Callable, Iterator, Sequence
|
|
5
|
+
from collections.abc import Callable, Generator, Iterator, Sequence
|
|
6
6
|
from typing import Any
|
|
7
7
|
|
|
8
8
|
import grpc
|
|
@@ -134,6 +134,21 @@ UNBOUNDED = _UnboundedTimeout()
|
|
|
134
134
|
TimeoutValue = float | None | _UnboundedTimeout
|
|
135
135
|
|
|
136
136
|
|
|
137
|
+
def _cancel_quietly(call: Any) -> None:
|
|
138
|
+
"""Best-effort cancel of a server-streaming gRPC call.
|
|
139
|
+
|
|
140
|
+
Called from each ``watch_*`` generator's ``finally``, so it runs on
|
|
141
|
+
normal exhaustion, on an exception, and on ``GeneratorExit`` when a
|
|
142
|
+
consumer abandons the stream. Cancelling an already-finished call is
|
|
143
|
+
a no-op in grpcio; a cancel that raises must not replace whatever
|
|
144
|
+
the generator was already propagating.
|
|
145
|
+
"""
|
|
146
|
+
try:
|
|
147
|
+
call.cancel()
|
|
148
|
+
except Exception:
|
|
149
|
+
logger.debug("stream cancel failed", exc_info=True)
|
|
150
|
+
|
|
151
|
+
|
|
137
152
|
class MacpStream:
|
|
138
153
|
_END = object()
|
|
139
154
|
|
|
@@ -674,6 +689,14 @@ class MacpClient:
|
|
|
674
689
|
def list_roots(
|
|
675
690
|
self, *, auth: AuthConfig | None = None, timeout: TimeoutValue = None
|
|
676
691
|
) -> core_pb2.ListRootsResponse:
|
|
692
|
+
"""List the roots the runtime exposes.
|
|
693
|
+
|
|
694
|
+
The runtime does not populate roots yet and returns an empty list;
|
|
695
|
+
since runtime v0.5.0 it advertises this up front via
|
|
696
|
+
``capabilities.roots.list_changed: false`` in ``Initialize``. The RPC
|
|
697
|
+
is wired and forward-compatible -- callers should treat an empty list
|
|
698
|
+
as "no roots advertised," not as an error.
|
|
699
|
+
"""
|
|
677
700
|
return self.stub.ListRoots(
|
|
678
701
|
core_pb2.ListRootsRequest(),
|
|
679
702
|
metadata=self._metadata(auth),
|
|
@@ -738,7 +761,7 @@ class MacpClient:
|
|
|
738
761
|
*,
|
|
739
762
|
auth: AuthConfig | None = None,
|
|
740
763
|
timeout: TimeoutValue = None,
|
|
741
|
-
) ->
|
|
764
|
+
) -> Generator[core_pb2.WatchSessionsResponse, None, None]:
|
|
742
765
|
"""Server-streaming RPC: yields session lifecycle events.
|
|
743
766
|
|
|
744
767
|
The runtime emits an initial ``EVENT_TYPE_CREATED`` frame for every
|
|
@@ -760,6 +783,8 @@ class MacpClient:
|
|
|
760
783
|
yield from call
|
|
761
784
|
except grpc.RpcError as exc:
|
|
762
785
|
raise self._transport_error_from_rpc(exc) from exc
|
|
786
|
+
finally:
|
|
787
|
+
_cancel_quietly(call)
|
|
763
788
|
|
|
764
789
|
def register_ext_mode(
|
|
765
790
|
self,
|
|
@@ -938,7 +963,7 @@ class MacpClient:
|
|
|
938
963
|
|
|
939
964
|
def watch_policies(
|
|
940
965
|
self, *, auth: AuthConfig | None = None, timeout: TimeoutValue = None
|
|
941
|
-
) ->
|
|
966
|
+
) -> Generator[policy_pb2.WatchPoliciesResponse, None, None]:
|
|
942
967
|
"""Server-streaming RPC: yields governance policy change events.
|
|
943
968
|
|
|
944
969
|
Auth is forwarded when available (``auth`` arg or ``client.auth``) but
|
|
@@ -955,6 +980,8 @@ class MacpClient:
|
|
|
955
980
|
yield from call
|
|
956
981
|
except grpc.RpcError as exc:
|
|
957
982
|
raise self._transport_error_from_rpc(exc) from exc
|
|
983
|
+
finally:
|
|
984
|
+
_cancel_quietly(call)
|
|
958
985
|
|
|
959
986
|
def open_stream(
|
|
960
987
|
self, *, auth: AuthConfig | None = None, timeout: TimeoutValue = None
|
|
@@ -968,7 +995,7 @@ class MacpClient:
|
|
|
968
995
|
|
|
969
996
|
def watch_mode_registry(
|
|
970
997
|
self, *, auth: AuthConfig | None = None, timeout: TimeoutValue = None
|
|
971
|
-
) ->
|
|
998
|
+
) -> Generator[core_pb2.WatchModeRegistryResponse, None, None]:
|
|
972
999
|
"""Server-streaming RPC: yields mode registry change events.
|
|
973
1000
|
|
|
974
1001
|
Auth is forwarded when available but not required.
|
|
@@ -983,10 +1010,12 @@ class MacpClient:
|
|
|
983
1010
|
yield from call
|
|
984
1011
|
except grpc.RpcError as exc:
|
|
985
1012
|
raise self._transport_error_from_rpc(exc) from exc
|
|
1013
|
+
finally:
|
|
1014
|
+
_cancel_quietly(call)
|
|
986
1015
|
|
|
987
1016
|
def watch_roots(
|
|
988
1017
|
self, *, auth: AuthConfig | None = None, timeout: TimeoutValue = None
|
|
989
|
-
) ->
|
|
1018
|
+
) -> Generator[core_pb2.WatchRootsResponse, None, None]:
|
|
990
1019
|
"""Server-streaming RPC: yields root change events.
|
|
991
1020
|
|
|
992
1021
|
The runtime advertises ``roots.list_changed: false`` and does not yet
|
|
@@ -1003,10 +1032,12 @@ class MacpClient:
|
|
|
1003
1032
|
yield from call
|
|
1004
1033
|
except grpc.RpcError as exc:
|
|
1005
1034
|
raise self._transport_error_from_rpc(exc) from exc
|
|
1035
|
+
finally:
|
|
1036
|
+
_cancel_quietly(call)
|
|
1006
1037
|
|
|
1007
1038
|
def watch_signals(
|
|
1008
1039
|
self, *, auth: AuthConfig | None = None, timeout: TimeoutValue = None
|
|
1009
|
-
) ->
|
|
1040
|
+
) -> Generator[core_pb2.WatchSignalsResponse, None, None]:
|
|
1010
1041
|
"""Server-streaming RPC: yields ambient signal envelopes.
|
|
1011
1042
|
|
|
1012
1043
|
Requires authentication since runtime v0.5.0 — an unauthenticated
|
|
@@ -1025,6 +1056,8 @@ class MacpClient:
|
|
|
1025
1056
|
yield from call
|
|
1026
1057
|
except grpc.RpcError as exc:
|
|
1027
1058
|
raise self._transport_error_from_rpc(exc) from exc
|
|
1059
|
+
finally:
|
|
1060
|
+
_cancel_quietly(call)
|
|
1028
1061
|
|
|
1029
1062
|
def send_signal(
|
|
1030
1063
|
self,
|
macp_sdk/envelope.py
CHANGED
|
@@ -14,7 +14,7 @@ from .constants import (
|
|
|
14
14
|
MACP_VERSION,
|
|
15
15
|
)
|
|
16
16
|
from .errors import MacpSessionError
|
|
17
|
-
from .validation import validate_commitment_hash
|
|
17
|
+
from .validation import validate_commitment_hash, validate_max_suspend_ms
|
|
18
18
|
|
|
19
19
|
# ── Outcome inference ────────────────────────────────────────────────
|
|
20
20
|
|
|
@@ -79,10 +79,7 @@ def build_session_start_payload(
|
|
|
79
79
|
client-side here with a clear message. proto3 does not serialize a scalar
|
|
80
80
|
``0``, so the default keeps byte-compatibility with pre-0.1.5 payloads.
|
|
81
81
|
"""
|
|
82
|
-
|
|
83
|
-
raise MacpSessionError(
|
|
84
|
-
f"max_suspend_ms must be >= 0 (0 selects the runtime default), got {max_suspend_ms}"
|
|
85
|
-
)
|
|
82
|
+
validate_max_suspend_ms(max_suspend_ms)
|
|
86
83
|
kwargs: dict[str, object] = dict(
|
|
87
84
|
intent=intent,
|
|
88
85
|
participants=list(participants),
|
macp_sdk/handoff.py
CHANGED
|
@@ -82,8 +82,8 @@ class HandoffProjection(BaseProjection):
|
|
|
82
82
|
if handoff.status == "offered":
|
|
83
83
|
handoff.status = "context_sent"
|
|
84
84
|
handoff.context_content_type = p.content_type
|
|
85
|
-
|
|
86
|
-
|
|
85
|
+
if self.phase == "OfferPending":
|
|
86
|
+
self._set_phase("ContextSharing")
|
|
87
87
|
return
|
|
88
88
|
|
|
89
89
|
if mt == "HandoffAccept":
|
macp_sdk/projections.py
CHANGED
|
@@ -67,6 +67,31 @@ class DecisionProjection(BaseProjection):
|
|
|
67
67
|
rationale=payload.rationale,
|
|
68
68
|
sender=envelope.sender,
|
|
69
69
|
)
|
|
70
|
+
# A Proposal opens the evaluation window: the runtime advances the
|
|
71
|
+
# session's phase when it accepts the Proposal
|
|
72
|
+
# (macp-runtime/crates/macp-modes/src/mode/decision.rs:172) and then
|
|
73
|
+
# *requires* that phase before it will accept an Evaluation or
|
|
74
|
+
# Objection at all (ensure_can_deliberate, same file :71-77, gating
|
|
75
|
+
# :187 and :212) -- so reporting "Proposal" in this window describes a
|
|
76
|
+
# state the session is demonstrably not in. macp-sdk-typescript's
|
|
77
|
+
# DecisionProjection advances here too. "Proposal" is still the initial
|
|
78
|
+
# phase set in __init__ -- it is observable before the first mode
|
|
79
|
+
# message, it just no longer survives the Proposal itself.
|
|
80
|
+
#
|
|
81
|
+
# Guarded on the phase still being the initial one, NOT unconditional:
|
|
82
|
+
# _set_phase (base_projection.py) blocks regression only out of
|
|
83
|
+
# "Committed", so an unconditional call here would let a Proposal
|
|
84
|
+
# redelivered under a distinct message_id rewind "Voting" ->
|
|
85
|
+
# "Evaluation". RFC-MACP-0007 §5 rule 6
|
|
86
|
+
# (rfcs/RFC-MACP-0007-decision-mode.md:94) says a runtime MUST reject
|
|
87
|
+
# any Proposal/Evaluation/Objection after the first accepted Vote, and
|
|
88
|
+
# a message the runtime must reject certainly must not rewind our local
|
|
89
|
+
# phase. Same shape as handoff.py:85's `if self.phase ==
|
|
90
|
+
# "OfferPending"` guard. (macp-sdk-typescript/src/projections/
|
|
91
|
+
# decision.ts:55 is unconditional and has this latent gap even though
|
|
92
|
+
# its own Vote arm guards at :124-126 -- do not copy it.)
|
|
93
|
+
if self.phase == "Proposal":
|
|
94
|
+
self._set_phase("Evaluation")
|
|
70
95
|
return
|
|
71
96
|
|
|
72
97
|
if message_type == "Evaluation":
|
|
@@ -100,6 +125,17 @@ class DecisionProjection(BaseProjection):
|
|
|
100
125
|
if message_type == "Vote":
|
|
101
126
|
payload = decision_pb2.VotePayload()
|
|
102
127
|
payload.ParseFromString(envelope.payload)
|
|
128
|
+
if payload.proposal_id not in self.proposals:
|
|
129
|
+
# A Vote for a proposal this projection never saw is ignored
|
|
130
|
+
# entirely -- no vote record, no phase advance, and deliberately
|
|
131
|
+
# NO anomaly. Rejecting an unknown proposal_id is the runtime's
|
|
132
|
+
# obligation (it has the full session state); a projection is a
|
|
133
|
+
# local, possibly partial view, and a mid-session joiner whose
|
|
134
|
+
# replay window starts after the Proposal legitimately never saw
|
|
135
|
+
# it. Recording an anomaly here would fire on conforming
|
|
136
|
+
# sessions. Same split, same shape as the guard issue #119
|
|
137
|
+
# shipped in proposal.py's Reject branch.
|
|
138
|
+
return
|
|
103
139
|
existing = self.votes.get(payload.proposal_id, {}).get(envelope.sender)
|
|
104
140
|
if existing is not None:
|
|
105
141
|
# First-wins (RFC-MACP-0007 §5.3: "the first accepted Vote
|
macp_sdk/quorum.py
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
+
import warnings
|
|
3
4
|
from dataclasses import dataclass
|
|
4
5
|
|
|
5
6
|
from macp.modes.quorum.v1 import quorum_pb2
|
|
@@ -23,7 +24,25 @@ class ApprovalRequestRecord:
|
|
|
23
24
|
action: str
|
|
24
25
|
summary: str
|
|
25
26
|
required_approvals: int
|
|
26
|
-
|
|
27
|
+
sender: str
|
|
28
|
+
|
|
29
|
+
# ── Deprecated alias (issue #120 / multiagentcoordinationprotocol#177) ──
|
|
30
|
+
# ``requester`` is kept as a read-only, INSTANCE-level property alias for
|
|
31
|
+
# one minor version, removed at this SDK's next major version. See
|
|
32
|
+
# ``ProposalRecord.proposer`` in proposal.py for the full rationale this
|
|
33
|
+
# mechanism shares: a plain same-named class attribute alias is not an
|
|
34
|
+
# option on a ``@dataclass(slots=True)``, but a property is not a field
|
|
35
|
+
# and is untouched by the slots transformation. No setter is defined:
|
|
36
|
+
# nothing in this module ever assigns ``.requester``.
|
|
37
|
+
@property
|
|
38
|
+
def requester(self) -> str:
|
|
39
|
+
warnings.warn(
|
|
40
|
+
"ApprovalRequestRecord.requester is deprecated; "
|
|
41
|
+
"use ApprovalRequestRecord.sender instead.",
|
|
42
|
+
DeprecationWarning,
|
|
43
|
+
stacklevel=2,
|
|
44
|
+
)
|
|
45
|
+
return self.sender
|
|
27
46
|
|
|
28
47
|
|
|
29
48
|
@dataclass(slots=True)
|
|
@@ -68,7 +87,7 @@ class QuorumProjection(BaseProjection):
|
|
|
68
87
|
action=p.action,
|
|
69
88
|
summary=p.summary,
|
|
70
89
|
required_approvals=p.required_approvals,
|
|
71
|
-
|
|
90
|
+
sender=envelope.sender,
|
|
72
91
|
)
|
|
73
92
|
self._set_phase("Voting")
|
|
74
93
|
return
|
|
@@ -103,6 +122,12 @@ class QuorumProjection(BaseProjection):
|
|
|
103
122
|
# Approve/Reject/Abstain arms, each rejecting when
|
|
104
123
|
# state.ballots.contains_key(&env.sender), confirmed at runtime
|
|
105
124
|
# v0.8.6) -- RFC-0011 itself is silent on that.
|
|
125
|
+
if request_id not in self.requests:
|
|
126
|
+
# Same projection-vs-runtime split as projections.py's Vote guard:
|
|
127
|
+
# rejecting an unknown request_id is the runtime's obligation, and a
|
|
128
|
+
# mid-session joiner may legitimately never have seen the
|
|
129
|
+
# ApprovalRequest, so this is a silent no-op with no anomaly.
|
|
130
|
+
return
|
|
106
131
|
sender = envelope.sender
|
|
107
132
|
sender_map = self.ballots.setdefault(request_id, {})
|
|
108
133
|
existing = sender_map.get(sender)
|
macp_sdk/task.py
CHANGED
|
@@ -25,19 +25,37 @@ class TaskRecord:
|
|
|
25
25
|
title: str
|
|
26
26
|
instructions: str
|
|
27
27
|
requested_assignee: str
|
|
28
|
-
|
|
28
|
+
sender: str
|
|
29
29
|
status: str = "requested"
|
|
30
30
|
progress: float = 0.0
|
|
31
31
|
assignee: str | None = None
|
|
32
32
|
deadline_unix_ms: int = 0
|
|
33
33
|
input: bytes = b""
|
|
34
34
|
|
|
35
|
+
# ── Deprecated alias (issue #120 / multiagentcoordinationprotocol#177) ──
|
|
36
|
+
# ``requester`` is kept as a read-only, INSTANCE-level property alias for
|
|
37
|
+
# one minor version, removed at this SDK's next major version. See
|
|
38
|
+
# ``ProposalRecord.proposer`` in proposal.py for the full rationale this
|
|
39
|
+
# mechanism shares: a plain same-named class attribute alias is not an
|
|
40
|
+
# option on a ``@dataclass(slots=True)``, but a property is not a field
|
|
41
|
+
# and is untouched by the slots transformation. No setter is defined:
|
|
42
|
+
# nothing in this module ever assigns ``.requester``.
|
|
43
|
+
@property
|
|
44
|
+
def requester(self) -> str:
|
|
45
|
+
warnings.warn(
|
|
46
|
+
"TaskRecord.requester is deprecated; use TaskRecord.sender instead.",
|
|
47
|
+
DeprecationWarning,
|
|
48
|
+
stacklevel=2,
|
|
49
|
+
)
|
|
50
|
+
return self.sender
|
|
51
|
+
|
|
35
52
|
|
|
36
53
|
@dataclass(slots=True)
|
|
37
54
|
class TaskRejectRecord:
|
|
38
55
|
task_id: str
|
|
39
56
|
assignee: str
|
|
40
57
|
reason: str
|
|
58
|
+
sender: str = ""
|
|
41
59
|
|
|
42
60
|
|
|
43
61
|
@dataclass(slots=True)
|
|
@@ -46,6 +64,7 @@ class TaskUpdateRecord:
|
|
|
46
64
|
status: str
|
|
47
65
|
progress: float
|
|
48
66
|
message: str
|
|
67
|
+
sender: str = ""
|
|
49
68
|
|
|
50
69
|
|
|
51
70
|
@dataclass(slots=True)
|
|
@@ -54,6 +73,7 @@ class TaskCompleteRecord:
|
|
|
54
73
|
assignee: str
|
|
55
74
|
summary: str
|
|
56
75
|
output: bytes
|
|
76
|
+
sender: str = ""
|
|
57
77
|
|
|
58
78
|
|
|
59
79
|
@dataclass(slots=True)
|
|
@@ -63,6 +83,7 @@ class TaskFailRecord:
|
|
|
63
83
|
error_code: str
|
|
64
84
|
reason: str
|
|
65
85
|
retryable: bool
|
|
86
|
+
sender: str = ""
|
|
66
87
|
|
|
67
88
|
|
|
68
89
|
# ---------------------------------------------------------------------------
|
|
@@ -108,7 +129,7 @@ class TaskProjection(BaseProjection):
|
|
|
108
129
|
title=p.title,
|
|
109
130
|
instructions=p.instructions,
|
|
110
131
|
requested_assignee=p.requested_assignee,
|
|
111
|
-
|
|
132
|
+
sender=envelope.sender,
|
|
112
133
|
status="requested",
|
|
113
134
|
progress=0.0,
|
|
114
135
|
assignee=None,
|
|
@@ -165,8 +186,12 @@ class TaskProjection(BaseProjection):
|
|
|
165
186
|
self.rejections.append(
|
|
166
187
|
TaskRejectRecord(
|
|
167
188
|
task_id=p.task_id,
|
|
189
|
+
# `assignee` is who the work is about (payload-declared,
|
|
190
|
+
# falling back to the sender); `sender` is who sent this
|
|
191
|
+
# message (envelope truth, never substituted).
|
|
168
192
|
assignee=p.assignee or envelope.sender,
|
|
169
193
|
reason=p.reason,
|
|
194
|
+
sender=envelope.sender,
|
|
170
195
|
)
|
|
171
196
|
)
|
|
172
197
|
if p.task_id in self.tasks:
|
|
@@ -191,6 +216,7 @@ class TaskProjection(BaseProjection):
|
|
|
191
216
|
status=p.status,
|
|
192
217
|
progress=p.progress,
|
|
193
218
|
message=p.message,
|
|
219
|
+
sender=envelope.sender,
|
|
194
220
|
)
|
|
195
221
|
)
|
|
196
222
|
if p.task_id in self.tasks:
|
|
@@ -207,6 +233,7 @@ class TaskProjection(BaseProjection):
|
|
|
207
233
|
assignee=p.assignee or envelope.sender,
|
|
208
234
|
summary=p.summary,
|
|
209
235
|
output=p.output,
|
|
236
|
+
sender=envelope.sender,
|
|
210
237
|
)
|
|
211
238
|
)
|
|
212
239
|
if p.task_id in self.tasks:
|
|
@@ -225,6 +252,7 @@ class TaskProjection(BaseProjection):
|
|
|
225
252
|
error_code=p.error_code,
|
|
226
253
|
reason=p.reason,
|
|
227
254
|
retryable=p.retryable,
|
|
255
|
+
sender=envelope.sender,
|
|
228
256
|
)
|
|
229
257
|
)
|
|
230
258
|
if p.task_id in self.tasks:
|
macp_sdk/validation.py
CHANGED
|
@@ -5,6 +5,7 @@ All validation functions raise ``MacpSessionError`` on failure.
|
|
|
5
5
|
|
|
6
6
|
from __future__ import annotations
|
|
7
7
|
|
|
8
|
+
import math
|
|
8
9
|
import re
|
|
9
10
|
from collections.abc import Sequence
|
|
10
11
|
|
|
@@ -96,7 +97,10 @@ def validate_recommendation(value: str) -> str:
|
|
|
96
97
|
|
|
97
98
|
def validate_confidence(value: float) -> None:
|
|
98
99
|
"""Validate that *value* is in [0.0, 1.0]."""
|
|
99
|
-
|
|
100
|
+
# NaN < 0.0 and NaN > 1.0 are both False, so a bare range check silently
|
|
101
|
+
# *accepts* NaN; inf was already rejected incidentally by > 1.0 and is
|
|
102
|
+
# now rejected explicitly.
|
|
103
|
+
if not math.isfinite(value) or value < 0.0 or value > 1.0:
|
|
100
104
|
raise MacpSessionError(f"confidence must be in [0.0, 1.0], got {value}")
|
|
101
105
|
|
|
102
106
|
|
|
@@ -160,10 +164,26 @@ def validate_progress_scope(session_id: str, mode: str) -> None:
|
|
|
160
164
|
|
|
161
165
|
def validate_ttl_ms(ttl_ms: int) -> None:
|
|
162
166
|
"""Validate that *ttl_ms* is in [1, 86_400_000]."""
|
|
163
|
-
if ttl_ms < 1 or ttl_ms > _MAX_TTL_MS:
|
|
167
|
+
if not math.isfinite(ttl_ms) or ttl_ms < 1 or ttl_ms > _MAX_TTL_MS:
|
|
164
168
|
raise MacpSessionError(f"ttl_ms must be in [1, {_MAX_TTL_MS}], got {ttl_ms}")
|
|
165
169
|
|
|
166
170
|
|
|
171
|
+
def validate_max_suspend_ms(max_suspend_ms: int) -> None:
|
|
172
|
+
"""Validate that *max_suspend_ms* is >= 0 (``0`` selects the runtime default).
|
|
173
|
+
|
|
174
|
+
No upper bound is imposed: the runtime does not cap the value either
|
|
175
|
+
-- ``macp-runtime/src/runtime.rs:487-495`` binds whatever positive
|
|
176
|
+
value it is given as the session's ``bound_max_suspend_ms`` and only
|
|
177
|
+
falls back to its own 7-day default when the field is ``0``. The
|
|
178
|
+
sibling TypeScript SDK's ``validateMaxSuspendMs``
|
|
179
|
+
(``src/validation.ts:134-139``) is identical, deliberately.
|
|
180
|
+
"""
|
|
181
|
+
if not math.isfinite(max_suspend_ms) or max_suspend_ms < 0:
|
|
182
|
+
raise MacpSessionError(
|
|
183
|
+
f"max_suspend_ms must be >= 0 (0 selects the runtime default), got {max_suspend_ms}"
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
|
|
167
187
|
def validate_participants(participants: Sequence[str], *, allow_empty: bool = False) -> None:
|
|
168
188
|
"""Validate participant list: non-empty, no duplicates, within count limit.
|
|
169
189
|
|
|
@@ -205,7 +225,15 @@ def validate_session_start(
|
|
|
205
225
|
``allow_empty_participants`` forwards to :func:`validate_participants` --
|
|
206
226
|
see its docstring for why Decision mode is the one caller that sets it.
|
|
207
227
|
"""
|
|
208
|
-
|
|
228
|
+
# RFC-MACP-0001 §7.1 does not require a non-empty intent, and the
|
|
229
|
+
# runtime accepts an empty one. ``intent`` is kept in the signature
|
|
230
|
+
# because this function is public and exported (``__init__.py``), so
|
|
231
|
+
# removing the parameter would be a breaking change for a fix whose
|
|
232
|
+
# entire point is to be *less* strict. ``del`` rather than an ARG001
|
|
233
|
+
# suppression comment: ruff's ARG rules apply to ``src/`` (per-file-
|
|
234
|
+
# ignores covers ``tests/**`` only), and this states the intent
|
|
235
|
+
# instead of silencing the check.
|
|
236
|
+
del intent # accepted but deliberately unvalidated (RFC-MACP-0001 §7.1)
|
|
209
237
|
validate_participants(participants, allow_empty=allow_empty_participants)
|
|
210
238
|
validate_ttl_ms(ttl_ms)
|
|
211
239
|
validate_required_field("mode_version", mode_version)
|
macp_sdk/watchers.py
CHANGED
|
@@ -9,10 +9,13 @@ Each watcher provides three consumption patterns:
|
|
|
9
9
|
from __future__ import annotations
|
|
10
10
|
|
|
11
11
|
import warnings
|
|
12
|
-
from collections.abc import Callable,
|
|
12
|
+
from collections.abc import Callable, Generator
|
|
13
|
+
from contextlib import closing
|
|
13
14
|
from dataclasses import dataclass, field
|
|
14
15
|
from typing import TYPE_CHECKING, Any
|
|
15
16
|
|
|
17
|
+
from .errors import MacpTransportError
|
|
18
|
+
|
|
16
19
|
if TYPE_CHECKING:
|
|
17
20
|
from .auth import AuthConfig
|
|
18
21
|
from .client import MacpClient
|
|
@@ -26,6 +29,33 @@ class PolicyChange:
|
|
|
26
29
|
observed_at_unix_ms: int = 0
|
|
27
30
|
|
|
28
31
|
|
|
32
|
+
TERMINAL_SESSION_LIFECYCLE_EVENT_NAMES: frozenset[str] = frozenset(
|
|
33
|
+
{"RESOLVED", "EXPIRED", "CANCELLED"}
|
|
34
|
+
)
|
|
35
|
+
"""Event-type names after which a session emits no further lifecycle events.
|
|
36
|
+
|
|
37
|
+
``SUSPENDED`` / ``RESUMED`` are deliberately absent: a suspended session
|
|
38
|
+
can still be resumed, so neither is terminal.
|
|
39
|
+
|
|
40
|
+
Cross-SDK note -- read this before comparing anything against it.
|
|
41
|
+
These are the proto enum names with the ``EVENT_TYPE_`` prefix
|
|
42
|
+
**stripped** (see ``_session_event_name``), which is the shape
|
|
43
|
+
``SessionLifecycleEvent.event_type`` carries in this SDK.
|
|
44
|
+
``macp-sdk-typescript`` exports a differently-shaped set under the
|
|
45
|
+
confusingly similar name ``TERMINAL_SESSION_LIFECYCLE_EVENT_TYPES``
|
|
46
|
+
(``src/watchers.ts:20-24``), holding the **un-stripped** names
|
|
47
|
+
(``"EVENT_TYPE_RESOLVED"``). This constant is named ``..._NAMES``
|
|
48
|
+
rather than ``..._TYPES`` precisely so the two cannot be mistaken for
|
|
49
|
+
the same contract. The value difference is deliberate and will not be
|
|
50
|
+
reconciled by changing Python: the stripped form is this SDK's
|
|
51
|
+
published field *value*, so changing it would break every
|
|
52
|
+
``event_type == "RESOLVED"`` call site with no deprecation path
|
|
53
|
+
available (a value, unlike a name, cannot carry a warning). To test a
|
|
54
|
+
prefixed string from a TypeScript-written log, normalise it first:
|
|
55
|
+
``t.removeprefix("EVENT_TYPE_") in TERMINAL_SESSION_LIFECYCLE_EVENT_NAMES``.
|
|
56
|
+
"""
|
|
57
|
+
|
|
58
|
+
|
|
29
59
|
@dataclass(slots=True)
|
|
30
60
|
class SessionLifecycleEvent:
|
|
31
61
|
"""A single session lifecycle event from ``WatchSessions``.
|
|
@@ -77,8 +107,12 @@ class SessionLifecycleEvent:
|
|
|
77
107
|
@property
|
|
78
108
|
def is_terminal(self) -> bool:
|
|
79
109
|
"""``True`` for RESOLVED, EXPIRED, or CANCELLED — the session won't
|
|
80
|
-
emit more events. ``SUSPENDED`` / ``RESUMED`` are non-terminal.
|
|
81
|
-
|
|
110
|
+
emit more events. ``SUSPENDED`` / ``RESUMED`` are non-terminal.
|
|
111
|
+
|
|
112
|
+
See ``TERMINAL_SESSION_LIFECYCLE_EVENT_NAMES`` for the exported,
|
|
113
|
+
reusable form of this set.
|
|
114
|
+
"""
|
|
115
|
+
return self.event_type in TERMINAL_SESSION_LIFECYCLE_EVENT_NAMES
|
|
82
116
|
|
|
83
117
|
|
|
84
118
|
class ModeRegistryWatcher:
|
|
@@ -88,20 +122,22 @@ class ModeRegistryWatcher:
|
|
|
88
122
|
self._client = client
|
|
89
123
|
self._auth = auth
|
|
90
124
|
|
|
91
|
-
def changes(self) ->
|
|
125
|
+
def changes(self) -> Generator[Any, None, None]:
|
|
92
126
|
"""Yield ``WatchModeRegistryResponse`` items from the runtime stream."""
|
|
93
127
|
yield from self._client.watch_mode_registry(auth=self._auth)
|
|
94
128
|
|
|
95
129
|
def watch(self, handler: Callable[[Any], None]) -> None:
|
|
96
130
|
"""Block and invoke *handler* for each registry change."""
|
|
97
|
-
|
|
98
|
-
|
|
131
|
+
with closing(self.changes()) as stream:
|
|
132
|
+
for change in stream:
|
|
133
|
+
handler(change)
|
|
99
134
|
|
|
100
135
|
def next_change(self) -> Any:
|
|
101
136
|
"""Pull a single change from the stream and return it."""
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
137
|
+
with closing(self.changes()) as stream:
|
|
138
|
+
for change in stream:
|
|
139
|
+
return change
|
|
140
|
+
raise MacpTransportError("stream ended before receiving a change")
|
|
105
141
|
|
|
106
142
|
|
|
107
143
|
class RootsWatcher:
|
|
@@ -111,20 +147,22 @@ class RootsWatcher:
|
|
|
111
147
|
self._client = client
|
|
112
148
|
self._auth = auth
|
|
113
149
|
|
|
114
|
-
def changes(self) ->
|
|
150
|
+
def changes(self) -> Generator[Any, None, None]:
|
|
115
151
|
"""Yield ``WatchRootsResponse`` items from the runtime stream."""
|
|
116
152
|
yield from self._client.watch_roots(auth=self._auth)
|
|
117
153
|
|
|
118
154
|
def watch(self, handler: Callable[[Any], None]) -> None:
|
|
119
155
|
"""Block and invoke *handler* for each root change."""
|
|
120
|
-
|
|
121
|
-
|
|
156
|
+
with closing(self.changes()) as stream:
|
|
157
|
+
for change in stream:
|
|
158
|
+
handler(change)
|
|
122
159
|
|
|
123
160
|
def next_change(self) -> Any:
|
|
124
161
|
"""Pull a single change from the stream and return it."""
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
162
|
+
with closing(self.changes()) as stream:
|
|
163
|
+
for change in stream:
|
|
164
|
+
return change
|
|
165
|
+
raise MacpTransportError("stream ended before receiving a change")
|
|
128
166
|
|
|
129
167
|
|
|
130
168
|
class SignalWatcher:
|
|
@@ -134,26 +172,36 @@ class SignalWatcher:
|
|
|
134
172
|
self._client = client
|
|
135
173
|
self._auth = auth
|
|
136
174
|
|
|
137
|
-
def signals(self) ->
|
|
175
|
+
def signals(self) -> Generator[Any, None, None]:
|
|
138
176
|
"""Yield envelope objects extracted from ``WatchSignalsResponse``.
|
|
139
177
|
|
|
140
178
|
Forwards the watcher's stored ``auth`` — runtime v0.5.0 requires
|
|
141
179
|
authentication for ``WatchSignals``.
|
|
180
|
+
|
|
181
|
+
Wraps the inner ``watch_signals()`` generator in its own
|
|
182
|
+
``closing()`` -- a bare ``for`` loop here would only release it
|
|
183
|
+
by refcounting when this generator is itself closed, which a
|
|
184
|
+
caller-held reference cycle (or a non-refcounting GC) can defer
|
|
185
|
+
indefinitely, undermining the whole point of the caller side
|
|
186
|
+
wrapping *this* generator in ``closing()`` in turn.
|
|
142
187
|
"""
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
188
|
+
with closing(self._client.watch_signals(auth=self._auth)) as responses:
|
|
189
|
+
for response in responses:
|
|
190
|
+
if hasattr(response, "envelope") and response.envelope.ByteSize() > 0:
|
|
191
|
+
yield response.envelope
|
|
146
192
|
|
|
147
193
|
def watch(self, handler: Callable[[Any], None]) -> None:
|
|
148
194
|
"""Block and invoke *handler* for each signal envelope."""
|
|
149
|
-
|
|
150
|
-
|
|
195
|
+
with closing(self.signals()) as stream:
|
|
196
|
+
for envelope in stream:
|
|
197
|
+
handler(envelope)
|
|
151
198
|
|
|
152
199
|
def next_signal(self) -> Any:
|
|
153
200
|
"""Pull a single signal envelope from the stream and return it."""
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
201
|
+
with closing(self.signals()) as stream:
|
|
202
|
+
for envelope in stream:
|
|
203
|
+
return envelope
|
|
204
|
+
raise MacpTransportError("stream ended before receiving a signal")
|
|
157
205
|
|
|
158
206
|
|
|
159
207
|
_SESSION_EVENT_PREFIX = "EVENT_TYPE_"
|
|
@@ -191,28 +239,36 @@ class SessionLifecycleWatcher:
|
|
|
191
239
|
self._client = client
|
|
192
240
|
self._auth = auth
|
|
193
241
|
|
|
194
|
-
def changes(self) ->
|
|
195
|
-
"""Yield ``SessionLifecycleEvent`` items from the runtime stream.
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
242
|
+
def changes(self) -> Generator[SessionLifecycleEvent, None, None]:
|
|
243
|
+
"""Yield ``SessionLifecycleEvent`` items from the runtime stream.
|
|
244
|
+
|
|
245
|
+
Wraps the inner ``watch_sessions()`` generator in its own
|
|
246
|
+
``closing()`` -- see ``SignalWatcher.signals()``'s docstring for
|
|
247
|
+
why a bare ``for`` loop here isn't enough.
|
|
248
|
+
"""
|
|
249
|
+
with closing(self._client.watch_sessions(auth=self._auth)) as responses:
|
|
250
|
+
for response in responses:
|
|
251
|
+
event = getattr(response, "event", None)
|
|
252
|
+
if event is None:
|
|
253
|
+
continue
|
|
254
|
+
yield SessionLifecycleEvent(
|
|
255
|
+
event_type=_session_event_name(event.event_type),
|
|
256
|
+
observed_at_unix_ms=event.observed_at_unix_ms,
|
|
257
|
+
session=event.session,
|
|
258
|
+
)
|
|
205
259
|
|
|
206
260
|
def watch(self, handler: Callable[[SessionLifecycleEvent], None]) -> None:
|
|
207
261
|
"""Block and invoke *handler* for each lifecycle event."""
|
|
208
|
-
|
|
209
|
-
|
|
262
|
+
with closing(self.changes()) as stream:
|
|
263
|
+
for change in stream:
|
|
264
|
+
handler(change)
|
|
210
265
|
|
|
211
266
|
def next_change(self) -> SessionLifecycleEvent:
|
|
212
267
|
"""Pull a single lifecycle event from the stream and return it."""
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
268
|
+
with closing(self.changes()) as stream:
|
|
269
|
+
for change in stream:
|
|
270
|
+
return change
|
|
271
|
+
raise MacpTransportError("stream ended before receiving a session lifecycle event")
|
|
216
272
|
|
|
217
273
|
|
|
218
274
|
class PolicyWatcher:
|
|
@@ -222,23 +278,31 @@ class PolicyWatcher:
|
|
|
222
278
|
self._client = client
|
|
223
279
|
self._auth = auth
|
|
224
280
|
|
|
225
|
-
def changes(self) ->
|
|
226
|
-
"""Yield ``PolicyChange`` items from the runtime stream.
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
281
|
+
def changes(self) -> Generator[PolicyChange, None, None]:
|
|
282
|
+
"""Yield ``PolicyChange`` items from the runtime stream.
|
|
283
|
+
|
|
284
|
+
Wraps the inner ``watch_policies()`` generator in its own
|
|
285
|
+
``closing()`` -- see ``SignalWatcher.signals()``'s docstring for
|
|
286
|
+
why a bare ``for`` loop here isn't enough.
|
|
287
|
+
"""
|
|
288
|
+
with closing(self._client.watch_policies(auth=self._auth)) as responses:
|
|
289
|
+
for response in responses:
|
|
290
|
+
descriptors = list(response.descriptors) if hasattr(response, "descriptors") else []
|
|
291
|
+
observed = getattr(response, "observed_at_unix_ms", 0)
|
|
292
|
+
yield PolicyChange(descriptors=descriptors, observed_at_unix_ms=observed)
|
|
231
293
|
|
|
232
294
|
def watch(self, handler: Callable[[PolicyChange], None]) -> None:
|
|
233
295
|
"""Block and invoke *handler* for each policy change."""
|
|
234
|
-
|
|
235
|
-
|
|
296
|
+
with closing(self.changes()) as stream:
|
|
297
|
+
for change in stream:
|
|
298
|
+
handler(change)
|
|
236
299
|
|
|
237
300
|
def next_change(self) -> PolicyChange:
|
|
238
301
|
"""Pull a single policy change from the stream and return it."""
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
302
|
+
with closing(self.changes()) as stream:
|
|
303
|
+
for change in stream:
|
|
304
|
+
return change
|
|
305
|
+
raise MacpTransportError("stream ended before receiving a policy change")
|
|
242
306
|
|
|
243
307
|
|
|
244
308
|
# ── Deprecated aliases (issue #103 / multiagentcoordinationprotocol#135) ─────
|
|
@@ -1,35 +1,35 @@
|
|
|
1
|
-
macp_sdk/__init__.py,sha256=
|
|
1
|
+
macp_sdk/__init__.py,sha256=3_jOyxk5PLEqR8thQ-6wnlG7UNjdtsx6c_x8yUplxRI,9464
|
|
2
2
|
macp_sdk/_logging.py,sha256=pHxyG89c9b_r8mcVSBA4E3e1GMAI5Jcc4_jslzKO6fM,431
|
|
3
|
-
macp_sdk/auth.py,sha256=
|
|
3
|
+
macp_sdk/auth.py,sha256=3476zrbbMYGBlofUszljP4Yl0-0rFzBOqvIQk6fhOcY,2900
|
|
4
4
|
macp_sdk/base_projection.py,sha256=5wIPPb8YTmSSuZlvSI8WuezArLELQE-Xz2GVAQtRXEk,21344
|
|
5
5
|
macp_sdk/base_session.py,sha256=RCpLdSNLm3Voj1KCH__pTPbsQL2Xm4Q1zp3mp3UDE1o,8730
|
|
6
|
-
macp_sdk/client.py,sha256=
|
|
6
|
+
macp_sdk/client.py,sha256=FUxn4Gtsq6VdfANt4TF18t1BXQzaFzaAg-8XGGsgQN4,45274
|
|
7
7
|
macp_sdk/commitment_hash.py,sha256=TO5H3IQdYOCVXRvv6dxOed0D9qV6fAe3zadOjbqQ1bI,14709
|
|
8
8
|
macp_sdk/constants.py,sha256=7BaQC6qycmPEc1ChOQVDxlhEBsBdI-RQBT07BQ2Uiis,481
|
|
9
9
|
macp_sdk/decision.py,sha256=8yU5bv-DUU7eWgioc9CYVSB1sPcO9essiy-jsDQEunc,4792
|
|
10
|
-
macp_sdk/envelope.py,sha256=
|
|
10
|
+
macp_sdk/envelope.py,sha256=P19YlSURfqPRkt1pt0HqZQlgf5-GC1yRc2coSyeK-hg,9103
|
|
11
11
|
macp_sdk/errors.py,sha256=19dutKnnl-jtMOWpNBl2UEMCH_Sunz84G05G-wr7w1k,3836
|
|
12
|
-
macp_sdk/handoff.py,sha256=
|
|
12
|
+
macp_sdk/handoff.py,sha256=mqvvu5PMAatuf-iiXfhNCSaVJvuNk05PcswSTcnBPJs,12783
|
|
13
13
|
macp_sdk/policy.py,sha256=MoyQ9l7zI0vkz_4Ewg8RRr-4DUhFBVPDQ-tn0r94sVw,19815
|
|
14
|
-
macp_sdk/projections.py,sha256=
|
|
14
|
+
macp_sdk/projections.py,sha256=gbapgUgdw9PxNURWNBGI2ARuLVxf_sOhQZx6CWlGDKM,10025
|
|
15
15
|
macp_sdk/proposal.py,sha256=DneQzr8bBOCue5zhJ7ZP_s5kVxcUgRrk8au_GHeOrgg,15326
|
|
16
16
|
macp_sdk/proto_registry.py,sha256=jgGKE4maGbTejVhYotHFdUnN_ZjZgEqsDWpU8Hh1mN8,17628
|
|
17
17
|
macp_sdk/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
18
|
-
macp_sdk/quorum.py,sha256=
|
|
18
|
+
macp_sdk/quorum.py,sha256=_u-f2fjPXEALMlboOrBcXbMkUveTdNxJ4BZgq3LZblo,12184
|
|
19
19
|
macp_sdk/retry.py,sha256=KgRGC4Raz5Yto_LoRoPApmeERdTMP6fBQcQRpX_-w6g,1866
|
|
20
|
-
macp_sdk/task.py,sha256=
|
|
21
|
-
macp_sdk/validation.py,sha256=
|
|
22
|
-
macp_sdk/watchers.py,sha256=
|
|
20
|
+
macp_sdk/task.py,sha256=SYZLOqDkTxCT3YvQDdJp0y1raSy8e2PRzt0H1Aj8l48,21264
|
|
21
|
+
macp_sdk/validation.py,sha256=Rb7ZBOI4n0DVZtdRkzT6l3BCZ9owzOSJTpsYlpHhwBI,10335
|
|
22
|
+
macp_sdk/watchers.py,sha256=lfklb1CAxYq2PkCPXOdp4vT4y5QKazFZacpajKEvTmQ,13121
|
|
23
23
|
macp_sdk/agent/__init__.py,sha256=vrwgt4vaBGom2m45cm39GOTZ5QLya-h1f4IGKihLsTc,3673
|
|
24
24
|
macp_sdk/agent/cancel_callback.py,sha256=fCbAZ2LlmPtbNz_B6zn_be-m-g4CsKQLE_5x674KNzE,5541
|
|
25
25
|
macp_sdk/agent/dispatcher.py,sha256=t1wLYGpqJdm2nIaaEVStp_8zUIetHL8FT2wYNniLDTA,3972
|
|
26
|
-
macp_sdk/agent/participant.py,sha256=
|
|
27
|
-
macp_sdk/agent/runner.py,sha256=
|
|
28
|
-
macp_sdk/agent/strategies.py,sha256=
|
|
26
|
+
macp_sdk/agent/participant.py,sha256=1j_AFN-7X8bMwbkheSlbUo895zRZJEVPkNJS4gTpnE4,30307
|
|
27
|
+
macp_sdk/agent/runner.py,sha256=OFsePj7rIeAEr_jQFlyFJV13MFF1zJ_6b12xGZ4DV1A,10371
|
|
28
|
+
macp_sdk/agent/strategies.py,sha256=RPz-lpVsjFMrzN6-L7_MoMoeHR-PGhLqq4kP036mdo0,16452
|
|
29
29
|
macp_sdk/agent/transports.py,sha256=xKM_cC2Qy3-PwktlZZMcTnbIwGKiXa3PvZmRQXXPDHo,14731
|
|
30
30
|
macp_sdk/agent/types.py,sha256=wtlF-tTRGnEGHhYknhu-5ZzOBZ1pRQ5GjYLKBR4YzQg,1610
|
|
31
|
-
macp_sdk_python-0.
|
|
32
|
-
macp_sdk_python-0.
|
|
33
|
-
macp_sdk_python-0.
|
|
34
|
-
macp_sdk_python-0.
|
|
35
|
-
macp_sdk_python-0.
|
|
31
|
+
macp_sdk_python-0.14.1.dist-info/licenses/LICENSE,sha256=xx0jnfkXJvxRnG63LTGOxlggYnIysveWIZ6H3PNdCrQ,11357
|
|
32
|
+
macp_sdk_python-0.14.1.dist-info/METADATA,sha256=b4Hmwu7IV0Og68AVhkXtYlaOTP1_ovP4P4jAdRsWygo,7478
|
|
33
|
+
macp_sdk_python-0.14.1.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
34
|
+
macp_sdk_python-0.14.1.dist-info/top_level.txt,sha256=AoSstnUrSaVCCQw4KLxLkpO-9BLoVZ230mPBR6z-jh8,9
|
|
35
|
+
macp_sdk_python-0.14.1.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|