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.
- {macp_sdk_python-0.12.1 → macp_sdk_python-0.14.0}/PKG-INFO +1 -1
- {macp_sdk_python-0.12.1 → macp_sdk_python-0.14.0}/pyproject.toml +1 -1
- {macp_sdk_python-0.12.1 → macp_sdk_python-0.14.0}/src/macp_sdk/__init__.py +4 -0
- {macp_sdk_python-0.12.1 → macp_sdk_python-0.14.0}/src/macp_sdk/agent/participant.py +97 -14
- {macp_sdk_python-0.12.1 → macp_sdk_python-0.14.0}/src/macp_sdk/agent/runner.py +22 -0
- {macp_sdk_python-0.12.1 → macp_sdk_python-0.14.0}/src/macp_sdk/agent/strategies.py +47 -24
- {macp_sdk_python-0.12.1 → macp_sdk_python-0.14.0}/src/macp_sdk/auth.py +3 -1
- {macp_sdk_python-0.12.1 → macp_sdk_python-0.14.0}/src/macp_sdk/client.py +39 -6
- {macp_sdk_python-0.12.1 → macp_sdk_python-0.14.0}/src/macp_sdk/envelope.py +2 -5
- {macp_sdk_python-0.12.1 → macp_sdk_python-0.14.0}/src/macp_sdk/handoff.py +2 -2
- {macp_sdk_python-0.12.1 → macp_sdk_python-0.14.0}/src/macp_sdk/projections.py +40 -1
- {macp_sdk_python-0.12.1 → macp_sdk_python-0.14.0}/src/macp_sdk/proposal.py +39 -4
- {macp_sdk_python-0.12.1 → macp_sdk_python-0.14.0}/src/macp_sdk/quorum.py +33 -4
- {macp_sdk_python-0.12.1 → macp_sdk_python-0.14.0}/src/macp_sdk/task.py +30 -2
- {macp_sdk_python-0.12.1 → macp_sdk_python-0.14.0}/src/macp_sdk/validation.py +31 -3
- {macp_sdk_python-0.12.1 → macp_sdk_python-0.14.0}/src/macp_sdk/watchers.py +115 -51
- {macp_sdk_python-0.12.1 → macp_sdk_python-0.14.0}/src/macp_sdk_python.egg-info/PKG-INFO +1 -1
- {macp_sdk_python-0.12.1 → macp_sdk_python-0.14.0}/LICENSE +0 -0
- {macp_sdk_python-0.12.1 → macp_sdk_python-0.14.0}/README.md +0 -0
- {macp_sdk_python-0.12.1 → macp_sdk_python-0.14.0}/setup.cfg +0 -0
- {macp_sdk_python-0.12.1 → macp_sdk_python-0.14.0}/src/macp_sdk/_logging.py +0 -0
- {macp_sdk_python-0.12.1 → macp_sdk_python-0.14.0}/src/macp_sdk/agent/__init__.py +0 -0
- {macp_sdk_python-0.12.1 → macp_sdk_python-0.14.0}/src/macp_sdk/agent/cancel_callback.py +0 -0
- {macp_sdk_python-0.12.1 → macp_sdk_python-0.14.0}/src/macp_sdk/agent/dispatcher.py +0 -0
- {macp_sdk_python-0.12.1 → macp_sdk_python-0.14.0}/src/macp_sdk/agent/transports.py +0 -0
- {macp_sdk_python-0.12.1 → macp_sdk_python-0.14.0}/src/macp_sdk/agent/types.py +0 -0
- {macp_sdk_python-0.12.1 → macp_sdk_python-0.14.0}/src/macp_sdk/base_projection.py +0 -0
- {macp_sdk_python-0.12.1 → macp_sdk_python-0.14.0}/src/macp_sdk/base_session.py +0 -0
- {macp_sdk_python-0.12.1 → macp_sdk_python-0.14.0}/src/macp_sdk/commitment_hash.py +0 -0
- {macp_sdk_python-0.12.1 → macp_sdk_python-0.14.0}/src/macp_sdk/constants.py +0 -0
- {macp_sdk_python-0.12.1 → macp_sdk_python-0.14.0}/src/macp_sdk/decision.py +0 -0
- {macp_sdk_python-0.12.1 → macp_sdk_python-0.14.0}/src/macp_sdk/errors.py +0 -0
- {macp_sdk_python-0.12.1 → macp_sdk_python-0.14.0}/src/macp_sdk/policy.py +0 -0
- {macp_sdk_python-0.12.1 → macp_sdk_python-0.14.0}/src/macp_sdk/proto_registry.py +0 -0
- {macp_sdk_python-0.12.1 → macp_sdk_python-0.14.0}/src/macp_sdk/py.typed +0 -0
- {macp_sdk_python-0.12.1 → macp_sdk_python-0.14.0}/src/macp_sdk/retry.py +0 -0
- {macp_sdk_python-0.12.1 → macp_sdk_python-0.14.0}/src/macp_sdk_python.egg-info/SOURCES.txt +0 -0
- {macp_sdk_python-0.12.1 → macp_sdk_python-0.14.0}/src/macp_sdk_python.egg-info/dependency_links.txt +0 -0
- {macp_sdk_python-0.12.1 → macp_sdk_python-0.14.0}/src/macp_sdk_python.egg-info/requires.txt +0 -0
- {macp_sdk_python-0.12.1 → macp_sdk_python-0.14.0}/src/macp_sdk_python.egg-info/top_level.txt +0 -0
|
@@ -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
|
-
|
|
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
|
|
@@ -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
|
|
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()
|
|
@@ -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:
|
|
@@ -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,
|
|
@@ -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),
|
|
@@ -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":
|
|
@@ -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():
|