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.
Files changed (39) hide show
  1. {macp_sdk_python-0.2.3 → macp_sdk_python-0.3.0}/PKG-INFO +8 -3
  2. {macp_sdk_python-0.2.3 → macp_sdk_python-0.3.0}/README.md +7 -2
  3. {macp_sdk_python-0.2.3 → macp_sdk_python-0.3.0}/pyproject.toml +1 -1
  4. {macp_sdk_python-0.2.3 → macp_sdk_python-0.3.0}/src/macp_sdk/__init__.py +6 -1
  5. {macp_sdk_python-0.2.3 → macp_sdk_python-0.3.0}/src/macp_sdk/agent/__init__.py +5 -1
  6. macp_sdk_python-0.3.0/src/macp_sdk/agent/cancel_callback.py +149 -0
  7. {macp_sdk_python-0.2.3 → macp_sdk_python-0.3.0}/src/macp_sdk/agent/participant.py +121 -35
  8. {macp_sdk_python-0.2.3 → macp_sdk_python-0.3.0}/src/macp_sdk/agent/runner.py +66 -16
  9. {macp_sdk_python-0.2.3 → macp_sdk_python-0.3.0}/src/macp_sdk/auth.py +16 -16
  10. {macp_sdk_python-0.2.3 → macp_sdk_python-0.3.0}/src/macp_sdk/client.py +58 -4
  11. {macp_sdk_python-0.2.3 → macp_sdk_python-0.3.0}/src/macp_sdk/quorum.py +15 -3
  12. {macp_sdk_python-0.2.3 → macp_sdk_python-0.3.0}/src/macp_sdk/task.py +53 -4
  13. {macp_sdk_python-0.2.3 → macp_sdk_python-0.3.0}/src/macp_sdk/watchers.py +89 -0
  14. {macp_sdk_python-0.2.3 → macp_sdk_python-0.3.0}/src/macp_sdk_python.egg-info/PKG-INFO +8 -3
  15. {macp_sdk_python-0.2.3 → macp_sdk_python-0.3.0}/src/macp_sdk_python.egg-info/SOURCES.txt +1 -0
  16. {macp_sdk_python-0.2.3 → macp_sdk_python-0.3.0}/LICENSE +0 -0
  17. {macp_sdk_python-0.2.3 → macp_sdk_python-0.3.0}/setup.cfg +0 -0
  18. {macp_sdk_python-0.2.3 → macp_sdk_python-0.3.0}/src/macp_sdk/_logging.py +0 -0
  19. {macp_sdk_python-0.2.3 → macp_sdk_python-0.3.0}/src/macp_sdk/agent/dispatcher.py +0 -0
  20. {macp_sdk_python-0.2.3 → macp_sdk_python-0.3.0}/src/macp_sdk/agent/strategies.py +0 -0
  21. {macp_sdk_python-0.2.3 → macp_sdk_python-0.3.0}/src/macp_sdk/agent/transports.py +0 -0
  22. {macp_sdk_python-0.2.3 → macp_sdk_python-0.3.0}/src/macp_sdk/agent/types.py +0 -0
  23. {macp_sdk_python-0.2.3 → macp_sdk_python-0.3.0}/src/macp_sdk/base_projection.py +0 -0
  24. {macp_sdk_python-0.2.3 → macp_sdk_python-0.3.0}/src/macp_sdk/base_session.py +0 -0
  25. {macp_sdk_python-0.2.3 → macp_sdk_python-0.3.0}/src/macp_sdk/constants.py +0 -0
  26. {macp_sdk_python-0.2.3 → macp_sdk_python-0.3.0}/src/macp_sdk/decision.py +0 -0
  27. {macp_sdk_python-0.2.3 → macp_sdk_python-0.3.0}/src/macp_sdk/envelope.py +0 -0
  28. {macp_sdk_python-0.2.3 → macp_sdk_python-0.3.0}/src/macp_sdk/errors.py +0 -0
  29. {macp_sdk_python-0.2.3 → macp_sdk_python-0.3.0}/src/macp_sdk/handoff.py +0 -0
  30. {macp_sdk_python-0.2.3 → macp_sdk_python-0.3.0}/src/macp_sdk/policy.py +0 -0
  31. {macp_sdk_python-0.2.3 → macp_sdk_python-0.3.0}/src/macp_sdk/projections.py +0 -0
  32. {macp_sdk_python-0.2.3 → macp_sdk_python-0.3.0}/src/macp_sdk/proposal.py +0 -0
  33. {macp_sdk_python-0.2.3 → macp_sdk_python-0.3.0}/src/macp_sdk/proto_registry.py +0 -0
  34. {macp_sdk_python-0.2.3 → macp_sdk_python-0.3.0}/src/macp_sdk/py.typed +0 -0
  35. {macp_sdk_python-0.2.3 → macp_sdk_python-0.3.0}/src/macp_sdk/retry.py +0 -0
  36. {macp_sdk_python-0.2.3 → macp_sdk_python-0.3.0}/src/macp_sdk/validation.py +0 -0
  37. {macp_sdk_python-0.2.3 → macp_sdk_python-0.3.0}/src/macp_sdk_python.egg-info/dependency_links.txt +0 -0
  38. {macp_sdk_python-0.2.3 → macp_sdk_python-0.3.0}/src/macp_sdk_python.egg-info/requires.txt +0 -0
  39. {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.2.3
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
@@ -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.3.0"
8
8
  description = "Python SDK for the MACP Rust runtime"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
@@ -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: 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)
@@ -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 and self.agent_id:
28
- raise ValueError("choose either bearer_token or agent_id, not both")
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
- agent_id=agent_id,
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 or self.agent_id
71
+ return self.sender_hint
67
72
 
68
73
  def metadata(self) -> list[tuple[str, str]]:
69
- headers: list[tuple[str, str]] = []
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[Callable[[Any], None]] = []
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: Callable[[Any], None]) -> None:
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.2.3",
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 is_threshold_reached(self, request_id: str) -> bool:
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 the threshold is reached."""
132
- return self.is_threshold_reached(request_id)
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 request(
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 update(
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 complete(
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 fail(
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.2.3
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
@@ -23,6 +23,7 @@ src/macp_sdk/task.py
23
23
  src/macp_sdk/validation.py
24
24
  src/macp_sdk/watchers.py
25
25
  src/macp_sdk/agent/__init__.py
26
+ src/macp_sdk/agent/cancel_callback.py
26
27
  src/macp_sdk/agent/dispatcher.py
27
28
  src/macp_sdk/agent/participant.py
28
29
  src/macp_sdk/agent/runner.py
File without changes