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.
Files changed (39) hide show
  1. {macp_sdk_python-0.2.3 → macp_sdk_python-0.4.0}/PKG-INFO +11 -5
  2. {macp_sdk_python-0.2.3 → macp_sdk_python-0.4.0}/README.md +8 -2
  3. {macp_sdk_python-0.2.3 → macp_sdk_python-0.4.0}/pyproject.toml +11 -7
  4. {macp_sdk_python-0.2.3 → macp_sdk_python-0.4.0}/src/macp_sdk/__init__.py +8 -1
  5. {macp_sdk_python-0.2.3 → macp_sdk_python-0.4.0}/src/macp_sdk/agent/__init__.py +5 -1
  6. macp_sdk_python-0.4.0/src/macp_sdk/agent/cancel_callback.py +149 -0
  7. {macp_sdk_python-0.2.3 → macp_sdk_python-0.4.0}/src/macp_sdk/agent/participant.py +121 -35
  8. {macp_sdk_python-0.2.3 → macp_sdk_python-0.4.0}/src/macp_sdk/agent/runner.py +66 -16
  9. {macp_sdk_python-0.2.3 → macp_sdk_python-0.4.0}/src/macp_sdk/auth.py +16 -16
  10. {macp_sdk_python-0.2.3 → macp_sdk_python-0.4.0}/src/macp_sdk/base_session.py +17 -1
  11. {macp_sdk_python-0.2.3 → macp_sdk_python-0.4.0}/src/macp_sdk/client.py +126 -4
  12. {macp_sdk_python-0.2.3 → macp_sdk_python-0.4.0}/src/macp_sdk/envelope.py +23 -0
  13. {macp_sdk_python-0.2.3 → macp_sdk_python-0.4.0}/src/macp_sdk/quorum.py +15 -3
  14. {macp_sdk_python-0.2.3 → macp_sdk_python-0.4.0}/src/macp_sdk/task.py +53 -4
  15. {macp_sdk_python-0.2.3 → macp_sdk_python-0.4.0}/src/macp_sdk/watchers.py +112 -0
  16. {macp_sdk_python-0.2.3 → macp_sdk_python-0.4.0}/src/macp_sdk_python.egg-info/PKG-INFO +11 -5
  17. {macp_sdk_python-0.2.3 → macp_sdk_python-0.4.0}/src/macp_sdk_python.egg-info/SOURCES.txt +1 -0
  18. {macp_sdk_python-0.2.3 → macp_sdk_python-0.4.0}/src/macp_sdk_python.egg-info/requires.txt +2 -2
  19. {macp_sdk_python-0.2.3 → macp_sdk_python-0.4.0}/LICENSE +0 -0
  20. {macp_sdk_python-0.2.3 → macp_sdk_python-0.4.0}/setup.cfg +0 -0
  21. {macp_sdk_python-0.2.3 → macp_sdk_python-0.4.0}/src/macp_sdk/_logging.py +0 -0
  22. {macp_sdk_python-0.2.3 → macp_sdk_python-0.4.0}/src/macp_sdk/agent/dispatcher.py +0 -0
  23. {macp_sdk_python-0.2.3 → macp_sdk_python-0.4.0}/src/macp_sdk/agent/strategies.py +0 -0
  24. {macp_sdk_python-0.2.3 → macp_sdk_python-0.4.0}/src/macp_sdk/agent/transports.py +0 -0
  25. {macp_sdk_python-0.2.3 → macp_sdk_python-0.4.0}/src/macp_sdk/agent/types.py +0 -0
  26. {macp_sdk_python-0.2.3 → macp_sdk_python-0.4.0}/src/macp_sdk/base_projection.py +0 -0
  27. {macp_sdk_python-0.2.3 → macp_sdk_python-0.4.0}/src/macp_sdk/constants.py +0 -0
  28. {macp_sdk_python-0.2.3 → macp_sdk_python-0.4.0}/src/macp_sdk/decision.py +0 -0
  29. {macp_sdk_python-0.2.3 → macp_sdk_python-0.4.0}/src/macp_sdk/errors.py +0 -0
  30. {macp_sdk_python-0.2.3 → macp_sdk_python-0.4.0}/src/macp_sdk/handoff.py +0 -0
  31. {macp_sdk_python-0.2.3 → macp_sdk_python-0.4.0}/src/macp_sdk/policy.py +0 -0
  32. {macp_sdk_python-0.2.3 → macp_sdk_python-0.4.0}/src/macp_sdk/projections.py +0 -0
  33. {macp_sdk_python-0.2.3 → macp_sdk_python-0.4.0}/src/macp_sdk/proposal.py +0 -0
  34. {macp_sdk_python-0.2.3 → macp_sdk_python-0.4.0}/src/macp_sdk/proto_registry.py +0 -0
  35. {macp_sdk_python-0.2.3 → macp_sdk_python-0.4.0}/src/macp_sdk/py.typed +0 -0
  36. {macp_sdk_python-0.2.3 → macp_sdk_python-0.4.0}/src/macp_sdk/retry.py +0 -0
  37. {macp_sdk_python-0.2.3 → macp_sdk_python-0.4.0}/src/macp_sdk/validation.py +0 -0
  38. {macp_sdk_python-0.2.3 → macp_sdk_python-0.4.0}/src/macp_sdk_python.egg-info/dependency_links.txt +0 -0
  39. {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.2.3
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.74.0
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.2
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.2.3"
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
- "grpcio>=1.74.0",
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.2+ for the RFC-MACP-0006-A1 subscribe_session_id /
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). 0.1.1 is
31
- # the earlier baseline for the context_id / extensions fields on
32
- # SessionStartPayload. See docs/contributing.md "Bumping macp-proto".
33
- "macp-proto>=0.1.2,<0.2.0",
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: Any | None = None,
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: update projection, dispatch handlers."""
357
- # Update the projection if available
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
- # Check for phase changes
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 transport)."""
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
- if message.message_type == "Commitment":
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 message.message_type == "SessionCancel":
402
- result = TerminalResult(state="Cancelled")
403
- self._dispatcher.dispatch_terminal(result)
404
- self._stopped = True
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
- self._dispatcher.dispatch(message, ctx)
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
- intent: str
19
- participants: list[str]
20
- ttl_ms: int
21
- context_id: str = ""
22
- roots: list[dict[str, str]] | None = None
23
- mode_version: str | None = None
24
- configuration_version: str | None = None
25
- policy_version: str | None = None
26
- kickoff_message_type: str | None = None
27
- kickoff_payload: dict[str, Any] = field(default_factory=dict)
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
- return Participant(
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)