macp-sdk-python 0.7.0__tar.gz → 0.8.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.7.0/src/macp_sdk_python.egg-info → macp_sdk_python-0.8.0}/PKG-INFO +5 -4
- {macp_sdk_python-0.7.0 → macp_sdk_python-0.8.0}/README.md +2 -2
- {macp_sdk_python-0.7.0 → macp_sdk_python-0.8.0}/pyproject.toml +3 -2
- {macp_sdk_python-0.7.0 → macp_sdk_python-0.8.0}/src/macp_sdk/__init__.py +9 -1
- macp_sdk_python-0.8.0/src/macp_sdk/base_projection.py +314 -0
- {macp_sdk_python-0.7.0 → macp_sdk_python-0.8.0}/src/macp_sdk/projections.py +19 -1
- {macp_sdk_python-0.7.0 → macp_sdk_python-0.8.0}/src/macp_sdk/quorum.py +33 -9
- {macp_sdk_python-0.7.0 → macp_sdk_python-0.8.0/src/macp_sdk_python.egg-info}/PKG-INFO +5 -4
- {macp_sdk_python-0.7.0 → macp_sdk_python-0.8.0}/src/macp_sdk_python.egg-info/requires.txt +2 -1
- macp_sdk_python-0.7.0/src/macp_sdk/base_projection.py +0 -52
- {macp_sdk_python-0.7.0 → macp_sdk_python-0.8.0}/LICENSE +0 -0
- {macp_sdk_python-0.7.0 → macp_sdk_python-0.8.0}/setup.cfg +0 -0
- {macp_sdk_python-0.7.0 → macp_sdk_python-0.8.0}/src/macp_sdk/_logging.py +0 -0
- {macp_sdk_python-0.7.0 → macp_sdk_python-0.8.0}/src/macp_sdk/agent/__init__.py +0 -0
- {macp_sdk_python-0.7.0 → macp_sdk_python-0.8.0}/src/macp_sdk/agent/cancel_callback.py +0 -0
- {macp_sdk_python-0.7.0 → macp_sdk_python-0.8.0}/src/macp_sdk/agent/dispatcher.py +0 -0
- {macp_sdk_python-0.7.0 → macp_sdk_python-0.8.0}/src/macp_sdk/agent/participant.py +0 -0
- {macp_sdk_python-0.7.0 → macp_sdk_python-0.8.0}/src/macp_sdk/agent/runner.py +0 -0
- {macp_sdk_python-0.7.0 → macp_sdk_python-0.8.0}/src/macp_sdk/agent/strategies.py +0 -0
- {macp_sdk_python-0.7.0 → macp_sdk_python-0.8.0}/src/macp_sdk/agent/transports.py +0 -0
- {macp_sdk_python-0.7.0 → macp_sdk_python-0.8.0}/src/macp_sdk/agent/types.py +0 -0
- {macp_sdk_python-0.7.0 → macp_sdk_python-0.8.0}/src/macp_sdk/auth.py +0 -0
- {macp_sdk_python-0.7.0 → macp_sdk_python-0.8.0}/src/macp_sdk/base_session.py +0 -0
- {macp_sdk_python-0.7.0 → macp_sdk_python-0.8.0}/src/macp_sdk/client.py +0 -0
- {macp_sdk_python-0.7.0 → macp_sdk_python-0.8.0}/src/macp_sdk/commitment_hash.py +0 -0
- {macp_sdk_python-0.7.0 → macp_sdk_python-0.8.0}/src/macp_sdk/constants.py +0 -0
- {macp_sdk_python-0.7.0 → macp_sdk_python-0.8.0}/src/macp_sdk/decision.py +0 -0
- {macp_sdk_python-0.7.0 → macp_sdk_python-0.8.0}/src/macp_sdk/envelope.py +0 -0
- {macp_sdk_python-0.7.0 → macp_sdk_python-0.8.0}/src/macp_sdk/errors.py +0 -0
- {macp_sdk_python-0.7.0 → macp_sdk_python-0.8.0}/src/macp_sdk/handoff.py +0 -0
- {macp_sdk_python-0.7.0 → macp_sdk_python-0.8.0}/src/macp_sdk/policy.py +0 -0
- {macp_sdk_python-0.7.0 → macp_sdk_python-0.8.0}/src/macp_sdk/proposal.py +0 -0
- {macp_sdk_python-0.7.0 → macp_sdk_python-0.8.0}/src/macp_sdk/proto_registry.py +0 -0
- {macp_sdk_python-0.7.0 → macp_sdk_python-0.8.0}/src/macp_sdk/py.typed +0 -0
- {macp_sdk_python-0.7.0 → macp_sdk_python-0.8.0}/src/macp_sdk/retry.py +0 -0
- {macp_sdk_python-0.7.0 → macp_sdk_python-0.8.0}/src/macp_sdk/task.py +0 -0
- {macp_sdk_python-0.7.0 → macp_sdk_python-0.8.0}/src/macp_sdk/validation.py +0 -0
- {macp_sdk_python-0.7.0 → macp_sdk_python-0.8.0}/src/macp_sdk/watchers.py +0 -0
- {macp_sdk_python-0.7.0 → macp_sdk_python-0.8.0}/src/macp_sdk_python.egg-info/SOURCES.txt +0 -0
- {macp_sdk_python-0.7.0 → macp_sdk_python-0.8.0}/src/macp_sdk_python.egg-info/dependency_links.txt +0 -0
- {macp_sdk_python-0.7.0 → macp_sdk_python-0.8.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.
|
|
3
|
+
Version: 0.8.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
|
|
@@ -22,7 +22,7 @@ Description-Content-Type: text/markdown
|
|
|
22
22
|
License-File: LICENSE
|
|
23
23
|
Requires-Dist: grpcio>=1.82.0rc2
|
|
24
24
|
Requires-Dist: protobuf>=7.35.0
|
|
25
|
-
Requires-Dist: macp-proto<0.1.
|
|
25
|
+
Requires-Dist: macp-proto<0.1.10,>=0.1.6
|
|
26
26
|
Provides-Extra: dev
|
|
27
27
|
Requires-Dist: pytest>=8.0; extra == "dev"
|
|
28
28
|
Requires-Dist: pytest-cov>=5.0; extra == "dev"
|
|
@@ -30,6 +30,7 @@ Requires-Dist: mypy>=1.10; extra == "dev"
|
|
|
30
30
|
Requires-Dist: ruff>=0.5; extra == "dev"
|
|
31
31
|
Requires-Dist: grpc-stubs>=1.53; extra == "dev"
|
|
32
32
|
Requires-Dist: build>=1.0; extra == "dev"
|
|
33
|
+
Requires-Dist: twine>=5.0; extra == "dev"
|
|
33
34
|
Requires-Dist: jsonschema>=4.0; extra == "dev"
|
|
34
35
|
Provides-Extra: docs
|
|
35
36
|
Requires-Dist: mkdocs-material>=9.5; extra == "docs"
|
|
@@ -127,7 +128,7 @@ if winner and not session.decision_projection.has_blocking_objection(winner):
|
|
|
127
128
|
make setup # pip install -e ".[dev,docs]"
|
|
128
129
|
|
|
129
130
|
# Quality
|
|
130
|
-
make lint # ruff check
|
|
131
|
+
make lint # ruff check + ruff format --check
|
|
131
132
|
make fmt # ruff format
|
|
132
133
|
make typecheck # mypy strict
|
|
133
134
|
make test # unit tests + coverage gate (85% branch, from pyproject)
|
|
@@ -136,7 +137,7 @@ make test-all # lint + typecheck + all tests
|
|
|
136
137
|
make coverage # HTML + terminal coverage report
|
|
137
138
|
|
|
138
139
|
# Build
|
|
139
|
-
make build # sdist + wheel
|
|
140
|
+
make build # build sdist + wheel, then twine check
|
|
140
141
|
|
|
141
142
|
# Proto definitions (provided by macp-proto package)
|
|
142
143
|
make dev-link-protos # link local proto package for development
|
|
@@ -89,7 +89,7 @@ if winner and not session.decision_projection.has_blocking_objection(winner):
|
|
|
89
89
|
make setup # pip install -e ".[dev,docs]"
|
|
90
90
|
|
|
91
91
|
# Quality
|
|
92
|
-
make lint # ruff check
|
|
92
|
+
make lint # ruff check + ruff format --check
|
|
93
93
|
make fmt # ruff format
|
|
94
94
|
make typecheck # mypy strict
|
|
95
95
|
make test # unit tests + coverage gate (85% branch, from pyproject)
|
|
@@ -98,7 +98,7 @@ make test-all # lint + typecheck + all tests
|
|
|
98
98
|
make coverage # HTML + terminal coverage report
|
|
99
99
|
|
|
100
100
|
# Build
|
|
101
|
-
make build # sdist + wheel
|
|
101
|
+
make build # build sdist + wheel, then twine check
|
|
102
102
|
|
|
103
103
|
# Proto definitions (provided by macp-proto package)
|
|
104
104
|
make dev-link-protos # link local proto package for development
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "macp-sdk-python"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.8.0"
|
|
8
8
|
description = "Python SDK for the MACP Rust runtime"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.11"
|
|
@@ -52,7 +52,7 @@ dependencies = [
|
|
|
52
52
|
# grpcio comment above) — PEP 440 sorts 1.82.0rc2 below 1.82.0, so no
|
|
53
53
|
# candidate exists. 0.1.6/0.1.7 declare "grpcio>=1.74.0" and resolve fine.
|
|
54
54
|
# Re-widen to <0.2.0 once grpcio publishes a stable >= 1.82.1.
|
|
55
|
-
"macp-proto>=0.1.6,<0.1.
|
|
55
|
+
"macp-proto>=0.1.6,<0.1.10",
|
|
56
56
|
]
|
|
57
57
|
|
|
58
58
|
[project.urls]
|
|
@@ -74,6 +74,7 @@ dev = [
|
|
|
74
74
|
"ruff>=0.5",
|
|
75
75
|
"grpc-stubs>=1.53",
|
|
76
76
|
"build>=1.0",
|
|
77
|
+
"twine>=5.0", # validates the built artifacts (`make build`), mirroring ci.yml's build job
|
|
77
78
|
"jsonschema>=4.0", # validate vendored conformance fixtures against schema.json
|
|
78
79
|
]
|
|
79
80
|
docs = [
|
|
@@ -1,6 +1,11 @@
|
|
|
1
1
|
from ._logging import configure_logging
|
|
2
2
|
from .auth import AuthConfig
|
|
3
|
-
from .base_projection import
|
|
3
|
+
from .base_projection import (
|
|
4
|
+
ANOMALY_DUPLICATE_BALLOT,
|
|
5
|
+
ANOMALY_DUPLICATE_VOTE,
|
|
6
|
+
BaseProjection,
|
|
7
|
+
ProjectionAnomaly,
|
|
8
|
+
)
|
|
4
9
|
from .base_session import BaseSession
|
|
5
10
|
from .client import InlineErrorCallback, MacpClient, MacpStream
|
|
6
11
|
from .commitment_hash import commitment_hash, is_canonical_commitment_hash
|
|
@@ -110,6 +115,8 @@ from .watchers import (
|
|
|
110
115
|
)
|
|
111
116
|
|
|
112
117
|
__all__ = [
|
|
118
|
+
"ANOMALY_DUPLICATE_BALLOT",
|
|
119
|
+
"ANOMALY_DUPLICATE_VOTE",
|
|
113
120
|
"DEFAULT_CONFIGURATION_VERSION",
|
|
114
121
|
"DEFAULT_MODE_VERSION",
|
|
115
122
|
"DEFAULT_POLICY_VERSION",
|
|
@@ -165,6 +172,7 @@ __all__ = [
|
|
|
165
172
|
"ObjectionHandlingRules",
|
|
166
173
|
"PolicyChange",
|
|
167
174
|
"PolicyWatcher",
|
|
175
|
+
"ProjectionAnomaly",
|
|
168
176
|
"ProposalAcceptanceRules",
|
|
169
177
|
"ProposalProjection",
|
|
170
178
|
"ProposalSession",
|
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from abc import ABC, abstractmethod
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from typing import ClassVar
|
|
6
|
+
|
|
7
|
+
from macp.v1 import core_pb2, envelope_pb2
|
|
8
|
+
|
|
9
|
+
from ._logging import logger
|
|
10
|
+
|
|
11
|
+
# Cross-SDK contract with macp-sdk-typescript#55 — these two string values are
|
|
12
|
+
# part of the wire-adjacent public API and must match the TypeScript SDK
|
|
13
|
+
# byte-for-byte. Do not rename, and do not add a third without coordinating
|
|
14
|
+
# there first.
|
|
15
|
+
ANOMALY_DUPLICATE_VOTE = "duplicate_vote" # RFC-MACP-0007 §5.3
|
|
16
|
+
ANOMALY_DUPLICATE_BALLOT = "duplicate_ballot" # RFC-MACP-0011 §5
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass(frozen=True, slots=True)
|
|
20
|
+
class ProjectionAnomaly:
|
|
21
|
+
"""A discarded-message observation recorded by a projection.
|
|
22
|
+
|
|
23
|
+
Cross-SDK contract with macp-sdk-typescript#55: the field names, their
|
|
24
|
+
order, and both ``kind`` string values (``ANOMALY_DUPLICATE_VOTE``,
|
|
25
|
+
``ANOMALY_DUPLICATE_BALLOT``) are a byte-for-byte contract shared with the
|
|
26
|
+
TypeScript SDK. Do not rename, reorder, or extend the field set without
|
|
27
|
+
coordinating there first.
|
|
28
|
+
|
|
29
|
+
Honesty clause: this records an **observation**, not a spec-violation
|
|
30
|
+
verdict. A projection cannot distinguish a genuinely non-conforming
|
|
31
|
+
source from a conforming source fed through an unfiltered loader --
|
|
32
|
+
acceptance is not a wire property (see ``apply_envelope``'s docstring).
|
|
33
|
+
Treat ``kind`` as "a second distinct message of this shape was observed
|
|
34
|
+
and discarded; the first stands," not as "this transcript violates the
|
|
35
|
+
spec."
|
|
36
|
+
|
|
37
|
+
Deliberately does not carry the full ``Envelope``: too heavy a public
|
|
38
|
+
commitment. The discarded envelope remains recoverable by correlating
|
|
39
|
+
``message_id`` against a projection's ``transcript``.
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
kind: str
|
|
43
|
+
mode: str
|
|
44
|
+
message_type: str
|
|
45
|
+
message_id: str
|
|
46
|
+
sender: str
|
|
47
|
+
subject_id: str # proposal_id / request_id
|
|
48
|
+
detail: str = ""
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class BaseProjection(ABC):
|
|
52
|
+
"""Abstract base for in-process mode state tracking.
|
|
53
|
+
|
|
54
|
+
Maintains a local transcript and delegates mode-specific message handling
|
|
55
|
+
to subclasses. Needed because the runtime's ``GetSession`` RPC returns
|
|
56
|
+
metadata only, not mode state or transcript.
|
|
57
|
+
"""
|
|
58
|
+
|
|
59
|
+
MODE: ClassVar[str]
|
|
60
|
+
|
|
61
|
+
def __init__(self) -> None:
|
|
62
|
+
self.transcript: list[envelope_pb2.Envelope] = []
|
|
63
|
+
self.phase: str = ""
|
|
64
|
+
self.commitment: core_pb2.CommitmentPayload | None = None
|
|
65
|
+
# Implementation detail, not public API: tracks message_id values already
|
|
66
|
+
# applied to this projection so a redelivered envelope is a no-op. See
|
|
67
|
+
# apply_envelope's docstring for the contract.
|
|
68
|
+
self._seen_message_ids: set[str] = set()
|
|
69
|
+
# Public: discarded-message observations recorded via _record_anomaly
|
|
70
|
+
# (Decision Vote, Quorum ballot -- see those projections' first-wins
|
|
71
|
+
# branches). See ProjectionAnomaly's docstring for the cross-SDK
|
|
72
|
+
# contract it carries.
|
|
73
|
+
self.anomalies: list[ProjectionAnomaly] = []
|
|
74
|
+
|
|
75
|
+
@property
|
|
76
|
+
def is_committed(self) -> bool:
|
|
77
|
+
return self.commitment is not None
|
|
78
|
+
|
|
79
|
+
@property
|
|
80
|
+
def has_anomalies(self) -> bool:
|
|
81
|
+
return bool(self.anomalies)
|
|
82
|
+
|
|
83
|
+
@property
|
|
84
|
+
def is_positive_outcome(self) -> bool | None:
|
|
85
|
+
"""Return the outcome polarity, or ``None`` if not yet committed."""
|
|
86
|
+
if self.commitment is None:
|
|
87
|
+
return None
|
|
88
|
+
return getattr(self.commitment, "outcome_positive", True)
|
|
89
|
+
|
|
90
|
+
def apply_envelope(self, envelope: envelope_pb2.Envelope) -> None:
|
|
91
|
+
"""Apply one envelope from **accepted history** to local state.
|
|
92
|
+
|
|
93
|
+
Precondition — accepted history only, not enforced here: callers MUST
|
|
94
|
+
feed only envelopes that a conforming runtime accepted. A subscribe
|
|
95
|
+
stream satisfies this automatically (RFC-MACP-0006 §3.2 obligation 4:
|
|
96
|
+
a runtime never replays rejected envelopes). Any other feed — hand-built
|
|
97
|
+
fixtures, captured logs, transcripts carrying non-accepted entries — MUST
|
|
98
|
+
be filtered by the caller before it reaches this method, the way
|
|
99
|
+
``tests/conformance/test_conformance_projections.py:153`` filters out
|
|
100
|
+
non-``accept`` fixture entries. This method has no way to verify that
|
|
101
|
+
precondition, and derived state is **undefined** without it.
|
|
102
|
+
|
|
103
|
+
Idempotency: applying an envelope whose ``message_id`` has already been
|
|
104
|
+
seen by this projection is a total no-op — it is not appended to
|
|
105
|
+
``transcript`` and is not dispatched to mode handling. This makes
|
|
106
|
+
redelivery (e.g. a stream resubscribe replaying accepted history from
|
|
107
|
+
the start) safe to re-apply without corrupting derived state. An empty
|
|
108
|
+
``message_id`` (the proto3 default for hand-built envelopes) is never
|
|
109
|
+
deduplicated — every such envelope is applied. See the code comment on
|
|
110
|
+
the dedup gate below for the precise normative basis.
|
|
111
|
+
|
|
112
|
+
Honest limitation: this dedups **redelivery of the same envelope**, not
|
|
113
|
+
every way a feed can carry a non-conforming message. A *genuine*
|
|
114
|
+
duplicate -- e.g. a second, distinct ``Vote`` from a sender who already
|
|
115
|
+
voted on the same proposal, carrying its own new ``message_id`` -- is
|
|
116
|
+
not caught here: it passes this method's dedup gate, is appended to
|
|
117
|
+
``transcript``, and is dispatched to mode handling like any other
|
|
118
|
+
envelope. Discarding it and recording the observation is the mode
|
|
119
|
+
projection's job, not this base method's -- see
|
|
120
|
+
``DecisionProjection``'s Vote branch and ``QuorumProjection._set_ballot``,
|
|
121
|
+
which apply first-wins and append a ``ProjectionAnomaly`` (``anomalies``,
|
|
122
|
+
``has_anomalies``) rather than silently overwriting. A rejected vote
|
|
123
|
+
from a sender who never votes again still corrupts derived state with
|
|
124
|
+
nothing here to detect it -- this method has no visibility into
|
|
125
|
+
cardinality rules, which are mode-specific. No mechanism in this
|
|
126
|
+
method closes that narrower gap; do not imply one exists.
|
|
127
|
+
|
|
128
|
+
Determinism holds in both directions: replaying ``transcript`` through a
|
|
129
|
+
fresh projection reproduces the same state, whether or not duplicates or
|
|
130
|
+
redeliveries were present in the original feed.
|
|
131
|
+
|
|
132
|
+
Acceptance is not a wire property: nothing on the envelope itself marks
|
|
133
|
+
it as "accepted" — it is accepted only by virtue of appearing in
|
|
134
|
+
accepted history at all, per the precondition above. If a caller sees a
|
|
135
|
+
surprising result, correlate the offending envelope's ``message_id``
|
|
136
|
+
against their own acceptance metadata (what they actually received back
|
|
137
|
+
from the runtime) to determine whether the surprise came from a
|
|
138
|
+
non-conforming source or from an unfiltered feed, rather than assuming
|
|
139
|
+
this method mis-processed it.
|
|
140
|
+
|
|
141
|
+
Not thread-safe: the ``message_id`` dedup check-then-add below is not
|
|
142
|
+
atomic across threads, and this class holds no lock. A single
|
|
143
|
+
projection instance must not be fed concurrently from multiple threads.
|
|
144
|
+
|
|
145
|
+
On failure: if applying the envelope raises, this method removes the
|
|
146
|
+
envelope from ``transcript`` and releases its ``message_id`` from the
|
|
147
|
+
dedup set before the exception propagates, so the caller may retry
|
|
148
|
+
the identical envelope and it will be applied rather than silently
|
|
149
|
+
swallowed as a redelivery. This rollback covers only ``transcript``
|
|
150
|
+
and the dedup set — subclass-derived state (e.g. ``self.phase``, or
|
|
151
|
+
any subclass collection) is not rolled back.
|
|
152
|
+
"""
|
|
153
|
+
if envelope.mode != self.MODE:
|
|
154
|
+
return
|
|
155
|
+
|
|
156
|
+
# Empty-id guard — the real reason, not a test accommodation: message_id
|
|
157
|
+
# is a proto3 scalar, so its unset value is "" — not a real identity.
|
|
158
|
+
# Deduplicating on "" would collapse every envelope lacking an id into a
|
|
159
|
+
# single logical message. Hand-built envelopes without an id are a real,
|
|
160
|
+
# exercised path in this repo: tests/unit/test_client_helpers.py:142 and
|
|
161
|
+
# tests/unit/test_client_stream.py:160 both construct one. So the guard
|
|
162
|
+
# only dedupes non-empty ids.
|
|
163
|
+
#
|
|
164
|
+
# At-least-once basis for dedup, cited precisely:
|
|
165
|
+
# - Load-bearing: RFC-MACP-0001-core.md:306 — "MACP assumes
|
|
166
|
+
# at-least-once delivery semantics at the transport layer."
|
|
167
|
+
# Unqualified, about the transport, so it binds any consumer of that
|
|
168
|
+
# transport including client projections. From this alone it
|
|
169
|
+
# follows that a projection may be handed the same envelope twice.
|
|
170
|
+
# This is an INFERENCE from a transport-layer premise, not a MUST
|
|
171
|
+
# addressed to clients.
|
|
172
|
+
# - Corroborating only, not compelling on its own: RFC-MACP-0001 §8.2
|
|
173
|
+
# (:316) — "Runtimes MUST enforce idempotent handling of duplicates
|
|
174
|
+
# using message_id." That MUST is addressed to runtimes at their
|
|
175
|
+
# ingress boundary, not to clients; its contribution here is only
|
|
176
|
+
# the *identity* to key dedup on (message_id, not envelope bytes).
|
|
177
|
+
# - Now normative for clients directly: RFC-MACP-0006 §3.2 (spec PR
|
|
178
|
+
# #80, RFC-0006 1.4.0-draft) requires a client to tolerate an
|
|
179
|
+
# already-observed envelope and to key duplicate detection on
|
|
180
|
+
# message_id.
|
|
181
|
+
# Mirrors the runtime's own boundary idempotency: src/macp_sdk/client.py
|
|
182
|
+
# :419-422 already treats ack.duplicate as idempotent success.
|
|
183
|
+
message_id = envelope.message_id
|
|
184
|
+
seen_id_added = False
|
|
185
|
+
if message_id:
|
|
186
|
+
if message_id in self._seen_message_ids:
|
|
187
|
+
return
|
|
188
|
+
self._seen_message_ids.add(message_id)
|
|
189
|
+
seen_id_added = True
|
|
190
|
+
|
|
191
|
+
self.transcript.append(envelope)
|
|
192
|
+
|
|
193
|
+
# Rollback scope — narrower than "atomic" might suggest: the
|
|
194
|
+
# dedup-set add and transcript append above both happen BEFORE the
|
|
195
|
+
# actual effect (Commitment ParseFromString, or the subclass's
|
|
196
|
+
# _apply_mode_message dispatch), either of which can raise. On such
|
|
197
|
+
# a raise, the except block below undoes exactly those two
|
|
198
|
+
# mutations — transcript and _seen_message_ids — and re-raises, so
|
|
199
|
+
# a retry of the SAME envelope is not swallowed as a redelivery.
|
|
200
|
+
# Concretely: a supervisor that catches an exception out of
|
|
201
|
+
# Participant.run() and resubscribes (agent/transports.py:60
|
|
202
|
+
# replays accepted history from after_sequence=0) re-feeds the same
|
|
203
|
+
# envelope to the same projection (agent/participant.py:410,
|
|
204
|
+
# apply_envelope unguarded) — without this rollback the dedup gate
|
|
205
|
+
# would silently swallow that retry, permanently losing the
|
|
206
|
+
# envelope's effect while transcript still claims it is present.
|
|
207
|
+
#
|
|
208
|
+
# What this does NOT cover: self.phase (assigned directly on
|
|
209
|
+
# BaseProjection by subclasses — see projections.py:84,
|
|
210
|
+
# task.py:103, handoff.py:71) and any subclass-owned collection
|
|
211
|
+
# (evaluations, objections, accepts, rejections, updates,
|
|
212
|
+
# completions, failures) are never rolled back, because this
|
|
213
|
+
# method has no way to know what a subclass mutated. That is safe
|
|
214
|
+
# only because every _apply_mode_message implementation in this
|
|
215
|
+
# SDK today performs its one fallible operation (ParseFromString)
|
|
216
|
+
# strictly before any mutation, and every record type it
|
|
217
|
+
# constructs is a plain slotted dataclass whose construction
|
|
218
|
+
# cannot raise — so there is presently no reachable path that
|
|
219
|
+
# mutates subclass state and then raises. This is a
|
|
220
|
+
# raise-before-mutate invariant that _apply_mode_message
|
|
221
|
+
# implementations must preserve. BaseProjection is exported for
|
|
222
|
+
# third-party subclassing (see __init__.py:143), and upcoming
|
|
223
|
+
# first-wins/anomaly-tracking logic must keep fallible work ahead
|
|
224
|
+
# of mutations to keep this guarantee honest.
|
|
225
|
+
#
|
|
226
|
+
# Catches Exception, not BaseException: KeyboardInterrupt/SystemExit
|
|
227
|
+
# signal that the process is being torn down, not a recoverable
|
|
228
|
+
# per-envelope failure, and should propagate immediately rather than
|
|
229
|
+
# be treated as "this envelope's apply failed, try rolling back."
|
|
230
|
+
try:
|
|
231
|
+
if envelope.message_type == "Commitment":
|
|
232
|
+
payload = core_pb2.CommitmentPayload()
|
|
233
|
+
payload.ParseFromString(envelope.payload)
|
|
234
|
+
self.commitment = payload
|
|
235
|
+
self.phase = "Committed"
|
|
236
|
+
return
|
|
237
|
+
|
|
238
|
+
self._apply_mode_message(envelope)
|
|
239
|
+
except Exception:
|
|
240
|
+
# Roll back exactly what this call added, nothing more. In
|
|
241
|
+
# every path exercised by this SDK today, the append two lines
|
|
242
|
+
# above is the only mutation of transcript between there and
|
|
243
|
+
# here, so transcript[-1] is the entry this call just
|
|
244
|
+
# appended. But BaseProjection is a public ABC (exported at
|
|
245
|
+
# __init__.py:143) that third parties may subclass, so guard
|
|
246
|
+
# with an identity check rather than popping unconditionally.
|
|
247
|
+
# The two ways the guard could see something else at [-1] are:
|
|
248
|
+
# - a subclass's _apply_mode_message appending to
|
|
249
|
+
# self.transcript itself before raising (no subclass in
|
|
250
|
+
# this SDK does this — each mutates only its own
|
|
251
|
+
# collections, never self.transcript directly);
|
|
252
|
+
# - a re-entrant call to apply_envelope on the same instance
|
|
253
|
+
# while this call is still on the stack (nothing in this
|
|
254
|
+
# SDK calls apply_envelope from within apply_envelope or
|
|
255
|
+
# from a mode handler).
|
|
256
|
+
# Neither happens here, but a wrong-entry pop under either
|
|
257
|
+
# would silently corrupt an unrelated transcript entry — data
|
|
258
|
+
# corruption strictly worse than the bug this rollback fixes.
|
|
259
|
+
# `is` identity, not `==`: protobuf messages compare by value,
|
|
260
|
+
# so two distinct envelopes can be equal without being the
|
|
261
|
+
# same object.
|
|
262
|
+
if self.transcript and self.transcript[-1] is envelope:
|
|
263
|
+
self.transcript.pop()
|
|
264
|
+
if seen_id_added:
|
|
265
|
+
self._seen_message_ids.discard(message_id)
|
|
266
|
+
raise
|
|
267
|
+
|
|
268
|
+
@abstractmethod
|
|
269
|
+
def _apply_mode_message(self, envelope: envelope_pb2.Envelope) -> None:
|
|
270
|
+
"""Handle a mode-specific (non-Commitment) envelope."""
|
|
271
|
+
|
|
272
|
+
def _record_anomaly(
|
|
273
|
+
self,
|
|
274
|
+
*,
|
|
275
|
+
kind: str,
|
|
276
|
+
message_type: str,
|
|
277
|
+
message_id: str,
|
|
278
|
+
sender: str,
|
|
279
|
+
subject_id: str,
|
|
280
|
+
detail: str = "",
|
|
281
|
+
) -> None:
|
|
282
|
+
"""Append a `ProjectionAnomaly` and emit exactly one WARNING log line.
|
|
283
|
+
|
|
284
|
+
Protected and keyword-only: subclasses are the only callers, and
|
|
285
|
+
keyword-only arguments prevent a positional call from silently
|
|
286
|
+
transposing two same-typed fields (e.g. sender/subject_id). ``mode``
|
|
287
|
+
is filled in from ``self.MODE`` rather than accepted as an argument,
|
|
288
|
+
so call sites cannot drift from the projection's own mode.
|
|
289
|
+
|
|
290
|
+
Call sites: ``DecisionProjection``'s Vote branch and
|
|
291
|
+
``QuorumProjection._set_ballot``, on first-wins discard of a genuine
|
|
292
|
+
duplicate Vote/ballot.
|
|
293
|
+
"""
|
|
294
|
+
anomaly = ProjectionAnomaly(
|
|
295
|
+
kind=kind,
|
|
296
|
+
mode=self.MODE,
|
|
297
|
+
message_type=message_type,
|
|
298
|
+
message_id=message_id,
|
|
299
|
+
sender=sender,
|
|
300
|
+
subject_id=subject_id,
|
|
301
|
+
detail=detail,
|
|
302
|
+
)
|
|
303
|
+
self.anomalies.append(anomaly)
|
|
304
|
+
logger.warning(
|
|
305
|
+
"projection anomaly kind=%s mode=%s message_type=%s message_id=%s "
|
|
306
|
+
"sender=%s subject_id=%s detail=%s",
|
|
307
|
+
anomaly.kind,
|
|
308
|
+
anomaly.mode,
|
|
309
|
+
anomaly.message_type,
|
|
310
|
+
anomaly.message_id,
|
|
311
|
+
anomaly.sender,
|
|
312
|
+
anomaly.subject_id,
|
|
313
|
+
anomaly.detail,
|
|
314
|
+
)
|
|
@@ -5,7 +5,7 @@ from dataclasses import dataclass
|
|
|
5
5
|
from macp.modes.decision.v1 import decision_pb2
|
|
6
6
|
from macp.v1 import envelope_pb2
|
|
7
7
|
|
|
8
|
-
from .base_projection import BaseProjection
|
|
8
|
+
from .base_projection import ANOMALY_DUPLICATE_VOTE, BaseProjection
|
|
9
9
|
from .constants import MODE_DECISION
|
|
10
10
|
|
|
11
11
|
|
|
@@ -100,6 +100,24 @@ class DecisionProjection(BaseProjection):
|
|
|
100
100
|
if message_type == "Vote":
|
|
101
101
|
payload = decision_pb2.VotePayload()
|
|
102
102
|
payload.ParseFromString(envelope.payload)
|
|
103
|
+
existing = self.votes.get(payload.proposal_id, {}).get(envelope.sender)
|
|
104
|
+
if existing is not None:
|
|
105
|
+
# First-wins (RFC-MACP-0007 §5.3: "the first accepted Vote
|
|
106
|
+
# stands"). A second, distinct Vote (its own message_id --
|
|
107
|
+
# redelivery of the same envelope never reaches here, see
|
|
108
|
+
# apply_envelope's dedup gate) from a sender who already
|
|
109
|
+
# voted on this proposal is discarded without mutating
|
|
110
|
+
# votes or phase. detail states facts only, no cause
|
|
111
|
+
# attribution.
|
|
112
|
+
self._record_anomaly(
|
|
113
|
+
kind=ANOMALY_DUPLICATE_VOTE,
|
|
114
|
+
message_type=envelope.message_type,
|
|
115
|
+
message_id=envelope.message_id,
|
|
116
|
+
sender=envelope.sender,
|
|
117
|
+
subject_id=payload.proposal_id,
|
|
118
|
+
detail=(f"kept first vote {existing.vote!r}; discarded {payload.vote!r}"),
|
|
119
|
+
)
|
|
120
|
+
return
|
|
103
121
|
self.votes.setdefault(payload.proposal_id, {})[envelope.sender] = DecisionVoteRecord(
|
|
104
122
|
proposal_id=payload.proposal_id,
|
|
105
123
|
vote=payload.vote,
|
|
@@ -6,7 +6,7 @@ from macp.modes.quorum.v1 import quorum_pb2
|
|
|
6
6
|
from macp.v1 import envelope_pb2
|
|
7
7
|
|
|
8
8
|
from .auth import AuthConfig
|
|
9
|
-
from .base_projection import BaseProjection
|
|
9
|
+
from .base_projection import ANOMALY_DUPLICATE_BALLOT, BaseProjection
|
|
10
10
|
from .base_session import BaseSession
|
|
11
11
|
from .constants import MODE_QUORUM
|
|
12
12
|
from .envelope import build_envelope, serialize_message
|
|
@@ -41,9 +41,11 @@ class BallotRecord:
|
|
|
41
41
|
class QuorumProjection(BaseProjection):
|
|
42
42
|
"""In-process state tracking for Quorum mode sessions.
|
|
43
43
|
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
44
|
+
RFC-MACP-0011 §5 rule 1 caps a session at one ``ApprovalRequest`` in
|
|
45
|
+
base v1. The ``requests`` / ``ballots`` maps are keyed by ``request_id``
|
|
46
|
+
because that is the identifier the protocol uses, not because multiple
|
|
47
|
+
concurrent requests are supported. Query methods accept a ``request_id``
|
|
48
|
+
parameter to target that single request.
|
|
47
49
|
"""
|
|
48
50
|
|
|
49
51
|
MODE = MODE_QUORUM
|
|
@@ -73,22 +75,44 @@ class QuorumProjection(BaseProjection):
|
|
|
73
75
|
if mt == "Approve":
|
|
74
76
|
p = quorum_pb2.ApprovePayload()
|
|
75
77
|
p.ParseFromString(envelope.payload)
|
|
76
|
-
self._set_ballot(p.request_id,
|
|
78
|
+
self._set_ballot(envelope, p.request_id, "approve", p.reason)
|
|
77
79
|
return
|
|
78
80
|
|
|
79
81
|
if mt == "Reject":
|
|
80
82
|
p = quorum_pb2.RejectPayload()
|
|
81
83
|
p.ParseFromString(envelope.payload)
|
|
82
|
-
self._set_ballot(p.request_id,
|
|
84
|
+
self._set_ballot(envelope, p.request_id, "reject", p.reason)
|
|
83
85
|
return
|
|
84
86
|
|
|
85
87
|
if mt == "Abstain":
|
|
86
88
|
p = quorum_pb2.AbstainPayload()
|
|
87
89
|
p.ParseFromString(envelope.payload)
|
|
88
|
-
self._set_ballot(p.request_id,
|
|
89
|
-
|
|
90
|
-
def _set_ballot(
|
|
90
|
+
self._set_ballot(envelope, p.request_id, "abstain", p.reason)
|
|
91
|
+
|
|
92
|
+
def _set_ballot(
|
|
93
|
+
self, envelope: envelope_pb2.Envelope, request_id: str, vote: str, reason: str
|
|
94
|
+
) -> None:
|
|
95
|
+
# Single funnel for Approve/Reject/Abstain -- this is exactly what
|
|
96
|
+
# enforces RFC-MACP-0011 §5 rule 3's *across-type* cardinality rule
|
|
97
|
+
# ("at most one ballot across Approve, Reject, or Abstain"). First
|
|
98
|
+
# ballot per sender stands; which-of-two-stands is inferred from
|
|
99
|
+
# RFC-MACP-0007 §5.3 parity plus what the only runtime enforces
|
|
100
|
+
# (quorum.rs:164/184/204) -- RFC-0011 itself is silent on that.
|
|
101
|
+
sender = envelope.sender
|
|
91
102
|
sender_map = self.ballots.setdefault(request_id, {})
|
|
103
|
+
existing = sender_map.get(sender)
|
|
104
|
+
if existing is not None:
|
|
105
|
+
# detail states facts only, no cause attribution. message_type
|
|
106
|
+
# here is the DISCARDED message's type, not the first ballot's.
|
|
107
|
+
self._record_anomaly(
|
|
108
|
+
kind=ANOMALY_DUPLICATE_BALLOT,
|
|
109
|
+
message_type=envelope.message_type,
|
|
110
|
+
message_id=envelope.message_id,
|
|
111
|
+
sender=sender,
|
|
112
|
+
subject_id=request_id,
|
|
113
|
+
detail=f"kept first ballot {existing.vote!r}; discarded {vote!r}",
|
|
114
|
+
)
|
|
115
|
+
return
|
|
92
116
|
sender_map[sender] = BallotRecord(
|
|
93
117
|
request_id=request_id,
|
|
94
118
|
vote=vote,
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: macp-sdk-python
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.8.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
|
|
@@ -22,7 +22,7 @@ Description-Content-Type: text/markdown
|
|
|
22
22
|
License-File: LICENSE
|
|
23
23
|
Requires-Dist: grpcio>=1.82.0rc2
|
|
24
24
|
Requires-Dist: protobuf>=7.35.0
|
|
25
|
-
Requires-Dist: macp-proto<0.1.
|
|
25
|
+
Requires-Dist: macp-proto<0.1.10,>=0.1.6
|
|
26
26
|
Provides-Extra: dev
|
|
27
27
|
Requires-Dist: pytest>=8.0; extra == "dev"
|
|
28
28
|
Requires-Dist: pytest-cov>=5.0; extra == "dev"
|
|
@@ -30,6 +30,7 @@ Requires-Dist: mypy>=1.10; extra == "dev"
|
|
|
30
30
|
Requires-Dist: ruff>=0.5; extra == "dev"
|
|
31
31
|
Requires-Dist: grpc-stubs>=1.53; extra == "dev"
|
|
32
32
|
Requires-Dist: build>=1.0; extra == "dev"
|
|
33
|
+
Requires-Dist: twine>=5.0; extra == "dev"
|
|
33
34
|
Requires-Dist: jsonschema>=4.0; extra == "dev"
|
|
34
35
|
Provides-Extra: docs
|
|
35
36
|
Requires-Dist: mkdocs-material>=9.5; extra == "docs"
|
|
@@ -127,7 +128,7 @@ if winner and not session.decision_projection.has_blocking_objection(winner):
|
|
|
127
128
|
make setup # pip install -e ".[dev,docs]"
|
|
128
129
|
|
|
129
130
|
# Quality
|
|
130
|
-
make lint # ruff check
|
|
131
|
+
make lint # ruff check + ruff format --check
|
|
131
132
|
make fmt # ruff format
|
|
132
133
|
make typecheck # mypy strict
|
|
133
134
|
make test # unit tests + coverage gate (85% branch, from pyproject)
|
|
@@ -136,7 +137,7 @@ make test-all # lint + typecheck + all tests
|
|
|
136
137
|
make coverage # HTML + terminal coverage report
|
|
137
138
|
|
|
138
139
|
# Build
|
|
139
|
-
make build # sdist + wheel
|
|
140
|
+
make build # build sdist + wheel, then twine check
|
|
140
141
|
|
|
141
142
|
# Proto definitions (provided by macp-proto package)
|
|
142
143
|
make dev-link-protos # link local proto package for development
|
|
@@ -1,52 +0,0 @@
|
|
|
1
|
-
from __future__ import annotations
|
|
2
|
-
|
|
3
|
-
from abc import ABC, abstractmethod
|
|
4
|
-
from typing import ClassVar
|
|
5
|
-
|
|
6
|
-
from macp.v1 import core_pb2, envelope_pb2
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
class BaseProjection(ABC):
|
|
10
|
-
"""Abstract base for in-process mode state tracking.
|
|
11
|
-
|
|
12
|
-
Maintains a local transcript and delegates mode-specific message handling
|
|
13
|
-
to subclasses. Needed because the runtime's ``GetSession`` RPC returns
|
|
14
|
-
metadata only, not mode state or transcript.
|
|
15
|
-
"""
|
|
16
|
-
|
|
17
|
-
MODE: ClassVar[str]
|
|
18
|
-
|
|
19
|
-
def __init__(self) -> None:
|
|
20
|
-
self.transcript: list[envelope_pb2.Envelope] = []
|
|
21
|
-
self.phase: str = ""
|
|
22
|
-
self.commitment: core_pb2.CommitmentPayload | None = None
|
|
23
|
-
|
|
24
|
-
@property
|
|
25
|
-
def is_committed(self) -> bool:
|
|
26
|
-
return self.commitment is not None
|
|
27
|
-
|
|
28
|
-
@property
|
|
29
|
-
def is_positive_outcome(self) -> bool | None:
|
|
30
|
-
"""Return the outcome polarity, or ``None`` if not yet committed."""
|
|
31
|
-
if self.commitment is None:
|
|
32
|
-
return None
|
|
33
|
-
return getattr(self.commitment, "outcome_positive", True)
|
|
34
|
-
|
|
35
|
-
def apply_envelope(self, envelope: envelope_pb2.Envelope) -> None:
|
|
36
|
-
"""Process an accepted envelope and update local state."""
|
|
37
|
-
if envelope.mode != self.MODE:
|
|
38
|
-
return
|
|
39
|
-
self.transcript.append(envelope)
|
|
40
|
-
|
|
41
|
-
if envelope.message_type == "Commitment":
|
|
42
|
-
payload = core_pb2.CommitmentPayload()
|
|
43
|
-
payload.ParseFromString(envelope.payload)
|
|
44
|
-
self.commitment = payload
|
|
45
|
-
self.phase = "Committed"
|
|
46
|
-
return
|
|
47
|
-
|
|
48
|
-
self._apply_mode_message(envelope)
|
|
49
|
-
|
|
50
|
-
@abstractmethod
|
|
51
|
-
def _apply_mode_message(self, envelope: envelope_pb2.Envelope) -> None:
|
|
52
|
-
"""Handle a mode-specific (non-Commitment) envelope."""
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{macp_sdk_python-0.7.0 → macp_sdk_python-0.8.0}/src/macp_sdk_python.egg-info/dependency_links.txt
RENAMED
|
File without changes
|
|
File without changes
|