macp-sdk-python 0.2.1__tar.gz → 0.2.3__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.1/src/macp_sdk_python.egg-info → macp_sdk_python-0.2.3}/PKG-INFO +3 -3
  2. {macp_sdk_python-0.2.1 → macp_sdk_python-0.2.3}/README.md +1 -1
  3. {macp_sdk_python-0.2.1 → macp_sdk_python-0.2.3}/pyproject.toml +7 -5
  4. {macp_sdk_python-0.2.1 → macp_sdk_python-0.2.3}/src/macp_sdk/agent/participant.py +80 -4
  5. macp_sdk_python-0.2.3/src/macp_sdk/agent/runner.py +140 -0
  6. {macp_sdk_python-0.2.1 → macp_sdk_python-0.2.3}/src/macp_sdk/agent/transports.py +23 -6
  7. {macp_sdk_python-0.2.1 → macp_sdk_python-0.2.3}/src/macp_sdk/base_session.py +4 -2
  8. {macp_sdk_python-0.2.1 → macp_sdk_python-0.2.3}/src/macp_sdk/client.py +42 -17
  9. {macp_sdk_python-0.2.1 → macp_sdk_python-0.2.3}/src/macp_sdk/envelope.py +4 -13
  10. {macp_sdk_python-0.2.1 → macp_sdk_python-0.2.3/src/macp_sdk_python.egg-info}/PKG-INFO +3 -3
  11. {macp_sdk_python-0.2.1 → macp_sdk_python-0.2.3}/src/macp_sdk_python.egg-info/requires.txt +1 -1
  12. macp_sdk_python-0.2.1/src/macp_sdk/agent/runner.py +0 -100
  13. {macp_sdk_python-0.2.1 → macp_sdk_python-0.2.3}/LICENSE +0 -0
  14. {macp_sdk_python-0.2.1 → macp_sdk_python-0.2.3}/setup.cfg +0 -0
  15. {macp_sdk_python-0.2.1 → macp_sdk_python-0.2.3}/src/macp_sdk/__init__.py +0 -0
  16. {macp_sdk_python-0.2.1 → macp_sdk_python-0.2.3}/src/macp_sdk/_logging.py +0 -0
  17. {macp_sdk_python-0.2.1 → macp_sdk_python-0.2.3}/src/macp_sdk/agent/__init__.py +0 -0
  18. {macp_sdk_python-0.2.1 → macp_sdk_python-0.2.3}/src/macp_sdk/agent/dispatcher.py +0 -0
  19. {macp_sdk_python-0.2.1 → macp_sdk_python-0.2.3}/src/macp_sdk/agent/strategies.py +0 -0
  20. {macp_sdk_python-0.2.1 → macp_sdk_python-0.2.3}/src/macp_sdk/agent/types.py +0 -0
  21. {macp_sdk_python-0.2.1 → macp_sdk_python-0.2.3}/src/macp_sdk/auth.py +0 -0
  22. {macp_sdk_python-0.2.1 → macp_sdk_python-0.2.3}/src/macp_sdk/base_projection.py +0 -0
  23. {macp_sdk_python-0.2.1 → macp_sdk_python-0.2.3}/src/macp_sdk/constants.py +0 -0
  24. {macp_sdk_python-0.2.1 → macp_sdk_python-0.2.3}/src/macp_sdk/decision.py +0 -0
  25. {macp_sdk_python-0.2.1 → macp_sdk_python-0.2.3}/src/macp_sdk/errors.py +0 -0
  26. {macp_sdk_python-0.2.1 → macp_sdk_python-0.2.3}/src/macp_sdk/handoff.py +0 -0
  27. {macp_sdk_python-0.2.1 → macp_sdk_python-0.2.3}/src/macp_sdk/policy.py +0 -0
  28. {macp_sdk_python-0.2.1 → macp_sdk_python-0.2.3}/src/macp_sdk/projections.py +0 -0
  29. {macp_sdk_python-0.2.1 → macp_sdk_python-0.2.3}/src/macp_sdk/proposal.py +0 -0
  30. {macp_sdk_python-0.2.1 → macp_sdk_python-0.2.3}/src/macp_sdk/proto_registry.py +0 -0
  31. {macp_sdk_python-0.2.1 → macp_sdk_python-0.2.3}/src/macp_sdk/py.typed +0 -0
  32. {macp_sdk_python-0.2.1 → macp_sdk_python-0.2.3}/src/macp_sdk/quorum.py +0 -0
  33. {macp_sdk_python-0.2.1 → macp_sdk_python-0.2.3}/src/macp_sdk/retry.py +0 -0
  34. {macp_sdk_python-0.2.1 → macp_sdk_python-0.2.3}/src/macp_sdk/task.py +0 -0
  35. {macp_sdk_python-0.2.1 → macp_sdk_python-0.2.3}/src/macp_sdk/validation.py +0 -0
  36. {macp_sdk_python-0.2.1 → macp_sdk_python-0.2.3}/src/macp_sdk/watchers.py +0 -0
  37. {macp_sdk_python-0.2.1 → macp_sdk_python-0.2.3}/src/macp_sdk_python.egg-info/SOURCES.txt +0 -0
  38. {macp_sdk_python-0.2.1 → macp_sdk_python-0.2.3}/src/macp_sdk_python.egg-info/dependency_links.txt +0 -0
  39. {macp_sdk_python-0.2.1 → macp_sdk_python-0.2.3}/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.1
3
+ Version: 0.2.3
4
4
  Summary: Python SDK for the MACP Rust runtime
5
5
  Author-email: OpenAI <support@openai.com>
6
6
  License: Apache-2.0
@@ -17,7 +17,7 @@ Description-Content-Type: text/markdown
17
17
  License-File: LICENSE
18
18
  Requires-Dist: grpcio>=1.74.0
19
19
  Requires-Dist: protobuf>=5.27.0
20
- Requires-Dist: macp-proto<0.2.0,>=0.1.0
20
+ Requires-Dist: macp-proto<0.2.0,>=0.1.2
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"
@@ -161,5 +161,5 @@ Business logic — voting rules, AI decision heuristics, policy enforcement —
161
161
  ## Known runtime limitations
162
162
 
163
163
  - `GetSession` returns metadata only (not mode state/transcript) — hence the local projection pattern
164
- - `StreamSession` has no late-attach handshake for already-running sessions
164
+ - `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
165
165
  - Business policy (majority, quorum, veto) belongs in your orchestrator/policy layer
@@ -127,5 +127,5 @@ Business logic — voting rules, AI decision heuristics, policy enforcement —
127
127
  ## Known runtime limitations
128
128
 
129
129
  - `GetSession` returns metadata only (not mode state/transcript) — hence the local projection pattern
130
- - `StreamSession` has no late-attach handshake for already-running sessions
130
+ - `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
131
131
  - 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.1"
7
+ version = "0.2.3"
8
8
  description = "Python SDK for the MACP Rust runtime"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
@@ -25,10 +25,12 @@ classifiers = [
25
25
  dependencies = [
26
26
  "grpcio>=1.74.0",
27
27
  "protobuf>=5.27.0",
28
- # Tested against 0.1.x; narrow the upper bound so a hypothetical 0.2.0
29
- # proto release doesn't silently land without the conformance suite
30
- # being re-run. See docs/contributing.md "Bumping macp-proto".
31
- "macp-proto>=0.1.0,<0.2.0",
28
+ # Requires 0.1.2+ for the RFC-MACP-0006-A1 subscribe_session_id /
29
+ # 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",
32
34
  ]
33
35
 
34
36
  [project.optional-dependencies]
@@ -70,6 +70,48 @@ class ParticipantActions:
70
70
  """Cancel the session."""
71
71
  return self._client.cancel_session(self._session_id, reason=reason, auth=self._auth)
72
72
 
73
+ def start_session(
74
+ self,
75
+ intent: str,
76
+ participants: list[str],
77
+ ttl_ms: int,
78
+ context_id: str = "",
79
+ extensions: dict[str, bytes] | None = None,
80
+ mode_version: str | None = None,
81
+ configuration_version: str | None = None,
82
+ policy_version: str | None = None,
83
+ ) -> Any:
84
+ """Send a SessionStart envelope to open the session."""
85
+ from ..constants import (
86
+ DEFAULT_CONFIGURATION_VERSION,
87
+ DEFAULT_MODE_VERSION,
88
+ DEFAULT_POLICY_VERSION,
89
+ )
90
+ from ..envelope import (
91
+ build_envelope,
92
+ build_session_start_payload,
93
+ serialize_message,
94
+ )
95
+
96
+ payload = build_session_start_payload(
97
+ intent=intent,
98
+ participants=participants,
99
+ ttl_ms=ttl_ms,
100
+ context_id=context_id,
101
+ extensions=extensions,
102
+ mode_version=mode_version or DEFAULT_MODE_VERSION,
103
+ configuration_version=configuration_version or DEFAULT_CONFIGURATION_VERSION,
104
+ policy_version=policy_version or DEFAULT_POLICY_VERSION,
105
+ )
106
+ envelope = build_envelope(
107
+ mode=self._mode,
108
+ message_type="SessionStart",
109
+ session_id=self._session_id,
110
+ sender=self._participant_id,
111
+ payload=serialize_message(payload),
112
+ )
113
+ return self.send_envelope(envelope)
114
+
73
115
  def evaluate(
74
116
  self,
75
117
  proposal_id: str,
@@ -222,6 +264,7 @@ class Participant:
222
264
  configuration_version: str | None = None,
223
265
  policy_version: str | None = None,
224
266
  transport: TransportAdapter | None = None,
267
+ initiator_config: Any | None = None,
225
268
  ) -> None:
226
269
  self._participant_id = participant_id
227
270
  self._session_id = session_id
@@ -229,6 +272,7 @@ class Participant:
229
272
  self._client = client
230
273
  self._auth = auth
231
274
  self._stopped = False
275
+ self._initiator_config = initiator_config
232
276
 
233
277
  self._dispatcher = Dispatcher()
234
278
  self._session = SessionInfo(
@@ -365,19 +409,23 @@ class Participant:
365
409
  def run(self) -> None:
366
410
  """Enter the blocking event loop.
367
411
 
368
- If a :class:`TransportAdapter` was provided, it is used to receive
369
- messages. Otherwise a gRPC ``StreamSession`` is opened.
412
+ If this participant is the initiator (``initiator_config`` is set),
413
+ emits SessionStart + kickoff before opening the stream.
370
414
 
371
415
  Dispatches received events to registered handlers until the session
372
416
  reaches a terminal state or ``stop()`` is called.
373
417
  """
374
418
  logger.info(
375
- "participant %s joining session %s (mode=%s)",
419
+ "participant %s joining session %s (mode=%s, initiator=%s)",
376
420
  self._participant_id,
377
421
  self._session_id,
378
422
  self._mode,
423
+ self._initiator_config is not None,
379
424
  )
380
425
 
426
+ if self._initiator_config is not None:
427
+ self._emit_initiator_envelopes()
428
+
381
429
  transport = self._transport or GrpcTransportAdapter(
382
430
  self._client,
383
431
  self._session_id,
@@ -387,7 +435,6 @@ class Participant:
387
435
  for message in transport.start():
388
436
  if self._stopped:
389
437
  break
390
- # If the transport yields raw envelopes (gRPC), process as envelope
391
438
  if message.raw is not None:
392
439
  self._process_envelope(message.raw)
393
440
  else:
@@ -395,6 +442,35 @@ class Participant:
395
442
  finally:
396
443
  transport.stop()
397
444
 
445
+ def _emit_initiator_envelopes(self) -> None:
446
+ """Emit SessionStart + kickoff envelope as the initiator."""
447
+ cfg = self._initiator_config
448
+ if cfg is None:
449
+ return
450
+
451
+ self._actions.start_session(
452
+ intent=cfg.intent,
453
+ participants=cfg.participants,
454
+ ttl_ms=cfg.ttl_ms,
455
+ context_id=cfg.context_id,
456
+ mode_version=cfg.mode_version,
457
+ configuration_version=cfg.configuration_version,
458
+ policy_version=cfg.policy_version,
459
+ )
460
+ logger.info("SessionStart emitted (session=%s)", self._session_id)
461
+
462
+ if cfg.kickoff_message_type == "Proposal":
463
+ payload = cfg.kickoff_payload or {}
464
+ proposal_id = str(
465
+ payload.get("proposalId")
466
+ or payload.get("proposal_id")
467
+ or f"{self._session_id}-kickoff"
468
+ )
469
+ option = str(payload.get("option", "decide"))
470
+ rationale = str(payload.get("rationale", ""))
471
+ self._actions.propose(proposal_id, option, rationale=rationale)
472
+ logger.info("Kickoff proposal emitted (proposalId=%s)", proposal_id)
473
+
398
474
  def process_event(self, envelope: Any) -> None:
399
475
  """Manually process a single envelope (for testing or polling transports)."""
400
476
  self._process_envelope(envelope)
@@ -0,0 +1,140 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import os
5
+ from dataclasses import dataclass, field
6
+ from typing import Any
7
+
8
+ from ..auth import AuthConfig
9
+ from ..client import MacpClient
10
+ from ..constants import DEFAULT_POLICY_VERSION
11
+ from .participant import Participant
12
+
13
+
14
+ @dataclass
15
+ class InitiatorConfig:
16
+ """Configuration for the initiator agent's SessionStart + kickoff."""
17
+
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)
28
+
29
+
30
+ def from_bootstrap(bootstrap_path: str | None = None) -> Participant:
31
+ """Create a Participant from a bootstrap context file.
32
+
33
+ Reads the flat bootstrap format produced by the examples-service::
34
+
35
+ {
36
+ "participant_id": "...",
37
+ "session_id": "...",
38
+ "mode": "macp.mode.decision.v1",
39
+ "runtime_url": "localhost:50051",
40
+ "auth_token": "...",
41
+ "participants": ["agent-a", "agent-b"],
42
+ "secure": false,
43
+ "allow_insecure": true,
44
+ "initiator": { ... }
45
+ }
46
+
47
+ Also accepts ``auth.bearer_token`` for backwards compatibility.
48
+ """
49
+ path = bootstrap_path or os.environ.get("MACP_BOOTSTRAP_FILE")
50
+ if not path:
51
+ raise ValueError("No bootstrap path provided and MACP_BOOTSTRAP_FILE not set")
52
+
53
+ with open(path) as f:
54
+ ctx: dict[str, Any] = json.load(f)
55
+
56
+ participant_id = str(ctx["participant_id"])
57
+ session_id = str(ctx["session_id"])
58
+ mode = str(ctx["mode"])
59
+ runtime_url = str(ctx.get("runtime_url") or ctx.get("runtime_address") or "localhost:50051")
60
+ secure = bool(ctx.get("secure", True))
61
+ allow_insecure = bool(ctx.get("allow_insecure", False))
62
+
63
+ auth: AuthConfig | None = None
64
+ auth_token = ctx.get("auth_token")
65
+ agent_id = ctx.get("agent_id")
66
+ auth_data = ctx.get("auth")
67
+
68
+ if auth_token:
69
+ auth = AuthConfig.for_bearer(
70
+ str(auth_token),
71
+ sender_hint=participant_id,
72
+ expected_sender=participant_id,
73
+ )
74
+ elif isinstance(auth_data, dict) and auth_data.get("bearer_token"):
75
+ auth = AuthConfig.for_bearer(
76
+ str(auth_data["bearer_token"]),
77
+ sender_hint=participant_id,
78
+ expected_sender=str(auth_data.get("expected_sender") or participant_id),
79
+ )
80
+ elif agent_id:
81
+ auth = AuthConfig.for_dev_agent(str(agent_id), expected_sender=participant_id)
82
+ elif isinstance(auth_data, dict) and auth_data.get("agent_id"):
83
+ auth = AuthConfig.for_dev_agent(str(auth_data["agent_id"]), expected_sender=participant_id)
84
+
85
+ client = MacpClient(
86
+ target=runtime_url,
87
+ secure=secure,
88
+ allow_insecure=allow_insecure,
89
+ auth=auth,
90
+ )
91
+
92
+ raw_participants = ctx.get("participants")
93
+ participants: list[str] = (
94
+ [str(p) for p in raw_participants] if isinstance(raw_participants, list) else []
95
+ )
96
+
97
+ mode_version = ctx.get("mode_version")
98
+ configuration_version = ctx.get("configuration_version")
99
+ policy_version = ctx.get("policy_version")
100
+
101
+ initiator_config: InitiatorConfig | None = None
102
+ initiator_data = ctx.get("initiator")
103
+ if isinstance(initiator_data, dict):
104
+ ss = initiator_data.get("session_start", {})
105
+ kickoff = initiator_data.get("kickoff")
106
+
107
+ def _str_or(key: str, fallback: object) -> str | None:
108
+ """Pick value from session_start, then fallback, coercing to str."""
109
+ val = ss.get(key)
110
+ if val is not None:
111
+ return str(val)
112
+ return str(fallback) if fallback else None
113
+
114
+ initiator_config = InitiatorConfig(
115
+ intent=str(ss.get("intent", "")),
116
+ participants=[str(p) for p in ss.get("participants", participants)],
117
+ ttl_ms=int(ss.get("ttl_ms", 300000)),
118
+ context_id=str(ss.get("context_id", "")),
119
+ roots=ss.get("roots"),
120
+ mode_version=_str_or("mode_version", mode_version),
121
+ configuration_version=_str_or("configuration_version", configuration_version),
122
+ policy_version=_str_or("policy_version", policy_version),
123
+ kickoff_message_type=(
124
+ str(kickoff["message_type"]) if kickoff and "message_type" in kickoff else None
125
+ ),
126
+ kickoff_payload=kickoff.get("payload", {}) if kickoff else {},
127
+ )
128
+
129
+ return Participant(
130
+ participant_id=participant_id,
131
+ session_id=session_id,
132
+ mode=mode,
133
+ client=client,
134
+ auth=auth,
135
+ participants=participants,
136
+ mode_version=str(mode_version) if mode_version else None,
137
+ configuration_version=str(configuration_version) if configuration_version else None,
138
+ policy_version=str(policy_version) if policy_version else DEFAULT_POLICY_VERSION,
139
+ initiator_config=initiator_config,
140
+ )
@@ -53,6 +53,12 @@ class GrpcTransportAdapter:
53
53
  """Open a stream and yield messages for the target session."""
54
54
  self._stream = self._client.open_stream(auth=self._auth, timeout=self._timeout)
55
55
  try:
56
+ # RFC-MACP-0006-A1: Subscribe to the session with history replay.
57
+ # The runtime replays accepted envelopes then switches to live
58
+ # broadcast, ensuring non-initiator agents receive SessionStart +
59
+ # Proposal regardless of spawn order or connection timing.
60
+ self._stream.send_subscribe(self._session_id)
61
+
56
62
  for envelope in self._stream.responses():
57
63
  if self._stopped:
58
64
  break
@@ -133,16 +139,27 @@ class HttpTransportAdapter:
133
139
 
134
140
  def _envelope_to_message(envelope: Any) -> IncomingMessage:
135
141
  """Convert a protobuf Envelope to an IncomingMessage."""
142
+ from ..proto_registry import ProtoRegistry
143
+
136
144
  payload_dict: dict[str, Any] = {}
137
145
  if envelope.payload:
138
146
  try:
139
- payload_dict = json.loads(envelope.payload)
140
- except (json.JSONDecodeError, UnicodeDecodeError):
141
- payload_dict = {"_raw_bytes": envelope.payload}
147
+ registry = ProtoRegistry()
148
+ decoded = registry.decode_known_payload(
149
+ envelope.mode, envelope.message_type, envelope.payload
150
+ )
151
+ payload_dict = decoded if decoded is not None else json.loads(envelope.payload)
152
+ except Exception:
153
+ try:
154
+ payload_dict = json.loads(envelope.payload)
155
+ except Exception:
156
+ payload_dict = {}
142
157
 
143
- proposal_id: str | None = None
144
- if "proposal_id" in payload_dict:
145
- proposal_id = str(payload_dict["proposal_id"])
158
+ proposal_id: str | None = (
159
+ payload_dict.get("proposal_id") or payload_dict.get("proposalId") or None
160
+ )
161
+ if proposal_id is not None:
162
+ proposal_id = str(proposal_id)
146
163
 
147
164
  return IncomingMessage(
148
165
  message_type=envelope.message_type,
@@ -99,7 +99,8 @@ class BaseSession(ABC):
99
99
  intent: str,
100
100
  participants: list[str],
101
101
  ttl_ms: int,
102
- context: bytes | str | Mapping[str, object] | None = None,
102
+ context_id: str = "",
103
+ extensions: Mapping[str, bytes] | None = None,
103
104
  roots: Iterable[Any] | None = None,
104
105
  sender: str | None = None,
105
106
  auth: AuthConfig | None = None,
@@ -113,7 +114,8 @@ class BaseSession(ABC):
113
114
  mode_version=self.mode_version,
114
115
  configuration_version=self.configuration_version,
115
116
  policy_version=self.policy_version,
116
- context=context,
117
+ context_id=context_id,
118
+ extensions=extensions,
117
119
  roots=roots,
118
120
  )
119
121
  envelope = build_envelope(
@@ -101,30 +101,42 @@ class MacpStream:
101
101
  item = self._requests.get()
102
102
  if item is self._END:
103
103
  return
104
- assert isinstance(item, envelope_pb2.Envelope)
105
- yield core_pb2.StreamSessionRequest(envelope=item)
104
+ # RFC-MACP-0006-A1: support both envelope sends and subscribe frames
105
+ if isinstance(item, core_pb2.StreamSessionRequest):
106
+ yield item
107
+ else:
108
+ assert isinstance(item, envelope_pb2.Envelope)
109
+ yield core_pb2.StreamSessionRequest(envelope=item)
106
110
 
107
111
  def _pump_responses(self) -> None:
108
112
  try:
109
113
  for response in self._call:
110
- # Support both formats:
111
- # New: StreamSessionResponse { response: { envelope | error } }
112
- # Old: StreamSessionResponse { envelope }
114
+ # StreamSessionResponse has envelope + error at the top level.
115
+ envelope = getattr(response, "envelope", None)
116
+ error = getattr(response, "error", None)
117
+
118
+ if error is not None and hasattr(error, "ByteSize") and error.ByteSize() > 0:
119
+ for cb in self._inline_error_callbacks:
120
+ cb(error)
121
+ logger.warning("inline stream error: %s", error)
122
+ continue
123
+
124
+ if envelope is not None and envelope.ByteSize() > 0:
125
+ self._responses.put(envelope)
126
+ continue
127
+
128
+ # Fallback: try a nested .response wrapper (legacy proto shape).
113
129
  inner = getattr(response, "response", None)
114
130
  if inner is not None and hasattr(inner, "ByteSize") and inner.ByteSize() > 0:
115
- envelope = getattr(inner, "envelope", None)
116
- error = getattr(inner, "error", None)
117
- if envelope is not None and envelope.ByteSize() > 0:
118
- self._responses.put(envelope)
119
- elif error is not None:
120
- # Inline application-level error — notify callbacks, keep stream open
131
+ inner_env = getattr(inner, "envelope", None)
132
+ inner_err = getattr(inner, "error", None)
133
+ if inner_env is not None and inner_env.ByteSize() > 0:
134
+ self._responses.put(inner_env)
135
+ elif inner_err is not None:
121
136
  for cb in self._inline_error_callbacks:
122
- cb(error)
123
- logger.warning("inline stream error: %s", error)
137
+ cb(inner_err)
138
+ logger.warning("inline stream error: %s", inner_err)
124
139
  continue
125
- else:
126
- # Flat format: response.envelope
127
- self._responses.put(response.envelope)
128
140
  except grpc.RpcError as exc:
129
141
  self._responses.put(exc)
130
142
  finally:
@@ -139,6 +151,19 @@ class MacpStream:
139
151
  raise MacpSdkError("stream is already closed")
140
152
  self._requests.put(envelope)
141
153
 
154
+ def send_subscribe(self, session_id: str, after_sequence: int = 0) -> None:
155
+ """RFC-MACP-0006-A1: Send a subscribe-only frame to receive session
156
+ history + live broadcast. The runtime replays accepted envelopes from
157
+ ``after_sequence`` onwards, then continues with live broadcast.
158
+ """
159
+ if self._closed:
160
+ raise MacpSdkError("stream is already closed")
161
+ req = core_pb2.StreamSessionRequest(
162
+ subscribe_session_id=session_id,
163
+ after_sequence=after_sequence,
164
+ )
165
+ self._requests.put(req)
166
+
142
167
  def read(self, timeout: float | None = None) -> envelope_pb2.Envelope | None:
143
168
  item = self._responses.get(timeout=timeout)
144
169
  if item is self._END:
@@ -182,7 +207,7 @@ class MacpClient:
182
207
  root_certificates: bytes | None = None,
183
208
  default_timeout: float | None = None,
184
209
  client_name: str = "macp-sdk-python",
185
- client_version: str = "0.2.1",
210
+ client_version: str = "0.2.3",
186
211
  ) -> None:
187
212
  if secure is None:
188
213
  secure = not allow_insecure
@@ -1,6 +1,5 @@
1
1
  from __future__ import annotations
2
2
 
3
- import json
4
3
  import time
5
4
  import uuid
6
5
  from collections.abc import Iterable, Mapping, Sequence
@@ -47,16 +46,6 @@ def now_unix_ms() -> int:
47
46
  return int(time.time() * 1000)
48
47
 
49
48
 
50
- def encode_context(context: bytes | str | Mapping[str, object] | None) -> bytes:
51
- if context is None:
52
- return b""
53
- if isinstance(context, bytes):
54
- return context
55
- if isinstance(context, str):
56
- return context.encode("utf-8")
57
- return json.dumps(context).encode("utf-8")
58
-
59
-
60
49
  def build_root(uri: str, name: str = "") -> core_pb2.Root:
61
50
  return core_pb2.Root(uri=uri, name=name)
62
51
 
@@ -69,7 +58,8 @@ def build_session_start_payload(
69
58
  mode_version: str = DEFAULT_MODE_VERSION,
70
59
  configuration_version: str = DEFAULT_CONFIGURATION_VERSION,
71
60
  policy_version: str = DEFAULT_POLICY_VERSION,
72
- context: bytes | str | Mapping[str, object] | None = None,
61
+ context_id: str = "",
62
+ extensions: Mapping[str, bytes] | None = None,
73
63
  roots: Iterable[core_pb2.Root] | None = None,
74
64
  ) -> core_pb2.SessionStartPayload:
75
65
  return core_pb2.SessionStartPayload(
@@ -79,7 +69,8 @@ def build_session_start_payload(
79
69
  configuration_version=configuration_version,
80
70
  policy_version=policy_version,
81
71
  ttl_ms=ttl_ms,
82
- context=encode_context(context),
72
+ context_id=context_id,
73
+ extensions=dict(extensions) if extensions else {},
83
74
  roots=list(roots or []),
84
75
  )
85
76
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: macp-sdk-python
3
- Version: 0.2.1
3
+ Version: 0.2.3
4
4
  Summary: Python SDK for the MACP Rust runtime
5
5
  Author-email: OpenAI <support@openai.com>
6
6
  License: Apache-2.0
@@ -17,7 +17,7 @@ Description-Content-Type: text/markdown
17
17
  License-File: LICENSE
18
18
  Requires-Dist: grpcio>=1.74.0
19
19
  Requires-Dist: protobuf>=5.27.0
20
- Requires-Dist: macp-proto<0.2.0,>=0.1.0
20
+ Requires-Dist: macp-proto<0.2.0,>=0.1.2
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"
@@ -161,5 +161,5 @@ Business logic — voting rules, AI decision heuristics, policy enforcement —
161
161
  ## Known runtime limitations
162
162
 
163
163
  - `GetSession` returns metadata only (not mode state/transcript) — hence the local projection pattern
164
- - `StreamSession` has no late-attach handshake for already-running sessions
164
+ - `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
165
165
  - Business policy (majority, quorum, veto) belongs in your orchestrator/policy layer
@@ -1,6 +1,6 @@
1
1
  grpcio>=1.74.0
2
2
  protobuf>=5.27.0
3
- macp-proto<0.2.0,>=0.1.0
3
+ macp-proto<0.2.0,>=0.1.2
4
4
 
5
5
  [dev]
6
6
  grpcio-tools>=1.74.0
@@ -1,100 +0,0 @@
1
- from __future__ import annotations
2
-
3
- import json
4
- import os
5
-
6
- from ..auth import AuthConfig
7
- from ..client import MacpClient
8
- from ..constants import DEFAULT_POLICY_VERSION
9
- from .participant import Participant
10
-
11
-
12
- def from_bootstrap(bootstrap_path: str | None = None) -> Participant:
13
- """Create a Participant from a bootstrap context file.
14
-
15
- The bootstrap file is a JSON document produced by the MACP hosting
16
- infrastructure containing the information needed to connect and
17
- participate in a session.
18
-
19
- If ``bootstrap_path`` is not provided, the ``MACP_BOOTSTRAP_FILE``
20
- environment variable is used.
21
-
22
- Expected bootstrap JSON structure::
23
-
24
- {
25
- "participant_id": "...",
26
- "session_id": "...",
27
- "mode": "macp.mode.decision.v1",
28
- "runtime_url": "localhost:50051",
29
- "auth": {
30
- "bearer_token": "...", // or "agent_id": "..."
31
- },
32
- "participants": ["agent-a", "agent-b"],
33
- "policy_version": "policy.default",
34
- "secure": true, // default; set false only for local dev
35
- "allow_insecure": false // required if secure=false
36
- }
37
-
38
- Transport security follows RFC-MACP-0006 §3: ``secure`` defaults to
39
- ``true``. Setting ``secure: false`` additionally requires
40
- ``allow_insecure: true`` in the bootstrap (opt-in for local dev only).
41
- """
42
- path = bootstrap_path or os.environ.get("MACP_BOOTSTRAP_FILE")
43
- if not path:
44
- raise ValueError("No bootstrap path provided and MACP_BOOTSTRAP_FILE not set")
45
-
46
- with open(path) as f:
47
- ctx: dict[str, object] = json.load(f)
48
-
49
- participant_id = str(ctx["participant_id"])
50
- session_id = str(ctx["session_id"])
51
- mode = str(ctx["mode"])
52
- runtime_url = str(ctx.get("runtime_url", "localhost:50051"))
53
- secure = bool(ctx.get("secure", True))
54
- allow_insecure = bool(ctx.get("allow_insecure", False))
55
-
56
- # Build auth config
57
- auth_data = ctx.get("auth")
58
- auth: AuthConfig | None = None
59
- if isinstance(auth_data, dict):
60
- bearer = auth_data.get("bearer_token")
61
- agent_id = auth_data.get("agent_id")
62
- expected_sender = auth_data.get("expected_sender") or participant_id
63
- if bearer:
64
- auth = AuthConfig.for_bearer(
65
- str(bearer),
66
- sender_hint=participant_id,
67
- expected_sender=str(expected_sender),
68
- )
69
- elif agent_id:
70
- auth = AuthConfig.for_dev_agent(str(agent_id), expected_sender=str(expected_sender))
71
-
72
- # Build client
73
- client = MacpClient(
74
- target=runtime_url,
75
- secure=secure,
76
- allow_insecure=allow_insecure,
77
- auth=auth,
78
- )
79
-
80
- # Extract optional fields
81
- raw_participants = ctx.get("participants")
82
- participants: list[str] = []
83
- if isinstance(raw_participants, list):
84
- participants = [str(p) for p in raw_participants]
85
-
86
- mode_version = ctx.get("mode_version")
87
- configuration_version = ctx.get("configuration_version")
88
- policy_version = ctx.get("policy_version")
89
-
90
- return Participant(
91
- participant_id=participant_id,
92
- session_id=session_id,
93
- mode=mode,
94
- client=client,
95
- auth=auth,
96
- participants=participants,
97
- mode_version=str(mode_version) if mode_version else None,
98
- configuration_version=str(configuration_version) if configuration_version else None,
99
- policy_version=str(policy_version) if policy_version else DEFAULT_POLICY_VERSION,
100
- )
File without changes