macp-sdk-python 0.2.3__tar.gz → 0.3.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.2.3 → macp_sdk_python-0.3.0}/PKG-INFO +8 -3
- {macp_sdk_python-0.2.3 → macp_sdk_python-0.3.0}/README.md +7 -2
- {macp_sdk_python-0.2.3 → macp_sdk_python-0.3.0}/pyproject.toml +1 -1
- {macp_sdk_python-0.2.3 → macp_sdk_python-0.3.0}/src/macp_sdk/__init__.py +6 -1
- {macp_sdk_python-0.2.3 → macp_sdk_python-0.3.0}/src/macp_sdk/agent/__init__.py +5 -1
- macp_sdk_python-0.3.0/src/macp_sdk/agent/cancel_callback.py +149 -0
- {macp_sdk_python-0.2.3 → macp_sdk_python-0.3.0}/src/macp_sdk/agent/participant.py +121 -35
- {macp_sdk_python-0.2.3 → macp_sdk_python-0.3.0}/src/macp_sdk/agent/runner.py +66 -16
- {macp_sdk_python-0.2.3 → macp_sdk_python-0.3.0}/src/macp_sdk/auth.py +16 -16
- {macp_sdk_python-0.2.3 → macp_sdk_python-0.3.0}/src/macp_sdk/client.py +58 -4
- {macp_sdk_python-0.2.3 → macp_sdk_python-0.3.0}/src/macp_sdk/quorum.py +15 -3
- {macp_sdk_python-0.2.3 → macp_sdk_python-0.3.0}/src/macp_sdk/task.py +53 -4
- {macp_sdk_python-0.2.3 → macp_sdk_python-0.3.0}/src/macp_sdk/watchers.py +89 -0
- {macp_sdk_python-0.2.3 → macp_sdk_python-0.3.0}/src/macp_sdk_python.egg-info/PKG-INFO +8 -3
- {macp_sdk_python-0.2.3 → macp_sdk_python-0.3.0}/src/macp_sdk_python.egg-info/SOURCES.txt +1 -0
- {macp_sdk_python-0.2.3 → macp_sdk_python-0.3.0}/LICENSE +0 -0
- {macp_sdk_python-0.2.3 → macp_sdk_python-0.3.0}/setup.cfg +0 -0
- {macp_sdk_python-0.2.3 → macp_sdk_python-0.3.0}/src/macp_sdk/_logging.py +0 -0
- {macp_sdk_python-0.2.3 → macp_sdk_python-0.3.0}/src/macp_sdk/agent/dispatcher.py +0 -0
- {macp_sdk_python-0.2.3 → macp_sdk_python-0.3.0}/src/macp_sdk/agent/strategies.py +0 -0
- {macp_sdk_python-0.2.3 → macp_sdk_python-0.3.0}/src/macp_sdk/agent/transports.py +0 -0
- {macp_sdk_python-0.2.3 → macp_sdk_python-0.3.0}/src/macp_sdk/agent/types.py +0 -0
- {macp_sdk_python-0.2.3 → macp_sdk_python-0.3.0}/src/macp_sdk/base_projection.py +0 -0
- {macp_sdk_python-0.2.3 → macp_sdk_python-0.3.0}/src/macp_sdk/base_session.py +0 -0
- {macp_sdk_python-0.2.3 → macp_sdk_python-0.3.0}/src/macp_sdk/constants.py +0 -0
- {macp_sdk_python-0.2.3 → macp_sdk_python-0.3.0}/src/macp_sdk/decision.py +0 -0
- {macp_sdk_python-0.2.3 → macp_sdk_python-0.3.0}/src/macp_sdk/envelope.py +0 -0
- {macp_sdk_python-0.2.3 → macp_sdk_python-0.3.0}/src/macp_sdk/errors.py +0 -0
- {macp_sdk_python-0.2.3 → macp_sdk_python-0.3.0}/src/macp_sdk/handoff.py +0 -0
- {macp_sdk_python-0.2.3 → macp_sdk_python-0.3.0}/src/macp_sdk/policy.py +0 -0
- {macp_sdk_python-0.2.3 → macp_sdk_python-0.3.0}/src/macp_sdk/projections.py +0 -0
- {macp_sdk_python-0.2.3 → macp_sdk_python-0.3.0}/src/macp_sdk/proposal.py +0 -0
- {macp_sdk_python-0.2.3 → macp_sdk_python-0.3.0}/src/macp_sdk/proto_registry.py +0 -0
- {macp_sdk_python-0.2.3 → macp_sdk_python-0.3.0}/src/macp_sdk/py.typed +0 -0
- {macp_sdk_python-0.2.3 → macp_sdk_python-0.3.0}/src/macp_sdk/retry.py +0 -0
- {macp_sdk_python-0.2.3 → macp_sdk_python-0.3.0}/src/macp_sdk/validation.py +0 -0
- {macp_sdk_python-0.2.3 → macp_sdk_python-0.3.0}/src/macp_sdk_python.egg-info/dependency_links.txt +0 -0
- {macp_sdk_python-0.2.3 → macp_sdk_python-0.3.0}/src/macp_sdk_python.egg-info/requires.txt +0 -0
- {macp_sdk_python-0.2.3 → macp_sdk_python-0.3.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.3.0
|
|
4
4
|
Summary: Python SDK for the MACP Rust runtime
|
|
5
5
|
Author-email: OpenAI <support@openai.com>
|
|
6
6
|
License: Apache-2.0
|
|
@@ -141,10 +141,13 @@ For local development against the runtime:
|
|
|
141
141
|
|
|
142
142
|
```bash
|
|
143
143
|
export MACP_ALLOW_INSECURE=1
|
|
144
|
-
export MACP_ALLOW_DEV_SENDER_HEADER=1
|
|
145
144
|
cargo run # in the runtime repo
|
|
146
145
|
```
|
|
147
146
|
|
|
147
|
+
Dev-agent auth rides the standard `Authorization: Bearer` header — the
|
|
148
|
+
runtime's `dev_authenticate` fallback binds the token value verbatim
|
|
149
|
+
as the sender.
|
|
150
|
+
|
|
148
151
|
## Documentation
|
|
149
152
|
|
|
150
153
|
Full docs available in `docs/` — build with `mkdocs serve` after `make setup`.
|
|
@@ -160,6 +163,8 @@ Business logic — voting rules, AI decision heuristics, policy enforcement —
|
|
|
160
163
|
|
|
161
164
|
## Known runtime limitations
|
|
162
165
|
|
|
163
|
-
- `GetSession` returns metadata only (not mode state/transcript) — hence the local projection pattern
|
|
166
|
+
- `GetSession` returns metadata only (not mode state/transcript) — hence the local projection pattern; use `StreamSession` + `send_subscribe` for the full transcript
|
|
164
167
|
- `StreamSession` is scoped to one session per stream; use `MacpStream.send_subscribe(session_id)` (RFC-MACP-0006-A1, since SDK 0.2.3 / `macp-proto 0.1.2`) to replay accepted history before live broadcast
|
|
168
|
+
- For cross-session observability, use `MacpClient.list_sessions()` and `SessionLifecycleWatcher` (SDK 0.3.0) to enumerate active sessions and stream `CREATED` / `RESOLVED` / `EXPIRED` lifecycle events
|
|
169
|
+
- `ListRoots` currently returns an empty list and `WatchRoots` idles — the runtime does not yet populate roots
|
|
165
170
|
- Business policy (majority, quorum, veto) belongs in your orchestrator/policy layer
|
|
@@ -107,10 +107,13 @@ For local development against the runtime:
|
|
|
107
107
|
|
|
108
108
|
```bash
|
|
109
109
|
export MACP_ALLOW_INSECURE=1
|
|
110
|
-
export MACP_ALLOW_DEV_SENDER_HEADER=1
|
|
111
110
|
cargo run # in the runtime repo
|
|
112
111
|
```
|
|
113
112
|
|
|
113
|
+
Dev-agent auth rides the standard `Authorization: Bearer` header — the
|
|
114
|
+
runtime's `dev_authenticate` fallback binds the token value verbatim
|
|
115
|
+
as the sender.
|
|
116
|
+
|
|
114
117
|
## Documentation
|
|
115
118
|
|
|
116
119
|
Full docs available in `docs/` — build with `mkdocs serve` after `make setup`.
|
|
@@ -126,6 +129,8 @@ Business logic — voting rules, AI decision heuristics, policy enforcement —
|
|
|
126
129
|
|
|
127
130
|
## Known runtime limitations
|
|
128
131
|
|
|
129
|
-
- `GetSession` returns metadata only (not mode state/transcript) — hence the local projection pattern
|
|
132
|
+
- `GetSession` returns metadata only (not mode state/transcript) — hence the local projection pattern; use `StreamSession` + `send_subscribe` for the full transcript
|
|
130
133
|
- `StreamSession` is scoped to one session per stream; use `MacpStream.send_subscribe(session_id)` (RFC-MACP-0006-A1, since SDK 0.2.3 / `macp-proto 0.1.2`) to replay accepted history before live broadcast
|
|
134
|
+
- For cross-session observability, use `MacpClient.list_sessions()` and `SessionLifecycleWatcher` (SDK 0.3.0) to enumerate active sessions and stream `CREATED` / `RESOLVED` / `EXPIRED` lifecycle events
|
|
135
|
+
- `ListRoots` currently returns an empty list and `WatchRoots` idles — the runtime does not yet populate roots
|
|
131
136
|
- Business policy (majority, quorum, veto) belongs in your orchestrator/policy layer
|
|
@@ -2,7 +2,7 @@ from ._logging import configure_logging
|
|
|
2
2
|
from .auth import AuthConfig
|
|
3
3
|
from .base_projection import BaseProjection
|
|
4
4
|
from .base_session import BaseSession
|
|
5
|
-
from .client import MacpClient, MacpStream
|
|
5
|
+
from .client import InlineErrorCallback, MacpClient, MacpStream
|
|
6
6
|
from .constants import (
|
|
7
7
|
DEFAULT_CONFIGURATION_VERSION,
|
|
8
8
|
DEFAULT_MODE_VERSION,
|
|
@@ -100,6 +100,8 @@ from .watchers import (
|
|
|
100
100
|
PolicyChange,
|
|
101
101
|
PolicyWatcher,
|
|
102
102
|
RootsWatcher,
|
|
103
|
+
SessionLifecycle,
|
|
104
|
+
SessionLifecycleWatcher,
|
|
103
105
|
SignalWatcher,
|
|
104
106
|
)
|
|
105
107
|
|
|
@@ -145,6 +147,7 @@ __all__ = [
|
|
|
145
147
|
"HandoffProjection",
|
|
146
148
|
"HandoffRecord",
|
|
147
149
|
"HandoffSession",
|
|
150
|
+
"InlineErrorCallback",
|
|
148
151
|
"MacpAckError",
|
|
149
152
|
"MacpClient",
|
|
150
153
|
"MacpIdentityMismatchError",
|
|
@@ -169,6 +172,8 @@ __all__ = [
|
|
|
169
172
|
"RejectionRules",
|
|
170
173
|
"RetryPolicy",
|
|
171
174
|
"RootsWatcher",
|
|
175
|
+
"SessionLifecycle",
|
|
176
|
+
"SessionLifecycleWatcher",
|
|
172
177
|
"SignalWatcher",
|
|
173
178
|
"TaskAssignmentRules",
|
|
174
179
|
"TaskCompletionRules",
|
|
@@ -1,5 +1,6 @@
|
|
|
1
|
+
from .cancel_callback import CancelCallbackServer, start_cancel_callback_server
|
|
1
2
|
from .dispatcher import Dispatcher
|
|
2
|
-
from .participant import Participant, ParticipantActions
|
|
3
|
+
from .participant import InitiatorConfig, Participant, ParticipantActions
|
|
3
4
|
from .runner import from_bootstrap
|
|
4
5
|
from .strategies import (
|
|
5
6
|
CommitmentDecision,
|
|
@@ -33,6 +34,7 @@ from .types import (
|
|
|
33
34
|
)
|
|
34
35
|
|
|
35
36
|
__all__ = [
|
|
37
|
+
"CancelCallbackServer",
|
|
36
38
|
"CommitmentDecision",
|
|
37
39
|
"CommitmentStrategy",
|
|
38
40
|
"Dispatcher",
|
|
@@ -42,6 +44,7 @@ __all__ = [
|
|
|
42
44
|
"HandlerContext",
|
|
43
45
|
"HttpTransportAdapter",
|
|
44
46
|
"IncomingMessage",
|
|
47
|
+
"InitiatorConfig",
|
|
45
48
|
"MessageHandler",
|
|
46
49
|
"Participant",
|
|
47
50
|
"ParticipantActions",
|
|
@@ -60,5 +63,6 @@ __all__ = [
|
|
|
60
63
|
"function_voter",
|
|
61
64
|
"majority_committer",
|
|
62
65
|
"majority_voter",
|
|
66
|
+
"start_cancel_callback_server",
|
|
63
67
|
"voting_handler",
|
|
64
68
|
]
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
"""Cancel-callback HTTP endpoint (RFC-0001 §7.2 Option A).
|
|
2
|
+
|
|
3
|
+
The examples-service's ``BootstrapPayload.cancel_callback`` field asks
|
|
4
|
+
each agent to listen on ``http://host:port{path}`` for a ``POST`` whose
|
|
5
|
+
JSON body is ``{"runId": ..., "reason": ...}``. Receipt of the POST
|
|
6
|
+
should stop the participant cleanly — typically by calling
|
|
7
|
+
``Participant.stop()``.
|
|
8
|
+
|
|
9
|
+
Before this module every agent had to hand-roll the HTTP endpoint (see
|
|
10
|
+
``examples-service/src/example-agents/runtime/risk-decider.worker.ts``
|
|
11
|
+
for the TS reference). ``start_cancel_callback_server`` encapsulates
|
|
12
|
+
that so callers only supply the ``on_cancel`` callback; the server
|
|
13
|
+
runs in a daemon thread backed by the stdlib ``http.server`` — no new
|
|
14
|
+
dependencies.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import contextlib
|
|
20
|
+
import json
|
|
21
|
+
import threading
|
|
22
|
+
from collections.abc import Callable
|
|
23
|
+
from dataclasses import dataclass
|
|
24
|
+
from http.server import BaseHTTPRequestHandler, HTTPServer
|
|
25
|
+
|
|
26
|
+
from .._logging import logger
|
|
27
|
+
|
|
28
|
+
_OnCancel = Callable[[str, str], None]
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass
|
|
32
|
+
class CancelCallbackServer:
|
|
33
|
+
"""Handle returned by :func:`start_cancel_callback_server`."""
|
|
34
|
+
|
|
35
|
+
address: tuple[str, int]
|
|
36
|
+
_httpd: HTTPServer
|
|
37
|
+
_thread: threading.Thread
|
|
38
|
+
|
|
39
|
+
def close(self) -> None:
|
|
40
|
+
"""Stop accepting requests and tear down the HTTP server.
|
|
41
|
+
|
|
42
|
+
Safe to call from any thread — including from the very handler
|
|
43
|
+
that just fired ``on_cancel`` (which is the normal case when a
|
|
44
|
+
``Participant.stop()`` call bubbles back here). Calling
|
|
45
|
+
``httpd.shutdown()`` directly from the server thread would
|
|
46
|
+
self-deadlock because ``shutdown()`` blocks until
|
|
47
|
+
``serve_forever()`` exits, so spawn a one-shot thread to
|
|
48
|
+
perform the teardown.
|
|
49
|
+
"""
|
|
50
|
+
shutter = threading.Thread(
|
|
51
|
+
target=self._shutdown_once,
|
|
52
|
+
name=f"macp-cancel-callback-shutdown-{self.address[1]}",
|
|
53
|
+
daemon=True,
|
|
54
|
+
)
|
|
55
|
+
shutter.start()
|
|
56
|
+
# Only block if we're not on the server thread (avoid deadlock).
|
|
57
|
+
if threading.current_thread() is not self._thread:
|
|
58
|
+
shutter.join(timeout=2.0)
|
|
59
|
+
|
|
60
|
+
def _shutdown_once(self) -> None:
|
|
61
|
+
try:
|
|
62
|
+
self._httpd.shutdown()
|
|
63
|
+
except Exception:
|
|
64
|
+
logger.debug("cancel_callback shutdown already in progress")
|
|
65
|
+
with contextlib.suppress(Exception):
|
|
66
|
+
self._httpd.server_close()
|
|
67
|
+
if self._thread.is_alive() and threading.current_thread() is not self._thread:
|
|
68
|
+
self._thread.join(timeout=2.0)
|
|
69
|
+
|
|
70
|
+
@property
|
|
71
|
+
def port(self) -> int:
|
|
72
|
+
return self.address[1]
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def start_cancel_callback_server(
|
|
76
|
+
*,
|
|
77
|
+
host: str,
|
|
78
|
+
port: int,
|
|
79
|
+
path: str,
|
|
80
|
+
on_cancel: _OnCancel,
|
|
81
|
+
) -> CancelCallbackServer:
|
|
82
|
+
"""Start a minimal HTTP server that invokes ``on_cancel`` on POST.
|
|
83
|
+
|
|
84
|
+
The server binds to ``(host, port)`` — pass ``port=0`` to let the OS
|
|
85
|
+
pick a free port; the actual port is available on the returned
|
|
86
|
+
handle's ``address`` tuple. Only the exact ``path`` is honoured;
|
|
87
|
+
other paths respond with 404.
|
|
88
|
+
|
|
89
|
+
``on_cancel`` receives ``(run_id, reason)`` parsed from the JSON
|
|
90
|
+
body. Either argument is ``""`` when missing. The callback is
|
|
91
|
+
invoked on the HTTP server thread, so it must not block the event
|
|
92
|
+
loop of the thing it's cancelling — typically it calls
|
|
93
|
+
``participant.stop()``, which only sets a flag.
|
|
94
|
+
"""
|
|
95
|
+
normalised_path = path if path.startswith("/") else "/" + path
|
|
96
|
+
|
|
97
|
+
class _Handler(BaseHTTPRequestHandler):
|
|
98
|
+
def log_message(self, fmt: str, *args: object) -> None:
|
|
99
|
+
logger.debug("cancel_callback %s - %s", self.address_string(), fmt % args)
|
|
100
|
+
|
|
101
|
+
def do_POST(self) -> None:
|
|
102
|
+
if self.path != normalised_path:
|
|
103
|
+
self.send_response(404)
|
|
104
|
+
self.end_headers()
|
|
105
|
+
return
|
|
106
|
+
|
|
107
|
+
length = int(self.headers.get("Content-Length") or 0)
|
|
108
|
+
raw = self.rfile.read(length) if length else b""
|
|
109
|
+
try:
|
|
110
|
+
body = json.loads(raw.decode("utf-8")) if raw else {}
|
|
111
|
+
except (ValueError, UnicodeDecodeError):
|
|
112
|
+
body = {}
|
|
113
|
+
|
|
114
|
+
run_id = str(body.get("runId") or body.get("run_id") or "")
|
|
115
|
+
reason = str(body.get("reason") or "")
|
|
116
|
+
logger.info("cancel_callback invoked (run=%s reason=%s)", run_id, reason)
|
|
117
|
+
|
|
118
|
+
try:
|
|
119
|
+
on_cancel(run_id, reason)
|
|
120
|
+
except Exception:
|
|
121
|
+
logger.exception("cancel_callback handler raised")
|
|
122
|
+
self.send_response(500)
|
|
123
|
+
self.end_headers()
|
|
124
|
+
return
|
|
125
|
+
|
|
126
|
+
self.send_response(202) # Accepted — cancellation acknowledged, async
|
|
127
|
+
self.send_header("Content-Type", "application/json")
|
|
128
|
+
self.end_headers()
|
|
129
|
+
self.wfile.write(b'{"ok":true}')
|
|
130
|
+
|
|
131
|
+
httpd = HTTPServer((host, port), _Handler)
|
|
132
|
+
# ``server_address`` is typed as a variant union on stdlib stubs; normalise
|
|
133
|
+
# to the plain ``(host, port)`` pair we expose on the handle.
|
|
134
|
+
bound_host = str(httpd.server_address[0])
|
|
135
|
+
bound_port = int(httpd.server_address[1])
|
|
136
|
+
address: tuple[str, int] = (bound_host, bound_port)
|
|
137
|
+
thread = threading.Thread(
|
|
138
|
+
target=httpd.serve_forever,
|
|
139
|
+
name=f"macp-cancel-callback-{bound_host}-{bound_port}",
|
|
140
|
+
daemon=True,
|
|
141
|
+
)
|
|
142
|
+
thread.start()
|
|
143
|
+
logger.debug(
|
|
144
|
+
"cancel_callback listening on http://%s:%d%s",
|
|
145
|
+
bound_host,
|
|
146
|
+
bound_port,
|
|
147
|
+
normalised_path,
|
|
148
|
+
)
|
|
149
|
+
return CancelCallbackServer(address=address, _httpd=httpd, _thread=thread)
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
+
from dataclasses import dataclass, field
|
|
3
4
|
from typing import Any
|
|
4
5
|
|
|
5
6
|
from .._logging import logger
|
|
@@ -39,6 +40,38 @@ _MODE_PROJECTIONS: dict[str, type[BaseProjection]] = {
|
|
|
39
40
|
MODE_HANDOFF: HandoffProjection,
|
|
40
41
|
}
|
|
41
42
|
|
|
43
|
+
# Canonical set of terminal projection phases across all modes. Mirrors
|
|
44
|
+
# ``TERMINAL_PHASES`` in typescript-sdk/src/agent/participant.ts so that both
|
|
45
|
+
# SDKs fire ``on_terminal`` at the same observable point in a session. When a
|
|
46
|
+
# mode introduces a new terminal phase, add it here and in the TypeScript SDK
|
|
47
|
+
# in the same change.
|
|
48
|
+
TERMINAL_PHASES: frozenset[str] = frozenset(
|
|
49
|
+
{"Committed", "Accepted", "Declined", "Cancelled", "TerminalRejected"}
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@dataclass
|
|
54
|
+
class InitiatorConfig:
|
|
55
|
+
"""Configuration for an initiator agent's SessionStart + kickoff.
|
|
56
|
+
|
|
57
|
+
Parity with typescript-sdk's ``InitiatorConfig`` interface. Passed to
|
|
58
|
+
:class:`Participant` via ``initiator_config``; when set, the participant
|
|
59
|
+
emits ``SessionStart`` (and, if configured, a kickoff envelope) before
|
|
60
|
+
opening the stream.
|
|
61
|
+
"""
|
|
62
|
+
|
|
63
|
+
intent: str
|
|
64
|
+
participants: list[str]
|
|
65
|
+
ttl_ms: int
|
|
66
|
+
context_id: str = ""
|
|
67
|
+
extensions: dict[str, bytes] = field(default_factory=dict)
|
|
68
|
+
roots: list[dict[str, str]] | None = None
|
|
69
|
+
mode_version: str | None = None
|
|
70
|
+
configuration_version: str | None = None
|
|
71
|
+
policy_version: str | None = None
|
|
72
|
+
kickoff_message_type: str | None = None
|
|
73
|
+
kickoff_payload: dict[str, Any] = field(default_factory=dict)
|
|
74
|
+
|
|
42
75
|
|
|
43
76
|
class ParticipantActions:
|
|
44
77
|
"""Thin wrapper providing action methods bound to a participant's session."""
|
|
@@ -264,7 +297,7 @@ class Participant:
|
|
|
264
297
|
configuration_version: str | None = None,
|
|
265
298
|
policy_version: str | None = None,
|
|
266
299
|
transport: TransportAdapter | None = None,
|
|
267
|
-
initiator_config:
|
|
300
|
+
initiator_config: InitiatorConfig | None = None,
|
|
268
301
|
) -> None:
|
|
269
302
|
self._participant_id = participant_id
|
|
270
303
|
self._session_id = session_id
|
|
@@ -299,6 +332,7 @@ class Participant:
|
|
|
299
332
|
)
|
|
300
333
|
self._last_phase: str | None = None
|
|
301
334
|
self._transport = transport
|
|
335
|
+
self._cancel_callback_server: Any | None = None
|
|
302
336
|
|
|
303
337
|
@property
|
|
304
338
|
def participant_id(self) -> str:
|
|
@@ -353,58 +387,89 @@ class Participant:
|
|
|
353
387
|
)
|
|
354
388
|
|
|
355
389
|
def _process_envelope(self, envelope: Any) -> None:
|
|
356
|
-
"""Process a single envelope:
|
|
357
|
-
|
|
390
|
+
"""Process a single envelope: apply projection, dispatch handlers,
|
|
391
|
+
and fire phase-change / terminal handlers on phase transitions.
|
|
392
|
+
|
|
393
|
+
Terminal dispatch is primarily driven by projection phase
|
|
394
|
+
transitioning into :data:`TERMINAL_PHASES` — matches TypeScript SDK
|
|
395
|
+
semantics so both SDKs fire ``on_terminal`` at the same observable
|
|
396
|
+
point. As a fallback (for envelopes projections don't model — e.g.
|
|
397
|
+
``SessionCancel``), we fire terminal on the message type itself so
|
|
398
|
+
clients always get a ``stop`` signal for end-of-session envelopes.
|
|
399
|
+
"""
|
|
358
400
|
if self._projection is not None:
|
|
359
401
|
self._projection.apply_envelope(envelope)
|
|
360
402
|
|
|
361
|
-
# Check for terminal messages
|
|
362
|
-
if envelope.message_type == "Commitment":
|
|
363
|
-
result = TerminalResult(
|
|
364
|
-
state="Committed",
|
|
365
|
-
commitment=envelope,
|
|
366
|
-
)
|
|
367
|
-
self._dispatcher.dispatch_terminal(result)
|
|
368
|
-
self._stopped = True
|
|
369
|
-
return
|
|
370
|
-
|
|
371
|
-
if envelope.message_type == "SessionCancel":
|
|
372
|
-
result = TerminalResult(state="Cancelled")
|
|
373
|
-
self._dispatcher.dispatch_terminal(result)
|
|
374
|
-
self._stopped = True
|
|
375
|
-
return
|
|
376
|
-
|
|
377
|
-
# Build the message and context
|
|
378
403
|
message = _envelope_to_message(envelope)
|
|
379
404
|
ctx = self._build_context()
|
|
380
405
|
|
|
381
|
-
# Dispatch message handler
|
|
406
|
+
# Dispatch message handler first, so handlers see the post-apply
|
|
407
|
+
# projection state.
|
|
382
408
|
self._dispatcher.dispatch(message, ctx)
|
|
383
409
|
|
|
384
|
-
|
|
410
|
+
fired_terminal = False
|
|
411
|
+
|
|
412
|
+
# Phase transition path — drives both on_phase_change and on_terminal.
|
|
385
413
|
if self._projection is not None:
|
|
386
414
|
current_phase = self._projection.phase
|
|
387
415
|
if current_phase and current_phase != self._last_phase:
|
|
388
|
-
self._dispatcher.dispatch_phase_change(current_phase, ctx)
|
|
389
416
|
self._last_phase = current_phase
|
|
417
|
+
self._dispatcher.dispatch_phase_change(current_phase, ctx)
|
|
418
|
+
|
|
419
|
+
if current_phase in TERMINAL_PHASES:
|
|
420
|
+
commitment = getattr(self._projection, "commitment", None)
|
|
421
|
+
result = TerminalResult(
|
|
422
|
+
state=current_phase,
|
|
423
|
+
commitment=commitment
|
|
424
|
+
if commitment is not None
|
|
425
|
+
else envelope
|
|
426
|
+
if envelope.message_type == "Commitment"
|
|
427
|
+
else None,
|
|
428
|
+
)
|
|
429
|
+
self._dispatcher.dispatch_terminal(result)
|
|
430
|
+
self._stopped = True
|
|
431
|
+
fired_terminal = True
|
|
432
|
+
|
|
433
|
+
# Fallback for envelopes projections don't transition phase on —
|
|
434
|
+
# principally ``SessionCancel``. Keeps terminal dispatch reliable
|
|
435
|
+
# while the phase-driven path remains primary.
|
|
436
|
+
if not fired_terminal and envelope.message_type == "SessionCancel":
|
|
437
|
+
self._dispatcher.dispatch_terminal(TerminalResult(state="Cancelled"))
|
|
438
|
+
self._stopped = True
|
|
390
439
|
|
|
391
440
|
def _process_message(self, message: IncomingMessage) -> None:
|
|
392
|
-
"""Process a pre-built IncomingMessage (from HTTP
|
|
441
|
+
"""Process a pre-built :class:`IncomingMessage` (from an HTTP polling
|
|
442
|
+
transport that decodes envelopes upstream).
|
|
443
|
+
|
|
444
|
+
Terminal dispatch follows the same phase-driven model as
|
|
445
|
+
:meth:`_process_envelope`; since HTTP transports may not carry raw
|
|
446
|
+
envelopes, we fall back to a message-type check for terminal events
|
|
447
|
+
only when no projection is attached.
|
|
448
|
+
"""
|
|
393
449
|
ctx = self._build_context()
|
|
394
450
|
|
|
395
|
-
|
|
396
|
-
result = TerminalResult(state="Committed")
|
|
397
|
-
self._dispatcher.dispatch_terminal(result)
|
|
398
|
-
self._stopped = True
|
|
399
|
-
return
|
|
451
|
+
self._dispatcher.dispatch(message, ctx)
|
|
400
452
|
|
|
401
|
-
if
|
|
402
|
-
|
|
403
|
-
self.
|
|
404
|
-
|
|
453
|
+
if self._projection is not None:
|
|
454
|
+
current_phase = self._projection.phase
|
|
455
|
+
if current_phase and current_phase != self._last_phase:
|
|
456
|
+
self._last_phase = current_phase
|
|
457
|
+
self._dispatcher.dispatch_phase_change(current_phase, ctx)
|
|
458
|
+
|
|
459
|
+
if current_phase in TERMINAL_PHASES:
|
|
460
|
+
commitment = getattr(self._projection, "commitment", None)
|
|
461
|
+
result = TerminalResult(state=current_phase, commitment=commitment)
|
|
462
|
+
self._dispatcher.dispatch_terminal(result)
|
|
463
|
+
self._stopped = True
|
|
405
464
|
return
|
|
406
465
|
|
|
407
|
-
|
|
466
|
+
# No projection attached — use message-type heuristic for terminal.
|
|
467
|
+
if message.message_type == "Commitment":
|
|
468
|
+
self._dispatcher.dispatch_terminal(TerminalResult(state="Committed"))
|
|
469
|
+
self._stopped = True
|
|
470
|
+
elif message.message_type == "SessionCancel":
|
|
471
|
+
self._dispatcher.dispatch_terminal(TerminalResult(state="Cancelled"))
|
|
472
|
+
self._stopped = True
|
|
408
473
|
|
|
409
474
|
def run(self) -> None:
|
|
410
475
|
"""Enter the blocking event loop.
|
|
@@ -453,6 +518,7 @@ class Participant:
|
|
|
453
518
|
participants=cfg.participants,
|
|
454
519
|
ttl_ms=cfg.ttl_ms,
|
|
455
520
|
context_id=cfg.context_id,
|
|
521
|
+
extensions=cfg.extensions or None,
|
|
456
522
|
mode_version=cfg.mode_version,
|
|
457
523
|
configuration_version=cfg.configuration_version,
|
|
458
524
|
policy_version=cfg.policy_version,
|
|
@@ -476,5 +542,25 @@ class Participant:
|
|
|
476
542
|
self._process_envelope(envelope)
|
|
477
543
|
|
|
478
544
|
def stop(self) -> None:
|
|
479
|
-
"""Signal the event loop to stop.
|
|
545
|
+
"""Signal the event loop to stop.
|
|
546
|
+
|
|
547
|
+
Also shuts down a bound cancel-callback HTTP server (if one was
|
|
548
|
+
started by :func:`from_bootstrap` for this participant).
|
|
549
|
+
"""
|
|
480
550
|
self._stopped = True
|
|
551
|
+
server = self._cancel_callback_server
|
|
552
|
+
if server is not None:
|
|
553
|
+
self._cancel_callback_server = None
|
|
554
|
+
try:
|
|
555
|
+
server.close()
|
|
556
|
+
except Exception:
|
|
557
|
+
logger.exception("cancel_callback server close failed")
|
|
558
|
+
|
|
559
|
+
def attach_cancel_callback_server(self, server: Any) -> None:
|
|
560
|
+
"""Attach a :class:`CancelCallbackServer` to this participant.
|
|
561
|
+
|
|
562
|
+
The server's lifetime is then tied to :meth:`stop` — the event
|
|
563
|
+
loop exit (or an incoming cancel POST that calls ``stop``) shuts
|
|
564
|
+
it down.
|
|
565
|
+
"""
|
|
566
|
+
self._cancel_callback_server = server
|
|
@@ -1,30 +1,38 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
+
import base64
|
|
4
|
+
import binascii
|
|
3
5
|
import json
|
|
4
6
|
import os
|
|
5
|
-
from dataclasses import dataclass, field
|
|
6
7
|
from typing import Any
|
|
7
8
|
|
|
8
9
|
from ..auth import AuthConfig
|
|
9
10
|
from ..client import MacpClient
|
|
10
11
|
from ..constants import DEFAULT_POLICY_VERSION
|
|
11
|
-
from .participant import Participant
|
|
12
|
+
from .participant import InitiatorConfig, Participant
|
|
12
13
|
|
|
14
|
+
__all__ = ["InitiatorConfig", "from_bootstrap"]
|
|
13
15
|
|
|
14
|
-
@dataclass
|
|
15
|
-
class InitiatorConfig:
|
|
16
|
-
"""Configuration for the initiator agent's SessionStart + kickoff."""
|
|
17
16
|
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
17
|
+
def _decode_extensions(raw: Any) -> dict[str, bytes]:
|
|
18
|
+
"""Coerce a bootstrap ``session_start.extensions`` map into ``dict[str, bytes]``.
|
|
19
|
+
|
|
20
|
+
The protobuf ``map<string, bytes>`` is JSON-encoded as base64 strings
|
|
21
|
+
(proto-JSON canonical), so try base64 first and fall back to raw
|
|
22
|
+
UTF-8 bytes for hand-authored bootstraps.
|
|
23
|
+
"""
|
|
24
|
+
if not isinstance(raw, dict):
|
|
25
|
+
return {}
|
|
26
|
+
decoded: dict[str, bytes] = {}
|
|
27
|
+
for key, value in raw.items():
|
|
28
|
+
if isinstance(value, bytes):
|
|
29
|
+
decoded[str(key)] = value
|
|
30
|
+
elif isinstance(value, str):
|
|
31
|
+
try:
|
|
32
|
+
decoded[str(key)] = base64.b64decode(value, validate=True)
|
|
33
|
+
except (binascii.Error, ValueError):
|
|
34
|
+
decoded[str(key)] = value.encode("utf-8")
|
|
35
|
+
return decoded
|
|
28
36
|
|
|
29
37
|
|
|
30
38
|
def from_bootstrap(bootstrap_path: str | None = None) -> Participant:
|
|
@@ -116,6 +124,7 @@ def from_bootstrap(bootstrap_path: str | None = None) -> Participant:
|
|
|
116
124
|
participants=[str(p) for p in ss.get("participants", participants)],
|
|
117
125
|
ttl_ms=int(ss.get("ttl_ms", 300000)),
|
|
118
126
|
context_id=str(ss.get("context_id", "")),
|
|
127
|
+
extensions=_decode_extensions(ss.get("extensions")),
|
|
119
128
|
roots=ss.get("roots"),
|
|
120
129
|
mode_version=_str_or("mode_version", mode_version),
|
|
121
130
|
configuration_version=_str_or("configuration_version", configuration_version),
|
|
@@ -126,7 +135,7 @@ def from_bootstrap(bootstrap_path: str | None = None) -> Participant:
|
|
|
126
135
|
kickoff_payload=kickoff.get("payload", {}) if kickoff else {},
|
|
127
136
|
)
|
|
128
137
|
|
|
129
|
-
|
|
138
|
+
participant = Participant(
|
|
130
139
|
participant_id=participant_id,
|
|
131
140
|
session_id=session_id,
|
|
132
141
|
mode=mode,
|
|
@@ -138,3 +147,44 @@ def from_bootstrap(bootstrap_path: str | None = None) -> Participant:
|
|
|
138
147
|
policy_version=str(policy_version) if policy_version else DEFAULT_POLICY_VERSION,
|
|
139
148
|
initiator_config=initiator_config,
|
|
140
149
|
)
|
|
150
|
+
|
|
151
|
+
_bind_cancel_callback(participant, ctx.get("cancel_callback"))
|
|
152
|
+
return participant
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _bind_cancel_callback(participant: Participant, raw: Any) -> None:
|
|
156
|
+
"""Start a cancel-callback HTTP server bound to ``participant.stop``.
|
|
157
|
+
|
|
158
|
+
Reads the bootstrap ``cancel_callback`` field (``{host, port, path}``)
|
|
159
|
+
and, if present, launches a daemon HTTP server that calls
|
|
160
|
+
``participant.stop()`` on POST. The server is attached to the
|
|
161
|
+
participant so its event-loop shutdown (or an incoming POST) tears
|
|
162
|
+
it down cleanly.
|
|
163
|
+
|
|
164
|
+
The bootstrap field is optional; callers that never set it see no
|
|
165
|
+
behavioural change. Reference: RFC-0001 §7.2 Option A, and the
|
|
166
|
+
TypeScript SDK's equivalent wiring in
|
|
167
|
+
``examples-service/src/example-agents/runtime/risk-decider.worker.ts``.
|
|
168
|
+
"""
|
|
169
|
+
if not isinstance(raw, dict):
|
|
170
|
+
return
|
|
171
|
+
host = str(raw.get("host") or "")
|
|
172
|
+
port = raw.get("port")
|
|
173
|
+
path = str(raw.get("path") or "")
|
|
174
|
+
if not host or port is None or not path:
|
|
175
|
+
return
|
|
176
|
+
|
|
177
|
+
# Local import so the stdlib ``http.server`` is paid for only when
|
|
178
|
+
# a bootstrap actually asks for a callback.
|
|
179
|
+
from .cancel_callback import start_cancel_callback_server
|
|
180
|
+
|
|
181
|
+
def _on_cancel(_run_id: str, _reason: str) -> None:
|
|
182
|
+
participant.stop()
|
|
183
|
+
|
|
184
|
+
server = start_cancel_callback_server(
|
|
185
|
+
host=host,
|
|
186
|
+
port=int(port),
|
|
187
|
+
path=path,
|
|
188
|
+
on_cancel=_on_cancel,
|
|
189
|
+
)
|
|
190
|
+
participant.attach_cancel_callback_server(server)
|
|
@@ -13,26 +13,31 @@ class AuthConfig:
|
|
|
13
13
|
reaches the wire. Per RFC-MACP-0004 §4 the runtime derives ``sender``
|
|
14
14
|
from authenticated identity, so mismatches are always rejected — this
|
|
15
15
|
check just surfaces the problem earlier and more clearly.
|
|
16
|
-
|
|
17
|
-
When ``expected_sender`` is ``None`` the check is skipped (preserves
|
|
18
|
-
legacy behaviour for dev/test flows that use a single shared identity).
|
|
19
16
|
"""
|
|
20
17
|
|
|
21
18
|
bearer_token: str | None = None
|
|
22
|
-
agent_id: str | None = None
|
|
23
19
|
sender_hint: str | None = None
|
|
24
20
|
expected_sender: str | None = None
|
|
25
21
|
|
|
26
22
|
def __post_init__(self) -> None:
|
|
27
|
-
if self.bearer_token
|
|
28
|
-
raise ValueError("
|
|
29
|
-
if not self.bearer_token and not self.agent_id:
|
|
30
|
-
raise ValueError("either bearer_token or agent_id is required")
|
|
23
|
+
if not self.bearer_token:
|
|
24
|
+
raise ValueError("bearer_token is required")
|
|
31
25
|
|
|
32
26
|
@classmethod
|
|
33
27
|
def for_dev_agent(cls, agent_id: str, *, expected_sender: str | None = None) -> AuthConfig:
|
|
28
|
+
"""Build an AuthConfig for local development without a real token.
|
|
29
|
+
|
|
30
|
+
Emits ``Authorization: Bearer <agent_id>``. The runtime's
|
|
31
|
+
``dev_authenticate`` fallback binds the bearer token value
|
|
32
|
+
verbatim as the authenticated sender, so passing the raw agent
|
|
33
|
+
id keeps participant lists like ``["coordinator", "alice"]``
|
|
34
|
+
working unchanged.
|
|
35
|
+
|
|
36
|
+
For production deployments issue real tokens via the auth
|
|
37
|
+
resolver chain and use :meth:`for_bearer` directly.
|
|
38
|
+
"""
|
|
34
39
|
return cls(
|
|
35
|
-
|
|
40
|
+
bearer_token=agent_id,
|
|
36
41
|
sender_hint=agent_id,
|
|
37
42
|
expected_sender=expected_sender or agent_id,
|
|
38
43
|
)
|
|
@@ -63,12 +68,7 @@ class AuthConfig:
|
|
|
63
68
|
|
|
64
69
|
@property
|
|
65
70
|
def sender(self) -> str | None:
|
|
66
|
-
return self.sender_hint
|
|
71
|
+
return self.sender_hint
|
|
67
72
|
|
|
68
73
|
def metadata(self) -> list[tuple[str, str]]:
|
|
69
|
-
|
|
70
|
-
if self.bearer_token:
|
|
71
|
-
headers.append(("authorization", f"Bearer {self.bearer_token}"))
|
|
72
|
-
if self.agent_id:
|
|
73
|
-
headers.append(("x-macp-agent-id", self.agent_id))
|
|
74
|
-
return headers
|
|
74
|
+
return [("authorization", f"Bearer {self.bearer_token}")]
|
|
@@ -24,6 +24,12 @@ from .errors import (
|
|
|
24
24
|
MacpTransportError,
|
|
25
25
|
)
|
|
26
26
|
|
|
27
|
+
# Public typing alias for inline stream-error callbacks. Parity with
|
|
28
|
+
# typescript-sdk's ``InlineErrorCallback``. Receives the protobuf
|
|
29
|
+
# ``StreamError`` (or a legacy error object) for application-level errors
|
|
30
|
+
# that do NOT tear down the stream — consumers typically log and continue.
|
|
31
|
+
InlineErrorCallback = Callable[[Any], None]
|
|
32
|
+
|
|
27
33
|
|
|
28
34
|
def _parse_ack_reasons(ack: object) -> list[str]:
|
|
29
35
|
"""Extract structured denial reasons from an ACK error's details."""
|
|
@@ -65,7 +71,7 @@ def _parse_grpc_metadata_reasons(rpc_error: grpc.RpcError) -> list[str]:
|
|
|
65
71
|
|
|
66
72
|
def _default_capabilities() -> core_pb2.Capabilities:
|
|
67
73
|
return core_pb2.Capabilities(
|
|
68
|
-
sessions=core_pb2.SessionsCapability(stream=True),
|
|
74
|
+
sessions=core_pb2.SessionsCapability(stream=True, list_sessions=True, watch_sessions=True),
|
|
69
75
|
cancellation=core_pb2.CancellationCapability(cancel_session=True),
|
|
70
76
|
progress=core_pb2.ProgressCapability(progress=True),
|
|
71
77
|
manifest=core_pb2.ManifestCapability(get_manifest=True),
|
|
@@ -91,7 +97,7 @@ class MacpStream:
|
|
|
91
97
|
self._requests: queue.Queue[object] = queue.Queue()
|
|
92
98
|
self._responses: queue.Queue[object] = queue.Queue()
|
|
93
99
|
self._closed = False
|
|
94
|
-
self._inline_error_callbacks: list[
|
|
100
|
+
self._inline_error_callbacks: list[InlineErrorCallback] = []
|
|
95
101
|
self._call = stub.StreamSession(self._request_iter(), metadata=metadata, timeout=timeout)
|
|
96
102
|
self._thread = threading.Thread(target=self._pump_responses, daemon=True)
|
|
97
103
|
self._thread.start()
|
|
@@ -142,7 +148,7 @@ class MacpStream:
|
|
|
142
148
|
finally:
|
|
143
149
|
self._responses.put(self._END)
|
|
144
150
|
|
|
145
|
-
def on_inline_error(self, callback:
|
|
151
|
+
def on_inline_error(self, callback: InlineErrorCallback) -> None:
|
|
146
152
|
"""Register a callback for inline application-level stream errors."""
|
|
147
153
|
self._inline_error_callbacks.append(callback)
|
|
148
154
|
|
|
@@ -207,7 +213,7 @@ class MacpClient:
|
|
|
207
213
|
root_certificates: bytes | None = None,
|
|
208
214
|
default_timeout: float | None = None,
|
|
209
215
|
client_name: str = "macp-sdk-python",
|
|
210
|
-
client_version: str = "0.
|
|
216
|
+
client_version: str = "0.3.0",
|
|
211
217
|
) -> None:
|
|
212
218
|
if secure is None:
|
|
213
219
|
secure = not allow_insecure
|
|
@@ -415,6 +421,54 @@ class MacpClient:
|
|
|
415
421
|
timeout=timeout or self.default_timeout,
|
|
416
422
|
)
|
|
417
423
|
|
|
424
|
+
def list_sessions(
|
|
425
|
+
self,
|
|
426
|
+
*,
|
|
427
|
+
auth: AuthConfig | None = None,
|
|
428
|
+
timeout: float | None = None,
|
|
429
|
+
) -> list[core_pb2.SessionMetadata]:
|
|
430
|
+
"""List all active sessions known to the runtime.
|
|
431
|
+
|
|
432
|
+
Returns the populated ``sessions`` repeated field of
|
|
433
|
+
``ListSessionsResponse`` as a plain ``list`` so callers don't have
|
|
434
|
+
to reach through the proto wrapper. Per runtime semantics each
|
|
435
|
+
entry includes ``context_id`` and ``extension_keys``.
|
|
436
|
+
"""
|
|
437
|
+
auth_cfg = self._require_auth(auth)
|
|
438
|
+
resp = self.stub.ListSessions(
|
|
439
|
+
core_pb2.ListSessionsRequest(),
|
|
440
|
+
metadata=self._metadata(auth_cfg),
|
|
441
|
+
timeout=timeout or self.default_timeout,
|
|
442
|
+
)
|
|
443
|
+
return list(resp.sessions)
|
|
444
|
+
|
|
445
|
+
def watch_sessions(
|
|
446
|
+
self,
|
|
447
|
+
*,
|
|
448
|
+
auth: AuthConfig | None = None,
|
|
449
|
+
timeout: float | None = None,
|
|
450
|
+
) -> Iterator[core_pb2.WatchSessionsResponse]:
|
|
451
|
+
"""Server-streaming RPC: yields session lifecycle events.
|
|
452
|
+
|
|
453
|
+
The runtime emits an initial ``EVENT_TYPE_CREATED`` frame for every
|
|
454
|
+
currently-open session, then live events as sessions transition
|
|
455
|
+
through ``CREATED``, ``RESOLVED``, and ``EXPIRED``. Each event
|
|
456
|
+
carries a full ``SessionMetadata`` (including ``context_id`` and
|
|
457
|
+
``extension_keys``), so callers can project run state without a
|
|
458
|
+
follow-up ``get_session``.
|
|
459
|
+
"""
|
|
460
|
+
logger.debug("watch_sessions starting")
|
|
461
|
+
auth_cfg = self._require_auth(auth)
|
|
462
|
+
call = self.stub.WatchSessions(
|
|
463
|
+
core_pb2.WatchSessionsRequest(),
|
|
464
|
+
metadata=self._metadata(auth_cfg),
|
|
465
|
+
timeout=timeout or self.default_timeout,
|
|
466
|
+
)
|
|
467
|
+
try:
|
|
468
|
+
yield from call
|
|
469
|
+
except grpc.RpcError as exc:
|
|
470
|
+
raise MacpTransportError(str(exc)) from exc
|
|
471
|
+
|
|
418
472
|
def register_ext_mode(
|
|
419
473
|
self,
|
|
420
474
|
descriptor: core_pb2.ModeDescriptor,
|
|
@@ -113,7 +113,11 @@ class QuorumProjection(BaseProjection):
|
|
|
113
113
|
def abstention_count(self, request_id: str) -> int:
|
|
114
114
|
return self._count_votes(request_id, "abstain")
|
|
115
115
|
|
|
116
|
-
def
|
|
116
|
+
def has_quorum(self, request_id: str) -> bool:
|
|
117
|
+
"""True if the approval count has reached the required threshold.
|
|
118
|
+
|
|
119
|
+
Canonical name across SDKs (parity with TypeScript ``hasQuorum``).
|
|
120
|
+
"""
|
|
117
121
|
req = self.requests.get(request_id)
|
|
118
122
|
if req is None:
|
|
119
123
|
return False
|
|
@@ -128,8 +132,16 @@ class QuorumProjection(BaseProjection):
|
|
|
128
132
|
return self.approval_count(request_id) + remaining < req.required_approvals
|
|
129
133
|
|
|
130
134
|
def commitment_ready(self, request_id: str) -> bool:
|
|
131
|
-
"""True if
|
|
132
|
-
|
|
135
|
+
"""True if quorum is reached and commit has not yet been issued.
|
|
136
|
+
|
|
137
|
+
Canonical cross-SDK semantics (parity with TypeScript
|
|
138
|
+
``QuorumProjection.commitmentReady``): ``has_quorum`` alone is
|
|
139
|
+
insufficient — once the session has moved to the ``Committed``
|
|
140
|
+
phase, the commit is no longer "ready" because it has already
|
|
141
|
+
happened. Callers that want the pure threshold-reached check
|
|
142
|
+
should use :meth:`has_quorum`.
|
|
143
|
+
"""
|
|
144
|
+
return self.has_quorum(request_id) and self.phase != "Committed"
|
|
133
145
|
|
|
134
146
|
def threshold(self, request_id: str) -> int:
|
|
135
147
|
"""Return the required approval count, or 0 if no request yet."""
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
+
import warnings
|
|
3
4
|
from dataclasses import dataclass
|
|
4
5
|
|
|
5
6
|
from macp.modes.task.v1 import task_pb2
|
|
@@ -213,7 +214,7 @@ class TaskSession(BaseSession):
|
|
|
213
214
|
assert isinstance(self.projection, TaskProjection)
|
|
214
215
|
return self.projection
|
|
215
216
|
|
|
216
|
-
def
|
|
217
|
+
def request_task(
|
|
217
218
|
self,
|
|
218
219
|
task_id: str,
|
|
219
220
|
title: str,
|
|
@@ -225,6 +226,9 @@ class TaskSession(BaseSession):
|
|
|
225
226
|
sender: str | None = None,
|
|
226
227
|
auth: AuthConfig | None = None,
|
|
227
228
|
) -> envelope_pb2.Ack:
|
|
229
|
+
"""Send a TaskRequest envelope. Canonical name across SDKs
|
|
230
|
+
(parity with TypeScript ``requestTask``).
|
|
231
|
+
"""
|
|
228
232
|
payload = task_pb2.TaskRequestPayload(
|
|
229
233
|
task_id=task_id,
|
|
230
234
|
title=title,
|
|
@@ -242,6 +246,15 @@ class TaskSession(BaseSession):
|
|
|
242
246
|
)
|
|
243
247
|
return self._send_and_track(envelope, auth=auth)
|
|
244
248
|
|
|
249
|
+
def request(self, *args: object, **kwargs: object) -> envelope_pb2.Ack:
|
|
250
|
+
"""Deprecated alias for :meth:`request_task`."""
|
|
251
|
+
warnings.warn(
|
|
252
|
+
"TaskSession.request() is deprecated; use request_task() instead.",
|
|
253
|
+
DeprecationWarning,
|
|
254
|
+
stacklevel=2,
|
|
255
|
+
)
|
|
256
|
+
return self.request_task(*args, **kwargs) # type: ignore[arg-type]
|
|
257
|
+
|
|
245
258
|
def accept_task(
|
|
246
259
|
self,
|
|
247
260
|
task_id: str,
|
|
@@ -288,7 +301,7 @@ class TaskSession(BaseSession):
|
|
|
288
301
|
)
|
|
289
302
|
return self._send_and_track(envelope, auth=auth)
|
|
290
303
|
|
|
291
|
-
def
|
|
304
|
+
def update_task(
|
|
292
305
|
self,
|
|
293
306
|
task_id: str,
|
|
294
307
|
*,
|
|
@@ -299,6 +312,9 @@ class TaskSession(BaseSession):
|
|
|
299
312
|
sender: str | None = None,
|
|
300
313
|
auth: AuthConfig | None = None,
|
|
301
314
|
) -> envelope_pb2.Ack:
|
|
315
|
+
"""Send a TaskUpdate envelope. Canonical name across SDKs
|
|
316
|
+
(parity with TypeScript ``updateTask``).
|
|
317
|
+
"""
|
|
302
318
|
payload = task_pb2.TaskUpdatePayload(
|
|
303
319
|
task_id=task_id,
|
|
304
320
|
status=status,
|
|
@@ -315,7 +331,16 @@ class TaskSession(BaseSession):
|
|
|
315
331
|
)
|
|
316
332
|
return self._send_and_track(envelope, auth=auth)
|
|
317
333
|
|
|
318
|
-
def
|
|
334
|
+
def update(self, *args: object, **kwargs: object) -> envelope_pb2.Ack:
|
|
335
|
+
"""Deprecated alias for :meth:`update_task`."""
|
|
336
|
+
warnings.warn(
|
|
337
|
+
"TaskSession.update() is deprecated; use update_task() instead.",
|
|
338
|
+
DeprecationWarning,
|
|
339
|
+
stacklevel=2,
|
|
340
|
+
)
|
|
341
|
+
return self.update_task(*args, **kwargs) # type: ignore[arg-type]
|
|
342
|
+
|
|
343
|
+
def complete_task(
|
|
319
344
|
self,
|
|
320
345
|
task_id: str,
|
|
321
346
|
*,
|
|
@@ -325,6 +350,9 @@ class TaskSession(BaseSession):
|
|
|
325
350
|
sender: str | None = None,
|
|
326
351
|
auth: AuthConfig | None = None,
|
|
327
352
|
) -> envelope_pb2.Ack:
|
|
353
|
+
"""Send a TaskComplete envelope. Canonical name across SDKs
|
|
354
|
+
(parity with TypeScript ``completeTask``).
|
|
355
|
+
"""
|
|
328
356
|
payload = task_pb2.TaskCompletePayload(
|
|
329
357
|
task_id=task_id,
|
|
330
358
|
assignee=assignee or self._sender_for(sender, auth=auth),
|
|
@@ -340,7 +368,16 @@ class TaskSession(BaseSession):
|
|
|
340
368
|
)
|
|
341
369
|
return self._send_and_track(envelope, auth=auth)
|
|
342
370
|
|
|
343
|
-
def
|
|
371
|
+
def complete(self, *args: object, **kwargs: object) -> envelope_pb2.Ack:
|
|
372
|
+
"""Deprecated alias for :meth:`complete_task`."""
|
|
373
|
+
warnings.warn(
|
|
374
|
+
"TaskSession.complete() is deprecated; use complete_task() instead.",
|
|
375
|
+
DeprecationWarning,
|
|
376
|
+
stacklevel=2,
|
|
377
|
+
)
|
|
378
|
+
return self.complete_task(*args, **kwargs) # type: ignore[arg-type]
|
|
379
|
+
|
|
380
|
+
def fail_task(
|
|
344
381
|
self,
|
|
345
382
|
task_id: str,
|
|
346
383
|
*,
|
|
@@ -351,6 +388,9 @@ class TaskSession(BaseSession):
|
|
|
351
388
|
sender: str | None = None,
|
|
352
389
|
auth: AuthConfig | None = None,
|
|
353
390
|
) -> envelope_pb2.Ack:
|
|
391
|
+
"""Send a TaskFail envelope. Canonical name across SDKs
|
|
392
|
+
(parity with TypeScript ``failTask``).
|
|
393
|
+
"""
|
|
354
394
|
payload = task_pb2.TaskFailPayload(
|
|
355
395
|
task_id=task_id,
|
|
356
396
|
assignee=assignee or self._sender_for(sender, auth=auth),
|
|
@@ -366,3 +406,12 @@ class TaskSession(BaseSession):
|
|
|
366
406
|
payload=serialize_message(payload),
|
|
367
407
|
)
|
|
368
408
|
return self._send_and_track(envelope, auth=auth)
|
|
409
|
+
|
|
410
|
+
def fail(self, *args: object, **kwargs: object) -> envelope_pb2.Ack:
|
|
411
|
+
"""Deprecated alias for :meth:`fail_task`."""
|
|
412
|
+
warnings.warn(
|
|
413
|
+
"TaskSession.fail() is deprecated; use fail_task() instead.",
|
|
414
|
+
DeprecationWarning,
|
|
415
|
+
stacklevel=2,
|
|
416
|
+
)
|
|
417
|
+
return self.fail_task(*args, **kwargs) # type: ignore[arg-type]
|
|
@@ -25,6 +25,38 @@ class PolicyChange:
|
|
|
25
25
|
observed_at_unix_ms: int = 0
|
|
26
26
|
|
|
27
27
|
|
|
28
|
+
@dataclass(slots=True)
|
|
29
|
+
class SessionLifecycle:
|
|
30
|
+
"""A single session lifecycle event from ``WatchSessions``.
|
|
31
|
+
|
|
32
|
+
Runtime event types (per ``SessionLifecycleEvent.EventType``): ``CREATED``
|
|
33
|
+
on SessionStart acceptance (also emitted for pre-existing sessions at
|
|
34
|
+
subscribe time), ``RESOLVED`` on mode-determined terminal outcome,
|
|
35
|
+
``EXPIRED`` on TTL expiry or explicit ``CancelSession``.
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
event_type: str = "UNSPECIFIED"
|
|
39
|
+
observed_at_unix_ms: int = 0
|
|
40
|
+
session: Any = None
|
|
41
|
+
|
|
42
|
+
@property
|
|
43
|
+
def is_created(self) -> bool:
|
|
44
|
+
return self.event_type == "CREATED"
|
|
45
|
+
|
|
46
|
+
@property
|
|
47
|
+
def is_resolved(self) -> bool:
|
|
48
|
+
return self.event_type == "RESOLVED"
|
|
49
|
+
|
|
50
|
+
@property
|
|
51
|
+
def is_expired(self) -> bool:
|
|
52
|
+
return self.event_type == "EXPIRED"
|
|
53
|
+
|
|
54
|
+
@property
|
|
55
|
+
def is_terminal(self) -> bool:
|
|
56
|
+
"""``True`` for RESOLVED or EXPIRED — the session won't emit more events."""
|
|
57
|
+
return self.event_type in ("RESOLVED", "EXPIRED")
|
|
58
|
+
|
|
59
|
+
|
|
28
60
|
class ModeRegistryWatcher:
|
|
29
61
|
"""Watch for mode registry changes from the runtime."""
|
|
30
62
|
|
|
@@ -96,6 +128,63 @@ class SignalWatcher:
|
|
|
96
128
|
raise RuntimeError("stream ended before receiving a signal")
|
|
97
129
|
|
|
98
130
|
|
|
131
|
+
_SESSION_EVENT_PREFIX = "EVENT_TYPE_"
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _session_event_name(event_type: int) -> str:
|
|
135
|
+
"""Map ``SessionLifecycleEvent.EventType`` enum ints to short string names.
|
|
136
|
+
|
|
137
|
+
The proto enum spells values as ``EVENT_TYPE_CREATED``; strip the
|
|
138
|
+
prefix so consumers can compare against ``"CREATED"`` without
|
|
139
|
+
importing the proto module.
|
|
140
|
+
"""
|
|
141
|
+
from macp.v1 import core_pb2
|
|
142
|
+
|
|
143
|
+
name = core_pb2.SessionLifecycleEvent.EventType.Name(event_type)
|
|
144
|
+
if name.startswith(_SESSION_EVENT_PREFIX):
|
|
145
|
+
return name[len(_SESSION_EVENT_PREFIX) :]
|
|
146
|
+
return name
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
class SessionLifecycleWatcher:
|
|
150
|
+
"""Watch for session lifecycle events from the runtime.
|
|
151
|
+
|
|
152
|
+
Wraps ``MacpClient.watch_sessions()`` and normalises each response into
|
|
153
|
+
a ``SessionLifecycle`` record carrying the event type as a short
|
|
154
|
+
string (``CREATED`` / ``RESOLVED`` / ``EXPIRED``) and the full
|
|
155
|
+
``SessionMetadata``. The runtime emits an initial CREATED event for
|
|
156
|
+
every already-open session at subscribe time, then live events
|
|
157
|
+
thereafter — see ``runtime/src/server.rs::watch_sessions``.
|
|
158
|
+
"""
|
|
159
|
+
|
|
160
|
+
def __init__(self, client: MacpClient, *, auth: AuthConfig | None = None) -> None:
|
|
161
|
+
self._client = client
|
|
162
|
+
self._auth = auth
|
|
163
|
+
|
|
164
|
+
def changes(self) -> Iterator[SessionLifecycle]:
|
|
165
|
+
"""Yield ``SessionLifecycle`` items from the runtime stream."""
|
|
166
|
+
for response in self._client.watch_sessions(auth=self._auth):
|
|
167
|
+
event = getattr(response, "event", None)
|
|
168
|
+
if event is None:
|
|
169
|
+
continue
|
|
170
|
+
yield SessionLifecycle(
|
|
171
|
+
event_type=_session_event_name(event.event_type),
|
|
172
|
+
observed_at_unix_ms=event.observed_at_unix_ms,
|
|
173
|
+
session=event.session,
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
def watch(self, handler: Callable[[SessionLifecycle], None]) -> None:
|
|
177
|
+
"""Block and invoke *handler* for each lifecycle event."""
|
|
178
|
+
for change in self.changes():
|
|
179
|
+
handler(change)
|
|
180
|
+
|
|
181
|
+
def next_change(self) -> SessionLifecycle:
|
|
182
|
+
"""Pull a single lifecycle event from the stream and return it."""
|
|
183
|
+
for change in self.changes():
|
|
184
|
+
return change
|
|
185
|
+
raise RuntimeError("stream ended before receiving a session lifecycle event")
|
|
186
|
+
|
|
187
|
+
|
|
99
188
|
class PolicyWatcher:
|
|
100
189
|
"""Watch for governance policy changes from the runtime."""
|
|
101
190
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: macp-sdk-python
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0
|
|
4
4
|
Summary: Python SDK for the MACP Rust runtime
|
|
5
5
|
Author-email: OpenAI <support@openai.com>
|
|
6
6
|
License: Apache-2.0
|
|
@@ -141,10 +141,13 @@ For local development against the runtime:
|
|
|
141
141
|
|
|
142
142
|
```bash
|
|
143
143
|
export MACP_ALLOW_INSECURE=1
|
|
144
|
-
export MACP_ALLOW_DEV_SENDER_HEADER=1
|
|
145
144
|
cargo run # in the runtime repo
|
|
146
145
|
```
|
|
147
146
|
|
|
147
|
+
Dev-agent auth rides the standard `Authorization: Bearer` header — the
|
|
148
|
+
runtime's `dev_authenticate` fallback binds the token value verbatim
|
|
149
|
+
as the sender.
|
|
150
|
+
|
|
148
151
|
## Documentation
|
|
149
152
|
|
|
150
153
|
Full docs available in `docs/` — build with `mkdocs serve` after `make setup`.
|
|
@@ -160,6 +163,8 @@ Business logic — voting rules, AI decision heuristics, policy enforcement —
|
|
|
160
163
|
|
|
161
164
|
## Known runtime limitations
|
|
162
165
|
|
|
163
|
-
- `GetSession` returns metadata only (not mode state/transcript) — hence the local projection pattern
|
|
166
|
+
- `GetSession` returns metadata only (not mode state/transcript) — hence the local projection pattern; use `StreamSession` + `send_subscribe` for the full transcript
|
|
164
167
|
- `StreamSession` is scoped to one session per stream; use `MacpStream.send_subscribe(session_id)` (RFC-MACP-0006-A1, since SDK 0.2.3 / `macp-proto 0.1.2`) to replay accepted history before live broadcast
|
|
168
|
+
- For cross-session observability, use `MacpClient.list_sessions()` and `SessionLifecycleWatcher` (SDK 0.3.0) to enumerate active sessions and stream `CREATED` / `RESOLVED` / `EXPIRED` lifecycle events
|
|
169
|
+
- `ListRoots` currently returns an empty list and `WatchRoots` idles — the runtime does not yet populate roots
|
|
165
170
|
- Business policy (majority, quorum, veto) belongs in your orchestrator/policy layer
|
|
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.2.3 → macp_sdk_python-0.3.0}/src/macp_sdk_python.egg-info/dependency_links.txt
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|