macp-sdk-python 0.12.1__tar.gz → 0.14.0__tar.gz

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.
Files changed (40) hide show
  1. {macp_sdk_python-0.12.1 → macp_sdk_python-0.14.0}/PKG-INFO +1 -1
  2. {macp_sdk_python-0.12.1 → macp_sdk_python-0.14.0}/pyproject.toml +1 -1
  3. {macp_sdk_python-0.12.1 → macp_sdk_python-0.14.0}/src/macp_sdk/__init__.py +4 -0
  4. {macp_sdk_python-0.12.1 → macp_sdk_python-0.14.0}/src/macp_sdk/agent/participant.py +97 -14
  5. {macp_sdk_python-0.12.1 → macp_sdk_python-0.14.0}/src/macp_sdk/agent/runner.py +22 -0
  6. {macp_sdk_python-0.12.1 → macp_sdk_python-0.14.0}/src/macp_sdk/agent/strategies.py +47 -24
  7. {macp_sdk_python-0.12.1 → macp_sdk_python-0.14.0}/src/macp_sdk/auth.py +3 -1
  8. {macp_sdk_python-0.12.1 → macp_sdk_python-0.14.0}/src/macp_sdk/client.py +39 -6
  9. {macp_sdk_python-0.12.1 → macp_sdk_python-0.14.0}/src/macp_sdk/envelope.py +2 -5
  10. {macp_sdk_python-0.12.1 → macp_sdk_python-0.14.0}/src/macp_sdk/handoff.py +2 -2
  11. {macp_sdk_python-0.12.1 → macp_sdk_python-0.14.0}/src/macp_sdk/projections.py +40 -1
  12. {macp_sdk_python-0.12.1 → macp_sdk_python-0.14.0}/src/macp_sdk/proposal.py +39 -4
  13. {macp_sdk_python-0.12.1 → macp_sdk_python-0.14.0}/src/macp_sdk/quorum.py +33 -4
  14. {macp_sdk_python-0.12.1 → macp_sdk_python-0.14.0}/src/macp_sdk/task.py +30 -2
  15. {macp_sdk_python-0.12.1 → macp_sdk_python-0.14.0}/src/macp_sdk/validation.py +31 -3
  16. {macp_sdk_python-0.12.1 → macp_sdk_python-0.14.0}/src/macp_sdk/watchers.py +115 -51
  17. {macp_sdk_python-0.12.1 → macp_sdk_python-0.14.0}/src/macp_sdk_python.egg-info/PKG-INFO +1 -1
  18. {macp_sdk_python-0.12.1 → macp_sdk_python-0.14.0}/LICENSE +0 -0
  19. {macp_sdk_python-0.12.1 → macp_sdk_python-0.14.0}/README.md +0 -0
  20. {macp_sdk_python-0.12.1 → macp_sdk_python-0.14.0}/setup.cfg +0 -0
  21. {macp_sdk_python-0.12.1 → macp_sdk_python-0.14.0}/src/macp_sdk/_logging.py +0 -0
  22. {macp_sdk_python-0.12.1 → macp_sdk_python-0.14.0}/src/macp_sdk/agent/__init__.py +0 -0
  23. {macp_sdk_python-0.12.1 → macp_sdk_python-0.14.0}/src/macp_sdk/agent/cancel_callback.py +0 -0
  24. {macp_sdk_python-0.12.1 → macp_sdk_python-0.14.0}/src/macp_sdk/agent/dispatcher.py +0 -0
  25. {macp_sdk_python-0.12.1 → macp_sdk_python-0.14.0}/src/macp_sdk/agent/transports.py +0 -0
  26. {macp_sdk_python-0.12.1 → macp_sdk_python-0.14.0}/src/macp_sdk/agent/types.py +0 -0
  27. {macp_sdk_python-0.12.1 → macp_sdk_python-0.14.0}/src/macp_sdk/base_projection.py +0 -0
  28. {macp_sdk_python-0.12.1 → macp_sdk_python-0.14.0}/src/macp_sdk/base_session.py +0 -0
  29. {macp_sdk_python-0.12.1 → macp_sdk_python-0.14.0}/src/macp_sdk/commitment_hash.py +0 -0
  30. {macp_sdk_python-0.12.1 → macp_sdk_python-0.14.0}/src/macp_sdk/constants.py +0 -0
  31. {macp_sdk_python-0.12.1 → macp_sdk_python-0.14.0}/src/macp_sdk/decision.py +0 -0
  32. {macp_sdk_python-0.12.1 → macp_sdk_python-0.14.0}/src/macp_sdk/errors.py +0 -0
  33. {macp_sdk_python-0.12.1 → macp_sdk_python-0.14.0}/src/macp_sdk/policy.py +0 -0
  34. {macp_sdk_python-0.12.1 → macp_sdk_python-0.14.0}/src/macp_sdk/proto_registry.py +0 -0
  35. {macp_sdk_python-0.12.1 → macp_sdk_python-0.14.0}/src/macp_sdk/py.typed +0 -0
  36. {macp_sdk_python-0.12.1 → macp_sdk_python-0.14.0}/src/macp_sdk/retry.py +0 -0
  37. {macp_sdk_python-0.12.1 → macp_sdk_python-0.14.0}/src/macp_sdk_python.egg-info/SOURCES.txt +0 -0
  38. {macp_sdk_python-0.12.1 → macp_sdk_python-0.14.0}/src/macp_sdk_python.egg-info/dependency_links.txt +0 -0
  39. {macp_sdk_python-0.12.1 → macp_sdk_python-0.14.0}/src/macp_sdk_python.egg-info/requires.txt +0 -0
  40. {macp_sdk_python-0.12.1 → macp_sdk_python-0.14.0}/src/macp_sdk_python.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: macp-sdk-python
3
- Version: 0.12.1
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
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "macp-sdk-python"
7
- version = "0.12.1"
7
+ version = "0.14.0"
8
8
  description = "Python SDK for the MACP Rust runtime"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
@@ -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
@@ -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()
@@ -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:
@@ -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,
@@ -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),
@@ -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":
@@ -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
@@ -132,7 +168,10 @@ class DecisionProjection(BaseProjection):
132
168
  """Count votes per proposal, keyed by proposal_id.
133
169
 
134
170
  ABSTAIN votes are tracked but excluded from the totals returned
135
- here (which counts only APPROVE votes).
171
+ here (which counts only APPROVE votes). A proposal_id that received
172
+ no votes at all is **absent** from the returned dict -- not present
173
+ with a 0 value. Use ``.get(proposal_id, 0)`` when checking an
174
+ arbitrary proposal_id.
136
175
  """
137
176
  totals: dict[str, int] = {}
138
177
  for proposal_id, sender_votes in self.votes.items():