macp-sdk-python 0.13.0__py3-none-any.whl → 0.14.0__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 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",
@@ -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
- self._last_phase: str | None = None
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
- transport.stop()
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
- server = self._cancel_callback_server
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 :meth:`stop` — the event
695
- loop exit (or an incoming cancel POST that calls ``stop``) shuts
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(
@@ -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.upper()
45
- if recommendation not in _VALID_RECOMMENDATIONS:
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
- # Gate on Evaluations, not on votes already cast -- voting_handler
283
- # already only calls should_vote() on an incoming Evaluation
284
- # message, so this mirrors typescript-sdk's
285
- # majorityVoter.shouldVote (strategies.ts:88:
286
- # `projection.evaluations.length > 0`).
287
- evaluations = getattr(projection, "evaluations", None)
288
- return bool(evaluations)
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 before commitment (default ``1``).
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
- totals = projection.vote_totals()
354
- total_votes = sum(totals.values())
355
- if total_votes < self._quorum:
367
+ winner = projection.majority_winner()
368
+ if winner is None:
356
369
  return False
357
- return projection.majority_winner() is not None
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 ValueError("bearer_token is required")
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
- ) -> Iterator[core_pb2.WatchSessionsResponse]:
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
- ) -> Iterator[policy_pb2.WatchPoliciesResponse]:
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
- ) -> Iterator[core_pb2.WatchModeRegistryResponse]:
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
- ) -> Iterator[core_pb2.WatchRootsResponse]:
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
- ) -> Iterator[core_pb2.WatchSignalsResponse]:
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
- if max_suspend_ms < 0:
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
- if self.phase == "OfferPending":
86
- self._set_phase("ContextSharing")
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
- requester: str
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
- requester=envelope.sender,
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
- requester: str
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
- requester=envelope.sender,
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
- if value < 0.0 or value > 1.0:
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
- validate_required_field("intent", intent)
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, Iterator
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
- return self.event_type in ("RESOLVED", "EXPIRED", "CANCELLED")
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) -> Iterator[Any]:
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
- for change in self.changes():
98
- handler(change)
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
- for change in self.changes():
103
- return change
104
- raise RuntimeError("stream ended before receiving a change")
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) -> Iterator[Any]:
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
- for change in self.changes():
121
- handler(change)
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
- for change in self.changes():
126
- return change
127
- raise RuntimeError("stream ended before receiving a change")
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) -> Iterator[Any]:
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
- for response in self._client.watch_signals(auth=self._auth):
144
- if hasattr(response, "envelope") and response.envelope.ByteSize() > 0:
145
- yield response.envelope
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
- for envelope in self.signals():
150
- handler(envelope)
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
- for envelope in self.signals():
155
- return envelope
156
- raise RuntimeError("stream ended before receiving a signal")
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) -> Iterator[SessionLifecycleEvent]:
195
- """Yield ``SessionLifecycleEvent`` items from the runtime stream."""
196
- for response in self._client.watch_sessions(auth=self._auth):
197
- event = getattr(response, "event", None)
198
- if event is None:
199
- continue
200
- yield SessionLifecycleEvent(
201
- event_type=_session_event_name(event.event_type),
202
- observed_at_unix_ms=event.observed_at_unix_ms,
203
- session=event.session,
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
- for change in self.changes():
209
- handler(change)
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
- for change in self.changes():
214
- return change
215
- raise RuntimeError("stream ended before receiving a session lifecycle event")
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) -> Iterator[PolicyChange]:
226
- """Yield ``PolicyChange`` items from the runtime stream."""
227
- for response in self._client.watch_policies(auth=self._auth):
228
- descriptors = list(response.descriptors) if hasattr(response, "descriptors") else []
229
- observed = getattr(response, "observed_at_unix_ms", 0)
230
- yield PolicyChange(descriptors=descriptors, observed_at_unix_ms=observed)
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
- for change in self.changes():
235
- handler(change)
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
- for change in self.changes():
240
- return change
241
- raise RuntimeError("stream ended before receiving a policy change")
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,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: macp-sdk-python
3
- Version: 0.13.0
3
+ Version: 0.14.0
4
4
  Summary: Python SDK for the MACP Rust runtime
5
5
  Author-email: Multi-Agent Coordination Protocol <macp@multiagentcoordinationprotocol.org>
6
6
  License: Apache-2.0
@@ -1,35 +1,35 @@
1
- macp_sdk/__init__.py,sha256=TsUKVkKC8ShuzIKtozE9v3tS_4zAhIJ1yANLuefNHB4,9314
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=ouqQW7P7LqUn6_0OilqDBmsrW1IrPQNJ-ofe1La2iqw,2856
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=AplJy86LNTKBQWX2OOAO7jUTq3Isp97DkFCUxVOPYs4,43987
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=_MlJ43Dih3_FcJ4hyrirFqrOvb5RaK9ssQo9Y3kMlF0,9200
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=EEyQkG-TeTF3p09Lotnzm5kPQ-fpIYxWk9jt5VnoCv8,12775
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=tH2kt3HydaJsewhoavV7unydH-ulkOm5COo9RC3QHj0,7483
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=_LYkbQWqbOHCt-ojgarWPcvdGm-7aB0ZMMKOfxXgUlE,10927
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=plfuF9f8krKnbbNA8NxkM61jX6PbRLHLWtSYEMKjSIY,19946
21
- macp_sdk/validation.py,sha256=nJUKx9hhrFtCIACe_TAzmzStloPCHYu8WGPXknPwAcE,8752
22
- macp_sdk/watchers.py,sha256=r0RayslkAKbwEI7ubfbnm_4eNq0HnUpWJugnDAsmfMM,9976
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=iC6I9gtdDuE_4o2G4nmVV0EjbIa8rZthnBkQ14XZ1E0,25738
27
- macp_sdk/agent/runner.py,sha256=jFZNVPm4tPuY_yDEsiFFB_FpaX5nMG6__L6xwuo52A8,9013
28
- macp_sdk/agent/strategies.py,sha256=8o55FR5a-L2TCX2nCw8L8PO-ucswHniR2m8qVmuP4V0,14665
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.13.0.dist-info/licenses/LICENSE,sha256=xx0jnfkXJvxRnG63LTGOxlggYnIysveWIZ6H3PNdCrQ,11357
32
- macp_sdk_python-0.13.0.dist-info/METADATA,sha256=iwAFkfL0b0X0QmI6rfYBdH1tdixtal3TWPUowelAWjI,7478
33
- macp_sdk_python-0.13.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
34
- macp_sdk_python-0.13.0.dist-info/top_level.txt,sha256=AoSstnUrSaVCCQw4KLxLkpO-9BLoVZ230mPBR6z-jh8,9
35
- macp_sdk_python-0.13.0.dist-info/RECORD,,
31
+ macp_sdk_python-0.14.0.dist-info/licenses/LICENSE,sha256=xx0jnfkXJvxRnG63LTGOxlggYnIysveWIZ6H3PNdCrQ,11357
32
+ macp_sdk_python-0.14.0.dist-info/METADATA,sha256=FojmVwT6W5G2TeDaXUxZ2Ulv4qC1QGfWhjOZaEsKGFk,7478
33
+ macp_sdk_python-0.14.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
34
+ macp_sdk_python-0.14.0.dist-info/top_level.txt,sha256=AoSstnUrSaVCCQw4KLxLkpO-9BLoVZ230mPBR6z-jh8,9
35
+ macp_sdk_python-0.14.0.dist-info/RECORD,,