macp-sdk-python 0.4.1__tar.gz → 0.5.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.4.1/src/macp_sdk_python.egg-info → macp_sdk_python-0.5.0}/PKG-INFO +11 -4
- {macp_sdk_python-0.4.1 → macp_sdk_python-0.5.0}/README.md +6 -0
- {macp_sdk_python-0.4.1 → macp_sdk_python-0.5.0}/pyproject.toml +20 -13
- {macp_sdk_python-0.4.1 → macp_sdk_python-0.5.0}/src/macp_sdk/__init__.py +2 -0
- {macp_sdk_python-0.4.1 → macp_sdk_python-0.5.0}/src/macp_sdk/agent/participant.py +11 -1
- {macp_sdk_python-0.4.1 → macp_sdk_python-0.5.0}/src/macp_sdk/agent/runner.py +5 -0
- {macp_sdk_python-0.4.1 → macp_sdk_python-0.5.0}/src/macp_sdk/base_session.py +19 -2
- {macp_sdk_python-0.4.1 → macp_sdk_python-0.5.0}/src/macp_sdk/client.py +262 -50
- {macp_sdk_python-0.4.1 → macp_sdk_python-0.5.0}/src/macp_sdk/envelope.py +35 -1
- {macp_sdk_python-0.4.1 → macp_sdk_python-0.5.0}/src/macp_sdk/errors.py +13 -1
- {macp_sdk_python-0.4.1 → macp_sdk_python-0.5.0}/src/macp_sdk/handoff.py +29 -0
- {macp_sdk_python-0.4.1 → macp_sdk_python-0.5.0}/src/macp_sdk/policy.py +23 -2
- {macp_sdk_python-0.4.1 → macp_sdk_python-0.5.0}/src/macp_sdk/proto_registry.py +27 -1
- {macp_sdk_python-0.4.1 → macp_sdk_python-0.5.0}/src/macp_sdk/validation.py +34 -6
- {macp_sdk_python-0.4.1 → macp_sdk_python-0.5.0}/src/macp_sdk/watchers.py +9 -5
- {macp_sdk_python-0.4.1 → macp_sdk_python-0.5.0/src/macp_sdk_python.egg-info}/PKG-INFO +11 -4
- {macp_sdk_python-0.4.1 → macp_sdk_python-0.5.0}/src/macp_sdk_python.egg-info/requires.txt +4 -3
- {macp_sdk_python-0.4.1 → macp_sdk_python-0.5.0}/LICENSE +0 -0
- {macp_sdk_python-0.4.1 → macp_sdk_python-0.5.0}/setup.cfg +0 -0
- {macp_sdk_python-0.4.1 → macp_sdk_python-0.5.0}/src/macp_sdk/_logging.py +0 -0
- {macp_sdk_python-0.4.1 → macp_sdk_python-0.5.0}/src/macp_sdk/agent/__init__.py +0 -0
- {macp_sdk_python-0.4.1 → macp_sdk_python-0.5.0}/src/macp_sdk/agent/cancel_callback.py +0 -0
- {macp_sdk_python-0.4.1 → macp_sdk_python-0.5.0}/src/macp_sdk/agent/dispatcher.py +0 -0
- {macp_sdk_python-0.4.1 → macp_sdk_python-0.5.0}/src/macp_sdk/agent/strategies.py +0 -0
- {macp_sdk_python-0.4.1 → macp_sdk_python-0.5.0}/src/macp_sdk/agent/transports.py +0 -0
- {macp_sdk_python-0.4.1 → macp_sdk_python-0.5.0}/src/macp_sdk/agent/types.py +0 -0
- {macp_sdk_python-0.4.1 → macp_sdk_python-0.5.0}/src/macp_sdk/auth.py +0 -0
- {macp_sdk_python-0.4.1 → macp_sdk_python-0.5.0}/src/macp_sdk/base_projection.py +0 -0
- {macp_sdk_python-0.4.1 → macp_sdk_python-0.5.0}/src/macp_sdk/constants.py +0 -0
- {macp_sdk_python-0.4.1 → macp_sdk_python-0.5.0}/src/macp_sdk/decision.py +0 -0
- {macp_sdk_python-0.4.1 → macp_sdk_python-0.5.0}/src/macp_sdk/projections.py +0 -0
- {macp_sdk_python-0.4.1 → macp_sdk_python-0.5.0}/src/macp_sdk/proposal.py +0 -0
- {macp_sdk_python-0.4.1 → macp_sdk_python-0.5.0}/src/macp_sdk/py.typed +0 -0
- {macp_sdk_python-0.4.1 → macp_sdk_python-0.5.0}/src/macp_sdk/quorum.py +0 -0
- {macp_sdk_python-0.4.1 → macp_sdk_python-0.5.0}/src/macp_sdk/retry.py +0 -0
- {macp_sdk_python-0.4.1 → macp_sdk_python-0.5.0}/src/macp_sdk/task.py +0 -0
- {macp_sdk_python-0.4.1 → macp_sdk_python-0.5.0}/src/macp_sdk_python.egg-info/SOURCES.txt +0 -0
- {macp_sdk_python-0.4.1 → macp_sdk_python-0.5.0}/src/macp_sdk_python.egg-info/dependency_links.txt +0 -0
- {macp_sdk_python-0.4.1 → macp_sdk_python-0.5.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.5.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
|
|
@@ -20,9 +20,9 @@ Classifier: Topic :: Software Development :: Libraries
|
|
|
20
20
|
Requires-Python: >=3.11
|
|
21
21
|
Description-Content-Type: text/markdown
|
|
22
22
|
License-File: LICENSE
|
|
23
|
-
Requires-Dist: grpcio>=1.
|
|
24
|
-
Requires-Dist: protobuf>=
|
|
25
|
-
Requires-Dist: macp-proto<0.2.0,>=0.1.
|
|
23
|
+
Requires-Dist: grpcio>=1.82.0
|
|
24
|
+
Requires-Dist: protobuf>=7.35.0
|
|
25
|
+
Requires-Dist: macp-proto<0.2.0,>=0.1.6
|
|
26
26
|
Provides-Extra: dev
|
|
27
27
|
Requires-Dist: grpcio-tools>=1.74.0; extra == "dev"
|
|
28
28
|
Requires-Dist: pytest>=8.0; extra == "dev"
|
|
@@ -32,6 +32,7 @@ Requires-Dist: mypy>=1.10; extra == "dev"
|
|
|
32
32
|
Requires-Dist: ruff>=0.5; extra == "dev"
|
|
33
33
|
Requires-Dist: grpc-stubs>=1.53; extra == "dev"
|
|
34
34
|
Requires-Dist: build>=1.0; extra == "dev"
|
|
35
|
+
Requires-Dist: jsonschema>=4.0; extra == "dev"
|
|
35
36
|
Provides-Extra: docs
|
|
36
37
|
Requires-Dist: mkdocs-material>=9.5; extra == "docs"
|
|
37
38
|
Requires-Dist: mkdocstrings[python]>=0.25; extra == "docs"
|
|
@@ -149,6 +150,12 @@ export MACP_ALLOW_INSECURE=1
|
|
|
149
150
|
cargo run # in the runtime repo
|
|
150
151
|
```
|
|
151
152
|
|
|
153
|
+
Since runtime **v0.5.0**, a runtime with no authentication configured
|
|
154
|
+
(`MACP_AUTH_TOKENS_*` / `MACP_AUTH_ISSUER` unset) **refuses to start** unless
|
|
155
|
+
`MACP_ALLOW_INSECURE=1` is set, and the published Docker image **no longer
|
|
156
|
+
bakes that env in** — pass it explicitly for local dev (as above and in the
|
|
157
|
+
`docker run` command in `CLAUDE.md`).
|
|
158
|
+
|
|
152
159
|
Dev-agent auth rides the standard `Authorization: Bearer` header — the
|
|
153
160
|
runtime's `dev_authenticate` fallback binds the token value verbatim
|
|
154
161
|
as the sender.
|
|
@@ -110,6 +110,12 @@ export MACP_ALLOW_INSECURE=1
|
|
|
110
110
|
cargo run # in the runtime repo
|
|
111
111
|
```
|
|
112
112
|
|
|
113
|
+
Since runtime **v0.5.0**, a runtime with no authentication configured
|
|
114
|
+
(`MACP_AUTH_TOKENS_*` / `MACP_AUTH_ISSUER` unset) **refuses to start** unless
|
|
115
|
+
`MACP_ALLOW_INSECURE=1` is set, and the published Docker image **no longer
|
|
116
|
+
bakes that env in** — pass it explicitly for local dev (as above and in the
|
|
117
|
+
`docker run` command in `CLAUDE.md`).
|
|
118
|
+
|
|
113
119
|
Dev-agent auth rides the standard `Authorization: Bearer` header — the
|
|
114
120
|
runtime's `dev_authenticate` fallback binds the token value verbatim
|
|
115
121
|
as the sender.
|
|
@@ -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.5.0"
|
|
8
8
|
description = "Python SDK for the MACP Rust runtime"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.11"
|
|
@@ -23,18 +23,24 @@ classifiers = [
|
|
|
23
23
|
"Topic :: Software Development :: Libraries",
|
|
24
24
|
]
|
|
25
25
|
dependencies = [
|
|
26
|
-
# macp-proto 0.1.
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
#
|
|
30
|
-
#
|
|
31
|
-
#
|
|
32
|
-
#
|
|
33
|
-
#
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
#
|
|
37
|
-
|
|
26
|
+
# macp-proto 0.1.6's gencode was produced by protobuf 7.35.0 / grpc 1.82.0.
|
|
27
|
+
# protobuf enforces this at import time: importing macp.v1.core_pb2 under an
|
|
28
|
+
# older protobuf raises google.protobuf.runtime_version.VersionError
|
|
29
|
+
# ("runtime version cannot be older than the linked gencode version"), so
|
|
30
|
+
# these floors are hard requirements derived from the gencode, not from
|
|
31
|
+
# macp-proto's own (looser) METADATA. This is the consumer-visible bump in
|
|
32
|
+
# SDK 0.5.0 — deployments pinned to protobuf 6.x / grpcio < 1.82 cannot
|
|
33
|
+
# straddle it.
|
|
34
|
+
"grpcio>=1.82.0",
|
|
35
|
+
"protobuf>=7.35.0",
|
|
36
|
+
# Requires 0.1.6+ for the runtime v0.5.0 wire surface referenced by the SDK:
|
|
37
|
+
# SessionStartPayload.max_suspend_ms (0.1.5), HandoffAcceptPayload.implicit
|
|
38
|
+
# and ListSessionsRequest.page_size/page_token (0.1.6), and the canonical
|
|
39
|
+
# macp.modes.multi_round.v1.ContributePayload proto encoding (0.1.4).
|
|
40
|
+
# Retains the 0.1.3 surface (suspend/cancel/supersede, subscribe_session_id/
|
|
41
|
+
# after_sequence, context_id/extensions). See docs/contributing.md
|
|
42
|
+
# "Bumping macp-proto".
|
|
43
|
+
"macp-proto>=0.1.6,<0.2.0",
|
|
38
44
|
]
|
|
39
45
|
|
|
40
46
|
[project.urls]
|
|
@@ -54,6 +60,7 @@ dev = [
|
|
|
54
60
|
"ruff>=0.5",
|
|
55
61
|
"grpc-stubs>=1.53",
|
|
56
62
|
"build>=1.0",
|
|
63
|
+
"jsonschema>=4.0", # validate vendored conformance fixtures against schema.json
|
|
57
64
|
]
|
|
58
65
|
docs = [
|
|
59
66
|
"mkdocs-material>=9.5",
|
|
@@ -20,6 +20,7 @@ from .decision import DecisionSession
|
|
|
20
20
|
from .envelope import (
|
|
21
21
|
build_commitment_payload,
|
|
22
22
|
build_commitment_ref,
|
|
23
|
+
build_contribute_payload,
|
|
23
24
|
build_envelope,
|
|
24
25
|
build_progress_payload,
|
|
25
26
|
build_root,
|
|
@@ -183,6 +184,7 @@ __all__ = [
|
|
|
183
184
|
"VotingRules",
|
|
184
185
|
"build_commitment_payload",
|
|
185
186
|
"build_commitment_ref",
|
|
187
|
+
"build_contribute_payload",
|
|
186
188
|
"build_decision_policy",
|
|
187
189
|
"build_envelope",
|
|
188
190
|
"build_handoff_policy",
|
|
@@ -69,6 +69,9 @@ class InitiatorConfig:
|
|
|
69
69
|
mode_version: str | None = None
|
|
70
70
|
configuration_version: str | None = None
|
|
71
71
|
policy_version: str | None = None
|
|
72
|
+
# Runtime v0.5.0 per-session maximum-suspension cap (ms); 0 = runtime
|
|
73
|
+
# default (currently 7 days). Negative values are rejected at build time.
|
|
74
|
+
max_suspend_ms: int = 0
|
|
72
75
|
kickoff_message_type: str | None = None
|
|
73
76
|
kickoff_payload: dict[str, Any] = field(default_factory=dict)
|
|
74
77
|
|
|
@@ -113,8 +116,13 @@ class ParticipantActions:
|
|
|
113
116
|
mode_version: str | None = None,
|
|
114
117
|
configuration_version: str | None = None,
|
|
115
118
|
policy_version: str | None = None,
|
|
119
|
+
max_suspend_ms: int = 0,
|
|
116
120
|
) -> Any:
|
|
117
|
-
"""Send a SessionStart envelope to open the session.
|
|
121
|
+
"""Send a SessionStart envelope to open the session.
|
|
122
|
+
|
|
123
|
+
``max_suspend_ms`` (runtime v0.5.0) binds a per-session maximum
|
|
124
|
+
suspension cap; ``0`` selects the runtime default.
|
|
125
|
+
"""
|
|
118
126
|
from ..constants import (
|
|
119
127
|
DEFAULT_CONFIGURATION_VERSION,
|
|
120
128
|
DEFAULT_MODE_VERSION,
|
|
@@ -135,6 +143,7 @@ class ParticipantActions:
|
|
|
135
143
|
mode_version=mode_version or DEFAULT_MODE_VERSION,
|
|
136
144
|
configuration_version=configuration_version or DEFAULT_CONFIGURATION_VERSION,
|
|
137
145
|
policy_version=policy_version or DEFAULT_POLICY_VERSION,
|
|
146
|
+
max_suspend_ms=max_suspend_ms,
|
|
138
147
|
)
|
|
139
148
|
envelope = build_envelope(
|
|
140
149
|
mode=self._mode,
|
|
@@ -522,6 +531,7 @@ class Participant:
|
|
|
522
531
|
mode_version=cfg.mode_version,
|
|
523
532
|
configuration_version=cfg.configuration_version,
|
|
524
533
|
policy_version=cfg.policy_version,
|
|
534
|
+
max_suspend_ms=cfg.max_suspend_ms,
|
|
525
535
|
)
|
|
526
536
|
logger.info("SessionStart emitted (session=%s)", self._session_id)
|
|
527
537
|
|
|
@@ -123,6 +123,11 @@ def from_bootstrap(bootstrap_path: str | None = None) -> Participant:
|
|
|
123
123
|
intent=str(ss.get("intent", "")),
|
|
124
124
|
participants=[str(p) for p in ss.get("participants", participants)],
|
|
125
125
|
ttl_ms=int(ss.get("ttl_ms", 300000)),
|
|
126
|
+
# Runtime v0.5.0 per-session suspension cap; absent → 0 (runtime
|
|
127
|
+
# default). The TypeScript bootstrap schema does not yet map this
|
|
128
|
+
# key, so it is Python-only for now; a bootstrap that omits it
|
|
129
|
+
# behaves identically in both SDKs.
|
|
130
|
+
max_suspend_ms=int(ss.get("max_suspend_ms", 0)),
|
|
126
131
|
context_id=str(ss.get("context_id", "")),
|
|
127
132
|
extensions=_decode_extensions(ss.get("extensions")),
|
|
128
133
|
roots=ss.get("roots"),
|
|
@@ -102,10 +102,17 @@ class BaseSession(ABC):
|
|
|
102
102
|
context_id: str = "",
|
|
103
103
|
extensions: Mapping[str, bytes] | None = None,
|
|
104
104
|
roots: Iterable[Any] | None = None,
|
|
105
|
+
max_suspend_ms: int = 0,
|
|
105
106
|
sender: str | None = None,
|
|
106
107
|
auth: AuthConfig | None = None,
|
|
107
108
|
) -> envelope_pb2.Ack:
|
|
108
|
-
"""Send SessionStart and begin tracking via the projection.
|
|
109
|
+
"""Send SessionStart and begin tracking via the projection.
|
|
110
|
+
|
|
111
|
+
``max_suspend_ms`` (runtime v0.5.0) binds a per-session maximum
|
|
112
|
+
suspension cap; ``0`` selects the runtime default (currently 7 days).
|
|
113
|
+
A suspension outlasting this cap expires the session
|
|
114
|
+
(``SUSPENDED`` → ``EXPIRED``). Negative values are rejected.
|
|
115
|
+
"""
|
|
109
116
|
validate_participant_count(len(participants))
|
|
110
117
|
payload = build_session_start_payload(
|
|
111
118
|
intent=intent,
|
|
@@ -117,6 +124,7 @@ class BaseSession(ABC):
|
|
|
117
124
|
context_id=context_id,
|
|
118
125
|
extensions=extensions,
|
|
119
126
|
roots=roots,
|
|
127
|
+
max_suspend_ms=max_suspend_ms,
|
|
120
128
|
)
|
|
121
129
|
envelope = build_envelope(
|
|
122
130
|
mode=self.MODE,
|
|
@@ -138,7 +146,16 @@ class BaseSession(ABC):
|
|
|
138
146
|
sender: str | None = None,
|
|
139
147
|
auth: AuthConfig | None = None,
|
|
140
148
|
) -> envelope_pb2.Ack:
|
|
141
|
-
"""Send Commitment to resolve the session.
|
|
149
|
+
"""Send Commitment to resolve the session.
|
|
150
|
+
|
|
151
|
+
The commitment echoes ``self.policy_version``. Runtime v0.5.0 relaxed
|
|
152
|
+
the match rule (RFC-MACP-0012, change review A3): an **empty**
|
|
153
|
+
``policy_version`` matches whatever policy the session is bound to, so
|
|
154
|
+
a session started with an empty policy_version no longer has to echo
|
|
155
|
+
``policy.default``. A **non-empty** value must still equal the
|
|
156
|
+
resolved policy id (e.g. ``policy.default``) or the commitment is
|
|
157
|
+
rejected with ``UNKNOWN_POLICY_VERSION``.
|
|
158
|
+
"""
|
|
142
159
|
payload = build_commitment_payload(
|
|
143
160
|
action=action,
|
|
144
161
|
authority_scope=authority_scope,
|
|
@@ -21,6 +21,7 @@ from .errors import (
|
|
|
21
21
|
MacpAckError,
|
|
22
22
|
MacpIdentityMismatchError,
|
|
23
23
|
MacpSdkError,
|
|
24
|
+
MacpSessionError,
|
|
24
25
|
MacpTransportError,
|
|
25
26
|
)
|
|
26
27
|
|
|
@@ -69,6 +70,33 @@ def _parse_grpc_metadata_reasons(rpc_error: grpc.RpcError) -> list[str]:
|
|
|
69
70
|
return []
|
|
70
71
|
|
|
71
72
|
|
|
73
|
+
def _rpc_status_name(rpc_err: grpc.RpcError) -> str | None:
|
|
74
|
+
"""Return the gRPC status code name for *rpc_err*, or None.
|
|
75
|
+
|
|
76
|
+
Defensive against error objects that don't implement ``code()`` (the bare
|
|
77
|
+
``grpc.RpcError`` base class doesn't; only concrete call errors do).
|
|
78
|
+
"""
|
|
79
|
+
code_fn = getattr(rpc_err, "code", None)
|
|
80
|
+
if not callable(code_fn):
|
|
81
|
+
return None
|
|
82
|
+
try:
|
|
83
|
+
status = code_fn()
|
|
84
|
+
except Exception:
|
|
85
|
+
return None
|
|
86
|
+
return status.name if status is not None else None
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _rpc_details(rpc_err: grpc.RpcError) -> str | None:
|
|
90
|
+
"""Return ``rpc_err.details()`` if available, else None."""
|
|
91
|
+
details_fn = getattr(rpc_err, "details", None)
|
|
92
|
+
if not callable(details_fn):
|
|
93
|
+
return None
|
|
94
|
+
try:
|
|
95
|
+
return details_fn()
|
|
96
|
+
except Exception:
|
|
97
|
+
return None
|
|
98
|
+
|
|
99
|
+
|
|
72
100
|
def _default_capabilities() -> core_pb2.Capabilities:
|
|
73
101
|
return core_pb2.Capabilities(
|
|
74
102
|
sessions=core_pb2.SessionsCapability(stream=True, list_sessions=True, watch_sessions=True),
|
|
@@ -159,8 +187,18 @@ class MacpStream:
|
|
|
159
187
|
|
|
160
188
|
def send_subscribe(self, session_id: str, after_sequence: int = 0) -> None:
|
|
161
189
|
"""RFC-MACP-0006-A1: Send a subscribe-only frame to receive session
|
|
162
|
-
history + live broadcast.
|
|
163
|
-
|
|
190
|
+
history + live broadcast.
|
|
191
|
+
|
|
192
|
+
``after_sequence`` (RFC-MACP-0006 §3.2, runtime v0.5.0) is the 1-based
|
|
193
|
+
ordinal over *accepted envelopes*, interpreted **exclusively**: ``0``
|
|
194
|
+
(default) replays from the start; ``N`` replays from envelope ``N+1``
|
|
195
|
+
onward, so envelope ``N`` is never re-delivered. Ordinals are
|
|
196
|
+
contiguous and stable across log compaction and runtime restart. To
|
|
197
|
+
resume, track how many envelopes you have consumed and pass that count
|
|
198
|
+
as ``after_sequence`` on reconnect. Resuming below a compacted range
|
|
199
|
+
fails the stream with gRPC ``FAILED_PRECONDITION`` (surfaced as
|
|
200
|
+
``MacpTransportError(code="FAILED_PRECONDITION")`` from :meth:`read`) —
|
|
201
|
+
restart from ``0`` and reconcile.
|
|
164
202
|
"""
|
|
165
203
|
if self._closed:
|
|
166
204
|
raise MacpSdkError("stream is already closed")
|
|
@@ -175,7 +213,10 @@ class MacpStream:
|
|
|
175
213
|
if item is self._END:
|
|
176
214
|
return None
|
|
177
215
|
if isinstance(item, grpc.RpcError):
|
|
178
|
-
raise MacpTransportError(
|
|
216
|
+
raise MacpTransportError(
|
|
217
|
+
_rpc_details(item) or str(item),
|
|
218
|
+
code=_rpc_status_name(item),
|
|
219
|
+
)
|
|
179
220
|
assert isinstance(item, envelope_pb2.Envelope)
|
|
180
221
|
return item
|
|
181
222
|
|
|
@@ -213,7 +254,7 @@ class MacpClient:
|
|
|
213
254
|
root_certificates: bytes | None = None,
|
|
214
255
|
default_timeout: float | None = None,
|
|
215
256
|
client_name: str = "macp-sdk-python",
|
|
216
|
-
client_version: str = "0.
|
|
257
|
+
client_version: str = "0.5.0",
|
|
217
258
|
) -> None:
|
|
218
259
|
if secure is None:
|
|
219
260
|
secure = not allow_insecure
|
|
@@ -256,6 +297,43 @@ class MacpClient:
|
|
|
256
297
|
raise MacpSdkError("this operation requires auth; pass auth= or configure client.auth")
|
|
257
298
|
return selected
|
|
258
299
|
|
|
300
|
+
@staticmethod
|
|
301
|
+
def _transport_error_from_rpc(rpc_err: grpc.RpcError) -> MacpTransportError:
|
|
302
|
+
"""Build a :class:`MacpTransportError` preserving the gRPC status code.
|
|
303
|
+
|
|
304
|
+
Watch/stream RPCs surface consumer lag as ``RESOURCE_EXHAUSTED`` and a
|
|
305
|
+
missing-auth ``WatchSignals`` as ``UNAUTHENTICATED``; attaching the
|
|
306
|
+
code lets callers decide to reconnect (lag) vs. fix auth vs. give up.
|
|
307
|
+
"""
|
|
308
|
+
return MacpTransportError(
|
|
309
|
+
_rpc_details(rpc_err) or str(rpc_err),
|
|
310
|
+
code=_rpc_status_name(rpc_err),
|
|
311
|
+
)
|
|
312
|
+
|
|
313
|
+
@staticmethod
|
|
314
|
+
def _map_registry_mutation_error(rpc_err: grpc.RpcError, *, read_only_hint: str) -> Exception:
|
|
315
|
+
"""Translate a registry-mutation ``RpcError`` into a typed SDK error.
|
|
316
|
+
|
|
317
|
+
A runtime configured with a read-only registry (e.g.
|
|
318
|
+
``MACP_POLICIES_DIR`` for policies) advertises the corresponding
|
|
319
|
+
capability as ``false`` in ``Initialize`` and rejects every mutating
|
|
320
|
+
RPC with gRPC ``FAILED_PRECONDITION``. Surface that as a
|
|
321
|
+
:class:`MacpAckError` carrying the code so callers can branch on it,
|
|
322
|
+
mirroring how ``send``/``cancel_session`` surface NACKs. Any other
|
|
323
|
+
status stays a :class:`MacpTransportError` with the code attached.
|
|
324
|
+
"""
|
|
325
|
+
name = _rpc_status_name(rpc_err)
|
|
326
|
+
if name == "FAILED_PRECONDITION":
|
|
327
|
+
failure = AckFailure(
|
|
328
|
+
code="FAILED_PRECONDITION",
|
|
329
|
+
message=_rpc_details(rpc_err) or read_only_hint,
|
|
330
|
+
)
|
|
331
|
+
return MacpAckError(failure)
|
|
332
|
+
return MacpTransportError(
|
|
333
|
+
_rpc_details(rpc_err) or str(rpc_err),
|
|
334
|
+
code=name,
|
|
335
|
+
)
|
|
336
|
+
|
|
259
337
|
@staticmethod
|
|
260
338
|
def _resolve_sender(auth_cfg: AuthConfig, sender: str) -> str:
|
|
261
339
|
"""Resolve and validate the envelope sender against auth.expected_sender.
|
|
@@ -418,6 +496,12 @@ class MacpClient:
|
|
|
418
496
|
suspended the runtime rejects messages sent to the session with a
|
|
419
497
|
non-OPEN error; call :meth:`resume_session` to return it to OPEN.
|
|
420
498
|
The returned ``Ack.session_state`` reflects ``SUSPENDED``.
|
|
499
|
+
|
|
500
|
+
A suspension that outlasts the session-bound ``max_suspend_ms`` cap
|
|
501
|
+
(set at ``SessionStart``; runtime v0.5.0) expires the session
|
|
502
|
+
(``SUSPENDED`` → ``EXPIRED``), observed as an ``EVENT_TYPE_EXPIRED``
|
|
503
|
+
lifecycle event. ``max_suspend_ms=0`` uses the runtime default
|
|
504
|
+
(currently 7 days).
|
|
421
505
|
"""
|
|
422
506
|
auth_cfg = self._require_auth(auth)
|
|
423
507
|
try:
|
|
@@ -491,23 +575,55 @@ class MacpClient:
|
|
|
491
575
|
def list_sessions(
|
|
492
576
|
self,
|
|
493
577
|
*,
|
|
578
|
+
page_size: int = 0,
|
|
494
579
|
auth: AuthConfig | None = None,
|
|
495
580
|
timeout: float | None = None,
|
|
496
581
|
) -> list[core_pb2.SessionMetadata]:
|
|
497
582
|
"""List all active sessions known to the runtime.
|
|
498
583
|
|
|
499
|
-
|
|
500
|
-
``
|
|
501
|
-
|
|
502
|
-
|
|
584
|
+
Auto-paginates (macp-proto >= 0.1.6 / runtime v0.5.0): repeatedly
|
|
585
|
+
requests ``ListSessions`` with the runtime's ``next_page_token`` until
|
|
586
|
+
it is empty, so the returned ``list`` is always the *complete* set
|
|
587
|
+
regardless of the runtime's page size. Each entry includes
|
|
588
|
+
``context_id`` and ``extension_keys``. ``page_size`` (0 = server
|
|
589
|
+
default) tunes the per-request batch. Use :meth:`list_sessions_page`
|
|
590
|
+
for manual, single-page control.
|
|
591
|
+
"""
|
|
592
|
+
auth_cfg = self._require_auth(auth)
|
|
593
|
+
sessions: list[core_pb2.SessionMetadata] = []
|
|
594
|
+
page_token = ""
|
|
595
|
+
while True:
|
|
596
|
+
batch, page_token = self.list_sessions_page(
|
|
597
|
+
page_size=page_size,
|
|
598
|
+
page_token=page_token,
|
|
599
|
+
auth=auth_cfg,
|
|
600
|
+
timeout=timeout,
|
|
601
|
+
)
|
|
602
|
+
sessions.extend(batch)
|
|
603
|
+
if not page_token:
|
|
604
|
+
return sessions
|
|
605
|
+
|
|
606
|
+
def list_sessions_page(
|
|
607
|
+
self,
|
|
608
|
+
*,
|
|
609
|
+
page_size: int = 0,
|
|
610
|
+
page_token: str = "",
|
|
611
|
+
auth: AuthConfig | None = None,
|
|
612
|
+
timeout: float | None = None,
|
|
613
|
+
) -> tuple[list[core_pb2.SessionMetadata], str]:
|
|
614
|
+
"""Fetch a single page of sessions (macp-proto >= 0.1.6).
|
|
615
|
+
|
|
616
|
+
Returns ``(sessions, next_page_token)``. An empty ``next_page_token``
|
|
617
|
+
means the last page. Callers that want to drain all pages should use
|
|
618
|
+
:meth:`list_sessions`, which loops this for them.
|
|
503
619
|
"""
|
|
504
620
|
auth_cfg = self._require_auth(auth)
|
|
505
621
|
resp = self.stub.ListSessions(
|
|
506
|
-
core_pb2.ListSessionsRequest(),
|
|
622
|
+
core_pb2.ListSessionsRequest(page_size=page_size, page_token=page_token),
|
|
507
623
|
metadata=self._metadata(auth_cfg),
|
|
508
624
|
timeout=timeout or self.default_timeout,
|
|
509
625
|
)
|
|
510
|
-
return list(resp.sessions)
|
|
626
|
+
return list(resp.sessions), resp.next_page_token
|
|
511
627
|
|
|
512
628
|
def watch_sessions(
|
|
513
629
|
self,
|
|
@@ -535,7 +651,7 @@ class MacpClient:
|
|
|
535
651
|
try:
|
|
536
652
|
yield from call
|
|
537
653
|
except grpc.RpcError as exc:
|
|
538
|
-
raise
|
|
654
|
+
raise self._transport_error_from_rpc(exc) from exc
|
|
539
655
|
|
|
540
656
|
def register_ext_mode(
|
|
541
657
|
self,
|
|
@@ -544,12 +660,37 @@ class MacpClient:
|
|
|
544
660
|
auth: AuthConfig | None = None,
|
|
545
661
|
timeout: float | None = None,
|
|
546
662
|
) -> core_pb2.RegisterExtModeResponse:
|
|
663
|
+
"""Register an extension-mode descriptor with the runtime.
|
|
664
|
+
|
|
665
|
+
Descriptors must declare ``Commitment`` among their
|
|
666
|
+
``terminal_message_types`` (runtime v0.5.0 rejects those that don't),
|
|
667
|
+
and an ext session started without ``mode_version`` binds the
|
|
668
|
+
registered descriptor's version. Against a read-only mode registry the
|
|
669
|
+
runtime rejects this with gRPC ``FAILED_PRECONDITION``, surfaced here
|
|
670
|
+
as :class:`MacpAckError`.
|
|
671
|
+
|
|
672
|
+
Raises :class:`MacpSessionError` client-side if the descriptor omits
|
|
673
|
+
``Commitment`` from ``terminal_message_types`` (runtime v0.5.0 requires
|
|
674
|
+
it), giving a clearer error than the runtime's rejection.
|
|
675
|
+
"""
|
|
676
|
+
if "Commitment" not in descriptor.terminal_message_types:
|
|
677
|
+
raise MacpSessionError(
|
|
678
|
+
"ext-mode descriptor must declare 'Commitment' in "
|
|
679
|
+
"terminal_message_types (runtime v0.5.0 rejects descriptors "
|
|
680
|
+
"without a Commitment terminal type)"
|
|
681
|
+
)
|
|
547
682
|
auth_cfg = self._require_auth(auth)
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
683
|
+
try:
|
|
684
|
+
return self.stub.RegisterExtMode(
|
|
685
|
+
core_pb2.RegisterExtModeRequest(mode_descriptor=descriptor),
|
|
686
|
+
metadata=self._metadata(auth_cfg),
|
|
687
|
+
timeout=timeout or self.default_timeout,
|
|
688
|
+
)
|
|
689
|
+
except grpc.RpcError as rpc_err:
|
|
690
|
+
raise self._map_registry_mutation_error(
|
|
691
|
+
rpc_err,
|
|
692
|
+
read_only_hint="RegisterExtMode refused: the mode registry is read-only",
|
|
693
|
+
) from rpc_err
|
|
553
694
|
|
|
554
695
|
def unregister_ext_mode(
|
|
555
696
|
self,
|
|
@@ -559,11 +700,17 @@ class MacpClient:
|
|
|
559
700
|
timeout: float | None = None,
|
|
560
701
|
) -> core_pb2.UnregisterExtModeResponse:
|
|
561
702
|
auth_cfg = self._require_auth(auth)
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
703
|
+
try:
|
|
704
|
+
return self.stub.UnregisterExtMode(
|
|
705
|
+
core_pb2.UnregisterExtModeRequest(mode=mode),
|
|
706
|
+
metadata=self._metadata(auth_cfg),
|
|
707
|
+
timeout=timeout or self.default_timeout,
|
|
708
|
+
)
|
|
709
|
+
except grpc.RpcError as rpc_err:
|
|
710
|
+
raise self._map_registry_mutation_error(
|
|
711
|
+
rpc_err,
|
|
712
|
+
read_only_hint="UnregisterExtMode refused: the mode registry is read-only",
|
|
713
|
+
) from rpc_err
|
|
567
714
|
|
|
568
715
|
def promote_mode(
|
|
569
716
|
self,
|
|
@@ -573,12 +720,25 @@ class MacpClient:
|
|
|
573
720
|
auth: AuthConfig | None = None,
|
|
574
721
|
timeout: float | None = None,
|
|
575
722
|
) -> core_pb2.PromoteModeResponse:
|
|
723
|
+
"""Promote a registered extension mode to a first-class mode.
|
|
724
|
+
|
|
725
|
+
Runtime v0.5.0 rejects promotion into the reserved ``macp.mode.*``
|
|
726
|
+
namespace. Against a read-only mode registry the runtime rejects this
|
|
727
|
+
with gRPC ``FAILED_PRECONDITION``, surfaced here as
|
|
728
|
+
:class:`MacpAckError`.
|
|
729
|
+
"""
|
|
576
730
|
auth_cfg = self._require_auth(auth)
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
731
|
+
try:
|
|
732
|
+
return self.stub.PromoteMode(
|
|
733
|
+
core_pb2.PromoteModeRequest(mode=mode, promoted_mode_name=promoted_mode_name),
|
|
734
|
+
metadata=self._metadata(auth_cfg),
|
|
735
|
+
timeout=timeout or self.default_timeout,
|
|
736
|
+
)
|
|
737
|
+
except grpc.RpcError as rpc_err:
|
|
738
|
+
raise self._map_registry_mutation_error(
|
|
739
|
+
rpc_err,
|
|
740
|
+
read_only_hint="PromoteMode refused: the mode registry is read-only",
|
|
741
|
+
) from rpc_err
|
|
582
742
|
|
|
583
743
|
# ── Governance policy lifecycle ───────────────────────────────────
|
|
584
744
|
|
|
@@ -589,13 +749,28 @@ class MacpClient:
|
|
|
589
749
|
auth: AuthConfig | None = None,
|
|
590
750
|
timeout: float | None = None,
|
|
591
751
|
) -> policy_pb2.RegisterPolicyResponse:
|
|
592
|
-
"""Register a governance policy with the runtime.
|
|
752
|
+
"""Register a governance policy with the runtime.
|
|
753
|
+
|
|
754
|
+
Raises :class:`MacpAckError` with ``code="FAILED_PRECONDITION"`` when
|
|
755
|
+
the runtime's policy registry is read-only — a runtime started with
|
|
756
|
+
``MACP_POLICIES_DIR`` advertises ``policy_registry.register_policy:
|
|
757
|
+
false`` in :meth:`initialize` and refuses all mutating policy RPCs.
|
|
758
|
+
Check that capability before registering to avoid the round-trip.
|
|
759
|
+
"""
|
|
593
760
|
auth_cfg = self._require_auth(auth)
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
761
|
+
try:
|
|
762
|
+
return self.stub.RegisterPolicy(
|
|
763
|
+
policy_pb2.RegisterPolicyRequest(policy_descriptor=descriptor),
|
|
764
|
+
metadata=self._metadata(auth_cfg),
|
|
765
|
+
timeout=timeout or self.default_timeout,
|
|
766
|
+
)
|
|
767
|
+
except grpc.RpcError as rpc_err:
|
|
768
|
+
raise self._map_registry_mutation_error(
|
|
769
|
+
rpc_err,
|
|
770
|
+
read_only_hint="RegisterPolicy refused: the policy registry is read-only "
|
|
771
|
+
"(runtime configured with MACP_POLICIES_DIR; "
|
|
772
|
+
"Initialize advertises policy_registry.register_policy=false)",
|
|
773
|
+
) from rpc_err
|
|
599
774
|
|
|
600
775
|
def unregister_policy(
|
|
601
776
|
self,
|
|
@@ -604,13 +779,24 @@ class MacpClient:
|
|
|
604
779
|
auth: AuthConfig | None = None,
|
|
605
780
|
timeout: float | None = None,
|
|
606
781
|
) -> policy_pb2.UnregisterPolicyResponse:
|
|
607
|
-
"""Unregister a governance policy from the runtime.
|
|
782
|
+
"""Unregister a governance policy from the runtime.
|
|
783
|
+
|
|
784
|
+
Like :meth:`register_policy`, raises :class:`MacpAckError` with
|
|
785
|
+
``code="FAILED_PRECONDITION"`` against a read-only registry.
|
|
786
|
+
"""
|
|
608
787
|
auth_cfg = self._require_auth(auth)
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
788
|
+
try:
|
|
789
|
+
return self.stub.UnregisterPolicy(
|
|
790
|
+
policy_pb2.UnregisterPolicyRequest(policy_id=policy_id),
|
|
791
|
+
metadata=self._metadata(auth_cfg),
|
|
792
|
+
timeout=timeout or self.default_timeout,
|
|
793
|
+
)
|
|
794
|
+
except grpc.RpcError as rpc_err:
|
|
795
|
+
raise self._map_registry_mutation_error(
|
|
796
|
+
rpc_err,
|
|
797
|
+
read_only_hint="UnregisterPolicy refused: the policy registry is read-only "
|
|
798
|
+
"(runtime configured with MACP_POLICIES_DIR)",
|
|
799
|
+
) from rpc_err
|
|
614
800
|
|
|
615
801
|
def get_policy(
|
|
616
802
|
self,
|
|
@@ -643,18 +829,24 @@ class MacpClient:
|
|
|
643
829
|
)
|
|
644
830
|
|
|
645
831
|
def watch_policies(
|
|
646
|
-
self, *, timeout: float | None = None
|
|
832
|
+
self, *, auth: AuthConfig | None = None, timeout: float | None = None
|
|
647
833
|
) -> Iterator[policy_pb2.WatchPoliciesResponse]:
|
|
648
|
-
"""Server-streaming RPC: yields governance policy change events.
|
|
834
|
+
"""Server-streaming RPC: yields governance policy change events.
|
|
835
|
+
|
|
836
|
+
Auth is forwarded when available (``auth`` arg or ``client.auth``) but
|
|
837
|
+
not required. A lagging consumer is terminated with
|
|
838
|
+
``RESOURCE_EXHAUSTED``; reconnect to resume.
|
|
839
|
+
"""
|
|
649
840
|
logger.debug("watch_policies starting")
|
|
650
841
|
call = self.stub.WatchPolicies(
|
|
651
842
|
policy_pb2.WatchPoliciesRequest(),
|
|
843
|
+
metadata=self._metadata(auth),
|
|
652
844
|
timeout=timeout or self.default_timeout,
|
|
653
845
|
)
|
|
654
846
|
try:
|
|
655
847
|
yield from call
|
|
656
848
|
except grpc.RpcError as exc:
|
|
657
|
-
raise
|
|
849
|
+
raise self._transport_error_from_rpc(exc) from exc
|
|
658
850
|
|
|
659
851
|
def open_stream(
|
|
660
852
|
self, *, auth: AuthConfig | None = None, timeout: float | None = None
|
|
@@ -667,44 +859,64 @@ class MacpClient:
|
|
|
667
859
|
)
|
|
668
860
|
|
|
669
861
|
def watch_mode_registry(
|
|
670
|
-
self, *, timeout: float | None = None
|
|
862
|
+
self, *, auth: AuthConfig | None = None, timeout: float | None = None
|
|
671
863
|
) -> Iterator[core_pb2.WatchModeRegistryResponse]:
|
|
672
|
-
"""Server-streaming RPC: yields mode registry change events.
|
|
864
|
+
"""Server-streaming RPC: yields mode registry change events.
|
|
865
|
+
|
|
866
|
+
Auth is forwarded when available but not required.
|
|
867
|
+
"""
|
|
673
868
|
logger.debug("watch_mode_registry starting")
|
|
674
869
|
call = self.stub.WatchModeRegistry(
|
|
675
870
|
core_pb2.WatchModeRegistryRequest(),
|
|
871
|
+
metadata=self._metadata(auth),
|
|
676
872
|
timeout=timeout or self.default_timeout,
|
|
677
873
|
)
|
|
678
874
|
try:
|
|
679
875
|
yield from call
|
|
680
876
|
except grpc.RpcError as exc:
|
|
681
|
-
raise
|
|
877
|
+
raise self._transport_error_from_rpc(exc) from exc
|
|
682
878
|
|
|
683
|
-
def watch_roots(
|
|
684
|
-
|
|
879
|
+
def watch_roots(
|
|
880
|
+
self, *, auth: AuthConfig | None = None, timeout: float | None = None
|
|
881
|
+
) -> Iterator[core_pb2.WatchRootsResponse]:
|
|
882
|
+
"""Server-streaming RPC: yields root change events.
|
|
883
|
+
|
|
884
|
+
The runtime advertises ``roots.list_changed: false`` and does not yet
|
|
885
|
+
populate roots, so this stream idles. Auth is forwarded when available
|
|
886
|
+
but not required.
|
|
887
|
+
"""
|
|
685
888
|
logger.debug("watch_roots starting")
|
|
686
889
|
call = self.stub.WatchRoots(
|
|
687
890
|
core_pb2.WatchRootsRequest(),
|
|
891
|
+
metadata=self._metadata(auth),
|
|
688
892
|
timeout=timeout or self.default_timeout,
|
|
689
893
|
)
|
|
690
894
|
try:
|
|
691
895
|
yield from call
|
|
692
896
|
except grpc.RpcError as exc:
|
|
693
|
-
raise
|
|
897
|
+
raise self._transport_error_from_rpc(exc) from exc
|
|
694
898
|
|
|
695
899
|
def watch_signals(
|
|
696
|
-
self, *, timeout: float | None = None
|
|
900
|
+
self, *, auth: AuthConfig | None = None, timeout: float | None = None
|
|
697
901
|
) -> Iterator[core_pb2.WatchSignalsResponse]:
|
|
698
|
-
"""Server-streaming RPC: yields ambient signal envelopes.
|
|
902
|
+
"""Server-streaming RPC: yields ambient signal envelopes.
|
|
903
|
+
|
|
904
|
+
Requires authentication since runtime v0.5.0 — an unauthenticated
|
|
905
|
+
``WatchSignals`` is rejected with gRPC ``UNAUTHENTICATED`` (surfaced
|
|
906
|
+
as ``MacpTransportError(code="UNAUTHENTICATED")``). A lagging consumer
|
|
907
|
+
is terminated with ``RESOURCE_EXHAUSTED``; reconnect to resume.
|
|
908
|
+
"""
|
|
699
909
|
logger.debug("watch_signals starting")
|
|
910
|
+
auth_cfg = self._require_auth(auth)
|
|
700
911
|
call = self.stub.WatchSignals(
|
|
701
912
|
core_pb2.WatchSignalsRequest(),
|
|
913
|
+
metadata=self._metadata(auth_cfg),
|
|
702
914
|
timeout=timeout or self.default_timeout,
|
|
703
915
|
)
|
|
704
916
|
try:
|
|
705
917
|
yield from call
|
|
706
918
|
except grpc.RpcError as exc:
|
|
707
|
-
raise
|
|
919
|
+
raise self._transport_error_from_rpc(exc) from exc
|
|
708
920
|
|
|
709
921
|
def send_signal(
|
|
710
922
|
self,
|
|
@@ -4,6 +4,7 @@ import time
|
|
|
4
4
|
import uuid
|
|
5
5
|
from collections.abc import Iterable, Mapping, Sequence
|
|
6
6
|
|
|
7
|
+
from macp.modes.multi_round.v1 import multi_round_pb2
|
|
7
8
|
from macp.v1 import core_pb2, envelope_pb2
|
|
8
9
|
|
|
9
10
|
from .constants import (
|
|
@@ -50,6 +51,11 @@ def build_root(uri: str, name: str = "") -> core_pb2.Root:
|
|
|
50
51
|
return core_pb2.Root(uri=uri, name=name)
|
|
51
52
|
|
|
52
53
|
|
|
54
|
+
def _has_max_suspend_ms_field() -> bool:
|
|
55
|
+
"""Check if the proto schema supports SessionStartPayload.max_suspend_ms (>=0.1.5)."""
|
|
56
|
+
return any(f.name == "max_suspend_ms" for f in core_pb2.SessionStartPayload.DESCRIPTOR.fields)
|
|
57
|
+
|
|
58
|
+
|
|
53
59
|
def build_session_start_payload(
|
|
54
60
|
*,
|
|
55
61
|
intent: str,
|
|
@@ -61,8 +67,22 @@ def build_session_start_payload(
|
|
|
61
67
|
context_id: str = "",
|
|
62
68
|
extensions: Mapping[str, bytes] | None = None,
|
|
63
69
|
roots: Iterable[core_pb2.Root] | None = None,
|
|
70
|
+
max_suspend_ms: int = 0,
|
|
64
71
|
) -> core_pb2.SessionStartPayload:
|
|
65
|
-
|
|
72
|
+
"""Build a ``SessionStartPayload``.
|
|
73
|
+
|
|
74
|
+
``max_suspend_ms`` (macp-proto >= 0.1.5, runtime v0.5.0) binds a
|
|
75
|
+
per-session maximum-suspension cap at start. ``0`` (the default) or an
|
|
76
|
+
absent field selects the runtime default (currently 7 days). The runtime
|
|
77
|
+
rejects negative values at ``SessionStart``, so they are rejected
|
|
78
|
+
client-side here with a clear message. proto3 does not serialize a scalar
|
|
79
|
+
``0``, so the default keeps byte-compatibility with pre-0.1.5 payloads.
|
|
80
|
+
"""
|
|
81
|
+
if max_suspend_ms < 0:
|
|
82
|
+
raise MacpSessionError(
|
|
83
|
+
f"max_suspend_ms must be >= 0 (0 selects the runtime default), got {max_suspend_ms}"
|
|
84
|
+
)
|
|
85
|
+
kwargs: dict[str, object] = dict(
|
|
66
86
|
intent=intent,
|
|
67
87
|
participants=list(participants),
|
|
68
88
|
mode_version=mode_version,
|
|
@@ -73,6 +93,9 @@ def build_session_start_payload(
|
|
|
73
93
|
extensions=dict(extensions) if extensions else {},
|
|
74
94
|
roots=list(roots or []),
|
|
75
95
|
)
|
|
96
|
+
if max_suspend_ms and _has_max_suspend_ms_field():
|
|
97
|
+
kwargs["max_suspend_ms"] = max_suspend_ms
|
|
98
|
+
return core_pb2.SessionStartPayload(**kwargs)
|
|
76
99
|
|
|
77
100
|
|
|
78
101
|
def _has_outcome_positive_field() -> bool:
|
|
@@ -164,6 +187,17 @@ def build_progress_payload(
|
|
|
164
187
|
)
|
|
165
188
|
|
|
166
189
|
|
|
190
|
+
def build_contribute_payload(value: str) -> multi_round_pb2.ContributePayload:
|
|
191
|
+
"""Build a ``ContributePayload`` for the ``ext.multi_round.v1`` mode.
|
|
192
|
+
|
|
193
|
+
Runtime v0.5.0 / macp-proto >= 0.1.4 moved Contribute onto the canonical
|
|
194
|
+
protobuf wire format. The runtime still permanently accepts legacy JSON
|
|
195
|
+
(``{"value": "..."}``) — see ``ProtoRegistry.decode_known_payload`` — but
|
|
196
|
+
new clients should emit proto via this builder.
|
|
197
|
+
"""
|
|
198
|
+
return multi_round_pb2.ContributePayload(value=value)
|
|
199
|
+
|
|
200
|
+
|
|
167
201
|
def serialize_message(message: object) -> bytes:
|
|
168
202
|
serializer = getattr(message, "SerializeToString", None)
|
|
169
203
|
if serializer is None:
|
|
@@ -69,7 +69,19 @@ class MacpAckError(MacpSdkError):
|
|
|
69
69
|
|
|
70
70
|
|
|
71
71
|
class MacpTransportError(MacpSdkError):
|
|
72
|
-
"""gRPC communication failure.
|
|
72
|
+
"""gRPC communication failure.
|
|
73
|
+
|
|
74
|
+
``code`` carries the gRPC status code name (e.g. ``"RESOURCE_EXHAUSTED"``,
|
|
75
|
+
``"UNAUTHENTICATED"``, ``"FAILED_PRECONDITION"``) when the failure
|
|
76
|
+
originated from an ``RpcError``, or ``None`` otherwise. It lets callers
|
|
77
|
+
distinguish a lagging watch/stream consumer (``RESOURCE_EXHAUSTED`` →
|
|
78
|
+
reconnect) or a missing-auth watch (``UNAUTHENTICATED``) from a generic
|
|
79
|
+
transport failure without parsing the message string.
|
|
80
|
+
"""
|
|
81
|
+
|
|
82
|
+
def __init__(self, message: str, *, code: str | None = None) -> None:
|
|
83
|
+
self.code = code
|
|
84
|
+
super().__init__(message)
|
|
73
85
|
|
|
74
86
|
|
|
75
87
|
class MacpSessionError(MacpSdkError):
|
|
@@ -27,6 +27,12 @@ class HandoffRecord:
|
|
|
27
27
|
context_content_type: str | None
|
|
28
28
|
accepted_by: str | None
|
|
29
29
|
declined_by: str | None
|
|
30
|
+
# True when the acceptance was an implicit accept synthesized by the
|
|
31
|
+
# runtime (RFC-MACP-0010 §5.1) rather than an explicit client HandoffAccept.
|
|
32
|
+
# Runtime v0.5.0 defines but does not yet emit these; the SDK surfaces the
|
|
33
|
+
# field so histories that contain them replay correctly. Client-submitted
|
|
34
|
+
# accepts are always ``implicit=False`` (the runtime rejects a forged True).
|
|
35
|
+
implicit: bool = False
|
|
30
36
|
|
|
31
37
|
|
|
32
38
|
# ---------------------------------------------------------------------------
|
|
@@ -83,6 +89,12 @@ class HandoffProjection(BaseProjection):
|
|
|
83
89
|
if handoff is not None:
|
|
84
90
|
handoff.status = "accepted"
|
|
85
91
|
handoff.accepted_by = p.accepted_by
|
|
92
|
+
# macp-proto >= 0.1.6: capture whether this was a runtime
|
|
93
|
+
# implicit accept (sender = target, message_id
|
|
94
|
+
# ``implicit-accept:<handoff_id>``). Absent field decodes to
|
|
95
|
+
# False. If ``handoff`` is None the offer was never observed
|
|
96
|
+
# (e.g. mid-session subscribe) — we still advance the phase.
|
|
97
|
+
handoff.implicit = getattr(p, "implicit", False)
|
|
86
98
|
self.phase = "Accepted"
|
|
87
99
|
return
|
|
88
100
|
|
|
@@ -119,6 +131,18 @@ class HandoffProjection(BaseProjection):
|
|
|
119
131
|
handoff = self.handoffs.get(handoff_id)
|
|
120
132
|
return handoff is not None and handoff.status == "declined"
|
|
121
133
|
|
|
134
|
+
def is_implicitly_accepted(self, handoff_id: str) -> bool:
|
|
135
|
+
"""True if *handoff_id* was accepted by a runtime implicit accept.
|
|
136
|
+
|
|
137
|
+
Distinguishes a timeout-driven implicit accept (RFC-MACP-0010 §5.1)
|
|
138
|
+
from an explicit client ``HandoffAccept``. Runtime v0.5.0 does not yet
|
|
139
|
+
emit implicit accepts, so this returns False for all live sessions
|
|
140
|
+
today; it exists so histories/replays that carry them surface the
|
|
141
|
+
distinction.
|
|
142
|
+
"""
|
|
143
|
+
handoff = self.handoffs.get(handoff_id)
|
|
144
|
+
return handoff is not None and handoff.status == "accepted" and handoff.implicit
|
|
145
|
+
|
|
122
146
|
def get_handoff(self, handoff_id: str) -> HandoffRecord | None:
|
|
123
147
|
"""Return the handoff record for *handoff_id*, or None."""
|
|
124
148
|
return self.handoffs.get(handoff_id)
|
|
@@ -203,6 +227,11 @@ class HandoffSession(BaseSession):
|
|
|
203
227
|
sender: str | None = None,
|
|
204
228
|
auth: AuthConfig | None = None,
|
|
205
229
|
) -> envelope_pb2.Ack:
|
|
230
|
+
# Deliberately no ``implicit`` parameter: RFC-MACP-0010 §5.1 reserves
|
|
231
|
+
# ``implicit=true`` for runtime-synthesized accepts and the runtime
|
|
232
|
+
# rejects a client-submitted True. Client accepts always leave the
|
|
233
|
+
# field at its proto3 default (False). See the regression test in
|
|
234
|
+
# tests/unit/test_handoff.py.
|
|
206
235
|
payload = handoff_pb2.HandoffAcceptPayload(
|
|
207
236
|
handoff_id=handoff_id,
|
|
208
237
|
accepted_by=accepted_by or self._sender_for(sender, auth=auth),
|
|
@@ -12,6 +12,8 @@ from dataclasses import dataclass, field
|
|
|
12
12
|
|
|
13
13
|
from macp.v1 import policy_pb2
|
|
14
14
|
|
|
15
|
+
from .errors import MacpSessionError
|
|
16
|
+
|
|
15
17
|
# ── Shared commitment rules (all modes) ─────────────────────────────
|
|
16
18
|
|
|
17
19
|
|
|
@@ -145,10 +147,19 @@ def build_decision_policy(
|
|
|
145
147
|
|
|
146
148
|
@dataclass(frozen=True, slots=True)
|
|
147
149
|
class QuorumThreshold:
|
|
148
|
-
"""Quorum threshold configuration (RFC
|
|
150
|
+
"""Quorum threshold configuration (RFC-MACP-0012 §4.2 ``threshold`` object).
|
|
151
|
+
|
|
152
|
+
``threshold`` is strictly the **approval bar** — the number/percentage of
|
|
153
|
+
approvals required to commit. There is no separate participation quorum in
|
|
154
|
+
schema_version <= 2. ``value`` is an **integer** in the canonical
|
|
155
|
+
``quorum-rules.schema.json``: an approval count for ``n_of_m`` /
|
|
156
|
+
``weighted``, and an integer percentage 0-100 for ``percentage``. A
|
|
157
|
+
fractional value (e.g. ``0.75``) is rejected by the runtime's schema
|
|
158
|
+
validation, so this is typed ``int`` and range-checked at build time.
|
|
159
|
+
"""
|
|
149
160
|
|
|
150
161
|
type: str = "n_of_m"
|
|
151
|
-
value:
|
|
162
|
+
value: int = 0
|
|
152
163
|
|
|
153
164
|
|
|
154
165
|
@dataclass(frozen=True, slots=True)
|
|
@@ -172,6 +183,16 @@ def build_quorum_policy(
|
|
|
172
183
|
a = abstention or AbstentionRules()
|
|
173
184
|
c = commitment or CommitmentRules()
|
|
174
185
|
|
|
186
|
+
# Match the canonical schema's constraints before the runtime does, so a
|
|
187
|
+
# bad descriptor fails immediately client-side instead of round-tripping
|
|
188
|
+
# to an INVALID_POLICY_DEFINITION from RegisterPolicy.
|
|
189
|
+
if t.value < 0:
|
|
190
|
+
raise MacpSessionError(f"quorum threshold value must be >= 0, got {t.value}")
|
|
191
|
+
if t.type == "percentage" and t.value > 100:
|
|
192
|
+
raise MacpSessionError(
|
|
193
|
+
f"quorum threshold value must be 0-100 for type 'percentage', got {t.value}"
|
|
194
|
+
)
|
|
195
|
+
|
|
175
196
|
rules: dict[str, object] = {
|
|
176
197
|
"threshold": {"type": t.type, "value": t.value},
|
|
177
198
|
"abstention": {
|
|
@@ -66,7 +66,11 @@ MODE_MAP: dict[str, dict[str, str]] = {
|
|
|
66
66
|
"Abstain": "macp.modes.quorum.v1.AbstainPayload",
|
|
67
67
|
},
|
|
68
68
|
MODE_MULTI_ROUND: {
|
|
69
|
-
|
|
69
|
+
# Runtime v0.5.0 / macp-proto >= 0.1.4: Contribute is the last
|
|
70
|
+
# advertised mode moved onto the canonical protobuf wire format.
|
|
71
|
+
# Legacy JSON (``{"value": "..."}``) is still decoded (tried first,
|
|
72
|
+
# permanently) — see ``decode_known_payload``.
|
|
73
|
+
"Contribute": "macp.modes.multi_round.v1.ContributePayload",
|
|
70
74
|
},
|
|
71
75
|
}
|
|
72
76
|
|
|
@@ -88,6 +92,7 @@ def _ensure_pb2_imports() -> None:
|
|
|
88
92
|
"macp.modes.task.v1.task_pb2",
|
|
89
93
|
"macp.modes.handoff.v1.handoff_pb2",
|
|
90
94
|
"macp.modes.quorum.v1.quorum_pb2",
|
|
95
|
+
"macp.modes.multi_round.v1.multi_round_pb2",
|
|
91
96
|
):
|
|
92
97
|
importlib.import_module(_mod)
|
|
93
98
|
_PB2_MODULES_LOADED = True
|
|
@@ -133,8 +138,29 @@ class ProtoRegistry:
|
|
|
133
138
|
type_name = self.get_known_type_name(mode, message_type)
|
|
134
139
|
if type_name is None or type_name == "__json__":
|
|
135
140
|
return self._try_decode_utf8(payload)
|
|
141
|
+
if mode == MODE_MULTI_ROUND and message_type == "Contribute":
|
|
142
|
+
# RFC-MACP contract: multi_round Contribute accepts legacy JSON
|
|
143
|
+
# (``{"value": "..."}``) *permanently* and tries it first, so
|
|
144
|
+
# pre-proto histories/replays decode byte-identically. Proto
|
|
145
|
+
# (``ContributePayload``) is the new canonical encoding; its
|
|
146
|
+
# serialized bytes are not valid JSON, so the JSON attempt fails
|
|
147
|
+
# loudly and we fall through to proto.
|
|
148
|
+
return self._decode_json_first_then_proto(type_name, payload)
|
|
136
149
|
return self.decode_message(type_name, payload)
|
|
137
150
|
|
|
151
|
+
def _decode_json_first_then_proto(
|
|
152
|
+
self, type_name: str, payload: bytes
|
|
153
|
+
) -> dict[str, Any] | None:
|
|
154
|
+
if not payload:
|
|
155
|
+
return None
|
|
156
|
+
try:
|
|
157
|
+
parsed = json.loads(payload.decode("utf-8"))
|
|
158
|
+
except (UnicodeDecodeError, json.JSONDecodeError, ValueError):
|
|
159
|
+
return self.decode_message(type_name, payload)
|
|
160
|
+
# Legacy JSON payload — keep the ``{"encoding": "json", "json": ...}``
|
|
161
|
+
# shape that existing consumers of decoded dicts already handle.
|
|
162
|
+
return {"encoding": "json", "json": parsed}
|
|
163
|
+
|
|
138
164
|
@staticmethod
|
|
139
165
|
def _try_decode_utf8(payload: bytes) -> dict[str, Any] | None:
|
|
140
166
|
if not payload:
|
|
@@ -10,7 +10,15 @@ from collections.abc import Sequence
|
|
|
10
10
|
|
|
11
11
|
from .errors import MacpSessionError
|
|
12
12
|
|
|
13
|
-
|
|
13
|
+
# A string with the structural shape of a UUID (36 chars, hyphens at
|
|
14
|
+
# 8-13-18-23, hex-only otherwise) — case-insensitive, any version/variant.
|
|
15
|
+
_UUID_SHAPE_RE = re.compile(
|
|
16
|
+
r"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$"
|
|
17
|
+
)
|
|
18
|
+
# Strict lowercase UUID v4/v7 (version nibble 4 or 7, RFC 9562 variant 8/9/a/b).
|
|
19
|
+
_UUID_V4V7_RE = re.compile(
|
|
20
|
+
r"^[0-9a-f]{8}-[0-9a-f]{4}-[47][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$"
|
|
21
|
+
)
|
|
14
22
|
_BASE64URL_RE = re.compile(r"^[A-Za-z0-9_-]{22,}$")
|
|
15
23
|
|
|
16
24
|
_VALID_VOTES = frozenset({"APPROVE", "REJECT", "ABSTAIN"})
|
|
@@ -22,11 +30,31 @@ _MAX_TTL_MS = 86_400_000 # 24 hours
|
|
|
22
30
|
|
|
23
31
|
|
|
24
32
|
def validate_session_id(sid: str) -> None:
|
|
25
|
-
"""Validate that *sid*
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
33
|
+
"""Validate that *sid* is a lowercase UUID v4/v7 or base64url (22+ chars).
|
|
34
|
+
|
|
35
|
+
Mirrors the runtime's **no-fall-through** rule (runtime v0.5.0 change
|
|
36
|
+
review A4): a string that has the structural shape of a UUID is validated
|
|
37
|
+
strictly as a lowercase v4/v7 UUID — it is *not* reinterpreted as
|
|
38
|
+
base64url (which would otherwise accept an uppercase or wrong-version
|
|
39
|
+
UUID). A 36-char base64url ID containing ``-`` that is not UUID-shaped
|
|
40
|
+
(e.g. non-hex characters) still validates via the base64url branch, as the
|
|
41
|
+
runtime accepts.
|
|
42
|
+
|
|
43
|
+
This validation is advisory — it runs only when an explicit ``session_id``
|
|
44
|
+
is passed; auto-generated IDs are always valid v4 UUIDs.
|
|
45
|
+
"""
|
|
46
|
+
if _UUID_SHAPE_RE.match(sid):
|
|
47
|
+
if not _UUID_V4V7_RE.match(sid):
|
|
48
|
+
raise MacpSessionError(
|
|
49
|
+
"session_id is UUID-shaped but not a lowercase v4/v7 UUID "
|
|
50
|
+
f"(no fall-through to base64url), got: {sid!r}"
|
|
51
|
+
)
|
|
52
|
+
return
|
|
53
|
+
if _BASE64URL_RE.match(sid):
|
|
54
|
+
return
|
|
55
|
+
raise MacpSessionError(
|
|
56
|
+
f"session_id must be a lowercase UUID v4/v7 or base64url (22+ chars), got: {sid!r}"
|
|
57
|
+
)
|
|
30
58
|
|
|
31
59
|
|
|
32
60
|
def validate_vote(value: str) -> str:
|
|
@@ -88,7 +88,7 @@ class ModeRegistryWatcher:
|
|
|
88
88
|
|
|
89
89
|
def changes(self) -> Iterator[Any]:
|
|
90
90
|
"""Yield ``WatchModeRegistryResponse`` items from the runtime stream."""
|
|
91
|
-
yield from self._client.watch_mode_registry()
|
|
91
|
+
yield from self._client.watch_mode_registry(auth=self._auth)
|
|
92
92
|
|
|
93
93
|
def watch(self, handler: Callable[[Any], None]) -> None:
|
|
94
94
|
"""Block and invoke *handler* for each registry change."""
|
|
@@ -111,7 +111,7 @@ class RootsWatcher:
|
|
|
111
111
|
|
|
112
112
|
def changes(self) -> Iterator[Any]:
|
|
113
113
|
"""Yield ``WatchRootsResponse`` items from the runtime stream."""
|
|
114
|
-
yield from self._client.watch_roots()
|
|
114
|
+
yield from self._client.watch_roots(auth=self._auth)
|
|
115
115
|
|
|
116
116
|
def watch(self, handler: Callable[[Any], None]) -> None:
|
|
117
117
|
"""Block and invoke *handler* for each root change."""
|
|
@@ -133,8 +133,12 @@ class SignalWatcher:
|
|
|
133
133
|
self._auth = auth
|
|
134
134
|
|
|
135
135
|
def signals(self) -> Iterator[Any]:
|
|
136
|
-
"""Yield envelope objects extracted from ``WatchSignalsResponse``.
|
|
137
|
-
|
|
136
|
+
"""Yield envelope objects extracted from ``WatchSignalsResponse``.
|
|
137
|
+
|
|
138
|
+
Forwards the watcher's stored ``auth`` — runtime v0.5.0 requires
|
|
139
|
+
authentication for ``WatchSignals``.
|
|
140
|
+
"""
|
|
141
|
+
for response in self._client.watch_signals(auth=self._auth):
|
|
138
142
|
if hasattr(response, "envelope") and response.envelope.ByteSize() > 0:
|
|
139
143
|
yield response.envelope
|
|
140
144
|
|
|
@@ -217,7 +221,7 @@ class PolicyWatcher:
|
|
|
217
221
|
|
|
218
222
|
def changes(self) -> Iterator[PolicyChange]:
|
|
219
223
|
"""Yield ``PolicyChange`` items from the runtime stream."""
|
|
220
|
-
for response in self._client.watch_policies():
|
|
224
|
+
for response in self._client.watch_policies(auth=self._auth):
|
|
221
225
|
descriptors = list(response.descriptors) if hasattr(response, "descriptors") else []
|
|
222
226
|
observed = getattr(response, "observed_at_unix_ms", 0)
|
|
223
227
|
yield PolicyChange(descriptors=descriptors, observed_at_unix_ms=observed)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: macp-sdk-python
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.5.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
|
|
@@ -20,9 +20,9 @@ Classifier: Topic :: Software Development :: Libraries
|
|
|
20
20
|
Requires-Python: >=3.11
|
|
21
21
|
Description-Content-Type: text/markdown
|
|
22
22
|
License-File: LICENSE
|
|
23
|
-
Requires-Dist: grpcio>=1.
|
|
24
|
-
Requires-Dist: protobuf>=
|
|
25
|
-
Requires-Dist: macp-proto<0.2.0,>=0.1.
|
|
23
|
+
Requires-Dist: grpcio>=1.82.0
|
|
24
|
+
Requires-Dist: protobuf>=7.35.0
|
|
25
|
+
Requires-Dist: macp-proto<0.2.0,>=0.1.6
|
|
26
26
|
Provides-Extra: dev
|
|
27
27
|
Requires-Dist: grpcio-tools>=1.74.0; extra == "dev"
|
|
28
28
|
Requires-Dist: pytest>=8.0; extra == "dev"
|
|
@@ -32,6 +32,7 @@ Requires-Dist: mypy>=1.10; extra == "dev"
|
|
|
32
32
|
Requires-Dist: ruff>=0.5; extra == "dev"
|
|
33
33
|
Requires-Dist: grpc-stubs>=1.53; extra == "dev"
|
|
34
34
|
Requires-Dist: build>=1.0; extra == "dev"
|
|
35
|
+
Requires-Dist: jsonschema>=4.0; extra == "dev"
|
|
35
36
|
Provides-Extra: docs
|
|
36
37
|
Requires-Dist: mkdocs-material>=9.5; extra == "docs"
|
|
37
38
|
Requires-Dist: mkdocstrings[python]>=0.25; extra == "docs"
|
|
@@ -149,6 +150,12 @@ export MACP_ALLOW_INSECURE=1
|
|
|
149
150
|
cargo run # in the runtime repo
|
|
150
151
|
```
|
|
151
152
|
|
|
153
|
+
Since runtime **v0.5.0**, a runtime with no authentication configured
|
|
154
|
+
(`MACP_AUTH_TOKENS_*` / `MACP_AUTH_ISSUER` unset) **refuses to start** unless
|
|
155
|
+
`MACP_ALLOW_INSECURE=1` is set, and the published Docker image **no longer
|
|
156
|
+
bakes that env in** — pass it explicitly for local dev (as above and in the
|
|
157
|
+
`docker run` command in `CLAUDE.md`).
|
|
158
|
+
|
|
152
159
|
Dev-agent auth rides the standard `Authorization: Bearer` header — the
|
|
153
160
|
runtime's `dev_authenticate` fallback binds the token value verbatim
|
|
154
161
|
as the sender.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
grpcio>=1.
|
|
2
|
-
protobuf>=
|
|
3
|
-
macp-proto<0.2.0,>=0.1.
|
|
1
|
+
grpcio>=1.82.0
|
|
2
|
+
protobuf>=7.35.0
|
|
3
|
+
macp-proto<0.2.0,>=0.1.6
|
|
4
4
|
|
|
5
5
|
[dev]
|
|
6
6
|
grpcio-tools>=1.74.0
|
|
@@ -11,6 +11,7 @@ mypy>=1.10
|
|
|
11
11
|
ruff>=0.5
|
|
12
12
|
grpc-stubs>=1.53
|
|
13
13
|
build>=1.0
|
|
14
|
+
jsonschema>=4.0
|
|
14
15
|
|
|
15
16
|
[docs]
|
|
16
17
|
mkdocs-material>=9.5
|
|
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.4.1 → macp_sdk_python-0.5.0}/src/macp_sdk_python.egg-info/dependency_links.txt
RENAMED
|
File without changes
|
|
File without changes
|