macp-sdk-python 0.2.3__tar.gz → 0.4.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.4.0}/PKG-INFO +11 -5
- {macp_sdk_python-0.2.3 → macp_sdk_python-0.4.0}/README.md +8 -2
- {macp_sdk_python-0.2.3 → macp_sdk_python-0.4.0}/pyproject.toml +11 -7
- {macp_sdk_python-0.2.3 → macp_sdk_python-0.4.0}/src/macp_sdk/__init__.py +8 -1
- {macp_sdk_python-0.2.3 → macp_sdk_python-0.4.0}/src/macp_sdk/agent/__init__.py +5 -1
- macp_sdk_python-0.4.0/src/macp_sdk/agent/cancel_callback.py +149 -0
- {macp_sdk_python-0.2.3 → macp_sdk_python-0.4.0}/src/macp_sdk/agent/participant.py +121 -35
- {macp_sdk_python-0.2.3 → macp_sdk_python-0.4.0}/src/macp_sdk/agent/runner.py +66 -16
- {macp_sdk_python-0.2.3 → macp_sdk_python-0.4.0}/src/macp_sdk/auth.py +16 -16
- {macp_sdk_python-0.2.3 → macp_sdk_python-0.4.0}/src/macp_sdk/base_session.py +17 -1
- {macp_sdk_python-0.2.3 → macp_sdk_python-0.4.0}/src/macp_sdk/client.py +126 -4
- {macp_sdk_python-0.2.3 → macp_sdk_python-0.4.0}/src/macp_sdk/envelope.py +23 -0
- {macp_sdk_python-0.2.3 → macp_sdk_python-0.4.0}/src/macp_sdk/quorum.py +15 -3
- {macp_sdk_python-0.2.3 → macp_sdk_python-0.4.0}/src/macp_sdk/task.py +53 -4
- {macp_sdk_python-0.2.3 → macp_sdk_python-0.4.0}/src/macp_sdk/watchers.py +112 -0
- {macp_sdk_python-0.2.3 → macp_sdk_python-0.4.0}/src/macp_sdk_python.egg-info/PKG-INFO +11 -5
- {macp_sdk_python-0.2.3 → macp_sdk_python-0.4.0}/src/macp_sdk_python.egg-info/SOURCES.txt +1 -0
- {macp_sdk_python-0.2.3 → macp_sdk_python-0.4.0}/src/macp_sdk_python.egg-info/requires.txt +2 -2
- {macp_sdk_python-0.2.3 → macp_sdk_python-0.4.0}/LICENSE +0 -0
- {macp_sdk_python-0.2.3 → macp_sdk_python-0.4.0}/setup.cfg +0 -0
- {macp_sdk_python-0.2.3 → macp_sdk_python-0.4.0}/src/macp_sdk/_logging.py +0 -0
- {macp_sdk_python-0.2.3 → macp_sdk_python-0.4.0}/src/macp_sdk/agent/dispatcher.py +0 -0
- {macp_sdk_python-0.2.3 → macp_sdk_python-0.4.0}/src/macp_sdk/agent/strategies.py +0 -0
- {macp_sdk_python-0.2.3 → macp_sdk_python-0.4.0}/src/macp_sdk/agent/transports.py +0 -0
- {macp_sdk_python-0.2.3 → macp_sdk_python-0.4.0}/src/macp_sdk/agent/types.py +0 -0
- {macp_sdk_python-0.2.3 → macp_sdk_python-0.4.0}/src/macp_sdk/base_projection.py +0 -0
- {macp_sdk_python-0.2.3 → macp_sdk_python-0.4.0}/src/macp_sdk/constants.py +0 -0
- {macp_sdk_python-0.2.3 → macp_sdk_python-0.4.0}/src/macp_sdk/decision.py +0 -0
- {macp_sdk_python-0.2.3 → macp_sdk_python-0.4.0}/src/macp_sdk/errors.py +0 -0
- {macp_sdk_python-0.2.3 → macp_sdk_python-0.4.0}/src/macp_sdk/handoff.py +0 -0
- {macp_sdk_python-0.2.3 → macp_sdk_python-0.4.0}/src/macp_sdk/policy.py +0 -0
- {macp_sdk_python-0.2.3 → macp_sdk_python-0.4.0}/src/macp_sdk/projections.py +0 -0
- {macp_sdk_python-0.2.3 → macp_sdk_python-0.4.0}/src/macp_sdk/proposal.py +0 -0
- {macp_sdk_python-0.2.3 → macp_sdk_python-0.4.0}/src/macp_sdk/proto_registry.py +0 -0
- {macp_sdk_python-0.2.3 → macp_sdk_python-0.4.0}/src/macp_sdk/py.typed +0 -0
- {macp_sdk_python-0.2.3 → macp_sdk_python-0.4.0}/src/macp_sdk/retry.py +0 -0
- {macp_sdk_python-0.2.3 → macp_sdk_python-0.4.0}/src/macp_sdk/validation.py +0 -0
- {macp_sdk_python-0.2.3 → macp_sdk_python-0.4.0}/src/macp_sdk_python.egg-info/dependency_links.txt +0 -0
- {macp_sdk_python-0.2.3 → macp_sdk_python-0.4.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.4.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
|
|
@@ -15,9 +15,9 @@ Classifier: Topic :: Software Development :: Libraries
|
|
|
15
15
|
Requires-Python: >=3.11
|
|
16
16
|
Description-Content-Type: text/markdown
|
|
17
17
|
License-File: LICENSE
|
|
18
|
-
Requires-Dist: grpcio>=1.
|
|
18
|
+
Requires-Dist: grpcio>=1.81.1
|
|
19
19
|
Requires-Dist: protobuf>=5.27.0
|
|
20
|
-
Requires-Dist: macp-proto<0.2.0,>=0.1.
|
|
20
|
+
Requires-Dist: macp-proto<0.2.0,>=0.1.3
|
|
21
21
|
Provides-Extra: dev
|
|
22
22
|
Requires-Dist: grpcio-tools>=1.74.0; extra == "dev"
|
|
23
23
|
Requires-Dist: pytest>=8.0; extra == "dev"
|
|
@@ -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,9 @@ 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` / `CANCELLED` / `SUSPENDED` / `RESUMED` lifecycle events
|
|
169
|
+
- Pause and cancel sessions with `MacpClient.suspend_session()` / `resume_session()` / `cancel_session()` (SDK 0.4.0 / `macp-proto 0.1.3`). A suspended session is non-terminal and rejects messages until resumed; an accepted `cancel_session()` now terminates as `CANCELLED` (previously surfaced as `EXPIRED`)
|
|
170
|
+
- `ListRoots` currently returns an empty list and `WatchRoots` idles — the runtime does not yet populate roots
|
|
165
171
|
- 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,9 @@ 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` / `CANCELLED` / `SUSPENDED` / `RESUMED` lifecycle events
|
|
135
|
+
- Pause and cancel sessions with `MacpClient.suspend_session()` / `resume_session()` / `cancel_session()` (SDK 0.4.0 / `macp-proto 0.1.3`). A suspended session is non-terminal and rejects messages until resumed; an accepted `cancel_session()` now terminates as `CANCELLED` (previously surfaced as `EXPIRED`)
|
|
136
|
+
- `ListRoots` currently returns an empty list and `WatchRoots` idles — the runtime does not yet populate roots
|
|
131
137
|
- Business policy (majority, quorum, veto) belongs in your orchestrator/policy layer
|
|
@@ -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.4.0"
|
|
8
8
|
description = "Python SDK for the MACP Rust runtime"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.11"
|
|
@@ -23,14 +23,18 @@ classifiers = [
|
|
|
23
23
|
"Topic :: Software Development :: Libraries",
|
|
24
24
|
]
|
|
25
25
|
dependencies = [
|
|
26
|
-
|
|
26
|
+
# macp-proto 0.1.3's generated gRPC stubs require grpcio>=1.81.1.
|
|
27
|
+
"grpcio>=1.81.1",
|
|
27
28
|
"protobuf>=5.27.0",
|
|
28
|
-
# Requires 0.1.
|
|
29
|
+
# Requires 0.1.3+ for the suspend/cancel/supersede surface: SuspendSession
|
|
30
|
+
# /ResumeSession RPCs, SESSION_STATE_SUSPENDED/CANCELLED, the SUSPENDED/
|
|
31
|
+
# RESUMED/CANCELLED lifecycle events, and CommitmentPayload.supersedes
|
|
32
|
+
# (CommitmentRef). 0.1.2 added the RFC-MACP-0006-A1 subscribe_session_id /
|
|
29
33
|
# after_sequence fields on StreamSessionRequest (used by MacpStream
|
|
30
|
-
# .send_subscribe and the gRPC agent transport adapter)
|
|
31
|
-
#
|
|
32
|
-
#
|
|
33
|
-
"macp-proto>=0.1.
|
|
34
|
+
# .send_subscribe and the gRPC agent transport adapter); 0.1.1 is the
|
|
35
|
+
# earlier baseline for context_id / extensions on SessionStartPayload.
|
|
36
|
+
# See docs/contributing.md "Bumping macp-proto".
|
|
37
|
+
"macp-proto>=0.1.3,<0.2.0",
|
|
34
38
|
]
|
|
35
39
|
|
|
36
40
|
[project.optional-dependencies]
|
|
@@ -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,
|
|
@@ -19,6 +19,7 @@ from .constants import (
|
|
|
19
19
|
from .decision import DecisionSession
|
|
20
20
|
from .envelope import (
|
|
21
21
|
build_commitment_payload,
|
|
22
|
+
build_commitment_ref,
|
|
22
23
|
build_envelope,
|
|
23
24
|
build_progress_payload,
|
|
24
25
|
build_root,
|
|
@@ -100,6 +101,8 @@ from .watchers import (
|
|
|
100
101
|
PolicyChange,
|
|
101
102
|
PolicyWatcher,
|
|
102
103
|
RootsWatcher,
|
|
104
|
+
SessionLifecycle,
|
|
105
|
+
SessionLifecycleWatcher,
|
|
103
106
|
SignalWatcher,
|
|
104
107
|
)
|
|
105
108
|
|
|
@@ -145,6 +148,7 @@ __all__ = [
|
|
|
145
148
|
"HandoffProjection",
|
|
146
149
|
"HandoffRecord",
|
|
147
150
|
"HandoffSession",
|
|
151
|
+
"InlineErrorCallback",
|
|
148
152
|
"MacpAckError",
|
|
149
153
|
"MacpClient",
|
|
150
154
|
"MacpIdentityMismatchError",
|
|
@@ -169,6 +173,8 @@ __all__ = [
|
|
|
169
173
|
"RejectionRules",
|
|
170
174
|
"RetryPolicy",
|
|
171
175
|
"RootsWatcher",
|
|
176
|
+
"SessionLifecycle",
|
|
177
|
+
"SessionLifecycleWatcher",
|
|
172
178
|
"SignalWatcher",
|
|
173
179
|
"TaskAssignmentRules",
|
|
174
180
|
"TaskCompletionRules",
|
|
@@ -176,6 +182,7 @@ __all__ = [
|
|
|
176
182
|
"TaskSession",
|
|
177
183
|
"VotingRules",
|
|
178
184
|
"build_commitment_payload",
|
|
185
|
+
"build_commitment_ref",
|
|
179
186
|
"build_decision_policy",
|
|
180
187
|
"build_envelope",
|
|
181
188
|
"build_handoff_policy",
|
|
@@ -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)
|