openbox-sdk-python 0.2.0__py3-none-any.whl

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 (52) hide show
  1. openbox_core/__init__.py +59 -0
  2. openbox_core/adapters/__init__.py +3 -0
  3. openbox_core/adapters/base.py +123 -0
  4. openbox_core/approvals.py +106 -0
  5. openbox_core/client.py +298 -0
  6. openbox_core/config.py +260 -0
  7. openbox_core/conformance/__init__.py +3 -0
  8. openbox_core/conformance/fake_core.py +169 -0
  9. openbox_core/conformance/hook_preflight.py +87 -0
  10. openbox_core/conformance/instrumentation.py +91 -0
  11. openbox_core/context.py +205 -0
  12. openbox_core/contracts/__init__.py +3 -0
  13. openbox_core/contracts/context.py +79 -0
  14. openbox_core/contracts/events.py +401 -0
  15. openbox_core/contracts/otel_spans.py +325 -0
  16. openbox_core/contracts/results.py +287 -0
  17. openbox_core/errors.py +287 -0
  18. openbox_core/gate.py +185 -0
  19. openbox_core/hooks/__init__.py +3 -0
  20. openbox_core/hooks/events.py +64 -0
  21. openbox_core/hooks/preflight.py +292 -0
  22. openbox_core/hooks/wrappers.py +105 -0
  23. openbox_core/identity.py +231 -0
  24. openbox_core/instrumentation/__init__.py +3 -0
  25. openbox_core/instrumentation/db.py +689 -0
  26. openbox_core/instrumentation/file.py +239 -0
  27. openbox_core/instrumentation/function.py +121 -0
  28. openbox_core/instrumentation/http.py +840 -0
  29. openbox_core/instrumentation/llm.py +3 -0
  30. openbox_core/instrumentation/manager.py +135 -0
  31. openbox_core/instrumentation/shared.py +27 -0
  32. openbox_core/otel/__init__.py +3 -0
  33. openbox_core/otel/propagation.py +45 -0
  34. openbox_core/otel/provider.py +35 -0
  35. openbox_core/otel/setup.py +36 -0
  36. openbox_core/otel/span_processor.py +62 -0
  37. openbox_core/otel/trace_context.py +71 -0
  38. openbox_core/py.typed +0 -0
  39. openbox_core/runtime.py +138 -0
  40. openbox_core/sdk_version.py +79 -0
  41. openbox_core/serialization.py +129 -0
  42. openbox_core/validation/__init__.py +3 -0
  43. openbox_core/validation/diagnostics.py +60 -0
  44. openbox_core/validation/event_rules.py +164 -0
  45. openbox_core/validation/registry.py +31 -0
  46. openbox_core/validation/span_normalization.py +107 -0
  47. openbox_core/wire/__init__.py +3 -0
  48. openbox_core/wire/core_span.py +130 -0
  49. openbox_core/wire/evaluate_payload.py +56 -0
  50. openbox_sdk_python-0.2.0.dist-info/METADATA +94 -0
  51. openbox_sdk_python-0.2.0.dist-info/RECORD +52 -0
  52. openbox_sdk_python-0.2.0.dist-info/WHEEL +4 -0
@@ -0,0 +1,205 @@
1
+ """ContextStore — contextvars bind/reset plus canonical trace-key correlation.
2
+
3
+ Two complementary lookup paths (both required):
4
+
5
+ 1. ``ContextVar`` — the bound context for the current async task/thread flow.
6
+ 2. trace→context map — hook code running where ContextVars don't propagate
7
+ (e.g. ``run_in_executor`` worker threads on Python 3.11) resolves the
8
+ context by OTel trace id instead.
9
+
10
+ Trace-key invariant: registration and lookup BOTH go through
11
+ ``canonical_trace_key()`` — the raw OTel ``SpanContext.trace_id`` integer.
12
+ The 32-hex ``trace_id`` string is a WIRE-ONLY representation; accepting one
13
+ here converts it to the canonical integer so both sides always agree.
14
+
15
+ Leak safety: callers MUST wrap bind/reset in try/finally (use
16
+ ``activity_scope``); ``unregister_trace`` runs on activity completion and
17
+ ``clear()`` on runtime close so long-lived workers never grow the map
18
+ unbounded or serve stale correlations.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import threading
24
+ from collections.abc import Iterator
25
+ from contextlib import contextmanager
26
+ from contextvars import ContextVar, Token
27
+
28
+ from .contracts.context import ActivityContext
29
+
30
+ __all__ = [
31
+ "canonical_trace_key",
32
+ "ContextStore",
33
+ "default_context_store",
34
+ "bind_activity_context",
35
+ "reset_activity_context",
36
+ "current_activity_context",
37
+ "register_trace",
38
+ "context_for_trace",
39
+ "unregister_trace",
40
+ "activity_scope",
41
+ ]
42
+
43
+
44
+ def canonical_trace_key(trace_id: int | str) -> int:
45
+ """Normalize any accepted trace-id representation to the canonical key.
46
+
47
+ The canonical key is the raw OTel ``SpanContext.trace_id`` INTEGER. Hex
48
+ strings convert through here so the wire representation can never end up
49
+ on only one side of a lookup.
50
+ """
51
+ if isinstance(trace_id, bool): # bool is an int subclass — reject explicitly
52
+ raise TypeError("trace_id must be an int or hex string, got bool")
53
+ if isinstance(trace_id, int):
54
+ return trace_id
55
+ if isinstance(trace_id, str):
56
+ try:
57
+ return int(trace_id, 16)
58
+ except ValueError:
59
+ raise ValueError(f"trace_id string is not hex: {trace_id!r}") from None
60
+ raise TypeError(f"trace_id must be an int or hex string, got {type(trace_id).__name__}")
61
+
62
+
63
+ class ContextStore:
64
+ """Thread-safe context binding + trace correlation + governance flags."""
65
+
66
+ def __init__(self) -> None:
67
+ self._current: ContextVar[ActivityContext | None] = ContextVar(
68
+ "openbox_activity_context", default=None
69
+ )
70
+ self._lock = threading.Lock()
71
+ self._trace_to_context: dict[int, ActivityContext] = {}
72
+ # Governance flags read by the hook runtime (set via adapter/runtime):
73
+ self._aborted_activities: set[str] = set()
74
+ self._halt_requested = False
75
+
76
+ # ── ContextVar binding ────────────────────────────────────────────────
77
+
78
+ def bind(self, ctx: ActivityContext) -> Token:
79
+ """Bind ``ctx`` to the current flow. Pair with :meth:`reset` in a
80
+ try/finally — or use :func:`activity_scope`."""
81
+ return self._current.set(ctx)
82
+
83
+ def reset(self, token: Token) -> None:
84
+ """Restore the previous binding (call in ``finally``)."""
85
+ self._current.reset(token)
86
+
87
+ def current_activity_context(self) -> ActivityContext | None:
88
+ return self._current.get()
89
+
90
+ # ── Trace correlation map ─────────────────────────────────────────────
91
+
92
+ def register_trace(self, trace_id: int | str, ctx: ActivityContext) -> None:
93
+ key = canonical_trace_key(trace_id)
94
+ with self._lock:
95
+ self._trace_to_context[key] = ctx
96
+
97
+ def context_for_trace(self, trace_id: int | str) -> ActivityContext | None:
98
+ key = canonical_trace_key(trace_id)
99
+ with self._lock:
100
+ return self._trace_to_context.get(key)
101
+
102
+ def unregister_trace(self, trace_id: int | str) -> None:
103
+ """Mandatory cleanup on activity completion/session end."""
104
+ key = canonical_trace_key(trace_id)
105
+ with self._lock:
106
+ self._trace_to_context.pop(key, None)
107
+
108
+ def trace_map_size(self) -> int:
109
+ """Observability/leak-test helper."""
110
+ with self._lock:
111
+ return len(self._trace_to_context)
112
+
113
+ # ── Governance flags (abort short-circuit, halt) ──────────────────────
114
+
115
+ @staticmethod
116
+ def activity_key(workflow_id: str | None, activity_id: str | None) -> str:
117
+ return f"{workflow_id}:{activity_id}"
118
+
119
+ def mark_activity_aborted(self, workflow_id: str | None, activity_id: str | None) -> None:
120
+ with self._lock:
121
+ self._aborted_activities.add(self.activity_key(workflow_id, activity_id))
122
+
123
+ def is_activity_aborted(self, workflow_id: str | None, activity_id: str | None) -> bool:
124
+ with self._lock:
125
+ return self.activity_key(workflow_id, activity_id) in self._aborted_activities
126
+
127
+ def clear_activity_aborted(self, workflow_id: str | None, activity_id: str | None) -> None:
128
+ with self._lock:
129
+ self._aborted_activities.discard(self.activity_key(workflow_id, activity_id))
130
+
131
+ def request_halt(self) -> None:
132
+ with self._lock:
133
+ self._halt_requested = True
134
+
135
+ @property
136
+ def halt_requested(self) -> bool:
137
+ with self._lock:
138
+ return self._halt_requested
139
+
140
+ # ── Shutdown ──────────────────────────────────────────────────────────
141
+
142
+ def clear(self) -> None:
143
+ """Drop ALL correlation state and flags (runtime close)."""
144
+ with self._lock:
145
+ self._trace_to_context.clear()
146
+ self._aborted_activities.clear()
147
+ self._halt_requested = False
148
+
149
+
150
+ # Default process-wide store: instrumentation resolves context here unless a
151
+ # runtime injects its own store.
152
+ _default_store = ContextStore()
153
+
154
+
155
+ def default_context_store() -> ContextStore:
156
+ return _default_store
157
+
158
+
159
+ def bind_activity_context(ctx: ActivityContext) -> Token:
160
+ return _default_store.bind(ctx)
161
+
162
+
163
+ def reset_activity_context(token: Token) -> None:
164
+ _default_store.reset(token)
165
+
166
+
167
+ def current_activity_context() -> ActivityContext | None:
168
+ return _default_store.current_activity_context()
169
+
170
+
171
+ def register_trace(trace_id: int | str, ctx: ActivityContext) -> None:
172
+ _default_store.register_trace(trace_id, ctx)
173
+
174
+
175
+ def context_for_trace(trace_id: int | str) -> ActivityContext | None:
176
+ return _default_store.context_for_trace(trace_id)
177
+
178
+
179
+ def unregister_trace(trace_id: int | str) -> None:
180
+ _default_store.unregister_trace(trace_id)
181
+
182
+
183
+ @contextmanager
184
+ def activity_scope(
185
+ ctx: ActivityContext,
186
+ *,
187
+ trace_id: int | str | None = None,
188
+ store: ContextStore | None = None,
189
+ ) -> Iterator[ActivityContext]:
190
+ """Bind a context (and optional trace registration) with GUARANTEED reset.
191
+
192
+ Reset and trace cleanup run even when the framework operation raises.
193
+ """
194
+ target = store if store is not None else _default_store
195
+ token = target.bind(ctx)
196
+ try:
197
+ # Inside the try: a bad trace_id (e.g. non-hex string) must not leak
198
+ # the ContextVar binding this helper promises to reset.
199
+ if trace_id is not None:
200
+ target.register_trace(trace_id, ctx)
201
+ yield ctx
202
+ finally:
203
+ target.reset(token)
204
+ if trace_id is not None:
205
+ target.unregister_trace(trace_id)
@@ -0,0 +1,3 @@
1
+ """Pure, import-safe contract dataclasses and enums. No network, crypto, OTel, or wall-clock."""
2
+
3
+ __all__: list[str] = []
@@ -0,0 +1,79 @@
1
+ """ActivityContext dataclass and ActivityContextProvider protocol.
2
+
3
+ Pure, import-safe module. The base SDK owns THE context shape; framework SDKs
4
+ populate it (they do not define competing context types). Core instrumentation
5
+ reads the bound context without knowing the framework.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from collections.abc import Mapping
11
+ from dataclasses import dataclass, field
12
+ from typing import Any, Protocol, runtime_checkable
13
+
14
+ __all__ = ["ActivityContext", "ActivityContextProvider"]
15
+
16
+
17
+ @dataclass(frozen=True)
18
+ class ActivityContext:
19
+ """The framework-agnostic activity/task execution context.
20
+
21
+ Immutable — per-operation deltas go in ``metadata`` or a freshly bound
22
+ context, never mutation. Framework-specific extras that have no first-class
23
+ field here belong in ``metadata``.
24
+ """
25
+
26
+ workflow_id: str | None = None
27
+ run_id: str | None = None
28
+ workflow_type: str | None = None
29
+ task_queue: str | None = None
30
+ activity_id: str | None = None
31
+ activity_type: str | None = None
32
+ activity_input: Any = None
33
+ agent_name: str | None = None
34
+ agent_role: str | None = None
35
+ session_id: str | None = None
36
+ multi_agent_session_id: str | None = None
37
+ metadata: Mapping[str, Any] = field(default_factory=dict)
38
+
39
+ def to_payload_fields(self) -> dict[str, Any]:
40
+ """Flat wire fields for hook payload assembly (omit-when-absent).
41
+
42
+ ``metadata`` entries merge at the top level last, but never overwrite
43
+ first-class fields.
44
+ """
45
+ fields_map = {
46
+ "workflow_id": self.workflow_id,
47
+ "run_id": self.run_id,
48
+ "workflow_type": self.workflow_type,
49
+ "task_queue": self.task_queue,
50
+ "activity_id": self.activity_id,
51
+ "activity_type": self.activity_type,
52
+ "activity_input": self.activity_input,
53
+ "agent_name": self.agent_name,
54
+ "agent_role": self.agent_role,
55
+ "session_id": self.session_id,
56
+ "multi_agent_session_id": self.multi_agent_session_id,
57
+ }
58
+ payload = {k: v for k, v in fields_map.items() if v is not None}
59
+ for key, value in self.metadata.items():
60
+ payload.setdefault(str(key), value)
61
+ return payload
62
+
63
+
64
+ @runtime_checkable
65
+ class ActivityContextProvider(Protocol):
66
+ """How core instrumentation resolves the bound context.
67
+
68
+ Implemented by ``openbox_core.context.ContextStore``; frameworks may plug
69
+ a custom provider as long as registration and lookup share the SAME
70
+ canonical trace key.
71
+ """
72
+
73
+ def current_activity_context(self) -> ActivityContext | None:
74
+ """The context bound to the current execution flow (or None)."""
75
+ ...
76
+
77
+ def context_for_trace(self, trace_id: int) -> ActivityContext | None:
78
+ """The context registered for an OTel trace id (or None)."""
79
+ ...
@@ -0,0 +1,401 @@
1
+ """Event contracts — EventType, EventKind, EventEnvelope, classification, factories.
2
+
3
+ Pure, import-safe module: no network, crypto, OTel, logging, wall-clock, or
4
+ random. Timestamps are RFC3339 strings *passed in* by callers — never generated
5
+ here (``datetime.now`` is forbidden in contracts; the sending layer stamps
6
+ missing timestamps).
7
+
8
+ Core wire rules:
9
+
10
+ - ``EventEnvelope.event_type`` stores the **backend wire type**.
11
+ - Hook evaluations are internally ``EventKind.HOOK`` but serialize as
12
+ ``ActivityStarted`` + ``hook_trigger=true`` + non-empty ``spans``.
13
+ - ``ActivityCompleted`` must not carry non-empty hook spans (validated by the
14
+ strict gate; factories make invalid states hard to build).
15
+ - Handoff payloads require ``from_agent_did`` + ``multi_agent_session_id``
16
+ (both non-empty); ``to_agent_did`` is omitted because the receiver is derived
17
+ server-side from the authenticated signed identity.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ from collections.abc import Mapping
23
+ from dataclasses import dataclass, field
24
+ from datetime import UTC, datetime
25
+ from enum import Enum
26
+ from typing import Any
27
+
28
+ __all__ = [
29
+ "SOURCE_WORKFLOW_TELEMETRY",
30
+ "EventType",
31
+ "EventKind",
32
+ "EventEnvelope",
33
+ "classify_event",
34
+ "wire_event_type",
35
+ "rfc3339_from_datetime",
36
+ "workflow_started",
37
+ "workflow_completed",
38
+ "workflow_failed",
39
+ "activity_started",
40
+ "activity_completed",
41
+ "signal_received",
42
+ "handoff",
43
+ "hook",
44
+ ]
45
+
46
+ # ``source`` field Core receives on every governance event.
47
+ SOURCE_WORKFLOW_TELEMETRY = "workflow-telemetry"
48
+
49
+
50
+ class EventType(str, Enum):
51
+ """Backend wire event types (Core's accepted ``event_type`` values)."""
52
+
53
+ WORKFLOW_STARTED = "WorkflowStarted"
54
+ WORKFLOW_COMPLETED = "WorkflowCompleted"
55
+ WORKFLOW_FAILED = "WorkflowFailed"
56
+ SIGNAL_RECEIVED = "SignalReceived"
57
+ ACTIVITY_STARTED = "ActivityStarted"
58
+ ACTIVITY_COMPLETED = "ActivityCompleted"
59
+ HANDOFF = "Handoff"
60
+
61
+
62
+ class EventKind(Enum):
63
+ """Internal event classification. Not a wire concept."""
64
+
65
+ LIFECYCLE = "lifecycle"
66
+ HOOK = "hook"
67
+ SIGNAL = "signal"
68
+ HANDOFF = "handoff"
69
+
70
+
71
+ def rfc3339_from_datetime(ts: datetime) -> str:
72
+ """Format a datetime as RFC3339 (UTC, millisecond precision, trailing ``Z``).
73
+
74
+ Pure formatter — the caller supplies the datetime; naive datetimes are
75
+ assumed UTC. This is the *event-payload* timestamp format. It is distinct
76
+ from the request-*signing* timestamp, which keeps ``+00:00`` (never ``Z``).
77
+ """
78
+ if ts.tzinfo is None:
79
+ ts = ts.replace(tzinfo=UTC)
80
+ return ts.astimezone(UTC).strftime("%Y-%m-%dT%H:%M:%S.%f")[:-3] + "Z"
81
+
82
+
83
+ @dataclass(frozen=True)
84
+ class EventEnvelope:
85
+ """A governance event addressed to OpenBox Core.
86
+
87
+ Attributes:
88
+ event_type: Backend **wire** event type (hook events store
89
+ ``ACTIVITY_STARTED`` — the wire type Core accepts).
90
+ payload: Flat wire fields (``workflow_id``, ``run_id``, ...). Serialized
91
+ at the top level of the request body, not nested.
92
+ spans: Flat Core SpanData hook payloads. Empty for lifecycle events.
93
+ hook_trigger: True only for hook evaluations.
94
+ activity_id / activity_type: The bound activity for activity-scoped and
95
+ hook events.
96
+ timestamp: RFC3339 ``Z`` string, passed in. ``None`` means the sending
97
+ layer stamps it (``setdefault`` semantics — an explicit value is
98
+ preserved).
99
+ source: Constant event source tag Core expects.
100
+ """
101
+
102
+ event_type: EventType
103
+ payload: Mapping[str, Any] = field(default_factory=dict)
104
+ spans: tuple[Any, ...] = ()
105
+ hook_trigger: bool = False
106
+ activity_id: str | None = None
107
+ activity_type: str | None = None
108
+ timestamp: str | None = None
109
+ source: str = SOURCE_WORKFLOW_TELEMETRY
110
+
111
+ def to_payload_dict(self) -> dict[str, Any]:
112
+ """Flat lifecycle wire dict (omit-when-absent; never null keys).
113
+
114
+ Spans and ``span_count`` are deliberately NOT emitted here — hook body
115
+ assembly is owned by ``wire/evaluate_payload.py``, the single owner of
116
+ the evaluate body shape.
117
+ """
118
+ body: dict[str, Any] = {
119
+ "source": self.source,
120
+ "event_type": wire_event_type(self).value,
121
+ **dict(self.payload),
122
+ }
123
+ if self.activity_id is not None:
124
+ body["activity_id"] = self.activity_id
125
+ if self.activity_type is not None:
126
+ body["activity_type"] = self.activity_type
127
+ if self.hook_trigger:
128
+ body["hook_trigger"] = True
129
+ if self.timestamp is not None:
130
+ body["timestamp"] = self.timestamp
131
+ return body
132
+
133
+
134
+ def classify_event(event: EventEnvelope) -> EventKind:
135
+ """Classify an envelope. Derived from stored fields — never trust callers
136
+ to pass a separate, possibly-inconsistent kind."""
137
+ if event.hook_trigger:
138
+ return EventKind.HOOK
139
+ if event.event_type is EventType.HANDOFF:
140
+ return EventKind.HANDOFF
141
+ if event.event_type is EventType.SIGNAL_RECEIVED:
142
+ return EventKind.SIGNAL
143
+ return EventKind.LIFECYCLE
144
+
145
+
146
+ def wire_event_type(event: EventEnvelope) -> EventType:
147
+ """Backend wire event type. A HOOK-kind event is always ``ActivityStarted``
148
+ on the wire regardless of what the envelope stores."""
149
+ if event.hook_trigger:
150
+ return EventType.ACTIVITY_STARTED
151
+ return event.event_type
152
+
153
+
154
+ # ─── Factories ───────────────────────────────────────────────────────────────
155
+ #
156
+ # Factories build wire-consistent envelopes. They raise ValueError on
157
+ # programmer misuse (missing required identity fields); *runtime* contract
158
+ # violations on arbitrary envelopes are the strict gate's job (ContractError).
159
+
160
+
161
+ def _base_workflow_payload(
162
+ workflow_id: str,
163
+ run_id: str,
164
+ workflow_type: str,
165
+ task_queue: str | None,
166
+ multi_agent_session_id: str | None,
167
+ extra: Mapping[str, Any] | None,
168
+ ) -> dict[str, Any]:
169
+ payload: dict[str, Any] = {
170
+ "workflow_id": workflow_id,
171
+ "run_id": run_id,
172
+ "workflow_type": workflow_type,
173
+ }
174
+ if task_queue is not None:
175
+ payload["task_queue"] = task_queue
176
+ # Omitted entirely when absent; never emitted as a null key.
177
+ if multi_agent_session_id:
178
+ payload["multi_agent_session_id"] = multi_agent_session_id
179
+ if extra:
180
+ payload.update(extra)
181
+ return payload
182
+
183
+
184
+ def workflow_started(
185
+ *,
186
+ workflow_id: str,
187
+ run_id: str,
188
+ workflow_type: str,
189
+ task_queue: str | None = None,
190
+ multi_agent_session_id: str | None = None,
191
+ timestamp: str | None = None,
192
+ extra: Mapping[str, Any] | None = None,
193
+ ) -> EventEnvelope:
194
+ """WorkflowStarted lifecycle event."""
195
+ return EventEnvelope(
196
+ event_type=EventType.WORKFLOW_STARTED,
197
+ payload=_base_workflow_payload(
198
+ workflow_id, run_id, workflow_type, task_queue, multi_agent_session_id, extra
199
+ ),
200
+ timestamp=timestamp,
201
+ )
202
+
203
+
204
+ def workflow_completed(
205
+ *,
206
+ workflow_id: str,
207
+ run_id: str,
208
+ workflow_type: str,
209
+ task_queue: str | None = None,
210
+ multi_agent_session_id: str | None = None,
211
+ timestamp: str | None = None,
212
+ extra: Mapping[str, Any] | None = None,
213
+ ) -> EventEnvelope:
214
+ """WorkflowCompleted lifecycle event."""
215
+ return EventEnvelope(
216
+ event_type=EventType.WORKFLOW_COMPLETED,
217
+ payload=_base_workflow_payload(
218
+ workflow_id, run_id, workflow_type, task_queue, multi_agent_session_id, extra
219
+ ),
220
+ timestamp=timestamp,
221
+ )
222
+
223
+
224
+ def workflow_failed(
225
+ *,
226
+ workflow_id: str,
227
+ run_id: str,
228
+ workflow_type: str,
229
+ error: str | None = None,
230
+ task_queue: str | None = None,
231
+ multi_agent_session_id: str | None = None,
232
+ timestamp: str | None = None,
233
+ extra: Mapping[str, Any] | None = None,
234
+ ) -> EventEnvelope:
235
+ """WorkflowFailed lifecycle event."""
236
+ payload = _base_workflow_payload(
237
+ workflow_id, run_id, workflow_type, task_queue, multi_agent_session_id, extra
238
+ )
239
+ if error is not None:
240
+ payload["error"] = error
241
+ return EventEnvelope(
242
+ event_type=EventType.WORKFLOW_FAILED, payload=payload, timestamp=timestamp
243
+ )
244
+
245
+
246
+ def activity_started(
247
+ *,
248
+ workflow_id: str,
249
+ run_id: str,
250
+ workflow_type: str,
251
+ activity_id: str,
252
+ activity_type: str,
253
+ task_queue: str | None = None,
254
+ activity_input: Any = None,
255
+ attempt: int | None = None,
256
+ multi_agent_session_id: str | None = None,
257
+ timestamp: str | None = None,
258
+ extra: Mapping[str, Any] | None = None,
259
+ ) -> EventEnvelope:
260
+ """ActivityStarted lifecycle event (NOT a hook — ``hook_trigger`` stays false)."""
261
+ payload = _base_workflow_payload(
262
+ workflow_id, run_id, workflow_type, task_queue, multi_agent_session_id, extra
263
+ )
264
+ if activity_input is not None:
265
+ payload["activity_input"] = activity_input
266
+ if attempt is not None:
267
+ payload["attempt"] = attempt
268
+ return EventEnvelope(
269
+ event_type=EventType.ACTIVITY_STARTED,
270
+ payload=payload,
271
+ activity_id=activity_id,
272
+ activity_type=activity_type,
273
+ timestamp=timestamp,
274
+ )
275
+
276
+
277
+ def activity_completed(
278
+ *,
279
+ workflow_id: str,
280
+ run_id: str,
281
+ workflow_type: str,
282
+ activity_id: str,
283
+ activity_type: str,
284
+ task_queue: str | None = None,
285
+ result: Any = None,
286
+ error: str | None = None,
287
+ attempt: int | None = None,
288
+ multi_agent_session_id: str | None = None,
289
+ timestamp: str | None = None,
290
+ extra: Mapping[str, Any] | None = None,
291
+ ) -> EventEnvelope:
292
+ """ActivityCompleted lifecycle event.
293
+
294
+ Never carries hook spans. Empty ``spans``/``span_count=0`` noise is not
295
+ produced here at all; the wire assembler additionally strips it from
296
+ hand-built payloads (recorded as a diagnostic).
297
+ """
298
+ payload = _base_workflow_payload(
299
+ workflow_id, run_id, workflow_type, task_queue, multi_agent_session_id, extra
300
+ )
301
+ if result is not None:
302
+ payload["result"] = result
303
+ if error is not None:
304
+ payload["error"] = error
305
+ if attempt is not None:
306
+ payload["attempt"] = attempt
307
+ return EventEnvelope(
308
+ event_type=EventType.ACTIVITY_COMPLETED,
309
+ payload=payload,
310
+ activity_id=activity_id,
311
+ activity_type=activity_type,
312
+ timestamp=timestamp,
313
+ )
314
+
315
+
316
+ def signal_received(
317
+ *,
318
+ workflow_id: str,
319
+ run_id: str,
320
+ workflow_type: str,
321
+ signal_name: str,
322
+ task_queue: str | None = None,
323
+ multi_agent_session_id: str | None = None,
324
+ timestamp: str | None = None,
325
+ extra: Mapping[str, Any] | None = None,
326
+ ) -> EventEnvelope:
327
+ """SignalReceived event."""
328
+ payload = _base_workflow_payload(
329
+ workflow_id, run_id, workflow_type, task_queue, multi_agent_session_id, extra
330
+ )
331
+ payload["signal_name"] = signal_name
332
+ return EventEnvelope(
333
+ event_type=EventType.SIGNAL_RECEIVED, payload=payload, timestamp=timestamp
334
+ )
335
+
336
+
337
+ def handoff(
338
+ *,
339
+ from_agent_did: str,
340
+ multi_agent_session_id: str,
341
+ timestamp: str | None = None,
342
+ ) -> EventEnvelope:
343
+ """Multi-agent Handoff event.
344
+
345
+ Both fields are required and non-empty (raises ValueError before any
346
+ network call). ``to_agent_did`` is not included; the receiver is derived
347
+ server-side from the authenticated signed identity.
348
+ """
349
+ if not from_agent_did or not from_agent_did.strip():
350
+ raise ValueError("handoff: from_agent_did is required and must be non-empty")
351
+ if not multi_agent_session_id or not multi_agent_session_id.strip():
352
+ raise ValueError(
353
+ "handoff: multi_agent_session_id is required and must be non-empty"
354
+ )
355
+ return EventEnvelope(
356
+ event_type=EventType.HANDOFF,
357
+ payload={
358
+ "from_agent_did": from_agent_did,
359
+ "multi_agent_session_id": multi_agent_session_id,
360
+ },
361
+ timestamp=timestamp,
362
+ )
363
+
364
+
365
+ def hook(
366
+ *,
367
+ activity_context: Mapping[str, Any],
368
+ activity_id: str,
369
+ activity_type: str,
370
+ spans: tuple[Any, ...] | list[Any],
371
+ timestamp: str | None = None,
372
+ ) -> EventEnvelope:
373
+ """Hook (span-bearing) evaluation event.
374
+
375
+ Internally ``EventKind.HOOK``; serializes as wire ``ActivityStarted`` +
376
+ ``hook_trigger=true`` + non-empty ``spans``. Must be attached to a bound
377
+ activity — callers resolve ``activity_context`` from the ContextStore
378
+ first (no bound context ⇒ skip the hook entirely; don't call this).
379
+
380
+ Args:
381
+ activity_context: Flat context payload fields (workflow_id, run_id, ...)
382
+ merged at the top level of the wire body.
383
+ activity_id / activity_type: The bound activity identity (required).
384
+ spans: Non-empty flat Core ``SpanData`` payloads.
385
+ """
386
+ if not activity_id or not activity_type:
387
+ raise ValueError(
388
+ "hook: activity_id and activity_type are required — hook events must "
389
+ "be attached to a bound activity"
390
+ )
391
+ if not spans:
392
+ raise ValueError("hook: spans must be non-empty for a hook evaluation")
393
+ return EventEnvelope(
394
+ event_type=EventType.ACTIVITY_STARTED,
395
+ payload=dict(activity_context),
396
+ spans=tuple(spans),
397
+ hook_trigger=True,
398
+ activity_id=activity_id,
399
+ activity_type=activity_type,
400
+ timestamp=timestamp,
401
+ )