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.
- openbox_core/__init__.py +59 -0
- openbox_core/adapters/__init__.py +3 -0
- openbox_core/adapters/base.py +123 -0
- openbox_core/approvals.py +106 -0
- openbox_core/client.py +298 -0
- openbox_core/config.py +260 -0
- openbox_core/conformance/__init__.py +3 -0
- openbox_core/conformance/fake_core.py +169 -0
- openbox_core/conformance/hook_preflight.py +87 -0
- openbox_core/conformance/instrumentation.py +91 -0
- openbox_core/context.py +205 -0
- openbox_core/contracts/__init__.py +3 -0
- openbox_core/contracts/context.py +79 -0
- openbox_core/contracts/events.py +401 -0
- openbox_core/contracts/otel_spans.py +325 -0
- openbox_core/contracts/results.py +287 -0
- openbox_core/errors.py +287 -0
- openbox_core/gate.py +185 -0
- openbox_core/hooks/__init__.py +3 -0
- openbox_core/hooks/events.py +64 -0
- openbox_core/hooks/preflight.py +292 -0
- openbox_core/hooks/wrappers.py +105 -0
- openbox_core/identity.py +231 -0
- openbox_core/instrumentation/__init__.py +3 -0
- openbox_core/instrumentation/db.py +689 -0
- openbox_core/instrumentation/file.py +239 -0
- openbox_core/instrumentation/function.py +121 -0
- openbox_core/instrumentation/http.py +840 -0
- openbox_core/instrumentation/llm.py +3 -0
- openbox_core/instrumentation/manager.py +135 -0
- openbox_core/instrumentation/shared.py +27 -0
- openbox_core/otel/__init__.py +3 -0
- openbox_core/otel/propagation.py +45 -0
- openbox_core/otel/provider.py +35 -0
- openbox_core/otel/setup.py +36 -0
- openbox_core/otel/span_processor.py +62 -0
- openbox_core/otel/trace_context.py +71 -0
- openbox_core/py.typed +0 -0
- openbox_core/runtime.py +138 -0
- openbox_core/sdk_version.py +79 -0
- openbox_core/serialization.py +129 -0
- openbox_core/validation/__init__.py +3 -0
- openbox_core/validation/diagnostics.py +60 -0
- openbox_core/validation/event_rules.py +164 -0
- openbox_core/validation/registry.py +31 -0
- openbox_core/validation/span_normalization.py +107 -0
- openbox_core/wire/__init__.py +3 -0
- openbox_core/wire/core_span.py +130 -0
- openbox_core/wire/evaluate_payload.py +56 -0
- openbox_sdk_python-0.2.0.dist-info/METADATA +94 -0
- openbox_sdk_python-0.2.0.dist-info/RECORD +52 -0
- openbox_sdk_python-0.2.0.dist-info/WHEEL +4 -0
openbox_core/context.py
ADDED
|
@@ -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,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
|
+
)
|